feat: reward paid members at user growth milestones

This commit is contained in:
2569718930@qq.com
2026-06-12 23:52:56 +08:00
parent 33fbf17c18
commit cf35e55da4
13 changed files with 1021 additions and 1 deletions
+6
View File
@@ -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
+2
View File
@@ -20,6 +20,7 @@ PolyWeather 是面向温度结算场景的气象决策层,不是通用天气
| 登录注册(Google + 邮箱) | 已上线 | Supabase 鉴权 |
| 订阅套餐(Pro 月付 / 季度) | 已上线 | `29.9 USDC / 30天``79.9 USDC / 90天` |
| 邀请积分 | 已上线 | 被邀请人首月 `20U`;邀请人每个有效付费邀请 `+3500` 积分 |
| 用户增长奖励 | 已上线 | 按已验证用户里程碑,为当前有效且有 confirmed 入账记录的付费会员延长 Pro |
| 积分抵扣 | 已上线 | `500分=1U`,最多 `3U`;邀请首月价不叠加积分抵扣 |
| 合约支付 | 已上线 | PolygonUSDC + 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 群发言发放
+10
View File
@@ -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."
}
]
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -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.
@@ -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.
+439
View File
@@ -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"🎉 <b>PolyWeather Growth Reward: {milestone} verified users</b>",
f"Active paid members received <b>+{days} Pro day{'s' if days != 1 else ''}</b>. Rewarded members: {rewarded_count}.",
"",
f"🎉 <b>PolyWeather 增长奖励:已验证用户达到 {milestone}</b>",
f"当前有效付费会员已获得 <b>+{days} 天 Pro</b>。本次奖励人数:{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
+34
View File
@@ -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(
+201
View File
@@ -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,
*,
+7 -1
View File
@@ -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)}")
+1
View File
@@ -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
+170
View File
@@ -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"]