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
+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)}")