完善后台管理功能:订阅管理增强、训练精度面板、支付记录查询

This commit is contained in:
2569718930@qq.com
2026-05-20 16:50:19 +08:00
parent 05f5d3c2dd
commit 66512d2623
8 changed files with 356 additions and 9 deletions
+2 -2
View File
@@ -181,9 +181,9 @@ POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RPC_URLS=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x3c499c542cef5e3811e1192ce70d8cc03d5c3359
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS"}]
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS","is_default":true},{"code":"usdt","symbol":"USDT","name":"USDT","address":"0xc2132d05d31c914a87c6611c10748aeb04b58e8f","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS"},{"code":"pusd","symbol":"pUSD","name":"Polymarket pUSD","address":"0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS"}]
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
+1 -1
View File
@@ -36,7 +36,7 @@ METEOBLUE_API_KEY=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS=
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS","is_default":true},{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS"}]
POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON=[{"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS","is_default":true},{"code":"usdt","symbol":"USDT","name":"USDT","address":"0xc2132d05d31c914a87c6611c10748aeb04b58e8f","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS"},{"code":"pusd","symbol":"pUSD","name":"Polymarket pUSD","address":"0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb","decimals":6,"receiver_contract":"YOUR_RECEIVER_ADDRESS"}]
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
########################################
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { RefreshCcw, X, Search } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
@@ -13,12 +13,30 @@ import {
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
type SubRow = {
id?: string;
status?: string;
plan_code?: string;
source?: string;
starts_at?: string;
expires_at?: string;
created_at?: string;
updated_at?: string;
};
export function MembershipsPageClient() {
const [loading, setLoading] = useState(true);
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
const [growth, setGrowth] = useState<GrowthPoint[]>([]);
const [filter, setFilter] = useState<"all" | "paid" | "trial">("all");
// Detail modal state
const [detailEmail, setDetailEmail] = useState<string | null>(null);
const [detailUserId, setDetailUserId] = useState<string>("");
const [detailRows, setDetailRows] = useState<SubRow[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string>("");
const load = async () => {
setLoading(true);
try {
@@ -47,6 +65,46 @@ export function MembershipsPageClient() {
return code;
};
const sourceLabel = (source?: string) => {
if (!source) return "—";
if (source === "payment_contract") return "链上支付";
if (source === "ops_manual_grant") return "后台赠送";
if (source === "signup_trial") return "注册体验";
if (source === "weekly_reward") return "周奖励";
return source;
};
const statusBadge = (status?: string) => {
if (status === "active") return <span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-medium text-emerald-400">active</span>;
if (status === "expired") return <span className="inline-flex items-center rounded-full bg-red-500/15 px-2 py-0.5 text-[11px] font-medium text-red-400">expired</span>;
if (status === "cancelled") return <span className="inline-flex items-center rounded-full bg-slate-500/15 px-2 py-0.5 text-[11px] font-medium text-slate-400">cancelled</span>;
return <span className="inline-flex items-center rounded-full bg-slate-500/15 px-2 py-0.5 text-[11px] font-medium text-slate-400">{status ?? "—"}</span>;
};
const openDetail = async (email: string) => {
if (!email) return;
setDetailEmail(email);
setDetailLoading(true);
setDetailError("");
setDetailRows([]);
setDetailUserId("");
try {
const data = await opsApi.userSubscriptions(email);
setDetailUserId(data.user_id ?? "");
setDetailRows(data.subscriptions ?? []);
} catch (e: unknown) {
setDetailError(e instanceof Error ? e.message : "查询失败");
}
setDetailLoading(false);
};
const closeDetail = () => {
setDetailEmail(null);
setDetailRows([]);
setDetailUserId("");
setDetailError("");
};
if (loading) return <div className="text-slate-400 animate-pulse">...</div>;
return (
@@ -154,7 +212,7 @@ export function MembershipsPageClient() {
</thead>
<tbody>
{filtered.map((m, i) => (
<tr key={m.user_id || i} className="border-b border-white/5">
<tr key={m.user_id || i} className="border-b border-white/5 hover:bg-white/[0.02] transition-colors">
<td className="py-2.5 px-4">
{m.is_trial ? (
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[11px] font-medium text-amber-400"></span>
@@ -162,7 +220,16 @@ export function MembershipsPageClient() {
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-2 py-0.5 text-[11px] font-medium text-emerald-400"></span>
)}
</td>
<td className="py-2.5 px-4 text-white">{m.email ?? "—"}</td>
<td className="py-2.5 px-4">
<button
className="text-white hover:text-cyan-400 transition-colors inline-flex items-center gap-1.5 group"
onClick={() => openDetail(m.email ?? "")}
title="查看全部订阅记录"
>
{m.email ?? "—"}
<Search className="h-3 w-3 opacity-0 group-hover:opacity-60 transition-opacity" />
</button>
</td>
<td className="py-2.5 px-4">{planLabel(m.plan_code)}</td>
<td className="py-2.5 px-4 text-slate-400 text-xs">{m.starts_at?.slice(0, 10) ?? "—"}</td>
<td className="py-2.5 px-4 text-slate-400 text-xs">{m.expires_at?.slice(0, 10) ?? "—"}</td>
@@ -177,6 +244,81 @@ export function MembershipsPageClient() {
</div>
</CardContent>
</Card>
{/* Subscription detail modal */}
{detailEmail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={closeDetail}>
<div
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div>
<h2 className="text-lg font-bold text-white"></h2>
<p className="text-xs text-slate-400 mt-0.5">
{detailEmail}
{detailUserId && <span className="ml-2 text-slate-500">ID: {detailUserId.slice(0, 8)}</span>}
</p>
</div>
<button onClick={closeDetail} className="text-slate-400 hover:text-white transition-colors p-1 rounded-lg hover:bg-white/10">
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto flex-1 px-6 py-4">
{detailLoading && (
<div className="text-slate-400 animate-pulse py-8 text-center">...</div>
)}
{detailError && (
<div className="text-red-400 py-8 text-center text-sm">{detailError}</div>
)}
{!detailLoading && !detailError && detailRows.length === 0 && (
<div className="text-slate-500 py-8 text-center text-sm"></div>
)}
{!detailLoading && detailRows.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-slate-400">
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
<th className="py-2.5 px-3 font-medium text-xs"></th>
</tr>
</thead>
<tbody>
{detailRows.map((row, i) => {
const isExpired = row.expires_at && new Date(row.expires_at) < new Date();
return (
<tr key={row.id ?? i} className={`border-b border-white/5 ${isExpired ? "opacity-50" : ""}`}>
<td className="py-2 px-3">{statusBadge(row.status)}</td>
<td className="py-2 px-3 text-white text-xs">{planLabel(row.plan_code)}</td>
<td className="py-2 px-3 text-slate-400 text-xs">{sourceLabel(row.source)}</td>
<td className="py-2 px-3 text-slate-400 text-xs font-mono">{row.starts_at?.slice(0, 19)?.replace("T", " ") ?? "—"}</td>
<td className="py-2 px-3 text-xs font-mono">
<span className={isExpired ? "text-red-400" : "text-emerald-400"}>
{row.expires_at?.slice(0, 19)?.replace("T", " ") ?? "—"}
</span>
</td>
<td className="py-2 px-3 text-slate-500 text-xs font-mono">{row.created_at?.slice(0, 19)?.replace("T", " ") ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
<div className="text-xs text-slate-500 mt-3 px-1">
{detailRows.length} · UTC
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -18,15 +18,38 @@ function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
);
}
interface CityAccuracy {
city_id: string;
name: string;
deb?: {
hit_rate: number;
mae: number;
total_days: number;
details_str: string;
} | null;
mu?: {
mae: number;
hit_rate: number;
brier_score: number | null;
total_days: number;
details_str: string;
} | null;
}
export function TrainingPageClient() {
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [accuracy, setAccuracy] = useState<CityAccuracy[] | null>(null);
const load = async () => {
setLoading(true);
try {
const s = await opsApi.systemStatus() as SystemStatusPayload;
const [s, accData] = await Promise.all([
opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.trainingAccuracy().catch(() => ({ accuracy: [] }))
]);
setStatus(s);
setAccuracy(accData.accuracy);
} catch { /* */ }
setLoading(false);
};
@@ -110,6 +133,105 @@ export function TrainingPageClient() {
</div>
) : null}
<Card>
<CardHeader>
<CardTitle> (DEB & )</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left text-slate-300">
<thead className="text-xs uppercase bg-slate-800/50 text-slate-400">
<tr>
<th scope="col" className="px-4 py-3"></th>
<th scope="col" className="px-4 py-3 text-center">DEB </th>
<th scope="col" className="px-4 py-3 text-center">DEB MAE</th>
<th scope="col" className="px-4 py-3 text-center">DEB </th>
<th scope="col" className="px-4 py-3 text-center"> μ </th>
<th scope="col" className="px-4 py-3 text-center"> μ MAE</th>
<th scope="col" className="px-4 py-3 text-center">Brier Score</th>
<th scope="col" className="px-4 py-3 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{accuracy && accuracy.length > 0 ? (
accuracy.map((row) => (
<tr key={row.city_id} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 font-medium text-white capitalize">
{row.name}
<span className="text-xs text-slate-500 block font-mono">{row.city_id}</span>
</td>
<td className="px-4 py-3 text-center">
{row.deb ? (
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
row.deb.hit_rate >= 80
? "bg-green-500/15 text-green-400"
: row.deb.hit_rate >= 60
? "bg-yellow-500/15 text-yellow-400"
: "bg-red-500/15 text-red-400"
}`}>
{row.deb.hit_rate.toFixed(0)}%
</span>
) : (
<span className="text-slate-600"></span>
)}
</td>
<td className="px-4 py-3 text-center font-mono">
{row.deb ? `${row.deb.mae.toFixed(1)}°` : "—"}
</td>
<td className="px-4 py-3 text-center text-slate-400">
{row.deb ? row.deb.total_days : "—"}
</td>
<td className="px-4 py-3 text-center">
{row.mu ? (
<span className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
row.mu.hit_rate >= 80
? "bg-green-500/15 text-green-400"
: row.mu.hit_rate >= 60
? "bg-yellow-500/15 text-yellow-400"
: "bg-red-500/15 text-red-400"
}`}>
{row.mu.hit_rate.toFixed(0)}%
</span>
) : (
<span className="text-slate-600"></span>
)}
</td>
<td className="px-4 py-3 text-center font-mono">
{row.mu ? `${row.mu.mae.toFixed(1)}°` : "—"}
</td>
<td className="px-4 py-3 text-center font-mono">
{row.mu && row.mu.brier_score !== null ? (
<span className={`${
row.mu.brier_score <= 0.1
? "text-green-400 font-bold"
: row.mu.brier_score <= 0.25
? "text-yellow-400"
: "text-red-400"
}`}>
{row.mu.brier_score.toFixed(3)}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3 text-center text-slate-400">
{row.mu ? row.mu.total_days : "—"}
</td>
</tr>
))
) : (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-slate-500">
daily_records.json
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -124,3 +246,4 @@ export function TrainingPageClient() {
</div>
);
}
+21
View File
@@ -105,4 +105,25 @@ export const opsApi = {
count: number;
}>(`/api/ops/subscriptions/user?email=${encodeURIComponent(email)}`);
},
trainingAccuracy() {
return opsFetch<{
accuracy: Array<{
city_id: string;
name: string;
deb?: {
hit_rate: number;
mae: number;
total_days: number;
details_str: string;
} | null;
mu?: {
mae: number;
hit_rate: number;
brier_score: number | null;
total_days: number;
details_str: string;
} | null;
}>;
}>("/api/ops/training/accuracy");
},
};
+3 -2
View File
@@ -1,4 +1,4 @@
import json
import json
import os
import threading
import time
@@ -33,7 +33,8 @@ ERC20_ABI = [
# Source: Polymarket official developer docs (Polygon contract addresses)
# https://docs.polymarket.com/developers/market-makers/setup
DEFAULT_POLYMARKET_CONTRACTS: Dict[str, str] = {
"USDC.e": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
"USDC": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
"pUSD": "0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb",
"CTF": "0x4d97dcd97ec945f40cf65f87097ace5ea0476045",
"CTF_EXCHANGE": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
"NEG_RISK_CTF_EXCHANGE": "0xC5d563A36AE78145C45a50134d48A1215220f80a",
+7
View File
@@ -21,6 +21,7 @@ from web.services.ops_api import (
list_ops_payments,
search_ops_users,
update_ops_config,
get_ops_training_accuracy,
)
router = APIRouter(tags=["ops"])
@@ -160,3 +161,9 @@ async def ops_logs(
@router.get("/api/ops/health-check")
async def ops_health_check(request: Request):
return get_ops_health_check(request)
@router.get("/api/ops/training/accuracy")
async def ops_training_accuracy(request: Request):
return get_ops_training_accuracy(request)
+53
View File
@@ -783,3 +783,56 @@ def get_ops_health_check(request: Request) -> dict[str, Any]:
all_ok = all(v.get("ok") for v in results.values())
return {"ok": all_ok, "checked_at": __import__("datetime").datetime.utcnow().isoformat() + "Z", "services": results}
def get_ops_training_accuracy(request: Request) -> Dict[str, Any]:
_require_ops(request)
from src.analysis.deb_algorithm import get_deb_accuracy, get_mu_accuracy
from src.data_collection.city_registry import CITY_REGISTRY
accuracy_data = []
for city_id, info in CITY_REGISTRY.items():
name = info.get("name") or city_id
# Calculate DEB accuracy
deb_acc = get_deb_accuracy(city_id)
deb_payload = None
if deb_acc:
deb_payload = {
"hit_rate": deb_acc[0],
"mae": deb_acc[1],
"total_days": deb_acc[2],
"details_str": deb_acc[3]
}
# Calculate Mu accuracy
mu_acc = get_mu_accuracy(city_id)
mu_payload = None
if mu_acc:
mu_payload = {
"mae": mu_acc[0],
"hit_rate": mu_acc[1],
"brier_score": mu_acc[2],
"total_days": mu_acc[3],
"details_str": mu_acc[4]
}
if deb_payload or mu_payload:
accuracy_data.append({
"city_id": city_id,
"name": name,
"deb": deb_payload,
"mu": mu_payload
})
# Sort by total days of DEB or Mu
accuracy_data.sort(
key=lambda x: max(
x["deb"]["total_days"] if x["deb"] else 0,
x["mu"]["total_days"] if x["mu"] else 0
),
reverse=True
)
return {"accuracy": accuracy_data}