feat: Implement initial web frontend, comprehensive database management, and a bot-based weekly reward system.
This commit is contained in:
@@ -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.
@@ -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>
|
||||
|
||||
Generated
+7254
-14
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user