Release v1.5.0 and add ops admin dashboard

This commit is contained in:
2569718930@qq.com
2026-03-21 00:40:28 +08:00
parent 345c56c4f4
commit e7e52c0da7
22 changed files with 893 additions and 11 deletions
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -1,4 +1,4 @@
# 前端交付与重构报告(v1.4.0
# 前端交付与重构报告(v1.5.0
最后更新:`2026-03-14`
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -166,5 +166,5 @@ docker compose logs -f polyweather | egrep "polymarket wallet activity watcher s
## 当前版本
- 版本:`v1.4.0`
- 版本:`v1.5.0`
- 文档最后更新:`2026-03-20`
+1 -1
View File
@@ -1 +1 @@
1.4.0
1.5.0
+1 -1
View File
@@ -1,4 +1,4 @@
# PolyWeather API 文档(v1.4.0
# PolyWeather API 文档(v1.5.0
最后更新:`2026-03-20`
+1 -1
View File
@@ -1,4 +1,4 @@
# Supabase + 登录 + 支付接入说明(v1.4.0
# Supabase + 登录 + 支付接入说明(v1.5.0
最后更新:`2026-03-14`
+1 -1
View File
@@ -1,4 +1,4 @@
# 技术债与工程待办(v1.4.0
# 技术债与工程待办(v1.5.0
最后更新:`2026-03-20`
+1 -1
View File
@@ -1,4 +1,4 @@
# 技术债与工程待办(v1.4.0
# 技术债与工程待办(v1.5.0
最后更新:`2026-03-20`
+1 -1
View File
@@ -1,4 +1,4 @@
# PolyWeatherCheckout PolygonScan 验证(v1.4.0
# PolyWeatherCheckout PolygonScan 验证(v1.5.0
最后更新:`2026-03-20`
+38
View File
@@ -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 },
);
}
}
@@ -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 },
);
}
}
@@ -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 },
);
}
}
+44
View File
@@ -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 },
);
}
}
@@ -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 },
);
}
}
+43
View File
@@ -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 },
);
}
}
+11
View File
@@ -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 <OpsDashboard />;
}
+494
View File
@@ -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<string, unknown>;
metrics?: Record<string, unknown>;
probability?: {
mode?: string;
rollout?: ProbabilityRollout;
};
integrations?: Record<string, unknown>;
};
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<T>(url: string): Promise<T> {
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<T>;
}
export function OpsDashboard() {
const [health, setHealth] = useState<HealthPayload | null>(null);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [payments, setPayments] = useState<PaymentRuntimePayload | null>(null);
const [auth, setAuth] = useState<AuthMePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshedAt, setRefreshedAt] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [users, setUsers] = useState<OpsUser[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [usersError, setUsersError] = useState<string | null>(null);
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
const [grantEmail, setGrantEmail] = useState("");
const [grantPoints, setGrantPoints] = useState("300");
const [grantStatus, setGrantStatus] = useState<string | null>(null);
const [grantError, setGrantError] = useState<string | null>(null);
const [grantLoading, setGrantLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [healthData, statusData, paymentData, authData] = await Promise.all([
readJson<HealthPayload>("/api/healthz"),
readJson<SystemStatusPayload>("/api/system/status"),
readJson<PaymentRuntimePayload>("/api/payments/runtime"),
readJson<AuthMePayload>("/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 (
<main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<section className="flex flex-col gap-4 rounded-3xl border border-slate-800 bg-slate-900/80 p-6 shadow-2xl backdrop-blur-xl lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge variant="secondary">Ops</Badge>
<Badge variant={health?.status === "ok" ? "success" : "danger"}>
Health {health?.status || "unknown"}
</Badge>
<Badge variant={status?.sqlite?.ok ? "success" : "danger"}>
SQLite {status?.sqlite?.ok ? "ok" : "error"}
</Badge>
<Badge variant={rolloutVariant}>
EMOS {status?.probability?.rollout?.decision || "hold"}
</Badge>
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">PolyWeather Ops</h1>
<p className="mt-2 max-w-3xl text-sm text-slate-400">
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>: {formatDateTime(refreshedAt)}</span>
<Button onClick={() => void load()} disabled={loading} className="gap-2">
<RefreshCcw className="h-4 w-4" />
{loading ? "加载中" : "刷新"}
</Button>
</div>
</section>
{error ? (
<Card className="border-rose-500/30 bg-rose-500/10">
<CardHeader>
<CardTitle className="text-rose-300"></CardTitle>
<CardDescription className="text-rose-200/80">{error}</CardDescription>
</CardHeader>
</Card>
) : null}
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-300">
<div className="flex justify-between gap-3"><span>healthz</span><span>{health?.status || "-"}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{auth?.entitlement_mode || "-"}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{status?.state_storage_mode || "-"}</span></div>
<div className="flex justify-between gap-3"><span>DB</span><span>{health?.db?.ok ? "ok" : "-"}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-4 w-4" />
</CardTitle>
<CardDescription>SQLite rollout </CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-300">
<div className="flex justify-between gap-3"><span>SQLite</span><span>{status?.sqlite?.ok ? "ok" : "error"}</span></div>
<div className="flex justify-between gap-3"><span>EMOS </span><span>{status?.probability?.mode || "-"}</span></div>
<div className="flex justify-between gap-3"><span>线</span><span>{status?.probability?.rollout?.decision || "-"}</span></div>
<div className="flex justify-between gap-3"><span>ready_for_primary</span><span>{status?.probability?.rollout?.ready_for_primary ? "true" : "false"}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wallet className="h-4 w-4" />
</CardTitle>
<CardDescription> RPC</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-300">
<div className="flex justify-between gap-3"><span></span><span>{payments?.checkout?.enabled ? "true" : "false"}</span></div>
<div className="flex justify-between gap-3"><span>RPC </span><span>{payments?.rpc?.configured_rpc_count ?? 0}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{payments?.checkout?.chain_id ?? "-"}</span></div>
<div className="flex justify-between gap-3"><span></span><span>{payments?.event_loop_state?.last_scanned_block ?? "-"}</span></div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-slate-300">
<div className="flex justify-between gap-3"><span>authenticated</span><span>{auth?.authenticated ? "true" : "false"}</span></div>
<div className="flex justify-between gap-3"><span>email</span><span className="truncate text-right">{auth?.email || "-"}</span></div>
<div className="flex justify-between gap-3"><span>points</span><span>{auth?.points ?? 0}</span></div>
<div className="flex justify-between gap-3"><span>weekly_rank</span><span>{auth?.weekly_rank ?? "-"}</span></div>
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">
<Card>
<CardHeader>
<CardTitle>EMOS 线</CardTitle>
<CardDescription> shadow primary </CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="flex flex-wrap gap-2">
<Badge variant={rolloutVariant}>{status?.probability?.rollout?.decision || "hold"}</Badge>
<Badge variant={status?.probability?.rollout?.ready_for_primary ? "success" : "warning"}>
ready={status?.probability?.rollout?.ready_for_primary ? "true" : "false"}
</Badge>
</div>
<div>
<div className="mb-2 text-xs font-bold uppercase tracking-[0.16em] text-slate-500"></div>
<ul className="space-y-2">
{(status?.probability?.rollout?.blocking_reasons || []).length ? (
(status?.probability?.rollout?.blocking_reasons || []).map((reason) => (
<li key={reason} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2">
{reason}
</li>
))
) : (
<li className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2"></li>
)}
</ul>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">RPC</div>
<div className="mt-2 break-all text-xs text-slate-300">{maskUrl(payments?.rpc?.active_rpc_url)}</div>
</div>
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500">Receiver Contract</div>
<div className="mt-2 break-all text-xs text-slate-300">{payments?.checkout?.receiver_contract || "-"}</div>
</div>
<div className="space-y-2">
{(payments?.recent_audit_events || []).slice(0, 6).map((item) => (
<div key={item.id} className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-slate-100">{item.event_type}</span>
<span className="text-xs text-slate-500">#{item.id}</span>
</div>
<div className="mt-1 text-xs text-slate-500">{formatDateTime(item.created_at)}</div>
</div>
))}
{!payments?.recent_audit_events?.length ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
) : null}
</div>
</CardContent>
</Card>
</section>
<section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> Telegram ID Supabase </CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="flex flex-col gap-3 sm:flex-row">
<input
value={searchQuery}
onChange={(event) => 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"
/>
<Button variant="secondary" onClick={() => void loadUsers(searchQuery)} disabled={usersLoading}>
{usersLoading ? "查询中" : "查询"}
</Button>
</div>
{usersError ? <div className="text-rose-300">{usersError}</div> : null}
<div className="space-y-3">
{users.map((user) => (
<div key={user.telegram_id} className="rounded-2xl border border-slate-800 bg-slate-950/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-slate-100">{user.username || "(未命名用户)"}</div>
<div className="text-xs text-slate-500">
TG {user.telegram_id} · {user.supabase_email || "未绑定邮箱"}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary"> {user.points ?? 0}</Badge>
<Badge variant="warning"> {user.weekly_points ?? 0}</Badge>
<Badge variant="default"> {user.message_count ?? 0}</Badge>
</div>
</div>
<div className="mt-2 text-xs text-slate-500">
{user.daily_points ?? 0} · {formatDateTime(user.last_message_at)}
</div>
</div>
))}
{!users.length && !usersLoading ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> Supabase </CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="space-y-3">
<input
value={grantEmail}
onChange={(event) => 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"
/>
<input
value={grantPoints}
onChange={(event) => 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"
/>
<Button onClick={() => void submitGrant()} disabled={grantLoading || !grantEmail.trim()} className="w-full">
{grantLoading ? "提交中" : "补分"}
</Button>
</div>
{grantStatus ? <div className="rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-300">{grantStatus}</div> : null}
{grantError ? <div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-rose-300">{grantError}</div> : null}
<div className="space-y-2">
<div className="text-xs font-bold uppercase tracking-[0.16em] text-slate-500"> 10</div>
{(leaderboard || []).map((item, index) => (
<div key={item.telegram_id} className="flex items-center justify-between gap-3 rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2">
<div>
<div className="font-semibold text-slate-100">#{index + 1} {item.username || "(未命名用户)"}</div>
<div className="text-xs text-slate-500">TG {item.telegram_id}</div>
</div>
<div className="text-right text-xs">
<div className="text-amber-300"> {item.weekly_points ?? 0}</div>
<div className="text-slate-500"> {item.points ?? 0}</div>
</div>
</div>
))}
{!leaderboard.length ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-3 py-2 text-slate-500"></div>
) : null}
</div>
</CardContent>
</Card>
</section>
</div>
</main>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "polyweather-frontend",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"scripts": {
"dev": "next dev",
+61
View File
@@ -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:
+23
View File
@@ -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
+39
View File
@@ -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)