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"]