feat: Implement initial web frontend, comprehensive database management, and a bot-based weekly reward system.

This commit is contained in:
2569718930@qq.com
2026-03-13 16:47:25 +08:00
parent e780830f76
commit ac9e537070
10 changed files with 8261 additions and 43 deletions
+14
View File
@@ -38,6 +38,10 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60
# Frontend wallet connection (WalletConnect v2)
# Apply in Vercel env as NEXT_PUBLIC_*
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
# Bot command access guard (/city, /deb):
# - Pro entitlement removed
# - user only needs to be a member of TELEGRAM_CHAT_ID group
@@ -49,6 +53,16 @@ POLYWEATHER_BOT_MESSAGE_MIN_LENGTH=3
POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC=30
POLYWEATHER_BOT_CITY_QUERY_COST=1
POLYWEATHER_BOT_DEB_QUERY_COST=1
# Weekly leaderboard reward settlement
# settle_weekday: 1=Mon ... 7=Sun
POLYWEATHER_WEEKLY_REWARD_ENABLED=true
POLYWEATHER_WEEKLY_REWARD_TIMEZONE=Asia/Shanghai
POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY=1
POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR=0
POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE=5
POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC=300
POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC=10
POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED=true
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
Binary file not shown.
+189 -27
View File
@@ -134,12 +134,34 @@ declare global {
type EvmProvider = {
request: (args: { method: string; params?: any[] | object }) => Promise<any>;
providers?: EvmProvider[];
connect?: (args?: any) => Promise<void>;
disconnect?: () => Promise<void>;
session?: unknown;
isMetaMask?: boolean;
isRabby?: boolean;
isOkxWallet?: boolean;
isBitKeep?: boolean;
};
type ProviderMode = "auto" | "walletconnect";
type ProviderSelection = {
provider: EvmProvider;
label: string;
mode: ProviderMode;
};
const WALLETCONNECT_PROJECT_ID = String(
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "",
).trim();
const WALLETCONNECT_POLYGON_RPC_URL = String(
process.env.NEXT_PUBLIC_WALLETCONNECT_POLYGON_RPC_URL ||
"https://polygon-bor-rpc.publicnode.com",
).trim();
let walletConnectProviderCache: EvmProvider | null = null;
let walletConnectProviderChainId: number | null = null;
// --- Helpers ---
type InfoRowProps = {
@@ -215,6 +237,61 @@ function getEvmWalletLabel(provider: EvmProvider | null): string {
return "EVM 钱包";
}
async function getWalletConnectProvider(
chainId: number,
rpcUrl: string,
): Promise<EvmProvider> {
if (!WALLETCONNECT_PROJECT_ID) {
throw new Error(
"WalletConnect 未配置:缺少 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID。",
);
}
if (
walletConnectProviderCache &&
walletConnectProviderChainId === chainId
) {
return walletConnectProviderCache;
}
const { EthereumProvider } = await import(
"@walletconnect/ethereum-provider"
);
const rpcMap: Record<number, string> = {
[chainId]: rpcUrl || WALLETCONNECT_POLYGON_RPC_URL,
};
const origin =
typeof window !== "undefined"
? window.location.origin
: "https://polyweather-pro.vercel.app";
const provider = (await EthereumProvider.init({
projectId: WALLETCONNECT_PROJECT_ID,
chains: [chainId],
optionalChains: [chainId],
showQrModal: true,
methods: [
"eth_sendTransaction",
"personal_sign",
"eth_signTypedData",
"eth_signTypedData_v4",
"eth_sign",
"eth_call",
"eth_chainId",
"eth_accounts",
"eth_requestAccounts",
],
events: ["accountsChanged", "chainChanged", "disconnect"],
rpcMap,
metadata: {
name: "PolyWeather",
description: "PolyWeather Pro checkout",
url: origin,
icons: [`${origin}/favicon.ico`],
},
})) as unknown as EvmProvider;
walletConnectProviderCache = provider;
walletConnectProviderChainId = chainId;
return provider;
}
function toPaddedHex(value: bigint) {
return value.toString(16).padStart(64, "0");
}
@@ -366,6 +443,7 @@ export function AccountCenter() {
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentInfo, setPaymentInfo] = useState("");
const [paymentError, setPaymentError] = useState("");
@@ -373,6 +451,7 @@ export function AccountCenter() {
const [lastTxHash, setLastTxHash] = useState("");
const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
/**
* Returns a valid access token, refreshing the session if the stored one
@@ -417,6 +496,45 @@ export function AccountCenter() {
[supabaseReady, getValidAccessToken],
);
const resolvePaymentProvider = useCallback(
async (mode: ProviderMode = "auto"): Promise<ProviderSelection> => {
const targetChainId = Number(paymentConfig?.chain_id || 137);
if (mode !== "walletconnect") {
const injected = getEvmProvider();
if (injected) {
return {
provider: injected,
label: getEvmWalletLabel(injected),
mode: "auto",
};
}
}
if (!walletConnectEnabled) {
throw new Error(
"未检测到浏览器扩展钱包,且 WalletConnect 未启用。请配置 NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID 或安装 EVM 钱包扩展。",
);
}
const wcProvider = await getWalletConnectProvider(
targetChainId,
WALLETCONNECT_POLYGON_RPC_URL,
);
const existingAccounts = (await wcProvider
.request({ method: "eth_accounts" })
.catch(() => [])) as string[];
if (!Array.isArray(existingAccounts) || existingAccounts.length === 0) {
if (typeof wcProvider.connect === "function") {
await wcProvider.connect({ chains: [targetChainId] });
}
}
return {
provider: wcProvider,
label: "WalletConnect",
mode: "walletconnect",
};
},
[paymentConfig?.chain_id, walletConnectEnabled],
);
const loadPaymentSnapshot = useCallback(async () => {
if (!backend?.authenticated) {
setPaymentConfig(null);
@@ -533,6 +651,15 @@ export function AccountCenter() {
};
const onSignOut = async () => {
if (walletConnectProviderCache?.disconnect) {
try {
await walletConnectProviderCache.disconnect();
} catch {
// ignore
}
walletConnectProviderCache = null;
walletConnectProviderChainId = null;
}
if (supabaseReady) {
try {
await getSupabaseBrowserClient().auth.signOut();
@@ -803,24 +930,20 @@ export function AccountCenter() {
}
};
const connectAndBindWallet = async () => {
const connectAndBindWallet = async (mode: ProviderMode = "auto") => {
setPaymentError("");
setPaymentInfo("");
if (!isAuthenticated) {
setPaymentError("请先登录后再绑定钱包。");
return;
}
const eth = getEvmProvider();
if (!eth) {
setPaymentError(
"未检测到浏览器钱包,请安装并启用 MetaMask / OKX Wallet / Rabby / Bitget 等 EVM 钱包扩展。",
);
return;
}
const walletLabel = getEvmWalletLabel(eth);
setPaymentBusy(true);
try {
const providerSelection = await resolvePaymentProvider(mode);
const eth = providerSelection.provider;
const walletLabel = providerSelection.label;
// Ensure we have a valid token BEFORE opening the wallet modal.
let accessToken: string;
try {
@@ -883,6 +1006,7 @@ export function AccountCenter() {
}
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}`);
setProviderMode(providerSelection.mode);
await loadPaymentSnapshot();
} catch (error) {
setPaymentInfo("");
@@ -905,18 +1029,10 @@ export function AccountCenter() {
return;
}
const eth = getEvmProvider();
if (!eth) {
setPaymentError(
"未检测到浏览器钱包,请安装并启用 EVM 钱包扩展(MetaMask / OKX / Rabby 等)。",
);
return;
}
const payingWallet = String(
const fallbackWallet = String(
selectedWallet || walletAddress || boundWallets[0]?.address || "",
).toLowerCase();
if (!payingWallet) {
if (!fallbackWallet) {
setPaymentError("请先绑定钱包。");
return;
}
@@ -924,6 +1040,29 @@ export function AccountCenter() {
setPaymentBusy(true);
let approvedInThisRun = false;
try {
const providerSelection = await resolvePaymentProvider(providerMode);
const eth = providerSelection.provider;
const activeAccounts = (await eth.request({
method: "eth_requestAccounts",
})) as string[];
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
if (!activeAddress) throw new Error("钱包账户为空");
const boundAddrSet = new Set(
boundWallets.map((row) => String(row.address || "").toLowerCase()),
);
if (boundAddrSet.size > 0 && !boundAddrSet.has(activeAddress)) {
throw new Error(
`当前连接钱包 ${shortAddress(activeAddress)} 未绑定,请先绑定该地址后支付。`,
);
}
const payingWallet = boundAddrSet.has(activeAddress)
? activeAddress
: fallbackWallet;
setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode);
// Ensure we have a valid token BEFORE switching chain / sending tx.
let accessToken: string;
try {
@@ -1112,7 +1251,7 @@ export function AccountCenter() {
return;
}
if (!hasPayingWallet) {
await connectAndBindWallet();
await connectAndBindWallet(providerMode);
return;
}
await createIntentAndPay();
@@ -1489,13 +1628,36 @@ export function AccountCenter() {
)}
</div>
<button
onClick={() => void connectAndBindWallet()}
disabled={paymentBusy || !isAuthenticated}
className="mt-6 w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2"
>
<PlusIcon className="w-4 h-4" /> (EVM)
</button>
<div className="mt-6 grid grid-cols-1 gap-2">
<button
onClick={() => {
setProviderMode("auto");
void connectAndBindWallet("auto");
}}
disabled={paymentBusy || !isAuthenticated}
className="w-full py-3 border border-white/10 bg-white/5 hover:bg-white/10 rounded-xl text-xs font-bold text-slate-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
>
<PlusIcon className="w-4 h-4" /> EVM扩展
</button>
<button
onClick={() => {
setProviderMode("walletconnect");
void connectAndBindWallet("walletconnect");
}}
disabled={
paymentBusy || !isAuthenticated || !walletConnectEnabled
}
className="w-full py-3 border border-cyan-400/30 bg-cyan-500/10 hover:bg-cyan-500/20 rounded-xl text-xs font-bold text-cyan-300 transition-all flex items-center justify-center gap-2 disabled:opacity-60"
>
<CreditCard className="w-4 h-4" /> WalletConnect
</button>
{!walletConnectEnabled && (
<p className="text-[11px] text-slate-500">
WalletConnect
<code className="mx-1">NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID</code>
</p>
)}
</div>
</section>
</div>
</main>
+7254 -14
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -14,6 +14,7 @@
"@supabase/supabase-js": "^2.57.2",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0",
"@walletconnect/ethereum-provider": "^2.23.8",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Optional
try:
from zoneinfo import ZoneInfo
except Exception:
ZoneInfo = None # type: ignore[assignment]
def compute_target_week_key(
now_local: datetime,
settle_weekday: int,
settle_hour: int,
settle_minute: int,
) -> str:
week_start = (now_local - timedelta(days=now_local.isoweekday() - 1)).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
settle_dt = week_start + timedelta(days=settle_weekday - 1)
settle_dt = settle_dt.replace(
hour=settle_hour,
minute=settle_minute,
second=0,
microsecond=0,
)
ref = now_local - timedelta(days=7 if now_local >= settle_dt else 14)
iso_year, iso_week, _ = ref.isocalendar()
return f"{iso_year}-W{iso_week:02d}"
def resolve_tz(tz_name: str):
if ZoneInfo is None:
return timezone(timedelta(hours=8)), "UTC+08:00"
try:
return ZoneInfo(tz_name), tz_name
except Exception:
return ZoneInfo("Asia/Shanghai"), "Asia/Shanghai"
def get_candidates(conn: sqlite3.Connection, week_key: str, limit: int):
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT *
FROM (
SELECT
u.telegram_id,
u.username,
lower(trim(COALESCE(u.supabase_user_id, ''))) AS supabase_user_id,
COALESCE(u.points, 0) AS points,
COALESCE(u.message_count, 0) AS message_count,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
) ranked
WHERE weekly_points > 0
ORDER BY weekly_points DESC, points DESC, message_count DESC, telegram_id ASC
LIMIT ?
""",
(week_key, week_key, max(1, int(limit))),
).fetchall()
return [dict(r) for r in rows]
def get_settle_run(conn: sqlite3.Connection, week_key: str) -> Optional[sqlite3.Row]:
conn.row_factory = sqlite3.Row
return conn.execute(
"""
SELECT week_key, settled_at, winners_count
FROM weekly_reward_runs
WHERE week_key = ?
LIMIT 1
""",
(week_key,),
).fetchone()
def get_recent_runs(conn: sqlite3.Connection, limit: int = 5):
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT week_key, settled_at, winners_count
FROM weekly_reward_runs
ORDER BY settled_at DESC
LIMIT ?
""",
(max(1, int(limit)),),
).fetchall()
return [dict(r) for r in rows]
def get_recent_payouts(conn: sqlite3.Connection, limit: int = 20):
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT week_key, telegram_id, username, rank, points_bonus, pro_days, pro_granted, pro_error, created_at
FROM weekly_reward_payouts
ORDER BY created_at DESC
LIMIT ?
""",
(max(1, int(limit)),),
).fetchall()
return [dict(r) for r in rows]
def main() -> int:
parser = argparse.ArgumentParser(description="Check PolyWeather weekly reward settlement status.")
parser.add_argument("--db", default=os.getenv("POLYWEATHER_DB_PATH", "data/polyweather.db"))
parser.add_argument("--timezone", default=os.getenv("POLYWEATHER_WEEKLY_REWARD_TIMEZONE", "Asia/Shanghai"))
parser.add_argument("--settle-weekday", type=int, default=int(os.getenv("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", "1")))
parser.add_argument("--settle-hour", type=int, default=int(os.getenv("POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR", "0")))
parser.add_argument("--settle-minute", type=int, default=int(os.getenv("POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE", "5")))
parser.add_argument("--top", type=int, default=10)
args = parser.parse_args()
settle_weekday = min(7, max(1, int(args.settle_weekday)))
settle_hour = min(23, max(0, int(args.settle_hour)))
settle_minute = min(59, max(0, int(args.settle_minute)))
tz, tz_label = resolve_tz(str(args.timezone).strip() or "Asia/Shanghai")
now_local = datetime.now(tz)
week_key = compute_target_week_key(
now_local,
settle_weekday=settle_weekday,
settle_hour=settle_hour,
settle_minute=settle_minute,
)
print("=== Weekly Reward Status ===")
print(f"db={args.db}")
print(f"now_local={now_local.isoformat()}")
print(f"timezone={tz_label}")
print(f"settle_rule=weekday:{settle_weekday} time:{settle_hour:02d}:{settle_minute:02d}")
print(f"target_week={week_key}")
with sqlite3.connect(args.db) as conn:
run = get_settle_run(conn, week_key)
print(f"settled={bool(run)}")
if run:
print(f"settled_at={run['settled_at']} winners_count={run['winners_count']}")
candidates = get_candidates(conn, week_key, args.top)
print(f"candidates={len(candidates)}")
for idx, row in enumerate(candidates, start=1):
print(
f" {idx}. {row.get('username')} "
f"(tg={row.get('telegram_id')}, weekly={row.get('weekly_points')}, total={row.get('points')})"
)
print("\nrecent_runs:")
runs = get_recent_runs(conn, limit=5)
if not runs:
print(" (none)")
else:
for row in runs:
print(
f" - {row.get('week_key')} settled_at={row.get('settled_at')} "
f"winners={row.get('winners_count')}"
)
print("\nrecent_payouts:")
payouts = get_recent_payouts(conn, limit=20)
if not payouts:
print(" (none)")
else:
for row in payouts:
print(
f" - week={row.get('week_key')} rank={row.get('rank')} "
f"user={row.get('username')} tg={row.get('telegram_id')} "
f"+{row.get('points_bonus')}pts +{row.get('pro_days')}dPro "
f"pro_granted={row.get('pro_granted')}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -44,7 +44,7 @@ class BotIOLayer:
f"当前积分: <code>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\n"
f"积分规则:每日签到(有效发言满 {MESSAGE_MIN_LENGTH}获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"积分规则:有效发言满 {MESSAGE_MIN_LENGTH} 字获得 <b>{MESSAGE_POINTS}</b> 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
),
parse_mode="HTML",
+49 -1
View File
@@ -84,6 +84,7 @@ class StartupCoordinator:
self._start_trade_alert_loop(),
self._start_polygon_wallet_loop(),
self._start_polymarket_wallet_activity_loop(),
self._start_weekly_reward_loop(),
]
self._runtime_status = RuntimeStatus(
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
@@ -121,7 +122,17 @@ class StartupCoordinator:
reason=validation_error,
details=details,
)
thread = starter()
try:
thread = starter()
except Exception as exc:
return LoopStatus(
key=key,
label=label,
configured_enabled=True,
started=False,
reason=f"starter_error:{exc}",
details=details,
)
started = thread is not None
reason = "started" if started else "starter_returned_none"
if started:
@@ -218,6 +229,43 @@ class StartupCoordinator:
).start_polymarket_wallet_activity_loop(self.bot),
)
def _start_weekly_reward_loop(self) -> LoopStatus:
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip()
settle_weekday = min(
7, max(1, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", 1))
)
settle_hour = min(23, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR", 0)))
settle_minute = min(
59, max(0, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE", 5))
)
check_interval = max(
30, _env_int("POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC", 300)
)
details = {
"timezone": str(
os.getenv("POLYWEATHER_WEEKLY_REWARD_TIMEZONE") or "Asia/Shanghai"
).strip(),
"settle_weekday": settle_weekday,
"settle_time": f"{settle_hour:02d}:{settle_minute:02d}",
"check_interval_sec": check_interval,
"announce": _env_bool("POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED", True),
}
announce_enabled = bool(details["announce"])
validation_error = None
if announce_enabled and not chat_id:
validation_error = "missing_TELEGRAM_CHAT_ID"
return self._start_with_validation(
key="weekly_reward",
label="周榜奖励结算",
configured_enabled=enabled,
details=details,
validation_error=validation_error,
starter=lambda: import_module("src.bot.weekly_reward_loop").start_weekly_reward_loop(
self.bot
),
)
def render_runtime_status_html(status: RuntimeStatus) -> str:
lines = [
+339
View File
@@ -0,0 +1,339 @@
from __future__ import annotations
import os
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
try:
from zoneinfo import ZoneInfo
except Exception: # pragma: no cover - Python < 3.9 fallback
ZoneInfo = None # type: ignore[assignment]
import requests
from loguru import logger
from src.database.db_manager import DBManager
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 _safe_week_key(dt: datetime) -> str:
iso_year, iso_week, _ = dt.isocalendar()
return f"{iso_year}-W{iso_week:02d}"
def _compute_target_week_key(
now_local: datetime,
settle_weekday: int,
settle_hour: int,
settle_minute: int,
) -> str:
week_start = (now_local - timedelta(days=now_local.isoweekday() - 1)).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
settle_dt = week_start + timedelta(days=settle_weekday - 1)
settle_dt = settle_dt.replace(
hour=settle_hour,
minute=settle_minute,
second=0,
microsecond=0,
)
if now_local >= settle_dt:
ref = now_local - timedelta(days=7)
else:
ref = now_local - timedelta(days=14)
return _safe_week_key(ref)
def _reward_rule_for_rank(rank: int) -> Optional[Tuple[int, int]]:
if rank == 1:
return 500, 7
if rank in (2, 3):
return 300, 3
if 4 <= rank <= 10:
return 150, 0
return None
def _service_headers(service_role_key: str) -> Dict[str, str]:
return {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def _grant_bonus_subscription_days(
*,
supabase_url: str,
service_role_key: str,
user_id: str,
days: int,
timeout_sec: int,
) -> Tuple[bool, str, Optional[str]]:
if not supabase_url or not service_role_key:
return False, "supabase_not_configured", None
uid = str(user_id or "").strip()
if not uid:
return False, "supabase_user_id_missing", None
if days <= 0:
return True, "", None
base = supabase_url.rstrip("/")
now = datetime.now(timezone.utc)
now_iso = now.isoformat()
headers = _service_headers(service_role_key)
try:
resp = requests.get(
f"{base}/rest/v1/subscriptions",
headers=headers,
params={
"select": "id,expires_at",
"user_id": f"eq.{uid}",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "expires_at.desc",
"limit": "1",
},
timeout=timeout_sec,
)
if resp.status_code != 200:
return False, f"subscriptions_query_http_{resp.status_code}", None
latest = None
payload = resp.json() if resp.content else []
if isinstance(payload, list) and payload:
latest = payload[0]
starts_at = now
if isinstance(latest, dict):
expires_raw = str(latest.get("expires_at") or "").strip()
if expires_raw:
try:
latest_exp = datetime.fromisoformat(expires_raw.replace("Z", "+00:00"))
if latest_exp.tzinfo is None:
latest_exp = latest_exp.replace(tzinfo=timezone.utc)
latest_exp = latest_exp.astimezone(timezone.utc)
if latest_exp > starts_at:
starts_at = latest_exp
except Exception:
pass
expires_at = starts_at + timedelta(days=days)
create_payload = {
"user_id": uid,
"plan_code": "weekly_reward_bonus",
"status": "active",
"starts_at": starts_at.isoformat(),
"expires_at": expires_at.isoformat(),
"source": "weekly_leaderboard_reward",
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
}
ins = requests.post(
f"{base}/rest/v1/subscriptions",
headers={**headers, "Prefer": "return=representation"},
json=create_payload,
timeout=timeout_sec,
)
if ins.status_code not in (200, 201):
return False, f"subscriptions_insert_http_{ins.status_code}", None
return True, "", expires_at.isoformat()
except Exception as exc:
return False, f"subscriptions_error:{exc}", None
def _render_settle_report(
week_key: str,
winners: List[Dict[str, Any]],
skipped: int,
) -> str:
lines = [f"🏆 <b>PolyWeather 周榜奖励已结算 ({week_key})</b>", "────────────────────"]
if not winners:
lines.append("本周无有效活跃用户,未发放奖励。")
else:
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
for row in winners:
rank = int(row.get("rank") or 0)
name = str(row.get("username") or "unknown")[:16]
points_bonus = int(row.get("points_bonus") or 0)
pro_days = int(row.get("pro_days") or 0)
pro_text = f" + {pro_days}天Pro" if pro_days > 0 else ""
medal = medals.get(rank, f"{rank}.")
lines.append(f"{medal} {name}: +{points_bonus} 积分{pro_text}")
if skipped > 0:
lines.append(f"(重复发放保护已生效,跳过 {skipped} 条)")
return "\n".join(lines)
def _runner(bot: Any) -> None:
enabled = _env_bool("POLYWEATHER_WEEKLY_REWARD_ENABLED", True)
if not enabled:
logger.info("weekly reward loop disabled")
return
tz_name = str(os.getenv("POLYWEATHER_WEEKLY_REWARD_TIMEZONE") or "Asia/Shanghai").strip()
if ZoneInfo is None:
local_tz = timezone(timedelta(hours=8))
tz_name = "UTC+08:00"
else:
try:
local_tz = ZoneInfo(tz_name)
except Exception:
local_tz = ZoneInfo("Asia/Shanghai")
tz_name = "Asia/Shanghai"
settle_weekday = _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_WEEKDAY", 1, 1)
if settle_weekday > 7:
settle_weekday = 7
settle_hour = min(23, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_HOUR", 0, 0))
settle_minute = min(59, _env_int("POLYWEATHER_WEEKLY_REWARD_SETTLE_MINUTE", 5, 0))
interval_sec = _env_int("POLYWEATHER_WEEKLY_REWARD_CHECK_INTERVAL_SEC", 300, 30)
timeout_sec = _env_int("POLYWEATHER_WEEKLY_REWARD_HTTP_TIMEOUT_SEC", 10, 3)
announce = _env_bool("POLYWEATHER_WEEKLY_REWARD_ANNOUNCE_ENABLED", True)
chat_id = str(os.getenv("TELEGRAM_CHAT_ID") or "").strip()
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
db = DBManager()
logger.info(
"weekly reward loop started tz={} settle={} {:02d}:{:02d} interval={}s announce={}",
tz_name,
settle_weekday,
settle_hour,
settle_minute,
interval_sec,
announce,
)
while True:
try:
now_local = datetime.now(local_tz)
week_key = _compute_target_week_key(
now_local,
settle_weekday=settle_weekday,
settle_hour=settle_hour,
settle_minute=settle_minute,
)
if db.is_weekly_reward_settled(week_key):
time.sleep(interval_sec)
continue
candidates = db.get_weekly_reward_candidates(week_key=week_key, limit=10)
winners: List[Dict[str, Any]] = []
skipped = 0
for idx, row in enumerate(candidates, start=1):
reward = _reward_rule_for_rank(idx)
if reward is None:
continue
points_bonus, pro_days = reward
telegram_id = int(row.get("telegram_id") or 0)
username = str(row.get("username") or f"user_{telegram_id}")
supabase_user_id = str(row.get("supabase_user_id") or "").strip().lower()
pro_granted = False
pro_error = ""
expires_at = None
if pro_days > 0 and supabase_user_id:
pro_granted, pro_error, expires_at = _grant_bonus_subscription_days(
supabase_url=supabase_url,
service_role_key=service_role_key,
user_id=supabase_user_id,
days=pro_days,
timeout_sec=timeout_sec,
)
elif pro_days > 0 and not supabase_user_id:
pro_error = "supabase_unbound"
applied = db.apply_weekly_reward_payout(
week_key=week_key,
telegram_id=telegram_id,
rank=idx,
username=username,
points_bonus=points_bonus,
pro_days=pro_days,
supabase_user_id=supabase_user_id,
pro_granted=pro_granted,
pro_error=pro_error,
)
if not applied:
skipped += 1
continue
winners.append(
{
"rank": idx,
"username": username,
"telegram_id": telegram_id,
"points_bonus": points_bonus,
"pro_days": pro_days,
"pro_granted": pro_granted,
"pro_error": pro_error,
"expires_at": expires_at,
}
)
summary = {
"week_key": week_key,
"winner_count": len(winners),
"skipped_count": skipped,
"winners": winners,
"settled_at": datetime.now(timezone.utc).isoformat(),
}
db.mark_weekly_reward_settled(
week_key=week_key,
winners_count=len(winners),
summary=summary,
)
logger.info(
"weekly reward settled week={} winners={} skipped={}",
week_key,
len(winners),
skipped,
)
if announce and chat_id:
try:
bot.send_message(
chat_id,
_render_settle_report(week_key=week_key, winners=winners, skipped=skipped),
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning(f"weekly reward announcement failed: {exc}")
except Exception as exc:
logger.warning(f"weekly reward cycle failed: {exc}")
time.sleep(interval_sec)
def start_weekly_reward_loop(bot: Any):
thread = threading.Thread(
target=_runner,
args=(bot,),
daemon=True,
name="weekly-reward-loop",
)
thread.start()
return thread
+221
View File
@@ -1,6 +1,7 @@
import sqlite3
import os
import hashlib
import json
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from loguru import logger
@@ -53,6 +54,38 @@ class DBManager:
PRIMARY KEY (telegram_id, activity_date, fingerprint)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_points_archive (
telegram_id INTEGER NOT NULL,
week_key TEXT NOT NULL,
points INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (telegram_id, week_key)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_reward_runs (
week_key TEXT PRIMARY KEY,
settled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
winners_count INTEGER DEFAULT 0,
summary_json TEXT
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS weekly_reward_payouts (
week_key TEXT NOT NULL,
telegram_id INTEGER NOT NULL,
rank INTEGER DEFAULT 0,
username TEXT,
points_bonus INTEGER DEFAULT 0,
pro_days INTEGER DEFAULT 0,
supabase_user_id TEXT,
pro_granted INTEGER DEFAULT 0,
pro_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (week_key, telegram_id)
)
""")
self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_points_date", "TEXT")
self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0")
@@ -62,6 +95,35 @@ class DBManager:
conn.commit()
logger.info(f"Database initialized successfully path={self.db_path}")
@staticmethod
def _safe_week_key(value: str) -> str:
text = str(value or "").strip()
if len(text) >= 8 and "-W" in text:
return text[:8]
return ""
def _upsert_weekly_archive(
self,
conn: sqlite3.Connection,
telegram_id: int,
week_key: str,
points: int,
) -> None:
wk = self._safe_week_key(week_key)
if not wk:
return
pts = max(0, int(points or 0))
conn.execute(
"""
INSERT INTO weekly_points_archive (telegram_id, week_key, points, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(telegram_id, week_key) DO UPDATE SET
points = excluded.points,
updated_at = excluded.updated_at
""",
(int(telegram_id), wk, pts, datetime.now().isoformat()),
)
def _ensure_column(self, conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
existing = {
row[1]
@@ -211,6 +273,13 @@ class DBManager:
weekly_points = int(row["weekly_points"] or 0)
weekly_points_week = row["weekly_points_week"] or ""
if weekly_points_week != week_key:
if weekly_points_week and weekly_points > 0:
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=weekly_points_week,
points=weekly_points,
)
weekly_points = 0
if daily_points >= daily_cap:
@@ -230,6 +299,12 @@ class DBManager:
telegram_id,
),
)
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=week_key,
points=weekly_points,
)
conn.commit()
return {
"awarded": False,
@@ -275,6 +350,12 @@ class DBManager:
""",
(telegram_id, today_str, fingerprint),
)
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=week_key,
points=weekly_points + points_added,
)
conn.commit()
return {
"awarded": True,
@@ -432,3 +513,143 @@ class DBManager:
"weekly_rank": weekly_rank,
"total_ranked": len(rows),
}
def get_weekly_reward_candidates(self, week_key: str, limit: int = 10):
wk = self._safe_week_key(week_key)
if not wk:
return []
top_n = max(1, int(limit or 10))
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT *
FROM (
SELECT
u.telegram_id,
u.username,
lower(trim(COALESCE(u.supabase_user_id, ''))) AS supabase_user_id,
COALESCE(u.supabase_email, '') AS supabase_email,
COALESCE(u.points, 0) AS points,
COALESCE(u.message_count, 0) AS message_count,
COALESCE(a.points,
CASE
WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0)
ELSE 0
END
) AS weekly_points
FROM users u
LEFT JOIN weekly_points_archive a
ON a.telegram_id = u.telegram_id
AND a.week_key = ?
) ranked
WHERE weekly_points > 0
ORDER BY weekly_points DESC, points DESC, message_count DESC, telegram_id ASC
LIMIT ?
""",
(wk, wk, top_n),
).fetchall()
return [dict(row) for row in rows]
def is_weekly_reward_settled(self, week_key: str) -> bool:
wk = self._safe_week_key(week_key)
if not wk:
return False
with self._get_connection() as conn:
row = conn.execute(
"SELECT 1 FROM weekly_reward_runs WHERE week_key = ? LIMIT 1",
(wk,),
).fetchone()
return bool(row)
def mark_weekly_reward_settled(
self,
week_key: str,
winners_count: int,
summary: Optional[Dict[str, Any]] = None,
) -> None:
wk = self._safe_week_key(week_key)
if not wk:
return
summary_json = None
if isinstance(summary, dict):
try:
summary_json = json.dumps(summary, ensure_ascii=False)
except Exception:
summary_json = None
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO weekly_reward_runs (week_key, settled_at, winners_count, summary_json)
VALUES (?, ?, ?, ?)
ON CONFLICT(week_key) DO UPDATE SET
settled_at = excluded.settled_at,
winners_count = excluded.winners_count,
summary_json = excluded.summary_json
""",
(
wk,
datetime.now().isoformat(),
max(0, int(winners_count or 0)),
summary_json,
),
)
conn.commit()
def apply_weekly_reward_payout(
self,
week_key: str,
telegram_id: int,
rank: int,
username: str,
points_bonus: int,
pro_days: int,
supabase_user_id: str = "",
pro_granted: bool = False,
pro_error: str = "",
) -> bool:
wk = self._safe_week_key(week_key)
if not wk:
return False
bonus = max(0, int(points_bonus or 0))
with self._get_connection() as conn:
exists = conn.execute(
"""
SELECT 1
FROM weekly_reward_payouts
WHERE week_key = ? AND telegram_id = ?
LIMIT 1
""",
(wk, int(telegram_id)),
).fetchone()
if exists:
return False
if bonus > 0:
conn.execute(
"UPDATE users SET points = COALESCE(points, 0) + ? WHERE telegram_id = ?",
(bonus, int(telegram_id)),
)
conn.execute(
"""
INSERT INTO weekly_reward_payouts (
week_key, telegram_id, rank, username, points_bonus, pro_days,
supabase_user_id, pro_granted, pro_error, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
wk,
int(telegram_id),
int(rank or 0),
str(username or ""),
bonus,
max(0, int(pro_days or 0)),
str(supabase_user_id or "").strip().lower(),
1 if pro_granted else 0,
str(pro_error or ""),
datetime.now().isoformat(),
),
)
conn.commit()
return True