From e7e52c0da763b28eb76a7c7e60d1fe7fde56c716 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 21 Mar 2026 00:40:28 +0800 Subject: [PATCH] Release v1.5.0 and add ops admin dashboard --- CHANGELOG.md | 2 +- FRONTEND_REDESIGN_REPORT.md | 2 +- README.md | 2 +- README_ZH.md | 2 +- VERSION | 2 +- docs/API_ZH.md | 2 +- docs/SUPABASE_SETUP_ZH.md | 2 +- docs/TECH_DEBT.md | 2 +- docs/TECH_DEBT_ZH.md | 2 +- docs/payments/POLYGONSCAN_VERIFY.md | 2 +- frontend/app/api/healthz/route.ts | 38 ++ .../app/api/ops/leaderboard/weekly/route.ts | 42 ++ .../app/api/ops/users/grant-points/route.ts | 44 ++ frontend/app/api/ops/users/route.ts | 44 ++ frontend/app/api/payments/runtime/route.ts | 43 ++ frontend/app/api/system/status/route.ts | 43 ++ frontend/app/ops/page.tsx | 11 + frontend/components/ops/OpsDashboard.tsx | 494 ++++++++++++++++++ frontend/package.json | 2 +- src/database/db_manager.py | 61 +++ web/core.py | 23 + web/routes.py | 39 ++ 22 files changed, 893 insertions(+), 11 deletions(-) create mode 100644 frontend/app/api/healthz/route.ts create mode 100644 frontend/app/api/ops/leaderboard/weekly/route.ts create mode 100644 frontend/app/api/ops/users/grant-points/route.ts create mode 100644 frontend/app/api/ops/users/route.ts create mode 100644 frontend/app/api/payments/runtime/route.ts create mode 100644 frontend/app/api/system/status/route.ts create mode 100644 frontend/app/ops/page.tsx create mode 100644 frontend/components/ops/OpsDashboard.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index dd442efa..0446cd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 1.5.0 - 2026-03-21 - 运行态状态与缓存支持 SQLite 渐进迁移,新增 `POLYWEATHER_STATE_STORAGE_MODE=file|dual|sqlite` - 新增 `/healthz`、`/api/system/status`、`/metrics` diff --git a/FRONTEND_REDESIGN_REPORT.md b/FRONTEND_REDESIGN_REPORT.md index 2ab7df83..4ae77275 100644 --- a/FRONTEND_REDESIGN_REPORT.md +++ b/FRONTEND_REDESIGN_REPORT.md @@ -1,4 +1,4 @@ -# 前端交付与重构报告(v1.4.0) +# 前端交付与重构报告(v1.5.0) 最后更新:`2026-03-14` diff --git a/README.md b/README.md index cf6bf4df..2e8ea389 100644 --- a/README.md +++ b/README.md @@ -136,5 +136,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s ## Version -- Version: `v1.4.0` +- Version: `v1.5.0` - Last Updated: `2026-03-14` diff --git a/README_ZH.md b/README_ZH.md index 31169f64..4dfdd8f5 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -166,5 +166,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s ## 当前版本 -- 版本:`v1.4.0` +- 版本:`v1.5.0` - 文档最后更新:`2026-03-20` diff --git a/VERSION b/VERSION index 88c5fb89..bc80560f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/docs/API_ZH.md b/docs/API_ZH.md index b6afad19..3b0d8bab 100644 --- a/docs/API_ZH.md +++ b/docs/API_ZH.md @@ -1,4 +1,4 @@ -# PolyWeather API 文档(v1.4.0) +# PolyWeather API 文档(v1.5.0) 最后更新:`2026-03-20` diff --git a/docs/SUPABASE_SETUP_ZH.md b/docs/SUPABASE_SETUP_ZH.md index e61f029a..0f0d244a 100644 --- a/docs/SUPABASE_SETUP_ZH.md +++ b/docs/SUPABASE_SETUP_ZH.md @@ -1,4 +1,4 @@ -# Supabase + 登录 + 支付接入说明(v1.4.0) +# Supabase + 登录 + 支付接入说明(v1.5.0) 最后更新:`2026-03-14` diff --git a/docs/TECH_DEBT.md b/docs/TECH_DEBT.md index ce03a817..0f522cdc 100644 --- a/docs/TECH_DEBT.md +++ b/docs/TECH_DEBT.md @@ -1,4 +1,4 @@ -# 技术债与工程待办(v1.4.0) +# 技术债与工程待办(v1.5.0) 最后更新:`2026-03-20` diff --git a/docs/TECH_DEBT_ZH.md b/docs/TECH_DEBT_ZH.md index ce03a817..0f522cdc 100644 --- a/docs/TECH_DEBT_ZH.md +++ b/docs/TECH_DEBT_ZH.md @@ -1,4 +1,4 @@ -# 技术债与工程待办(v1.4.0) +# 技术债与工程待办(v1.5.0) 最后更新:`2026-03-20` diff --git a/docs/payments/POLYGONSCAN_VERIFY.md b/docs/payments/POLYGONSCAN_VERIFY.md index a654a548..3756bdac 100644 --- a/docs/payments/POLYGONSCAN_VERIFY.md +++ b/docs/payments/POLYGONSCAN_VERIFY.md @@ -1,4 +1,4 @@ -# PolyWeatherCheckout PolygonScan 验证(v1.4.0) +# PolyWeatherCheckout PolygonScan 验证(v1.5.0) 最后更新:`2026-03-20` diff --git a/frontend/app/api/healthz/route.ts b/frontend/app/api/healthz/route.ts new file mode 100644 index 00000000..66ffb3a1 --- /dev/null +++ b/frontend/app/api/healthz/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/healthz`, { + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch healthz", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/ops/leaderboard/weekly/route.ts b/frontend/app/api/ops/leaderboard/weekly/route.ts new file mode 100644 index 00000000..97bcc6ce --- /dev/null +++ b/frontend/app/api/ops/leaderboard/weekly/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const url = new URL(`${API_BASE}/api/ops/leaderboard/weekly`); + const limit = req.nextUrl.searchParams.get("limit"); + if (limit) url.searchParams.set("limit", limit); + + const res = await fetch(url.toString(), { + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch weekly leaderboard", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/ops/users/grant-points/route.ts b/frontend/app/api/ops/users/grant-points/route.ts new file mode 100644 index 00000000..5bdd09a3 --- /dev/null +++ b/frontend/app/api/ops/users/grant-points/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const body = await req.text(); + const res = await fetch(`${API_BASE}/api/ops/users/grant-points`, { + method: "POST", + headers: { + ...auth.headers, + "Content-Type": "application/json", + }, + body, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to grant points", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/ops/users/route.ts b/frontend/app/api/ops/users/route.ts new file mode 100644 index 00000000..7f235168 --- /dev/null +++ b/frontend/app/api/ops/users/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const url = new URL(`${API_BASE}/api/ops/users`); + const q = req.nextUrl.searchParams.get("q"); + const limit = req.nextUrl.searchParams.get("limit"); + if (q) url.searchParams.set("q", q); + if (limit) url.searchParams.set("limit", limit); + + const res = await fetch(url.toString(), { + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch ops users", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/payments/runtime/route.ts b/frontend/app/api/payments/runtime/route.ts new file mode 100644 index 00000000..f941f112 --- /dev/null +++ b/frontend/app/api/payments/runtime/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/payments/runtime`, { + headers: auth.headers, + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) }, + { status: res.status }, + ); + return applyAuthResponseCookies(response, auth.response); + } + + const data = await res.json(); + const response = NextResponse.json(data, { + headers: { "Cache-Control": "no-store" }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch payment runtime", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/system/status/route.ts b/frontend/app/api/system/status/route.ts new file mode 100644 index 00000000..5c8273da --- /dev/null +++ b/frontend/app/api/system/status/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const res = await fetch(`${API_BASE}/api/system/status`, { + headers: auth.headers, + cache: "no-store", + }); + if (!res.ok) { + const raw = await res.text(); + const response = NextResponse.json( + { error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) }, + { status: res.status }, + ); + return applyAuthResponseCookies(response, auth.response); + } + + const data = await res.json(); + const response = NextResponse.json(data, { + headers: { "Cache-Control": "no-store" }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch system status", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/ops/page.tsx b/frontend/app/ops/page.tsx new file mode 100644 index 00000000..7af1b6d4 --- /dev/null +++ b/frontend/app/ops/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { OpsDashboard } from "@/components/ops/OpsDashboard"; + +export const metadata: Metadata = { + title: "PolyWeather Ops", + description: "PolyWeather lightweight operations dashboard.", +}; + +export default function OpsPage() { + return ; +} diff --git a/frontend/components/ops/OpsDashboard.tsx b/frontend/components/ops/OpsDashboard.tsx new file mode 100644 index 00000000..fdc3ccc9 --- /dev/null +++ b/frontend/components/ops/OpsDashboard.tsx @@ -0,0 +1,494 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertTriangle, Database, RefreshCcw, ShieldCheck, Wallet } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +type HealthPayload = { + status?: string; + db?: { ok?: boolean }; +}; + +type ProbabilityRollout = { + decision?: string; + ready_for_primary?: boolean; + blocking_reasons?: string[]; +}; + +type SystemStatusPayload = { + state_storage_mode?: string; + sqlite?: { ok?: boolean; path?: string }; + features?: Record; + metrics?: Record; + probability?: { + mode?: string; + rollout?: ProbabilityRollout; + }; + integrations?: Record; +}; + +type PaymentRuntimePayload = { + checkout?: { + enabled?: boolean; + configured?: boolean; + chain_id?: number; + receiver_contract?: string; + confirmations?: number; + }; + rpc?: { + configured_rpc_count?: number; + active_rpc_url?: string; + }; + event_loop_state?: { + last_scanned_block?: number; + updated_at?: string; + }; + recent_audit_events?: Array<{ + id: number; + event_type: string; + created_at: string; + }>; +}; + +type AuthMePayload = { + authenticated?: boolean; + email?: string | null; + entitlement_mode?: string; + subscription_active?: boolean | null; + weekly_rank?: number | null; + points?: number; +}; + +type OpsUser = { + telegram_id: number; + username?: string | null; + points?: number; + daily_points?: number; + daily_points_date?: string | null; + weekly_points?: number; + weekly_points_week?: string | null; + message_count?: number; + supabase_email?: string | null; + last_message_at?: string | null; +}; + +type WeeklyLeaderboardEntry = { + telegram_id: number; + username?: string | null; + points?: number; + message_count?: number; + weekly_points?: number; +}; + +function formatDateTime(value?: string | null) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function maskUrl(value?: string | null) { + if (!value) return "-"; + if (value.length <= 40) return value; + return `${value.slice(0, 28)}...${value.slice(-8)}`; +} + +async function readJson(url: string): Promise { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) { + const raw = await response.text(); + throw new Error(`${url} -> HTTP ${response.status} ${raw.slice(0, 180)}`); + } + return response.json() as Promise; +} + +export function OpsDashboard() { + const [health, setHealth] = useState(null); + const [status, setStatus] = useState(null); + const [payments, setPayments] = useState(null); + const [auth, setAuth] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshedAt, setRefreshedAt] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [users, setUsers] = useState([]); + const [usersLoading, setUsersLoading] = useState(false); + const [usersError, setUsersError] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const [grantEmail, setGrantEmail] = useState(""); + const [grantPoints, setGrantPoints] = useState("300"); + const [grantStatus, setGrantStatus] = useState(null); + const [grantError, setGrantError] = useState(null); + const [grantLoading, setGrantLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [healthData, statusData, paymentData, authData] = await Promise.all([ + readJson("/api/healthz"), + readJson("/api/system/status"), + readJson("/api/payments/runtime"), + readJson("/api/auth/me"), + ]); + + setHealth(healthData); + setStatus(statusData); + setPayments(paymentData); + setAuth(authData); + setRefreshedAt(new Date().toISOString()); + } catch (loadError) { + setError(String(loadError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const loadUsers = useCallback(async (query: string) => { + setUsersLoading(true); + setUsersError(null); + try { + const url = new URL("/api/ops/users", window.location.origin); + if (query.trim()) { + url.searchParams.set("q", query.trim()); + } + url.searchParams.set("limit", "20"); + const data = await readJson<{ users?: OpsUser[] }>(url.toString()); + setUsers(data.users || []); + } catch (loadError) { + setUsersError(String(loadError)); + } finally { + setUsersLoading(false); + } + }, []); + + const loadLeaderboard = useCallback(async () => { + try { + const data = await readJson<{ leaderboard?: WeeklyLeaderboardEntry[] }>( + "/api/ops/leaderboard/weekly?limit=10", + ); + setLeaderboard(data.leaderboard || []); + } catch { + setLeaderboard([]); + } + }, []); + + useEffect(() => { + void loadUsers(""); + void loadLeaderboard(); + }, [loadLeaderboard, loadUsers]); + + const rolloutVariant = useMemo(() => { + const decision = status?.probability?.rollout?.decision; + if (decision === "promote") return "success" as const; + if (decision === "observe") return "warning" as const; + return "danger" as const; + }, [status?.probability?.rollout?.decision]); + + const submitGrant = useCallback(async () => { + setGrantLoading(true); + setGrantError(null); + setGrantStatus(null); + try { + const response = await fetch("/api/ops/users/grant-points", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: grantEmail.trim(), + points: Number(grantPoints), + }), + }); + const payload = (await response.json()) as { + ok?: boolean; + detail?: { reason?: string }; + points_after?: number; + points_added?: number; + supabase_email?: string; + }; + if (!response.ok) { + throw new Error( + payload?.detail?.reason || `HTTP ${response.status}`, + ); + } + setGrantStatus( + `${payload.supabase_email || grantEmail.trim()} 已补 ${payload.points_added || grantPoints} 分,当前 ${payload.points_after ?? "-"}`, + ); + await loadUsers(searchQuery); + await loadLeaderboard(); + } catch (submitError) { + setGrantError(String(submitError)); + } finally { + setGrantLoading(false); + } + }, [grantEmail, grantPoints, loadLeaderboard, loadUsers, searchQuery]); + + return ( +
+
+
+
+
+ Ops + + Health {health?.status || "unknown"} + + + SQLite {status?.sqlite?.ok ? "ok" : "error"} + + + EMOS {status?.probability?.rollout?.decision || "hold"} + +
+
+

PolyWeather Ops

+

+ 直接挂在现有域名下的轻量运营页。先做只读运维视图,把系统状态、支付运行态和当前登录态聚合起来。 +

+
+
+
+ 刷新时间: {formatDateTime(refreshedAt)} + +
+
+ + {error ? ( + + + 加载失败 + {error} + + + ) : null} + +
+ + + + 系统健康 + + 后端健康、鉴权策略、状态存储模式。 + + +
healthz{health?.status || "-"}
+
鉴权模式{auth?.entitlement_mode || "-"}
+
状态存储{status?.state_storage_mode || "-"}
+
DB{health?.db?.ok ? "ok" : "-"}
+
+
+ + + + + 运行态存储 + + SQLite 是否正常,以及 rollout 当前状态。 + + +
SQLite{status?.sqlite?.ok ? "ok" : "error"}
+
EMOS 模式{status?.probability?.mode || "-"}
+
上线门禁{status?.probability?.rollout?.decision || "-"}
+
ready_for_primary{status?.probability?.rollout?.ready_for_primary ? "true" : "false"}
+
+
+ + + + + 支付运行态 + + 当前 RPC、事件循环区块、合约配置。 + + +
支付启用{payments?.checkout?.enabled ? "true" : "false"}
+
RPC 数量{payments?.rpc?.configured_rpc_count ?? 0}
+
{payments?.checkout?.chain_id ?? "-"}
+
最后区块{payments?.event_loop_state?.last_scanned_block ?? "-"}
+
+
+ + + + + 当前登录态 + + 先确认管理员自己当前有没有会话。 + + +
authenticated{auth?.authenticated ? "true" : "false"}
+
email{auth?.email || "-"}
+
points{auth?.points ?? 0}
+
weekly_rank{auth?.weekly_rank ?? "-"}
+
+
+
+ +
+ + + EMOS 上线门禁 + 当前 shadow 到 primary 的发布判断。 + + +
+ {status?.probability?.rollout?.decision || "hold"} + + ready={status?.probability?.rollout?.ready_for_primary ? "true" : "false"} + +
+
+
阻塞原因
+
    + {(status?.probability?.rollout?.blocking_reasons || []).length ? ( + (status?.probability?.rollout?.blocking_reasons || []).map((reason) => ( +
  • + {reason} +
  • + )) + ) : ( +
  • 当前无阻塞项
  • + )} +
+
+
+
+ + + + 支付审计摘要 + 先展示最新几条事件,日常巡检够用。 + + +
+
RPC
+
{maskUrl(payments?.rpc?.active_rpc_url)}
+
+
+
Receiver Contract
+
{payments?.checkout?.receiver_contract || "-"}
+
+
+ {(payments?.recent_audit_events || []).slice(0, 6).map((item) => ( +
+
+ {item.event_type} + #{item.id} +
+
{formatDateTime(item.created_at)}
+
+ ))} + {!payments?.recent_audit_events?.length ? ( +
暂无审计事件
+ ) : null} +
+
+
+
+ +
+ + + 用户查询 + 按 Telegram ID、用户名或 Supabase 邮箱搜用户。 + + +
+ setSearchQuery(event.target.value)} + placeholder="telegram id / username / email" + className="h-10 flex-1 rounded-xl border border-slate-800 bg-slate-950 px-3 text-sm text-slate-100 outline-none ring-0" + /> + +
+ {usersError ?
{usersError}
: null} +
+ {users.map((user) => ( +
+
+
+
{user.username || "(未命名用户)"}
+
+ TG {user.telegram_id} · {user.supabase_email || "未绑定邮箱"} +
+
+
+ 总分 {user.points ?? 0} + 周分 {user.weekly_points ?? 0} + 发言 {user.message_count ?? 0} +
+
+
+ 今日积分 {user.daily_points ?? 0} · 最近发言 {formatDateTime(user.last_message_at)} +
+
+ ))} + {!users.length && !usersLoading ? ( +
没有匹配用户
+ ) : null} +
+
+
+ + + + 积分运营 + 先做最小版:按 Supabase 邮箱手动补分。 + + +
+ setGrantEmail(event.target.value)} + placeholder="user@example.com" + className="h-10 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 text-sm text-slate-100 outline-none ring-0" + /> + setGrantPoints(event.target.value)} + placeholder="300" + className="h-10 w-full rounded-xl border border-slate-800 bg-slate-950 px-3 text-sm text-slate-100 outline-none ring-0" + /> + +
+ {grantStatus ?
{grantStatus}
: null} + {grantError ?
{grantError}
: null} + +
+
本周榜前 10
+ {(leaderboard || []).map((item, index) => ( +
+
+
#{index + 1} {item.username || "(未命名用户)"}
+
TG {item.telegram_id}
+
+
+
周分 {item.weekly_points ?? 0}
+
总分 {item.points ?? 0}
+
+
+ ))} + {!leaderboard.length ? ( +
当前没有周榜数据
+ ) : null} +
+
+
+
+
+
+ ); +} diff --git a/frontend/package.json b/frontend/package.json index 3db26119..84c9fab1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "polyweather-frontend", - "version": "1.4.0", + "version": "1.5.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/database/db_manager.py b/src/database/db_manager.py index ab66d67b..9a1f63da 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -345,6 +345,67 @@ class DBManager: return dict(row) return None + def search_users(self, query: str, limit: int = 20) -> List[Dict[str, Any]]: + text = str(query or "").strip() + safe_limit = max(1, min(int(limit or 20), 100)) + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + if not text: + rows = conn.execute( + """ + SELECT + telegram_id, + username, + points, + daily_points, + daily_points_date, + weekly_points, + weekly_points_week, + message_count, + supabase_user_id, + supabase_email, + created_at, + last_message_at + FROM users + ORDER BY points DESC, message_count DESC, telegram_id ASC + LIMIT ? + """, + (safe_limit,), + ).fetchall() + return [dict(row) for row in rows] + + rows = conn.execute( + """ + SELECT + telegram_id, + username, + points, + daily_points, + daily_points_date, + weekly_points, + weekly_points_week, + message_count, + supabase_user_id, + supabase_email, + created_at, + last_message_at + FROM users + WHERE + CAST(telegram_id AS TEXT) = ? + OR lower(trim(COALESCE(username, ''))) LIKE ? + OR lower(trim(COALESCE(supabase_email, ''))) LIKE ? + ORDER BY points DESC, message_count DESC, telegram_id ASC + LIMIT ? + """, + ( + text, + f"%{text.lower()}%", + f"%{text.lower()}%", + safe_limit, + ), + ).fetchall() + return [dict(row) for row in rows] + def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int: user = self.get_user_by_supabase_user_id(supabase_user_id) if not user: diff --git a/web/core.py b/web/core.py index 93f084c6..67b10b4b 100644 --- a/web/core.py +++ b/web/core.py @@ -151,6 +151,11 @@ _SUPABASE_AUTH_REQUIRED = _env_bool( "POLYWEATHER_AUTH_REQUIRED", SUPABASE_ENTITLEMENT.enabled, ) +_OPS_ADMIN_EMAILS = { + item.strip().lower() + for item in str(os.getenv("POLYWEATHER_OPS_ADMIN_EMAILS") or "").split(",") + if item.strip() +} def _legacy_service_token_valid(request: Request) -> bool: @@ -287,6 +292,19 @@ def _require_supabase_identity(request: Request) -> Dict[str, str]: raise HTTPException(status_code=401, detail="Unauthorized") +def _require_ops_admin(request: Request) -> Dict[str, str]: + identity = _require_supabase_identity(request) + if not _OPS_ADMIN_EMAILS: + raise HTTPException( + status_code=503, + detail="ops admin is not configured; set POLYWEATHER_OPS_ADMIN_EMAILS", + ) + email = str(identity.get("email") or "").strip().lower() + if not email or email not in _OPS_ADMIN_EMAILS: + raise HTTPException(status_code=403, detail="ops admin required") + return identity + + class WalletChallengeRequest(BaseModel): address: str = Field(..., min_length=8) @@ -320,6 +338,11 @@ class ConfirmPaymentTxRequest(BaseModel): tx_hash: Optional[str] = None +class GrantPointsRequest(BaseModel): + email: str = Field(..., min_length=3) + points: int = Field(..., gt=0, le=100000) + + def _sf(v) -> Optional[float]: if v is None: return None diff --git a/web/routes.py b/web/routes.py index cc9aa796..f96836ee 100644 --- a/web/routes.py +++ b/web/routes.py @@ -25,6 +25,7 @@ from web.core import ( SUPABASE_ENTITLEMENT, ConfirmPaymentTxRequest, CreatePaymentIntentRequest, + GrantPointsRequest, SubmitPaymentTxRequest, WalletChallengeRequest, WalletUnbindRequest, @@ -33,6 +34,7 @@ from web.core import ( _SUPABASE_AUTH_REQUIRED, _assert_entitlement, _bind_optional_supabase_identity, + _require_ops_admin, build_health_payload, build_system_status_payload, _require_supabase_identity, @@ -225,6 +227,43 @@ async def auth_me(request: Request): } +@router.get("/api/ops/users") +async def ops_search_users(request: Request, q: str = "", limit: int = 20): + _assert_entitlement(request) + _require_ops_admin(request) + from src.database.db_manager import DBManager + + db = DBManager() + users = db.search_users(q, limit=limit) + return {"users": users} + + +@router.get("/api/ops/leaderboard/weekly") +async def ops_weekly_leaderboard(request: Request, limit: int = 20): + _assert_entitlement(request) + _require_ops_admin(request) + from src.database.db_manager import DBManager + + db = DBManager() + return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} + + +@router.post("/api/ops/users/grant-points") +async def ops_grant_points(request: Request, body: GrantPointsRequest): + _assert_entitlement(request) + admin = _require_ops_admin(request) + from src.database.db_manager import DBManager + + db = DBManager() + result = db.grant_points_by_supabase_email(body.email, body.points) + result["operator_email"] = admin.get("email") + if not result.get("ok"): + reason = str(result.get("reason") or "grant_points_failed") + status_code = 404 if reason == "user_not_found" else 400 + raise HTTPException(status_code=status_code, detail=result) + return result + + @router.get("/api/payments/config") async def payment_config(request: Request): _assert_entitlement(request)