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_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30 SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60 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): # Bot command access guard (/city, /deb):
# - Pro entitlement removed # - Pro entitlement removed
# - user only needs to be a member of TELEGRAM_CHAT_ID group # - 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_MESSAGE_COOLDOWN_SEC=30
POLYWEATHER_BOT_CITY_QUERY_COST=1 POLYWEATHER_BOT_CITY_QUERY_COST=1
POLYWEATHER_BOT_DEB_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/*) # Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
POLYWEATHER_REQUIRE_ENTITLEMENT=false POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
Binary file not shown.
+189 -27
View File
@@ -134,12 +134,34 @@ declare global {
type EvmProvider = { type EvmProvider = {
request: (args: { method: string; params?: any[] | object }) => Promise<any>; request: (args: { method: string; params?: any[] | object }) => Promise<any>;
providers?: EvmProvider[]; providers?: EvmProvider[];
connect?: (args?: any) => Promise<void>;
disconnect?: () => Promise<void>;
session?: unknown;
isMetaMask?: boolean; isMetaMask?: boolean;
isRabby?: boolean; isRabby?: boolean;
isOkxWallet?: boolean; isOkxWallet?: boolean;
isBitKeep?: 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 --- // --- Helpers ---
type InfoRowProps = { type InfoRowProps = {
@@ -215,6 +237,61 @@ function getEvmWalletLabel(provider: EvmProvider | null): string {
return "EVM 钱包"; 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) { function toPaddedHex(value: bigint) {
return value.toString(16).padStart(64, "0"); return value.toString(16).padStart(64, "0");
} }
@@ -366,6 +443,7 @@ export function AccountCenter() {
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
const [selectedWallet, setSelectedWallet] = useState(""); const [selectedWallet, setSelectedWallet] = useState("");
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
const [paymentBusy, setPaymentBusy] = useState(false); const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentInfo, setPaymentInfo] = useState(""); const [paymentInfo, setPaymentInfo] = useState("");
const [paymentError, setPaymentError] = useState(""); const [paymentError, setPaymentError] = useState("");
@@ -373,6 +451,7 @@ export function AccountCenter() {
const [lastTxHash, setLastTxHash] = useState(""); const [lastTxHash, setLastTxHash] = useState("");
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const walletConnectEnabled = Boolean(WALLETCONNECT_PROJECT_ID);
/** /**
* Returns a valid access token, refreshing the session if the stored one * Returns a valid access token, refreshing the session if the stored one
@@ -417,6 +496,45 @@ export function AccountCenter() {
[supabaseReady, getValidAccessToken], [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 () => { const loadPaymentSnapshot = useCallback(async () => {
if (!backend?.authenticated) { if (!backend?.authenticated) {
setPaymentConfig(null); setPaymentConfig(null);
@@ -533,6 +651,15 @@ export function AccountCenter() {
}; };
const onSignOut = async () => { const onSignOut = async () => {
if (walletConnectProviderCache?.disconnect) {
try {
await walletConnectProviderCache.disconnect();
} catch {
// ignore
}
walletConnectProviderCache = null;
walletConnectProviderChainId = null;
}
if (supabaseReady) { if (supabaseReady) {
try { try {
await getSupabaseBrowserClient().auth.signOut(); await getSupabaseBrowserClient().auth.signOut();
@@ -803,24 +930,20 @@ export function AccountCenter() {
} }
}; };
const connectAndBindWallet = async () => { const connectAndBindWallet = async (mode: ProviderMode = "auto") => {
setPaymentError(""); setPaymentError("");
setPaymentInfo(""); setPaymentInfo("");
if (!isAuthenticated) { if (!isAuthenticated) {
setPaymentError("请先登录后再绑定钱包。"); setPaymentError("请先登录后再绑定钱包。");
return; return;
} }
const eth = getEvmProvider();
if (!eth) {
setPaymentError(
"未检测到浏览器钱包,请安装并启用 MetaMask / OKX Wallet / Rabby / Bitget 等 EVM 钱包扩展。",
);
return;
}
const walletLabel = getEvmWalletLabel(eth);
setPaymentBusy(true); setPaymentBusy(true);
try { 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. // Ensure we have a valid token BEFORE opening the wallet modal.
let accessToken: string; let accessToken: string;
try { try {
@@ -883,6 +1006,7 @@ export function AccountCenter() {
} }
setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}`); setPaymentInfo(`${walletLabel} 绑定成功: ${shortAddress(address)}`);
setProviderMode(providerSelection.mode);
await loadPaymentSnapshot(); await loadPaymentSnapshot();
} catch (error) { } catch (error) {
setPaymentInfo(""); setPaymentInfo("");
@@ -905,18 +1029,10 @@ export function AccountCenter() {
return; return;
} }
const eth = getEvmProvider(); const fallbackWallet = String(
if (!eth) {
setPaymentError(
"未检测到浏览器钱包,请安装并启用 EVM 钱包扩展(MetaMask / OKX / Rabby 等)。",
);
return;
}
const payingWallet = String(
selectedWallet || walletAddress || boundWallets[0]?.address || "", selectedWallet || walletAddress || boundWallets[0]?.address || "",
).toLowerCase(); ).toLowerCase();
if (!payingWallet) { if (!fallbackWallet) {
setPaymentError("请先绑定钱包。"); setPaymentError("请先绑定钱包。");
return; return;
} }
@@ -924,6 +1040,29 @@ export function AccountCenter() {
setPaymentBusy(true); setPaymentBusy(true);
let approvedInThisRun = false; let approvedInThisRun = false;
try { 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. // Ensure we have a valid token BEFORE switching chain / sending tx.
let accessToken: string; let accessToken: string;
try { try {
@@ -1112,7 +1251,7 @@ export function AccountCenter() {
return; return;
} }
if (!hasPayingWallet) { if (!hasPayingWallet) {
await connectAndBindWallet(); await connectAndBindWallet(providerMode);
return; return;
} }
await createIntentAndPay(); await createIntentAndPay();
@@ -1489,13 +1628,36 @@ export function AccountCenter() {
)} )}
</div> </div>
<button <div className="mt-6 grid grid-cols-1 gap-2">
onClick={() => void connectAndBindWallet()} <button
disabled={paymentBusy || !isAuthenticated} onClick={() => {
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" setProviderMode("auto");
> void connectAndBindWallet("auto");
<PlusIcon className="w-4 h-4" /> (EVM) }}
</button> 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> </section>
</div> </div>
</main> </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", "@supabase/supabase-js": "^2.57.2",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"@walletconnect/ethereum-provider": "^2.23.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.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>{balance}</code>\n"
f"需要积分: <code>{required}</code>\n" f"需要积分: <code>{required}</code>\n"
f"还差积分: <code>{missing}</code>\n\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} 分。" f"每日上限 {MESSAGE_DAILY_CAP} 分。"
), ),
parse_mode="HTML", parse_mode="HTML",
+49 -1
View File
@@ -84,6 +84,7 @@ class StartupCoordinator:
self._start_trade_alert_loop(), self._start_trade_alert_loop(),
self._start_polygon_wallet_loop(), self._start_polygon_wallet_loop(),
self._start_polymarket_wallet_activity_loop(), self._start_polymarket_wallet_activity_loop(),
self._start_weekly_reward_loop(),
] ]
self._runtime_status = RuntimeStatus( self._runtime_status = RuntimeStatus(
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
@@ -121,7 +122,17 @@ class StartupCoordinator:
reason=validation_error, reason=validation_error,
details=details, 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 started = thread is not None
reason = "started" if started else "starter_returned_none" reason = "started" if started else "starter_returned_none"
if started: if started:
@@ -218,6 +229,43 @@ class StartupCoordinator:
).start_polymarket_wallet_activity_loop(self.bot), ).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: def render_runtime_status_html(status: RuntimeStatus) -> str:
lines = [ 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 sqlite3
import os import os
import hashlib import hashlib
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from loguru import logger from loguru import logger
@@ -53,6 +54,38 @@ class DBManager:
PRIMARY KEY (telegram_id, activity_date, fingerprint) 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", "INTEGER DEFAULT 0")
self._ensure_column(conn, "users", "daily_points_date", "TEXT") self._ensure_column(conn, "users", "daily_points_date", "TEXT")
self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0") self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0")
@@ -62,6 +95,35 @@ class DBManager:
conn.commit() conn.commit()
logger.info(f"Database initialized successfully path={self.db_path}") 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: def _ensure_column(self, conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
existing = { existing = {
row[1] row[1]
@@ -211,6 +273,13 @@ class DBManager:
weekly_points = int(row["weekly_points"] or 0) weekly_points = int(row["weekly_points"] or 0)
weekly_points_week = row["weekly_points_week"] or "" weekly_points_week = row["weekly_points_week"] or ""
if weekly_points_week != week_key: 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 weekly_points = 0
if daily_points >= daily_cap: if daily_points >= daily_cap:
@@ -230,6 +299,12 @@ class DBManager:
telegram_id, telegram_id,
), ),
) )
self._upsert_weekly_archive(
conn,
telegram_id=telegram_id,
week_key=week_key,
points=weekly_points,
)
conn.commit() conn.commit()
return { return {
"awarded": False, "awarded": False,
@@ -275,6 +350,12 @@ class DBManager:
""", """,
(telegram_id, today_str, fingerprint), (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() conn.commit()
return { return {
"awarded": True, "awarded": True,
@@ -432,3 +513,143 @@ class DBManager:
"weekly_rank": weekly_rank, "weekly_rank": weekly_rank,
"total_ranked": len(rows), "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