diff --git a/.env.example b/.env.example index 559660f5..daf3c785 100644 --- a/.env.example +++ b/.env.example @@ -161,6 +161,12 @@ POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300 POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10 POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true +# Verified-user growth milestone rewards +POLYWEATHER_GROWTH_REWARD_ENABLED=false +POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC=21600 +POLYWEATHER_GROWTH_REWARD_HTTP_TIMEOUT_SEC=15 +POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED=true + # Group message points POLYWEATHER_BOT_MESSAGE_POINTS=4 POLYWEATHER_BOT_MESSAGE_DAILY_CAP=40 diff --git a/docs/COMMERCIALIZATION.md b/docs/COMMERCIALIZATION.md index e4fa7451..ee23c979 100644 --- a/docs/COMMERCIALIZATION.md +++ b/docs/COMMERCIALIZATION.md @@ -20,6 +20,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气 | 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 | | 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天`;`79.9 USDC / 90天` | | 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 | +| 用户增长奖励 | 已上线 | 按已验证用户里程碑,为当前有效且有 confirmed 入账记录的付费会员延长 Pro | | 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 | | 合约支付 | 已上线 | Polygon,USDC + USDC.e | | 多链直转支付 | 已上线 | Ethereum 主网 USDC 直转确认 | @@ -46,6 +47,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气 - 套餐:`pro_monthly`(29.9 USDC / 30 天)、`pro_quarterly`(79.9 USDC / 90 天) - 邀请首月:被邀请人首次月付 20 USDC - 邀请奖励:邀请人 +3500 积分,每月最多 10 个有效付费邀请 +- 用户增长奖励:600 已验证用户 `+1天`、750 `+2天`、1000 `+3天`,此后每增长 100 人 `+3天` - 抵扣:500 积分抵 1 USDC;月付最高抵 3 USDC,季度最高抵 8 USDC - 积分来源:仅通过有效付费邀请和后台人工补发,不再通过 Telegram 群发言发放 diff --git a/docs/operations/user-growth-history.json b/docs/operations/user-growth-history.json new file mode 100644 index 00000000..39f8e3f9 --- /dev/null +++ b/docs/operations/user-growth-history.json @@ -0,0 +1,10 @@ +[ + { + "recorded_at": "2026-06-12T23:32:49+08:00", + "total_registered_users": 588, + "verified_users": 573, + "ever_signed_in_users": 561, + "source": "supabase_auth_admin", + "note": "Baseline recorded before verified-user growth milestone rewards launched." + } +] diff --git a/docs/social/2026-06-12-growth-rewards-x-post.md b/docs/social/2026-06-12-growth-rewards-x-post.md new file mode 100644 index 00000000..000530c4 --- /dev/null +++ b/docs/social/2026-06-12-growth-rewards-x-post.md @@ -0,0 +1,32 @@ +# PolyWeather Growth Rewards + +PolyWeather has reached **588 registered users**. + +That number matters because every new user helps us improve the product: more +feedback, more real-world usage, and more pressure to make weather observations +faster, clearer, and more reliable for prediction-market decisions. + +We are introducing **PolyWeather Growth Rewards**. + +From now on, verified-user milestones will unlock additional Pro time for every +active paid member: + +- 600 verified users: **+1 Pro day** +- 750 verified users: **+2 Pro days** +- 1,000 verified users: **+3 Pro days** +- Every additional 100 verified users after 1,000: **+3 Pro days** + +Rewards are issued automatically to active paid members when each milestone is +reached. Every milestone can only be claimed once. + +This is separate from our referral program. Referrals still reward the people +who directly help PolyWeather grow, while Growth Rewards let all paying members +share in the progress of the community. + +We currently have **588 registered users**, including **573 verified users**. +The first Growth Reward unlocks at 600 verified users. + +Grow together. Earn more Pro time. + +https://polyweather.top + diff --git a/docs/social/assets/polyweather-588-growth-rewards.png b/docs/social/assets/polyweather-588-growth-rewards.png new file mode 100644 index 00000000..cf0af6b6 Binary files /dev/null and b/docs/social/assets/polyweather-588-growth-rewards.png differ diff --git a/docs/superpowers/plans/2026-06-12-growth-milestone-pro-rewards.md b/docs/superpowers/plans/2026-06-12-growth-milestone-pro-rewards.md new file mode 100644 index 00000000..bbc15b5c --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-growth-milestone-pro-rewards.md @@ -0,0 +1,68 @@ +# Growth Milestone Pro Rewards Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically extend active paid Pro memberships when verified-user growth milestones are reached. + +**Architecture:** A low-frequency Bot-owned loop reads Supabase Auth and payment data, stores daily growth history in SQLite, and grants idempotent milestone-specific bonus subscriptions. SQLite settlement and payout records prevent duplicate processing and support retries. + +**Tech Stack:** Python, SQLite, Supabase REST/Auth Admin API, Telegram Bot runtime, pytest + +--- + +### Task 1: Define milestone and eligibility policy + +**Files:** +- Create: `src/bot/growth_milestone_reward_loop.py` +- Test: `tests/test_growth_milestone_reward_loop.py` + +- [ ] Write failing tests for the 600/750/1000/100-step milestone schedule and paid-member eligibility intersection. +- [ ] Run `python -m pytest tests/test_growth_milestone_reward_loop.py -q` and confirm failure because the module is missing. +- [ ] Implement pure policy helpers and Supabase query helpers. +- [ ] Re-run the focused tests and confirm they pass. + +### Task 2: Persist history and settlement state + +**Files:** +- Modify: `src/database/db_manager.py` +- Test: `tests/test_growth_milestone_reward_loop.py` + +- [ ] Write failing tests for daily snapshot upsert, payout idempotency, and milestone settlement. +- [ ] Run the focused tests and confirm the new DB methods are missing. +- [ ] Add `user_growth_snapshots`, `growth_milestone_runs`, and `growth_milestone_payouts`. +- [ ] Re-run the focused tests and confirm they pass. + +### Task 3: Run and announce automatic settlement + +**Files:** +- Modify: `src/bot/growth_milestone_reward_loop.py` +- Modify: `src/bot/runtime_coordinator.py` +- Modify: `src/utils/config_validation.py` +- Modify: `.env.example` +- Test: `tests/test_growth_milestone_reward_loop.py` +- Test: `tests/test_bot_runtime_coordinator.py` + +- [ ] Write failing tests for idempotent bonus grants and runtime-loop registration. +- [ ] Run focused tests and confirm the expected failures. +- [ ] Implement the settlement loop, retry behavior, and bilingual announcement. +- [ ] Re-run focused tests and confirm they pass. + +### Task 4: Record baseline and publishable announcement + +**Files:** +- Create: `docs/operations/user-growth-history.json` +- Create: `docs/social/2026-06-12-growth-rewards-x-post.md` +- Create: `docs/social/assets/polyweather-588-growth-rewards.png` + +- [ ] Record the verified 2026-06-12 baseline. +- [ ] Write the English X announcement with accurate total-user and verified-user wording. +- [ ] Save the generated social image in the repository. + +### Task 5: Verify and deploy + +- [ ] Run focused backend tests. +- [ ] Run `python -m ruff check src/bot/growth_milestone_reward_loop.py src/bot/runtime_coordinator.py src/database/db_manager.py tests/test_growth_milestone_reward_loop.py tests/test_bot_runtime_coordinator.py`. +- [ ] Run the broader relevant test suite. +- [ ] Commit and push to `main`. +- [ ] Check GitHub Actions and production health. + diff --git a/docs/superpowers/specs/2026-06-12-growth-milestone-pro-rewards-design.md b/docs/superpowers/specs/2026-06-12-growth-milestone-pro-rewards-design.md new file mode 100644 index 00000000..d2dce992 --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-growth-milestone-pro-rewards-design.md @@ -0,0 +1,51 @@ +# Growth Milestone Pro Rewards Design + +## Goal + +Reward active paid Pro members with additional membership time when PolyWeather +reaches verified-user growth milestones. + +## Confirmed Rules + +- Growth is measured using verified Supabase Auth users. +- The historical launch baseline is: + - 588 total registered users + - 573 verified users + - recorded on 2026-06-12 +- Milestones and rewards: + - 600 verified users: +1 Pro day + - 750 verified users: +2 Pro days + - 1000 verified users: +3 Pro days + - every additional 100 verified users after 1000: +3 Pro days +- Each milestone settles at most once. +- Only users who currently have active membership access and have at least one + confirmed real payment are eligible. +- Trial-only, manual-grant-only, and reward-only users are excluded. + +## Architecture + +The Telegram Bot process already acts as the single background-job owner. A new +growth milestone loop runs there on a low-frequency interval. It reads verified +Auth-user counts, writes one daily growth snapshot, checks unsettled milestones, +selects eligible paid members, and grants an append-only bonus subscription +window. + +SQLite stores daily snapshots, milestone settlement summaries, and per-user +payout records. Supabase stores the actual bonus subscription. The bonus source +contains the milestone number so a retry can detect an already-issued reward. + +## Failure Handling + +- Supabase read failures do not advance or settle a milestone. +- Successful user payouts are recorded individually. +- Failed payouts are retried on the next loop run. +- A milestone is marked settled only when all eligible payouts succeed. +- Bonus subscription writes use a milestone-specific source as an additional + idempotency guard. + +## Notification + +After a milestone settles, the Bot posts one concise bilingual group +announcement when announcements are enabled. It includes the milestone, reward +days, and rewarded member count. + diff --git a/src/bot/growth_milestone_reward_loop.py b/src/bot/growth_milestone_reward_loop.py new file mode 100644 index 00000000..3e6a7200 --- /dev/null +++ b/src/bot/growth_milestone_reward_loop.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import os +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List, Optional, Tuple + +import requests +from loguru import logger + +from src.database.db_manager import DBManager +from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int, min_value: int = 0) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + value = int(raw) + except Exception: + return default + return max(min_value, value) + + +def _service_headers(service_role_key: str, prefer: str = "") -> Dict[str, str]: + headers = { + "apikey": service_role_key, + "Authorization": f"Bearer {service_role_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + if prefer: + headers["Prefer"] = prefer + return headers + + +def _parse_datetime(value: object) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def reached_growth_milestones(verified_users: int) -> List[Tuple[int, int]]: + count = max(0, int(verified_users or 0)) + reached: List[Tuple[int, int]] = [] + if count >= 600: + reached.append((600, 1)) + if count >= 750: + reached.append((750, 2)) + if count >= 1000: + reached.extend((milestone, 3) for milestone in range(1000, count + 1, 100)) + return reached + + +def select_eligible_paid_user_ids( + subscriptions: Iterable[Dict[str, Any]], + confirmed_payments: Iterable[Dict[str, Any]], + *, + now: Optional[datetime] = None, +) -> List[str]: + current = now or datetime.now(timezone.utc) + if current.tzinfo is None: + current = current.replace(tzinfo=timezone.utc) + current = current.astimezone(timezone.utc) + paid_user_ids = { + str(row.get("user_id") or "").strip().lower() + for row in confirmed_payments + if str(row.get("status") or "").strip().lower() == "confirmed" + and str(row.get("user_id") or "").strip() + } + active_user_ids = set() + for row in subscriptions: + user_id = str(row.get("user_id") or "").strip().lower() + if not user_id or str(row.get("status") or "").strip().lower() != "active": + continue + starts_at = _parse_datetime(row.get("starts_at")) + expires_at = _parse_datetime(row.get("expires_at")) + if starts_at and starts_at > current: + continue + if not expires_at or expires_at <= current: + continue + active_user_ids.add(user_id) + return sorted(active_user_ids & paid_user_ids) + + +def fetch_auth_user_counts( + *, + supabase_url: str, + service_role_key: str, + timeout_sec: int, +) -> Dict[str, int]: + users: List[Dict[str, Any]] = [] + page = 1 + base = supabase_url.rstrip("/") + headers = _service_headers(service_role_key) + while True: + response = requests.get( + f"{base}/auth/v1/admin/users", + headers=headers, + params={"page": page, "per_page": 1000}, + timeout=timeout_sec, + ) + if response.status_code != 200: + raise RuntimeError(f"auth_users_http_{response.status_code}") + payload = response.json() if response.content else {} + batch = payload.get("users", []) if isinstance(payload, dict) else [] + rows = [row for row in batch if isinstance(row, dict)] + users.extend(rows) + if len(rows) < 1000: + break + page += 1 + return { + "total_registered": len(users), + "verified_users": sum( + 1 + for user in users + if user.get("email_confirmed_at") + or user.get("phone_confirmed_at") + or user.get("confirmed_at") + ), + "ever_signed_in": sum(1 for user in users if user.get("last_sign_in_at")), + } + + +def _fetch_rows( + *, + supabase_url: str, + service_role_key: str, + table: str, + params: Dict[str, str], + timeout_sec: int, +) -> List[Dict[str, Any]]: + response = requests.get( + f"{supabase_url.rstrip('/')}/rest/v1/{table}", + headers=_service_headers(service_role_key), + params=params, + timeout=timeout_sec, + ) + if response.status_code != 200: + raise RuntimeError(f"{table}_http_{response.status_code}") + payload = response.json() if response.content else [] + return [row for row in payload if isinstance(row, dict)] if isinstance(payload, list) else [] + + +def fetch_current_subscriptions_and_confirmed_payments( + *, + supabase_url: str, + service_role_key: str, + timeout_sec: int, +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + now_iso = datetime.now(timezone.utc).isoformat() + subscriptions = _fetch_rows( + supabase_url=supabase_url, + service_role_key=service_role_key, + table="subscriptions", + params={ + "select": "user_id,status,starts_at,expires_at", + "status": "eq.active", + "starts_at": f"lte.{now_iso}", + "expires_at": f"gt.{now_iso}", + "limit": "5000", + }, + timeout_sec=timeout_sec, + ) + confirmed = _fetch_rows( + supabase_url=supabase_url, + service_role_key=service_role_key, + table="payments", + params={ + "select": "user_id,status", + "status": "eq.confirmed", + "limit": "5000", + }, + timeout_sec=timeout_sec, + ) + return subscriptions, confirmed + + +def grant_growth_milestone_days( + *, + supabase_url: str, + service_role_key: str, + user_id: str, + milestone: int, + days: int, + timeout_sec: int, +) -> Tuple[bool, str, Optional[str]]: + uid = str(user_id or "").strip().lower() + if not supabase_url or not service_role_key: + return False, "supabase_not_configured", None + if not uid: + return False, "supabase_user_id_missing", None + safe_days = max(1, int(days or 0)) + source = f"growth_milestone_reward_{int(milestone)}" + base = supabase_url.rstrip("/") + headers = _service_headers(service_role_key) + now = datetime.now(timezone.utc) + try: + existing = requests.get( + f"{base}/rest/v1/subscriptions", + headers=headers, + params={ + "select": "expires_at", + "user_id": f"eq.{uid}", + "source": f"eq.{source}", + "limit": "1", + }, + timeout=timeout_sec, + ) + if existing.status_code != 200: + return False, f"idempotency_query_http_{existing.status_code}", None + existing_rows = existing.json() if existing.content else [] + if isinstance(existing_rows, list) and existing_rows: + return True, "already_granted", str(existing_rows[0].get("expires_at") or "") or None + + current = requests.get( + f"{base}/rest/v1/subscriptions", + headers=headers, + params={ + "select": "expires_at", + "user_id": f"eq.{uid}", + "status": "eq.active", + "expires_at": f"gt.{now.isoformat()}", + "order": "expires_at.desc", + "limit": "1", + }, + timeout=timeout_sec, + ) + if current.status_code != 200: + return False, f"subscriptions_query_http_{current.status_code}", None + rows = current.json() if current.content else [] + starts_at = now + if isinstance(rows, list) and rows: + latest_expiry = _parse_datetime(rows[0].get("expires_at")) + if latest_expiry and latest_expiry > starts_at: + starts_at = latest_expiry + expires_at = starts_at + timedelta(days=safe_days) + payload = { + "user_id": uid, + "plan_code": "growth_milestone_bonus", + "status": "active", + "starts_at": starts_at.isoformat(), + "expires_at": expires_at.isoformat(), + "source": source, + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + } + created = requests.post( + f"{base}/rest/v1/subscriptions", + headers=_service_headers(service_role_key, "return=minimal"), + json=payload, + timeout=timeout_sec, + ) + if created.status_code not in (200, 201): + return False, f"subscriptions_insert_http_{created.status_code}", None + return True, "", expires_at.isoformat() + except Exception as exc: + return False, f"subscriptions_error:{exc}", None + + +def _render_announcement(milestone: int, days: int, rewarded_count: int) -> str: + return "\n".join( + [ + f"🎉 PolyWeather Growth Reward: {milestone} verified users", + f"Active paid members received +{days} Pro day{'s' if days != 1 else ''}. Rewarded members: {rewarded_count}.", + "", + f"🎉 PolyWeather 增长奖励:已验证用户达到 {milestone}", + f"当前有效付费会员已获得 +{days} 天 Pro。本次奖励人数:{rewarded_count}。", + ] + ) + + +def run_growth_milestone_cycle( + *, + bot: Any, + db: DBManager, + supabase_url: str, + service_role_key: str, + timeout_sec: int, + announce: bool, + chat_ids: Iterable[int], +) -> Dict[str, Any]: + counts = fetch_auth_user_counts( + supabase_url=supabase_url, + service_role_key=service_role_key, + timeout_sec=timeout_sec, + ) + today = datetime.now(timezone.utc).date().isoformat() + db.record_user_growth_snapshot(snapshot_date=today, **counts) + settlements: List[Dict[str, Any]] = [] + for milestone, days in reached_growth_milestones(counts["verified_users"]): + if db.is_growth_milestone_settled(milestone): + continue + frozen_payouts = db.list_growth_milestone_payouts(milestone) + if frozen_payouts: + eligible = sorted( + { + str(row.get("supabase_user_id") or "").strip().lower() + for row in frozen_payouts + if str(row.get("supabase_user_id") or "").strip() + } + ) + else: + subscriptions, confirmed = fetch_current_subscriptions_and_confirmed_payments( + supabase_url=supabase_url, + service_role_key=service_role_key, + timeout_sec=timeout_sec, + ) + eligible = select_eligible_paid_user_ids(subscriptions, confirmed) + for user_id in eligible: + db.record_growth_milestone_payout( + milestone, + user_id, + days, + "pending", + "", + ) + rewarded = 0 + failed = 0 + for user_id in eligible: + if db.has_growth_milestone_payout(milestone, user_id): + rewarded += 1 + continue + ok, reason, expires_at = grant_growth_milestone_days( + supabase_url=supabase_url, + service_role_key=service_role_key, + user_id=user_id, + milestone=milestone, + days=days, + timeout_sec=timeout_sec, + ) + db.record_growth_milestone_payout( + milestone, + user_id, + days, + "granted" if ok else "failed", + reason, + expires_at=expires_at or "", + ) + if ok: + rewarded += 1 + else: + failed += 1 + summary = { + "milestone": milestone, + "verified_users": counts["verified_users"], + "reward_days": days, + "eligible_count": len(eligible), + "rewarded_count": rewarded, + "failed_count": failed, + } + if failed == 0: + db.mark_growth_milestone_settled( + milestone, + counts["verified_users"], + days, + rewarded, + failed, + summary, + ) + if announce: + message = _render_announcement(milestone, days, rewarded) + for chat_id in chat_ids: + try: + bot.send_message( + chat_id, + message, + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logger.warning( + "growth milestone announcement failed milestone={} chat_id={} error={}", + milestone, + chat_id, + exc, + ) + settlements.append(summary) + return {"counts": counts, "settlements": settlements} + + +def _runner(bot: Any) -> None: + if not _env_bool("POLYWEATHER_GROWTH_REWARD_ENABLED", False): + logger.info("growth milestone reward loop disabled") + return + interval_sec = _env_int("POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC", 21600, 300) + timeout_sec = _env_int("POLYWEATHER_GROWTH_REWARD_HTTP_TIMEOUT_SEC", 15, 3) + announce = _env_bool("POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED", True) + supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") + service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() + chat_ids = get_telegram_chat_ids_from_env() + db = DBManager() + logger.info( + "growth milestone reward loop started interval={}s announce={} chat_targets={}", + interval_sec, + announce, + len(chat_ids), + ) + while True: + try: + run_growth_milestone_cycle( + bot=bot, + db=db, + supabase_url=supabase_url, + service_role_key=service_role_key, + timeout_sec=timeout_sec, + announce=announce, + chat_ids=chat_ids, + ) + except Exception as exc: + logger.warning(f"growth milestone reward cycle failed: {exc}") + time.sleep(interval_sec) + + +def start_growth_milestone_reward_loop(bot: Any): + thread = threading.Thread( + target=_runner, + args=(bot,), + daemon=True, + name="growth-milestone-reward-loop", + ) + thread.start() + return thread diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py index 7ddda0f2..c3787925 100644 --- a/src/bot/runtime_coordinator.py +++ b/src/bot/runtime_coordinator.py @@ -84,6 +84,7 @@ class StartupCoordinator: def start_all(self) -> RuntimeStatus: loops = [ self._start_airport_high_freq_loop(), + self._start_growth_milestone_reward_loop(), self._start_weekly_reward_loop(), self._start_payment_event_loop(), self._start_payment_confirm_loop(), @@ -227,6 +228,39 @@ class StartupCoordinator: ).start_weekly_reward_loop(self.bot), ) + def _start_growth_milestone_reward_loop(self) -> LoopStatus: + enabled = _env_bool("POLYWEATHER_GROWTH_REWARD_ENABLED", False) + chat_ids = get_telegram_chat_ids_from_env() + interval_sec = max( + 300, _env_int("POLYWEATHER_GROWTH_REWARD_CHECK_INTERVAL_SEC", 21600) + ) + announce = _env_bool("POLYWEATHER_GROWTH_REWARD_ANNOUNCE_ENABLED", True) + details = { + "metric": "verified_supabase_auth_users", + "check_interval_sec": interval_sec, + "announce": announce, + "chat_targets": len(chat_ids), + "next_milestones": "600:+1d,750:+2d,1000+ every 100:+3d", + } + validation_error = None + if enabled and ( + not str(os.getenv("SUPABASE_URL") or "").strip() + or not str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() + ): + validation_error = "missing_supabase_service_credentials" + elif enabled and announce and not chat_ids: + validation_error = "missing_TELEGRAM_CHAT_IDS" + return self._start_with_validation( + key="growth_milestone_reward", + label="用户增长里程碑奖励", + configured_enabled=enabled, + details=details, + validation_error=validation_error, + starter=lambda: import_module( + "src.bot.growth_milestone_reward_loop" + ).start_growth_milestone_reward_loop(self.bot), + ) + def _start_payment_confirm_loop(self) -> LoopStatus: enabled = _env_bool("POLYWEATHER_PAYMENT_CONFIRM_LOOP_ENABLED", True) interval_sec = max( diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 9a87f190..83d77c2a 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -426,6 +426,39 @@ class DBManager: PRIMARY KEY (week_key, telegram_id) ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS user_growth_snapshots ( + snapshot_date TEXT PRIMARY KEY, + total_registered INTEGER NOT NULL DEFAULT 0, + verified_users INTEGER NOT NULL DEFAULT 0, + ever_signed_in INTEGER NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT 'supabase_auth_admin', + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS growth_milestone_runs ( + milestone INTEGER PRIMARY KEY, + verified_users INTEGER NOT NULL DEFAULT 0, + reward_days INTEGER NOT NULL DEFAULT 0, + rewarded_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + summary_json TEXT, + settled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS growth_milestone_payouts ( + milestone INTEGER NOT NULL, + supabase_user_id TEXT NOT NULL, + reward_days INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + expires_at TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (milestone, supabase_user_id) + ) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS payment_runtime_state ( state_key TEXT PRIMARY KEY, @@ -3203,6 +3236,174 @@ class DBManager: ) return True + def record_user_growth_snapshot( + self, + *, + snapshot_date: str, + total_registered: int, + verified_users: int, + ever_signed_in: int, + source: str = "supabase_auth_admin", + ) -> None: + with self._get_connection() as conn: + conn.execute( + """ + INSERT INTO user_growth_snapshots ( + snapshot_date, total_registered, verified_users, + ever_signed_in, source, recorded_at + ) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(snapshot_date) DO UPDATE SET + total_registered = excluded.total_registered, + verified_users = excluded.verified_users, + ever_signed_in = excluded.ever_signed_in, + source = excluded.source, + recorded_at = excluded.recorded_at + """, + ( + str(snapshot_date or "").strip(), + max(0, int(total_registered or 0)), + max(0, int(verified_users or 0)), + max(0, int(ever_signed_in or 0)), + str(source or "supabase_auth_admin"), + datetime.now().isoformat(), + ), + ) + conn.commit() + + def list_user_growth_snapshots(self, limit: int = 90) -> List[Dict[str, Any]]: + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT snapshot_date, total_registered, verified_users, + ever_signed_in, source, recorded_at + FROM user_growth_snapshots + ORDER BY snapshot_date DESC + LIMIT ? + """, + (max(1, min(int(limit or 90), 1000)),), + ).fetchall() + return [dict(row) for row in rows] + + def is_growth_milestone_settled(self, milestone: int) -> bool: + with self._get_connection() as conn: + row = conn.execute( + "SELECT 1 FROM growth_milestone_runs WHERE milestone = ? LIMIT 1", + (int(milestone),), + ).fetchone() + return bool(row) + + def has_growth_milestone_payout(self, milestone: int, supabase_user_id: str) -> bool: + with self._get_connection() as conn: + row = conn.execute( + """ + SELECT 1 FROM growth_milestone_payouts + WHERE milestone = ? AND supabase_user_id = ? AND status = 'granted' + LIMIT 1 + """, + (int(milestone), str(supabase_user_id or "").strip().lower()), + ).fetchone() + return bool(row) + + def list_growth_milestone_payouts(self, milestone: int) -> List[Dict[str, Any]]: + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT milestone, supabase_user_id, reward_days, status, + error, expires_at, updated_at + FROM growth_milestone_payouts + WHERE milestone = ? + ORDER BY supabase_user_id ASC + """, + (int(milestone),), + ).fetchall() + return [dict(row) for row in rows] + + def record_growth_milestone_payout( + self, + milestone: int, + supabase_user_id: str, + reward_days: int, + status: str, + error: str, + *, + expires_at: str = "", + ) -> bool: + user_id = str(supabase_user_id or "").strip().lower() + if not user_id: + return False + with self._get_connection() as conn: + existing = conn.execute( + """ + SELECT status FROM growth_milestone_payouts + WHERE milestone = ? AND supabase_user_id = ? + LIMIT 1 + """, + (int(milestone), user_id), + ).fetchone() + if existing and str(existing[0] or "").strip().lower() == "granted": + return False + conn.execute( + """ + INSERT INTO growth_milestone_payouts ( + milestone, supabase_user_id, reward_days, status, + error, expires_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(milestone, supabase_user_id) DO UPDATE SET + reward_days = excluded.reward_days, + status = excluded.status, + error = excluded.error, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + """, + ( + int(milestone), + user_id, + max(0, int(reward_days or 0)), + str(status or ""), + str(error or ""), + str(expires_at or ""), + datetime.now().isoformat(), + ), + ) + conn.commit() + return True + + def mark_growth_milestone_settled( + self, + milestone: int, + verified_users: int, + reward_days: int, + rewarded_count: int, + failed_count: int, + summary: Optional[Dict[str, Any]] = None, + ) -> None: + summary_json = json.dumps(summary or {}, ensure_ascii=False) + with self._get_connection() as conn: + conn.execute( + """ + INSERT INTO growth_milestone_runs ( + milestone, verified_users, reward_days, rewarded_count, + failed_count, summary_json, settled_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(milestone) DO NOTHING + """, + ( + int(milestone), + max(0, int(verified_users or 0)), + max(0, int(reward_days or 0)), + max(0, int(rewarded_count or 0)), + max(0, int(failed_count or 0)), + summary_json, + datetime.now().isoformat(), + ), + ) + conn.commit() + def append_airport_obs( self, *, diff --git a/src/utils/config_validation.py b/src/utils/config_validation.py index c5535707..817078b7 100644 --- a/src/utils/config_validation.py +++ b/src/utils/config_validation.py @@ -71,6 +71,7 @@ def validate_runtime_env( entitlement_guard = _env_bool("POLYWEATHER_REQUIRE_ENTITLEMENT", False) payment_enabled = _env_bool("POLYWEATHER_PAYMENT_ENABLED", False) weekly_reward_enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", False) + growth_reward_enabled = _env_bool("POLYWEATHER_GROWTH_REWARD_ENABLED", False) if component_key == "bot": missing = _missing(["TELEGRAM_BOT_TOKEN"]) @@ -85,7 +86,12 @@ def validate_runtime_env( missing = _missing(["SUPABASE_URL", "SUPABASE_ANON_KEY"]) if missing: report.errors.append(f"已启用鉴权,但缺少变量: {', '.join(missing)}") - if auth_required or auth_require_subscription or weekly_reward_enabled: + if ( + auth_required + or auth_require_subscription + or weekly_reward_enabled + or growth_reward_enabled + ): missing = _missing(["SUPABASE_SERVICE_ROLE_KEY"]) if missing: report.errors.append(f"当前鉴权/订阅能力需要变量: {', '.join(missing)}") diff --git a/tests/test_bot_runtime_coordinator.py b/tests/test_bot_runtime_coordinator.py index 3bbfd76d..90753e27 100644 --- a/tests/test_bot_runtime_coordinator.py +++ b/tests/test_bot_runtime_coordinator.py @@ -19,6 +19,7 @@ def test_startup_coordinator_respects_disable_flags(monkeypatch): loop_map = runtime.loop_map() assert "weekly_reward" in loop_map + assert "growth_milestone_reward" in loop_map assert "polygon_wallet_watch" not in loop_map diff --git a/tests/test_growth_milestone_reward_loop.py b/tests/test_growth_milestone_reward_loop.py new file mode 100644 index 00000000..b902191c --- /dev/null +++ b/tests/test_growth_milestone_reward_loop.py @@ -0,0 +1,170 @@ +from datetime import datetime, timezone + +from src.bot import growth_milestone_reward_loop as growth +from src.database.db_manager import DBManager + + +def test_reached_growth_milestones_follow_confirmed_schedule(): + assert growth.reached_growth_milestones(599) == [] + assert growth.reached_growth_milestones(600) == [(600, 1)] + assert growth.reached_growth_milestones(999) == [(600, 1), (750, 2)] + assert growth.reached_growth_milestones(1000) == [(600, 1), (750, 2), (1000, 3)] + assert growth.reached_growth_milestones(1250)[-2:] == [(1100, 3), (1200, 3)] + + +def test_select_eligible_paid_users_requires_current_access_and_confirmed_payment(): + now = datetime(2026, 6, 12, tzinfo=timezone.utc) + subscriptions = [ + { + "user_id": "paid-current", + "status": "active", + "starts_at": "2026-06-01T00:00:00+00:00", + "expires_at": "2026-07-01T00:00:00+00:00", + }, + { + "user_id": "trial-only", + "status": "active", + "starts_at": "2026-06-11T00:00:00+00:00", + "expires_at": "2026-06-14T00:00:00+00:00", + }, + { + "user_id": "paid-expired", + "status": "active", + "starts_at": "2026-05-01T00:00:00+00:00", + "expires_at": "2026-06-01T00:00:00+00:00", + }, + ] + confirmed = [ + {"user_id": "paid-current", "status": "confirmed"}, + {"user_id": "paid-expired", "status": "confirmed"}, + ] + + assert growth.select_eligible_paid_user_ids(subscriptions, confirmed, now=now) == [ + "paid-current" + ] + + +def test_growth_history_and_payouts_are_idempotent(tmp_path): + db = DBManager(str(tmp_path / "growth.db")) + db.record_user_growth_snapshot( + snapshot_date="2026-06-12", + total_registered=588, + verified_users=573, + ever_signed_in=561, + ) + db.record_user_growth_snapshot( + snapshot_date="2026-06-12", + total_registered=590, + verified_users=575, + ever_signed_in=563, + ) + snapshots = db.list_user_growth_snapshots(limit=10) + assert len(snapshots) == 1 + assert snapshots[0]["total_registered"] == 590 + assert snapshots[0]["verified_users"] == 575 + + assert db.record_growth_milestone_payout(600, "user-1", 1, "granted", "") is True + assert db.record_growth_milestone_payout(600, "user-1", 1, "granted", "") is False + assert db.is_growth_milestone_settled(600) is False + db.mark_growth_milestone_settled(600, 601, 1, 1, 0, {"ok": True}) + assert db.is_growth_milestone_settled(600) is True + + +def test_growth_bonus_grant_uses_milestone_source_as_idempotency_guard(monkeypatch): + calls = [] + + class Response: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + self.content = b"1" + + def json(self): + return self._payload + + def fake_get(url, headers=None, params=None, timeout=None): + calls.append(("GET", params)) + if params.get("source") == "eq.growth_milestone_reward_600": + return Response(200, []) + return Response( + 200, + [{"expires_at": "2026-07-01T00:00:00+00:00"}], + ) + + def fake_post(url, headers=None, json=None, timeout=None): + calls.append(("POST", json)) + return Response(201, []) + + monkeypatch.setattr(growth.requests, "get", fake_get) + monkeypatch.setattr(growth.requests, "post", fake_post) + + ok, reason, expires_at = growth.grant_growth_milestone_days( + supabase_url="https://example.supabase.co", + service_role_key="service-role", + user_id="user-1", + milestone=600, + days=1, + timeout_sec=5, + ) + + assert ok is True + assert reason == "" + assert expires_at + assert calls[-1][1]["source"] == "growth_milestone_reward_600" + assert calls[-1][1]["plan_code"] == "growth_milestone_bonus" + + +def test_growth_cycle_retries_frozen_failed_recipient_after_membership_changes(monkeypatch): + grants = [] + + class FakeDb: + def record_user_growth_snapshot(self, **kwargs): + pass + + def is_growth_milestone_settled(self, milestone): + return False + + def list_growth_milestone_payouts(self, milestone): + return [{"supabase_user_id": "user-frozen", "status": "failed"}] + + def has_growth_milestone_payout(self, milestone, user_id): + return False + + def record_growth_milestone_payout(self, *args, **kwargs): + return True + + def mark_growth_milestone_settled(self, *args, **kwargs): + pass + + monkeypatch.setattr( + growth, + "fetch_auth_user_counts", + lambda **kwargs: { + "total_registered": 615, + "verified_users": 600, + "ever_signed_in": 590, + }, + ) + monkeypatch.setattr( + growth, + "fetch_current_subscriptions_and_confirmed_payments", + lambda **kwargs: ([], []), + ) + + def fake_grant(**kwargs): + grants.append(kwargs["user_id"]) + return True, "", "2026-07-01T00:00:00+00:00" + + monkeypatch.setattr(growth, "grant_growth_milestone_days", fake_grant) + + growth.run_growth_milestone_cycle( + bot=object(), + db=FakeDb(), + supabase_url="https://example.supabase.co", + service_role_key="service-role", + timeout_sec=5, + announce=False, + chat_ids=[], + ) + + assert grants == ["user-frozen"]