Add market watch and match hub workflows

这个提交包含在:
cryptocommuniums-afk
2026-04-07 11:00:03 +08:00
父节点 495da60212
当前提交 32ffad1545
修改 39 个文件,包含 6974 行新增330 行删除

查看文件

@@ -19,6 +19,8 @@ import LiveCamera from "./pages/LiveCamera";
import Recorder from "./pages/Recorder";
import Tutorials from "./pages/Tutorials";
import Reminders from "./pages/Reminders";
import Market from "./pages/Market";
import Matches from "./pages/Matches";
import VisionLab from "./pages/VisionLab";
import Logs from "./pages/Logs";
import AdminConsole from "./pages/AdminConsole";
@@ -76,6 +78,12 @@ function Router() {
<Route path="/reminders">
<DashboardRoute component={Reminders} />
</Route>
<Route path="/market">
<DashboardRoute component={Market} />
</Route>
<Route path="/matches">
<DashboardRoute component={Matches} />
</Route>
<Route path="/logs">
<DashboardRoute component={Logs} />
</Route>

查看文件

@@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/useMobile";
import {
LayoutDashboard, LogOut, PanelLeft, Target, Video,
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
BookOpen, Bell, Microscope, ScrollText, Shield
BookOpen, Bell, Microscope, ScrollText, Shield, Radar, Swords
} from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation, Redirect } from "wouter";
@@ -47,6 +47,8 @@ const menuItems: MenuItem[] = [
{ icon: Video, label: "视频分析", path: "/analysis", group: "analysis" },
{ icon: FileVideo, label: "视频库", path: "/videos", group: "analysis" },
{ icon: Activity, label: "训练进度", path: "/progress", group: "stats" },
{ icon: Radar, label: "球拍行情", path: "/market", group: "stats" },
{ icon: Swords, label: "比赛入库", path: "/matches", group: "stats" },
{ icon: Award, label: "NTRP评分", path: "/rating", group: "stats" },
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },

查看文件

@@ -8,6 +8,46 @@ export type ChangeLogEntry = {
};
export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [
{
version: "2026.03.27-match-review-hub",
releaseDate: "2026-03-27",
repoVersion: "495da60",
summary:
"新增比赛入库板块,支持双人双摄比赛建档、自动计分建议、H1 管理员审核确认、正式结算以及竞赛排行榜。",
features: [
"新增 `/matches` 页面和侧边栏入口,用户可以查看自己绑定的比赛、双机位状态、自动计分事件时间线以及正式入库结果",
"服务端新增 `match_sessions`、`match_participants`、`match_score_events` 数据表,并把自动计分建议和正式结算接入 `background_tasks` 异步 worker 链路",
"补齐 `0012_market_watch` 与 `0013_match_hub` 的 Drizzle statement breakpoint,让 MySQL 生产迁移可以顺序执行球拍行情与比赛入库表结构并完成上线",
"管理后台新增“比赛入库”工作台,H1 / 管理员可以创建日常或竞赛双人比赛、固定两位用户与对应双机位、刷新自动计分建议、提交审核比分并发起正式结算",
"排行榜新增训练榜 / 竞赛榜双视图;竞赛榜只统计已正式结算的竞赛比赛,避免未审核自动计分直接进入正式名次",
"正式结算后会把比赛写入用户训练记录;日常比赛同步累计训练型指标,竞赛比赛单独进入正式比赛统计与排行榜,不直接用未审核结果污染 NTRP",
],
tests: [
"pnpm check",
"pnpm exec vitest run server/match.test.ts server/features.test.ts",
"pnpm build",
"docker compose up -d --build migrate app app-worker",
"线上 smoke: 公开站点部署前仍停留在 2026-03-17 的旧 revision;重新部署后已切到包含比赛入库与更新日志修正的新构建,Playwright 真实登录 `H1` 验证 `/matches`、`/admin` 的“比赛入库”工作台、`/leaderboard` 的“训练榜 / 竞赛榜”以及 `/changelog` 最新条目均可访问;当前仅剩 `/favicon.ico` 404,不影响功能使用",
],
},
{
version: "2026.03.23-racket-market-watch",
releaseDate: "2026-03-23",
repoVersion: "495da60",
summary:
"新增球拍行情板块,支持闲鱼/京东/转转异步抓取、球拍结构化分级、用户监控规则和默认飞书 webhook 推送。",
features: [
"新增 `/market` 页面和侧边栏入口,集中展示低价雷达、监控规则、命中记录和市场后台任务状态",
"服务端新增球拍行情数据表、监控规则与命中记录,并把市场刷新、来源抓取、飞书推送接入现有 `background_tasks` worker 链路",
"抓取结果会对球拍做品牌、型号、系列、品类、重量、成色与价格分级;命中用户目标价后写入站内记录,并按去重规则推送到默认飞书 webhook",
"全局设置新增默认飞书 webhook、抓取超时、重试次数、闲鱼/京东/转转抓取 UA/Cookie 与转转搜索模板配置,管理员可直接在后台调整",
],
tests: [
"pnpm check",
"pnpm exec vitest run server/market.test.ts server/market.routes.test.ts",
"pnpm build",
],
},
{
version: "2026.03.17-live-camera-relay-mp4-hardening",
releaseDate: "2026-03-17",

118
client/src/lib/matches.ts 普通文件
查看文件

@@ -0,0 +1,118 @@
type PlayerSlot = "player_a" | "player_b";
type PlayerLike = {
playerSlot: PlayerSlot;
userId: number;
userName?: string | null;
isWinner?: number;
cameraSlot?: string | null;
cameraStatus?: string | null;
suggestedGamesWon?: number | null;
finalGamesWon?: number | null;
suggestedSetsWon?: number | null;
finalSetsWon?: number | null;
suggestedPointsWon?: number | null;
finalPointsWon?: number | null;
suggestedStats?: unknown;
finalStats?: unknown;
};
export type ScoreLike = {
sets?: Record<PlayerSlot, number>;
games?: Record<PlayerSlot, number>;
points?: Record<PlayerSlot, number>;
winnerSlot?: PlayerSlot | null;
};
export type MatchLike = {
matchMode?: "daily" | "competitive" | string | null;
workflowStatus?: string | null;
suggestedScore?: unknown;
finalScore?: unknown;
participants?: PlayerLike[] | null;
};
function toScore(raw: unknown): ScoreLike {
if (!raw || typeof raw !== "object") {
return {
sets: { player_a: 0, player_b: 0 },
games: { player_a: 0, player_b: 0 },
points: { player_a: 0, player_b: 0 },
winnerSlot: null,
};
}
const value = raw as Record<string, any>;
return {
sets: {
player_a: Number(value.sets?.player_a ?? 0),
player_b: Number(value.sets?.player_b ?? 0),
},
games: {
player_a: Number(value.games?.player_a ?? 0),
player_b: Number(value.games?.player_b ?? 0),
},
points: {
player_a: Number(value.points?.player_a ?? 0),
player_b: Number(value.points?.player_b ?? 0),
},
winnerSlot: value.winnerSlot === "player_a" || value.winnerSlot === "player_b" ? value.winnerSlot : null,
};
}
export function getMatchModeLabel(mode?: MatchLike["matchMode"]) {
return mode === "competitive" ? "竞赛" : "日常";
}
export function getMatchStatusLabel(status?: string | null) {
switch (status) {
case "draft":
return "待入库";
case "recording":
return "采集中";
case "review_pending":
return "待审核";
case "reviewed":
return "待结算";
case "finalizing":
return "结算中";
case "finalized":
return "已入库";
case "cancelled":
return "已取消";
default:
return "未知";
}
}
export function getMatchDisplayScore(match: MatchLike) {
return toScore(match.finalScore ?? match.suggestedScore);
}
export function formatMatchScore(match: MatchLike) {
const score = getMatchDisplayScore(match);
return `${score.sets?.player_a ?? 0}:${score.sets?.player_b ?? 0} · 局 ${score.games?.player_a ?? 0}:${score.games?.player_b ?? 0} · 分 ${score.points?.player_a ?? 0}:${score.points?.player_b ?? 0}`;
}
export function getMatchParticipant(match: MatchLike, slot: PlayerSlot) {
return (match.participants || []).find((participant) => participant.playerSlot === slot) ?? null;
}
export function getParticipantResult(match: MatchLike, userId?: number | null) {
if (!userId) return null;
const participant = (match.participants || []).find((item) => item.userId === userId);
if (!participant) return null;
const score = getMatchDisplayScore(match);
const winnerSlot = score.winnerSlot ?? ((match.participants || []).find((item) => item.isWinner === 1)?.playerSlot ?? null);
if (!winnerSlot) {
return "待确认";
}
return participant.playerSlot === winnerSlot ? "获胜" : "失利";
}
export function getWinnerName(match: MatchLike) {
const score = getMatchDisplayScore(match);
const winnerSlot = score.winnerSlot ?? ((match.participants || []).find((item) => item.isWinner === 1)?.playerSlot ?? null);
if (!winnerSlot) return "待确认";
return getMatchParticipant(match, winnerSlot)?.userName || winnerSlot;
}

查看文件

@@ -6,10 +6,13 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDateTimeShanghai } from "@/lib/time";
import { formatMatchScore, getMatchModeLabel, getMatchParticipant, getMatchStatusLabel, getWinnerName } from "@/lib/matches";
import { toast } from "sonner";
import { Activity, Database, RefreshCw, Settings2, Shield, Sparkles, Users } from "lucide-react";
import { Activity, Camera, ClipboardCheck, Database, RefreshCw, Settings2, Shield, Sparkles, Swords, Users } from "lucide-react";
export default function AdminConsole() {
const { user } = useAuth();
@@ -19,8 +22,32 @@ export default function AdminConsole() {
const liveSessionsQuery = trpc.admin.liveSessions.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
const settingsQuery = trpc.admin.settings.useQuery(undefined, { enabled: user?.role === "admin" });
const auditQuery = trpc.admin.auditLogs.useQuery({ limit: 100 }, { enabled: user?.role === "admin" });
const matchesQuery = trpc.match.list.useQuery({ limit: 50 }, { enabled: user?.role === "admin" });
const [settingsDrafts, setSettingsDrafts] = useState<Record<string, string>>({});
const [selectedMatchId, setSelectedMatchId] = useState<number | null>(null);
const [createDraft, setCreateDraft] = useState({
title: "",
matchMode: "daily",
playerAUserId: "",
playerBUserId: "",
courtName: "",
durationMinutes: "90",
});
const [reviewDraft, setReviewDraft] = useState({
playerASet: "0",
playerBSet: "0",
playerAGame: "0",
playerBGame: "0",
playerAPoint: "0",
playerBPoint: "0",
reviewNotes: "",
});
const [cameraLabelDrafts, setCameraLabelDrafts] = useState<Record<string, string>>({});
const matchDetailQuery = trpc.match.get.useQuery(
{ matchId: selectedMatchId ?? 0 },
{ enabled: user?.role === "admin" && selectedMatchId != null },
);
const refreshAllMutation = trpc.admin.refreshAllNtrp.useMutation({
onSuccess: () => {
@@ -52,6 +79,52 @@ export default function AdminConsole() {
},
onError: (error) => toast.error(`设置更新失败: ${error.message}`),
});
const createMatchMutation = trpc.match.create.useMutation({
onSuccess: (detail) => {
toast.success("比赛入库草稿已创建");
utils.match.list.invalidate();
if (detail?.id) {
setSelectedMatchId(detail.id);
}
},
onError: (error) => toast.error(`创建失败: ${error.message}`),
});
const bindCameraMutation = trpc.match.bindCamera.useMutation({
onSuccess: () => {
toast.success("机位状态已更新");
utils.match.list.invalidate();
utils.match.get.invalidate();
},
onError: (error) => toast.error(`更新失败: ${error.message}`),
});
const requestSuggestionMutation = trpc.match.requestSuggestion.useMutation({
onSuccess: () => {
toast.success("自动计分建议已加入后台队列");
utils.match.list.invalidate();
utils.match.get.invalidate();
utils.admin.tasks.invalidate();
},
onError: (error) => toast.error(`提交失败: ${error.message}`),
});
const reviewSubmitMutation = trpc.match.reviewSubmit.useMutation({
onSuccess: () => {
toast.success("审核比分已保存");
utils.match.list.invalidate();
utils.match.get.invalidate();
utils.admin.auditLogs.invalidate();
},
onError: (error) => toast.error(`审核保存失败: ${error.message}`),
});
const finalizeMatchMutation = trpc.match.finalize.useMutation({
onSuccess: () => {
toast.success("正式结算已加入后台队列");
utils.match.list.invalidate();
utils.match.get.invalidate();
utils.admin.tasks.invalidate();
utils.admin.auditLogs.invalidate();
},
onError: (error) => toast.error(`结算失败: ${error.message}`),
});
useEffect(() => {
const drafts: Record<string, string> = {};
@@ -61,11 +134,82 @@ export default function AdminConsole() {
setSettingsDrafts(drafts);
}, [settingsQuery.data]);
useEffect(() => {
if (!selectedMatchId && matchesQuery.data?.length) {
setSelectedMatchId(matchesQuery.data[0].id);
}
}, [matchesQuery.data, selectedMatchId]);
useEffect(() => {
const detail = matchDetailQuery.data;
if (!detail) return;
const score: any = detail.finalScore || detail.suggestedScore || {};
setReviewDraft({
playerASet: String(score?.sets?.player_a ?? 0),
playerBSet: String(score?.sets?.player_b ?? 0),
playerAGame: String(score?.games?.player_a ?? 0),
playerBGame: String(score?.games?.player_b ?? 0),
playerAPoint: String(score?.points?.player_a ?? 0),
playerBPoint: String(score?.points?.player_b ?? 0),
reviewNotes: detail.reviewNotes || "",
});
const nextLabels: Record<string, string> = {};
(detail.participants || []).forEach((item: any) => {
nextLabels[item.playerSlot] = item.cameraLabel || "";
});
setCameraLabelDrafts(nextLabels);
}, [matchDetailQuery.data]);
const totals = useMemo(() => ({
users: (usersQuery.data || []).length,
tasks: (tasksQuery.data || []).length,
sessions: (liveSessionsQuery.data || []).length,
}), [liveSessionsQuery.data, tasksQuery.data, usersQuery.data]);
matches: (matchesQuery.data || []).length,
}), [liveSessionsQuery.data, matchesQuery.data, tasksQuery.data, usersQuery.data]);
const submitCreateMatch = () => {
const playerAUserId = Number(createDraft.playerAUserId);
const playerBUserId = Number(createDraft.playerBUserId);
const durationMinutes = Number(createDraft.durationMinutes || 90);
if (!createDraft.title.trim()) {
toast.error("请填写比赛标题");
return;
}
if (!Number.isFinite(playerAUserId) || !Number.isFinite(playerBUserId)) {
toast.error("请选择两位参赛用户");
return;
}
createMatchMutation.mutate({
title: createDraft.title.trim(),
matchMode: createDraft.matchMode as "daily" | "competitive",
playerAUserId,
playerBUserId,
courtName: createDraft.courtName.trim() || undefined,
durationMinutes,
});
};
const submitReview = () => {
if (!selectedMatchId) return;
reviewSubmitMutation.mutate({
matchId: selectedMatchId,
reviewNotes: reviewDraft.reviewNotes.trim() || undefined,
finalScore: {
sets: {
player_a: Number(reviewDraft.playerASet || 0),
player_b: Number(reviewDraft.playerBSet || 0),
},
games: {
player_a: Number(reviewDraft.playerAGame || 0),
player_b: Number(reviewDraft.playerBGame || 0),
},
points: {
player_a: Number(reviewDraft.playerAPoint || 0),
player_b: Number(reviewDraft.playerBPoint || 0),
},
},
});
};
if (user?.role !== "admin") {
return (
@@ -96,7 +240,7 @@ export default function AdminConsole() {
</div>
</section>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
@@ -130,11 +274,23 @@ export default function AdminConsole() {
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Swords className="h-5 w-5 text-amber-700" />
<div>
<div className="text-sm text-muted-foreground"></div>
<div className="mt-1 text-xl font-semibold">{totals.matches}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="users" className="space-y-4">
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="users"></TabsTrigger>
<TabsTrigger value="matches"></TabsTrigger>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="sessions"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
@@ -176,6 +332,277 @@ export default function AdminConsole() {
</Card>
</TabsContent>
<TabsContent value="matches">
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<ClipboardCheck className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid gap-3 lg:grid-cols-6">
<Input
placeholder="比赛标题"
value={createDraft.title}
onChange={(event) => setCreateDraft((current) => ({ ...current, title: event.target.value }))}
className="lg:col-span-2"
/>
<select
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
value={createDraft.matchMode}
onChange={(event) => setCreateDraft((current) => ({ ...current, matchMode: event.target.value }))}
>
<option value="daily"></option>
<option value="competitive"></option>
</select>
<select
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
value={createDraft.playerAUserId}
onChange={(event) => setCreateDraft((current) => ({ ...current, playerAUserId: event.target.value }))}
>
<option value=""> A </option>
{(usersQuery.data || []).map((item: any) => (
<option key={`a-${item.id}`} value={item.id}>{item.name || item.id}</option>
))}
</select>
<select
className="h-11 rounded-2xl border border-border/70 bg-background px-3 text-sm"
value={createDraft.playerBUserId}
onChange={(event) => setCreateDraft((current) => ({ ...current, playerBUserId: event.target.value }))}
>
<option value=""> B </option>
{(usersQuery.data || []).map((item: any) => (
<option key={`b-${item.id}`} value={item.id}>{item.name || item.id}</option>
))}
</select>
<Input
placeholder="场地 / 场馆"
value={createDraft.courtName}
onChange={(event) => setCreateDraft((current) => ({ ...current, courtName: event.target.value }))}
/>
<Input
placeholder="时长(分钟)"
value={createDraft.durationMinutes}
onChange={(event) => setCreateDraft((current) => ({ ...current, durationMinutes: event.target.value }))}
/>
<div className="lg:col-span-6">
<Button onClick={submitCreateMatch} disabled={createMatchMutation.isPending} className="gap-2">
<Swords className="h-4 w-4" />
稿
</Button>
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-[360px,1fr]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(!matchesQuery.data || matchesQuery.data.length === 0) ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
</div>
) : (
(matchesQuery.data || []).map((match: any) => (
<button
key={match.id}
type="button"
onClick={() => setSelectedMatchId(match.id)}
className={`w-full rounded-2xl border p-4 text-left transition ${selectedMatchId === match.id ? "border-primary bg-primary/5" : "border-border/60 bg-muted/20 hover:bg-muted/40"}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{match.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{getMatchParticipant(match, "player_a")?.userName || "A"} vs {getMatchParticipant(match, "player_b")?.userName || "B"}
</div>
</div>
<Badge variant={match.workflowStatus === "finalized" ? "secondary" : "outline"}>
{getMatchStatusLabel(match.workflowStatus)}
</Badge>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{getMatchModeLabel(match.matchMode)} · {formatMatchScore(match)}
</div>
</button>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>H1 / </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!selectedMatchId ? (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
</div>
) : matchDetailQuery.isLoading ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-64 w-full" />
</div>
) : !matchDetailQuery.data ? (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
</div>
) : (
<>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-lg font-semibold">{matchDetailQuery.data.title}</span>
<Badge variant="secondary">{getMatchModeLabel(matchDetailQuery.data.matchMode)}</Badge>
<Badge variant="outline">{getMatchStatusLabel(matchDetailQuery.data.workflowStatus)}</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{getWinnerName(matchDetailQuery.data)} · {formatMatchScore(matchDetailQuery.data)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => requestSuggestionMutation.mutate({ matchId: matchDetailQuery.data.id })}
disabled={requestSuggestionMutation.isPending || matchDetailQuery.data.workflowStatus === "cancelled"}
>
</Button>
<Button
onClick={() => finalizeMatchMutation.mutate({ matchId: matchDetailQuery.data.id })}
disabled={finalizeMatchMutation.isPending || matchDetailQuery.data.workflowStatus === "finalized" || matchDetailQuery.data.workflowStatus === "cancelled"}
>
</Button>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{(["player_a", "player_b"] as const).map((slot) => {
const participant = getMatchParticipant(matchDetailQuery.data, slot);
if (!participant) return null;
return (
<Card key={slot} className="border border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base">{participant.userName || slot}</CardTitle>
<CardDescription>{slot === "player_a" ? "A 机位" : "B 机位"} · {participant.cameraStatus}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input
placeholder="机位标签"
value={cameraLabelDrafts[slot] || ""}
onChange={(event) => setCameraLabelDrafts((current) => ({ ...current, [slot]: event.target.value }))}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => bindCameraMutation.mutate({
matchId: matchDetailQuery.data.id,
playerSlot: slot,
cameraStatus: "bound",
cameraLabel: cameraLabelDrafts[slot] || undefined,
})}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => bindCameraMutation.mutate({
matchId: matchDetailQuery.data.id,
playerSlot: slot,
cameraStatus: "completed",
cameraLabel: cameraLabelDrafts[slot] || undefined,
})}
>
</Button>
</div>
<div className="rounded-2xl bg-muted/30 px-3 py-2 text-sm text-muted-foreground">
{participant.suggestedGamesWon || 0} · {participant.finalGamesWon || 0}
</div>
</CardContent>
</Card>
);
})}
</div>
<Card className="border border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>沿</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<Input value={reviewDraft.playerASet} onChange={(event) => setReviewDraft((current) => ({ ...current, playerASet: event.target.value }))} placeholder="A 盘数" />
<Input value={reviewDraft.playerAGame} onChange={(event) => setReviewDraft((current) => ({ ...current, playerAGame: event.target.value }))} placeholder="A 局数" />
<Input value={reviewDraft.playerAPoint} onChange={(event) => setReviewDraft((current) => ({ ...current, playerAPoint: event.target.value }))} placeholder="A 得分点" />
<Input value={reviewDraft.playerBSet} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBSet: event.target.value }))} placeholder="B 盘数" />
<Input value={reviewDraft.playerBGame} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBGame: event.target.value }))} placeholder="B 局数" />
<Input value={reviewDraft.playerBPoint} onChange={(event) => setReviewDraft((current) => ({ ...current, playerBPoint: event.target.value }))} placeholder="B 得分点" />
</div>
<Textarea
value={reviewDraft.reviewNotes}
onChange={(event) => setReviewDraft((current) => ({ ...current, reviewNotes: event.target.value }))}
placeholder="审核说明"
className="min-h-[120px] rounded-2xl"
/>
<Button onClick={submitReview} disabled={reviewSubmitMutation.isPending}>
</Button>
</CardContent>
</Card>
<Card className="border border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Camera className="h-4 w-4 text-primary" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(!matchDetailQuery.data.events || matchDetailQuery.data.events.length === 0) ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
</div>
) : (
matchDetailQuery.data.events.map((event: any) => (
<div key={event.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">#{event.eventIndex}</Badge>
<span className="font-medium">{event.eventType}</span>
<Badge variant="secondary">{event.source}</Badge>
</div>
<div className="text-xs text-muted-foreground">
{Number(event.confidence || 0).toFixed(2)} · {formatDateTimeShanghai(event.createdAt)}
</div>
</div>
<pre className="mt-3 overflow-x-auto rounded-xl bg-background/80 p-3 text-xs text-muted-foreground">
{JSON.stringify(event.payload ?? {}, null, 2)}
</pre>
</div>
))
)}
</CardContent>
</Card>
</>
)}
</CardContent>
</Card>
</div>
</div>
</TabsContent>
<TabsContent value="tasks">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">

查看文件

@@ -1,3 +1,4 @@
import { useMemo, useState } from "react";
import { trpc } from "@/lib/trpc";
import { useAuth } from "@/_core/hooks/useAuth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -5,18 +6,28 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Trophy, Clock, Zap, Target, Crown, Medal, Award } from "lucide-react";
import { useState, useMemo } from "react";
import { Trophy, Clock, Zap, Target, Crown, Medal, Award, Swords, Percent } from "lucide-react";
type SortKey = "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots";
type LeaderboardScope = "training" | "competitive";
type TrainingSortKey = "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots";
type CompetitiveSortKey = "wins" | "winRate" | "setsWon" | "pointsWon" | "matches";
type SortKey = TrainingSortKey | CompetitiveSortKey;
const tabConfig: { key: SortKey; label: string; icon: React.ReactNode; unit: string }[] = [
const trainingTabs: Array<{ key: TrainingSortKey; label: string; icon: React.ReactNode; unit: string }> = [
{ key: "ntrpRating", label: "NTRP评分", icon: <Trophy className="h-4 w-4" />, unit: "" },
{ key: "totalMinutes", label: "训练时长", icon: <Clock className="h-4 w-4" />, unit: "分钟" },
{ key: "totalSessions", label: "训练次数", icon: <Target className="h-4 w-4" />, unit: "次" },
{ key: "totalShots", label: "总击球数", icon: <Zap className="h-4 w-4" />, unit: "次" },
];
const competitiveTabs: Array<{ key: CompetitiveSortKey; label: string; icon: React.ReactNode; unit: string }> = [
{ key: "wins", label: "胜场", icon: <Trophy className="h-4 w-4" />, unit: "场" },
{ key: "winRate", label: "胜率", icon: <Percent className="h-4 w-4" />, unit: "%" },
{ key: "setsWon", label: "赢盘", icon: <Swords className="h-4 w-4" />, unit: "盘" },
{ key: "pointsWon", label: "赢分", icon: <Zap className="h-4 w-4" />, unit: "分" },
{ key: "matches", label: "场次", icon: <Target className="h-4 w-4" />, unit: "场" },
];
const rankIcons = [
<Crown className="h-5 w-5 text-yellow-500" />,
<Medal className="h-5 w-5 text-gray-400" />,
@@ -31,76 +42,107 @@ const skillLevelMap: Record<string, string> = {
export default function Leaderboard() {
const { user } = useAuth();
const [sortBy, setSortBy] = useState<SortKey>("ntrpRating");
const { data: leaderboard, isLoading } = trpc.leaderboard.get.useQuery({ sortBy, limit: 50 });
const [scope, setScope] = useState<LeaderboardScope>("training");
const [trainingSortBy, setTrainingSortBy] = useState<TrainingSortKey>("ntrpRating");
const [competitiveSortBy, setCompetitiveSortBy] = useState<CompetitiveSortKey>("wins");
const sortBy = scope === "training" ? trainingSortBy : competitiveSortBy;
const tabConfig = scope === "training" ? trainingTabs : competitiveTabs;
const { data: leaderboard, isLoading } = trpc.leaderboard.get.useQuery({ scope, sortBy, limit: 50 });
const myRank = useMemo(() => {
if (!leaderboard || !user) return null;
const idx = leaderboard.findIndex((u: any) => u.id === user.id);
const idx = leaderboard.findIndex((item: any) => item.id === user.id);
return idx >= 0 ? idx + 1 : null;
}, [leaderboard, user]);
const myEntry = useMemo(() => {
if (!leaderboard || !user) return null;
return leaderboard.find((item: any) => item.id === user.id) || null;
}, [leaderboard, user]);
const getValue = (item: any, key: SortKey) => {
const v = item[key] ?? 0;
return key === "ntrpRating" ? (v as number).toFixed(1) : v;
const value = item?.[key] ?? 0;
if (key === "ntrpRating") return Number(value).toFixed(1);
if (key === "winRate") return `${Number(value).toFixed(1)}`;
return value;
};
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-16 w-full" />)}
<Skeleton className="h-24 w-full" />
{[1, 2, 3, 4, 5].map((item) => <Skeleton key={item} className="h-16 w-full" />)}
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground text-sm mt-1">
{myRank && <span className="ml-2 text-primary font-medium">· {myRank} </span>}
</p>
</div>
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.14),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{myRank ? <span className="ml-2 font-medium text-primary"> {myRank} </span> : null}
</p>
</div>
<Tabs value={scope} onValueChange={(value) => setScope(value as LeaderboardScope)}>
<TabsList className="grid w-full grid-cols-2 lg:w-[280px]">
<TabsTrigger value="training"></TabsTrigger>
<TabsTrigger value="competitive"></TabsTrigger>
</TabsList>
</Tabs>
</div>
</section>
{/* My rank card */}
{myRank && user && (
{myRank && user && myEntry ? (
<Card className="border-primary/20 bg-primary/5 shadow-sm">
<CardContent className="py-4">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-lg">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-lg font-bold text-primary">
#{myRank}
</div>
<div className="flex-1">
<p className="font-semibold">{user.name}</p>
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="text-right">
<p className="text-xl font-bold text-primary">{getValue(leaderboard?.find((u: any) => u.id === user.id) || {}, sortBy)}</p>
<p className="text-xs text-muted-foreground">{tabConfig.find(t => t.key === sortBy)?.unit}</p>
<p className="text-xl font-bold text-primary">{getValue(myEntry, sortBy)}</p>
<p className="text-xs text-muted-foreground">{tabConfig.find((item) => item.key === sortBy)?.unit}</p>
</div>
</div>
</CardContent>
</Card>
)}
) : null}
<Tabs value={sortBy} onValueChange={(v) => setSortBy(v as SortKey)}>
<TabsList className="grid grid-cols-2 lg:grid-cols-4 w-full">
{tabConfig.map(tab => (
<Tabs value={sortBy} onValueChange={(value) => {
if (scope === "training") {
setTrainingSortBy(value as TrainingSortKey);
} else {
setCompetitiveSortBy(value as CompetitiveSortKey);
}
}}>
<TabsList className={`grid w-full ${scope === "training" ? "grid-cols-2 lg:grid-cols-4" : "grid-cols-2 lg:grid-cols-5"}`}>
{tabConfig.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key} className="gap-1.5 text-xs sm:text-sm">
{tab.icon}{tab.label}
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabConfig.map(tab => (
{tabConfig.map((tab) => (
<TabsContent key={tab.key} value={tab.key}>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{tab.label}</CardTitle>
</CardHeader>
<CardContent className="p-0">
{(!leaderboard || leaderboard.length === 0) ? (
<div className="py-16 text-center text-muted-foreground">
<Trophy className="h-10 w-10 mx-auto mb-3 opacity-30" />
<Trophy className="mx-auto mb-3 h-10 w-10 opacity-30" />
<p></p>
</div>
) : (
@@ -109,47 +151,48 @@ export default function Leaderboard() {
const isMe = user && item.id === user.id;
return (
<div key={item.id} className={`flex items-center gap-3 px-4 py-3 transition-colors ${isMe ? "bg-primary/5" : "hover:bg-muted/50"}`}>
{/* Rank */}
<div className="w-8 text-center shrink-0">
{idx < 3 ? rankIcons[idx] : (
<span className="text-sm font-medium text-muted-foreground">{idx + 1}</span>
)}
<div className="w-8 shrink-0 text-center">
{idx < 3 ? rankIcons[idx] : <span className="text-sm font-medium text-muted-foreground">{idx + 1}</span>}
</div>
{/* Avatar */}
<Avatar className="h-9 w-9 shrink-0">
<AvatarFallback className={`text-xs font-medium ${idx < 3 ? "bg-primary/10 text-primary" : ""}`}>
{(item.name || "U").slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium truncate ${isMe ? "text-primary" : ""}`}>
<p className={`truncate text-sm font-medium ${isMe ? "text-primary" : ""}`}>
{item.name || "匿名用户"}
</p>
{isMe && <Badge variant="secondary" className="text-[10px] px-1.5 py-0"></Badge>}
{isMe ? <Badge variant="secondary" className="px-1.5 py-0 text-[10px]"></Badge> : null}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>NTRP {(item.ntrpRating || 1.5).toFixed(1)}</span>
<span>·</span>
<span>{skillLevelMap[item.skillLevel || "beginner"] || "初级"}</span>
{(item.currentStreak || 0) > 0 && (
{scope === "training" ? (
(item.currentStreak || 0) > 0 ? (
<>
<span>·</span>
<span className="text-orange-500">🔥{item.currentStreak}</span>
</>
) : null
) : (
<>
<span>·</span>
<span className="text-orange-500">🔥{item.currentStreak}</span>
<span>{item.wins || 0} / {item.losses || 0} </span>
</>
)}
</div>
</div>
{/* Value */}
<div className="text-right shrink-0">
<div className="shrink-0 text-right">
<p className={`text-lg font-bold ${idx < 3 ? "text-primary" : ""}`}>
{getValue(item, tab.key)}
</p>
{tab.unit && <p className="text-[10px] text-muted-foreground">{tab.unit}</p>}
{tab.unit ? <p className="text-[10px] text-muted-foreground">{tab.unit}</p> : null}
</div>
</div>
);

788
client/src/pages/Market.tsx 普通文件
查看文件

@@ -0,0 +1,788 @@
import { useState } from "react";
import { trpc } from "@/lib/trpc";
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
BellRing,
CircleDollarSign,
ExternalLink,
Filter,
Gauge,
Plus,
Radar,
RefreshCcw,
Search,
ShieldCheck,
Siren,
Sparkles,
} from "lucide-react";
const SOURCE_LABELS = {
xianyu: "闲鱼",
jd: "京东",
zhuanzhuan: "转转",
} as const;
const CATEGORY_LABELS = {
adult: "成人球拍",
junior: "儿童球拍",
competitive: "比赛拍",
recreational: "娱乐拍",
unknown: "未知",
} as const;
const GRADE_LABELS = {
high_value: "高性价比",
standard: "标准价",
overpriced: "偏高",
pending_review: "待确认",
} as const;
const GRADE_TONES = {
high_value: "bg-emerald-50 text-emerald-700 border-emerald-200",
standard: "bg-sky-50 text-sky-700 border-sky-200",
overpriced: "bg-orange-50 text-orange-700 border-orange-200",
pending_review: "bg-muted text-muted-foreground border-border",
} as const;
const TASK_STATUS_LABELS = {
queued: "排队中",
running: "执行中",
succeeded: "已完成",
failed: "失败",
} as const;
type RuleDraft = {
ruleId?: number;
title: string;
brand: string;
modelKeyword: string;
seriesKeyword: string;
category: "all" | keyof typeof CATEGORY_LABELS;
weightMinGram: string;
weightMaxGram: string;
targetPrice: string;
pushEnabled: boolean;
};
const EMPTY_RULE: RuleDraft = {
title: "",
brand: "",
modelKeyword: "",
seriesKeyword: "",
category: "all",
weightMinGram: "",
weightMaxGram: "",
targetPrice: "",
pushEnabled: true,
};
function formatCurrency(value: number | null | undefined) {
if (value == null) return "-";
return `¥${Number(value).toFixed(0)}`;
}
function normalizeDraftToPayload(draft: RuleDraft) {
const targetPrice = Number.parseFloat(draft.targetPrice);
if (!Number.isFinite(targetPrice) || targetPrice <= 0) {
throw new Error("请输入有效的目标价格");
}
const payload = {
title: draft.title.trim() || undefined,
brand: draft.brand.trim(),
modelKeyword: draft.modelKeyword.trim() || undefined,
seriesKeyword: draft.seriesKeyword.trim() || undefined,
category: draft.category === "all" ? undefined : draft.category,
weightMinGram: draft.weightMinGram ? Number.parseFloat(draft.weightMinGram) : undefined,
weightMaxGram: draft.weightMaxGram ? Number.parseFloat(draft.weightMaxGram) : undefined,
targetPrice,
pushEnabled: draft.pushEnabled,
};
if (!payload.brand) {
throw new Error("请输入要监控的品牌");
}
if (
payload.weightMinGram != null &&
payload.weightMaxGram != null &&
payload.weightMinGram > payload.weightMaxGram
) {
throw new Error("最小重量不能大于最大重量");
}
return payload;
}
export default function Market() {
const utils = trpc.useUtils();
const [filters, setFilters] = useState({
source: "all",
category: "all",
keyword: "",
lowPriceOnly: true,
});
const [dialogOpen, setDialogOpen] = useState(false);
const [ruleDraft, setRuleDraft] = useState<RuleDraft>(EMPTY_RULE);
const [webhookDialogOpen, setWebhookDialogOpen] = useState(false);
const [webhookDraft, setWebhookDraft] = useState("");
const dashboardQuery = trpc.market.dashboard.useQuery(undefined, {
refetchInterval: (query) => {
const hasActive = (query.state.data?.recentTasks ?? []).some((task) => task.status === "queued" || task.status === "running");
return hasActive ? 4_000 : 12_000;
},
});
const listingsQuery = trpc.market.listings.useQuery({
source: filters.source === "all" ? undefined : filters.source as "xianyu" | "jd" | "zhuanzhuan",
category: filters.category === "all" ? undefined : filters.category as keyof typeof CATEGORY_LABELS,
keyword: filters.keyword.trim() || undefined,
lowPriceOnly: filters.lowPriceOnly,
limit: 40,
});
const rulesQuery = trpc.market.watchRuleList.useQuery();
const hitsQuery = trpc.market.watchHits.useQuery({ limit: 20 });
const pushConfigQuery = trpc.market.pushConfigGet.useQuery();
const createRuleMutation = trpc.market.watchRuleCreate.useMutation({
onSuccess: () => {
toast.success("监控规则已创建,后台开始抓取");
setDialogOpen(false);
setRuleDraft(EMPTY_RULE);
void utils.market.watchRuleList.invalidate();
void utils.market.dashboard.invalidate();
},
onError: (error) => toast.error(`创建失败: ${error.message}`),
});
const updateRuleMutation = trpc.market.watchRuleUpdate.useMutation({
onSuccess: () => {
toast.success("监控规则已更新");
setDialogOpen(false);
setRuleDraft(EMPTY_RULE);
void utils.market.watchRuleList.invalidate();
void utils.market.dashboard.invalidate();
},
onError: (error) => toast.error(`更新失败: ${error.message}`),
});
const deleteRuleMutation = trpc.market.watchRuleDelete.useMutation({
onSuccess: () => {
toast.success("监控规则已删除");
void utils.market.watchRuleList.invalidate();
void utils.market.dashboard.invalidate();
},
onError: (error) => toast.error(`删除失败: ${error.message}`),
});
const toggleRuleMutation = trpc.market.watchRuleToggle.useMutation({
onSuccess: () => {
void utils.market.watchRuleList.invalidate();
void utils.market.dashboard.invalidate();
},
onError: (error) => toast.error(`切换失败: ${error.message}`),
});
const refreshMutation = trpc.market.triggerRefresh.useMutation({
onSuccess: () => {
toast.success("刷新任务已加入后台队列");
void utils.market.dashboard.invalidate();
},
onError: (error) => toast.error(`刷新失败: ${error.message}`),
});
const updateWebhookMutation = trpc.market.pushConfigUpdate.useMutation({
onSuccess: () => {
toast.success("默认飞书 webhook 已更新");
setWebhookDialogOpen(false);
void utils.market.pushConfigGet.invalidate();
void utils.market.dashboard.invalidate();
},
onError: (error) => toast.error(`更新失败: ${error.message}`),
});
const handleSubmitRule = () => {
try {
const payload = normalizeDraftToPayload(ruleDraft);
if (ruleDraft.ruleId) {
updateRuleMutation.mutate({ ruleId: ruleDraft.ruleId, ...payload });
} else {
createRuleMutation.mutate(payload);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "请检查规则输入");
}
};
const handleEditRule = (rule: any) => {
setRuleDraft({
ruleId: rule.id,
title: rule.title ?? "",
brand: rule.brand ?? "",
modelKeyword: rule.modelKeyword ?? "",
seriesKeyword: rule.seriesKeyword ?? "",
category: (rule.category as RuleDraft["category"]) ?? "all",
weightMinGram: rule.weightMinGram != null ? String(rule.weightMinGram) : "",
weightMaxGram: rule.weightMaxGram != null ? String(rule.weightMaxGram) : "",
targetPrice: rule.targetPrice != null ? String(rule.targetPrice) : "",
pushEnabled: rule.pushEnabled === 1,
});
setDialogOpen(true);
};
const recentTasks = dashboardQuery.data?.recentTasks ?? [];
const sourceSummary = dashboardQuery.data?.sourceSummary ?? [];
const listings = listingsQuery.data ?? [];
const rules = rulesQuery.data ?? [];
const hits = hitsQuery.data ?? [];
return (
<div className="space-y-6">
<section className="relative overflow-hidden rounded-[30px] border border-border/60 bg-[radial-gradient(circle_at_15%_20%,rgba(34,197,94,0.16),transparent_26%),radial-gradient(circle_at_85%_18%,rgba(251,146,60,0.16),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-5 shadow-sm md:p-6">
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(15,23,42,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,0.03)_1px,transparent_1px)] bg-[size:18px_18px]" />
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl">
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
<Radar className="h-3.5 w-3.5" />
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-foreground"></h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
webhook
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => refreshMutation.mutate(undefined)}
disabled={refreshMutation.isPending}
className="gap-2"
>
<RefreshCcw className={`h-4 w-4 ${refreshMutation.isPending ? "animate-spin" : ""}`} />
</Button>
<Dialog open={webhookDialogOpen} onOpenChange={(open) => {
setWebhookDialogOpen(open);
if (open) setWebhookDraft("");
}}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2" disabled={!pushConfigQuery.data?.canEdit}>
<BellRing className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> Webhook</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-2xl border border-border/60 bg-muted/30 p-3 text-sm text-muted-foreground">
: {pushConfigQuery.data?.maskedWebhookUrl || "未配置"}
</div>
<div className="space-y-2">
<Label htmlFor="market-webhook">Webhook URL</Label>
<Input
id="market-webhook"
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/..."
value={webhookDraft}
onChange={(event) => setWebhookDraft(event.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWebhookDialogOpen(false)}></Button>
<Button
onClick={() => updateWebhookMutation.mutate({ webhookUrl: webhookDraft.trim() })}
disabled={updateWebhookMutation.isPending || !webhookDraft.trim()}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div className="relative mt-6 grid gap-3 md:grid-cols-4">
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Gauge className="h-5 w-5 text-emerald-700" />
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Active Rules</div>
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.activeRuleCount ?? 0}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<CircleDollarSign className="h-5 w-5 text-sky-700" />
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">24H Listings</div>
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.recentListingCount ?? 0}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<Siren className="h-5 w-5 text-orange-700" />
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Recent Hits</div>
<div className="mt-1 text-2xl font-semibold">{dashboardQuery.data?.overview.hitCount ?? 0}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-white/80 shadow-sm backdrop-blur">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<ShieldCheck className="h-5 w-5 text-violet-700" />
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Push Channel</div>
<div className="mt-1 text-sm font-semibold">
{pushConfigQuery.data?.hasWebhookConfigured ? "飞书默认通道已启用" : "尚未配置"}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</section>
<div className="grid gap-6 xl:grid-cols-[1.3fr_0.9fr]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle className="text-lg"></CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
<div className="grid gap-2 sm:grid-cols-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="搜品牌/型号"
value={filters.keyword}
onChange={(event) => setFilters((current) => ({ ...current, keyword: event.target.value }))}
/>
</div>
<Select
value={filters.source}
onValueChange={(value) => setFilters((current) => ({ ...current, source: value }))}
>
<SelectTrigger>
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="xianyu"></SelectItem>
<SelectItem value="jd"></SelectItem>
<SelectItem value="zhuanzhuan"></SelectItem>
</SelectContent>
</Select>
<Select
value={filters.category}
onValueChange={(value) => setFilters((current) => ({ ...current, category: value }))}
>
<SelectTrigger>
<SelectValue placeholder="品类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center justify-between rounded-xl border border-border/60 bg-muted/20 px-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
</div>
<Switch
checked={filters.lowPriceOnly}
onCheckedChange={(checked) => setFilters((current) => ({ ...current, lowPriceOnly: checked }))}
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{listingsQuery.isLoading ? (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
...
</div>
) : listings.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
</div>
) : (
listings.map((listing) => (
<article key={`${listing.source}-${listing.sourceListingId}-${listing.id}`} className="rounded-[24px] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.92))] p-4 shadow-sm">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{SOURCE_LABELS[listing.source as keyof typeof SOURCE_LABELS]}</Badge>
<Badge className={GRADE_TONES[(listing.gradeLevel as keyof typeof GRADE_TONES) ?? "pending_review"]}>
{GRADE_LABELS[(listing.gradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
</Badge>
{listing.isLowPriceCandidate === 1 ? (
<Badge variant="secondary" className="bg-emerald-50 text-emerald-700"></Badge>
) : null}
</div>
<div>
<h3 className="text-base font-semibold leading-6 text-foreground">{listing.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{listing.brand || "未识别品牌"}
{listing.model ? ` · ${listing.model}` : ""}
{listing.category ? ` · ${CATEGORY_LABELS[listing.category as keyof typeof CATEGORY_LABELS] ?? listing.category}` : ""}
{listing.weightGram != null ? ` · ${Math.round(listing.weightGram)}g` : ""}
</p>
</div>
<div className="text-sm text-muted-foreground">{listing.gradeReason || "暂无分级说明"}</div>
</div>
<div className="flex min-w-[220px] flex-col items-start gap-3 rounded-2xl border border-border/60 bg-muted/10 p-4">
<div>
<div className="text-xs uppercase tracking-[0.22em] text-muted-foreground">Price Watch</div>
<div className="mt-1 text-3xl font-semibold text-foreground">{formatCurrency(listing.price)}</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatCurrency(listing.originalPrice)} · {formatDateTimeShanghai(listing.fetchedAt)}
</div>
</div>
<Button asChild className="gap-2">
<a href={listing.listingUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</article>
))
)}
</CardContent>
</Card>
<div className="space-y-6">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-lg"></CardTitle>
<CardDescription className="mt-1"></CardDescription>
</div>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setRuleDraft(EMPTY_RULE);
}}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{ruleDraft.ruleId ? "编辑监控规则" : "新建监控规则"}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-1 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rule-title"></Label>
<Input
id="rule-title"
placeholder="留空会自动生成,例如 Yonex Ezone ≤ ¥500"
value={ruleDraft.title}
onChange={(event) => setRuleDraft((current) => ({ ...current, title: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rule-brand"></Label>
<Input
id="rule-brand"
placeholder="如 Yonex / Wilson"
value={ruleDraft.brand}
onChange={(event) => setRuleDraft((current) => ({ ...current, brand: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rule-target-price"></Label>
<Input
id="rule-target-price"
type="number"
placeholder="500"
value={ruleDraft.targetPrice}
onChange={(event) => setRuleDraft((current) => ({ ...current, targetPrice: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rule-series"></Label>
<Input
id="rule-series"
placeholder="如 Ezone / Blade"
value={ruleDraft.seriesKeyword}
onChange={(event) => setRuleDraft((current) => ({ ...current, seriesKeyword: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rule-model"></Label>
<Input
id="rule-model"
placeholder="如 98 / 100L"
value={ruleDraft.modelKeyword}
onChange={(event) => setRuleDraft((current) => ({ ...current, modelKeyword: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={ruleDraft.category}
onValueChange={(value) => setRuleDraft((current) => ({ ...current, category: value as RuleDraft["category"] }))}
>
<SelectTrigger>
<SelectValue placeholder="不限" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(CATEGORY_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rule-weight-min"></Label>
<Input
id="rule-weight-min"
type="number"
placeholder="285"
value={ruleDraft.weightMinGram}
onChange={(event) => setRuleDraft((current) => ({ ...current, weightMinGram: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rule-weight-max"></Label>
<Input
id="rule-weight-max"
type="number"
placeholder="305"
value={ruleDraft.weightMaxGram}
onChange={(event) => setRuleDraft((current) => ({ ...current, weightMaxGram: event.target.value }))}
/>
</div>
<div className="flex items-center justify-between rounded-2xl border border-border/60 bg-muted/20 px-4 py-3 sm:col-span-2">
<div>
<div className="font-medium"></div>
<div className="text-sm text-muted-foreground"> webhook</div>
</div>
<Switch
checked={ruleDraft.pushEnabled}
onCheckedChange={(checked) => setRuleDraft((current) => ({ ...current, pushEnabled: checked }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button
onClick={handleSubmitRule}
disabled={createRuleMutation.isPending || updateRuleMutation.isPending}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-3">
{rules.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
</div>
) : (
rules.map((rule) => (
<div key={rule.id} className="rounded-[22px] border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium text-foreground">{rule.title}</div>
<Badge variant="outline">{rule.brand}</Badge>
<Badge variant={rule.isActive === 1 ? "secondary" : "outline"}>
{rule.isActive === 1 ? "监控中" : "已暂停"}
</Badge>
{rule.pushEnabled === 1 ? (
<Badge variant="secondary" className="bg-sky-50 text-sky-700"></Badge>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{formatCurrency(rule.targetPrice)}
{rule.seriesKeyword ? ` · 系列 ${rule.seriesKeyword}` : ""}
{rule.modelKeyword ? ` · 型号 ${rule.modelKeyword}` : ""}
{rule.category ? ` · ${CATEGORY_LABELS[rule.category as keyof typeof CATEGORY_LABELS] ?? rule.category}` : ""}
{rule.weightMinGram != null ? ` · ≥${Math.round(rule.weightMinGram)}g` : ""}
{rule.weightMaxGram != null ? ` · ≤${Math.round(rule.weightMaxGram)}g` : ""}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<div>
{rule.lastCheckedAt ? formatDateTimeShanghai(rule.lastCheckedAt) : "尚未检查"}
{rule.lastMatchedAt ? ` · 最近命中 ${formatDateShanghai(rule.lastMatchedAt)}` : ""}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => refreshMutation.mutate({ ruleId: rule.id })}>
</Button>
<Button size="sm" variant="outline" onClick={() => handleEditRule(rule)}>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => toggleRuleMutation.mutate({ ruleId: rule.id, isActive: rule.isActive !== 1 })}
>
{rule.isActive === 1 ? "暂停" : "启用"}
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive"
onClick={() => deleteRuleMutation.mutate({ ruleId: rule.id })}
>
</Button>
</div>
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-lg"></CardTitle>
<CardDescription className="mt-1"></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{sourceSummary.map((item) => (
<div key={item.source} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium">{SOURCE_LABELS[item.source as keyof typeof SOURCE_LABELS]}</div>
<div className="mt-1 text-sm text-muted-foreground">
{item.latestFetchedAt ? formatDateTimeShanghai(item.latestFetchedAt) : "暂无数据"}
</div>
</div>
<div className="text-right">
<div className="text-xl font-semibold">{item.total}</div>
<div className="text-xs text-muted-foreground">{item.lowPriceCount} </div>
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-lg"></CardTitle>
<CardDescription className="mt-1"></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{hits.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
</div>
) : (
hits.map((hit) => (
<div key={hit.id} className="rounded-[22px] border border-border/60 bg-[linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.9))] p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{SOURCE_LABELS[hit.listingSource as keyof typeof SOURCE_LABELS]}</Badge>
<Badge className={GRADE_TONES[(hit.listingGradeLevel as keyof typeof GRADE_TONES) ?? "pending_review"]}>
{GRADE_LABELS[(hit.listingGradeLevel as keyof typeof GRADE_LABELS) ?? "pending_review"]}
</Badge>
<Badge variant={hit.status === "pushed" ? "secondary" : hit.status === "push_queued" ? "outline" : "secondary"}>
{hit.status === "matched" ? "已命中" : hit.status === "push_queued" ? "待推送" : hit.status === "pushed" ? "已推送" : "已抑制"}
</Badge>
</div>
<div className="font-medium text-foreground">{hit.ruleTitle}</div>
<div className="text-sm text-muted-foreground">{hit.listingTitle}</div>
<div className="text-sm text-muted-foreground">
{formatCurrency(hit.matchedPrice)} · {formatCurrency(hit.listingPrice)} · {hit.pushCount}
</div>
<div className="text-xs text-muted-foreground">
{formatDateTimeShanghai(hit.firstMatchedAt)} · {formatDateTimeShanghai(hit.lastMatchedAt)}
</div>
</div>
<Button asChild size="sm" className="gap-2">
<a href={hit.listingUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Sparkles className="h-4 w-4 text-primary" />
</CardTitle>
<CardDescription className="mt-1"></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{recentTasks.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
</div>
) : (
recentTasks.map((task) => (
<div key={task.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium">{task.title}</div>
<div className="mt-1 text-sm text-muted-foreground">{task.message || "无额外说明"}</div>
</div>
<Badge variant={task.status === "failed" ? "destructive" : task.status === "succeeded" ? "secondary" : "outline"}>
{TASK_STATUS_LABELS[task.status as keyof typeof TASK_STATUS_LABELS] ?? task.status}
</Badge>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{formatDateTimeShanghai(task.createdAt)}</span>
<span>{task.progress}%</span>
</div>
{task.error ? (
<div className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">{task.error}</div>
) : null}
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}

327
client/src/pages/Matches.tsx 普通文件
查看文件

@@ -0,0 +1,327 @@
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/_core/hooks/useAuth";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
import { formatMatchScore, getMatchModeLabel, getMatchParticipant, getMatchStatusLabel, getParticipantResult, getWinnerName } from "@/lib/matches";
import { toast } from "sonner";
import { Camera, ClipboardCheck, ShieldCheck, Swords, Trophy } from "lucide-react";
type StatusFilter = "all" | "draft" | "review_pending" | "reviewed" | "finalized";
const statusOptions: Array<{ key: StatusFilter; label: string }> = [
{ key: "all", label: "全部" },
{ key: "review_pending", label: "待审核" },
{ key: "reviewed", label: "待结算" },
{ key: "finalized", label: "已入库" },
];
function StatCard(props: { icon: React.ReactNode; label: string; value: string | number; hint: string }) {
return (
<Card className="border-0 shadow-sm">
<CardContent className="pt-5">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{props.icon}
</div>
<div>
<div className="text-sm text-muted-foreground">{props.label}</div>
<div className="mt-1 text-xl font-semibold">{props.value}</div>
<div className="mt-1 text-xs text-muted-foreground">{props.hint}</div>
</div>
</div>
</CardContent>
</Card>
);
}
export default function Matches() {
const { user } = useAuth();
const utils = trpc.useUtils();
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [selectedMatchId, setSelectedMatchId] = useState<number | null>(null);
const statsQuery = trpc.match.stats.useQuery();
const listQuery = trpc.match.list.useQuery({
limit: 50,
workflowStatus: statusFilter,
});
const detailQuery = trpc.match.get.useQuery(
{ matchId: selectedMatchId ?? 0 },
{ enabled: selectedMatchId != null },
);
const requestSuggestionMutation = trpc.match.requestSuggestion.useMutation({
onSuccess: () => {
toast.success("已提交自动计分建议刷新");
utils.match.list.invalidate();
utils.match.get.invalidate();
},
onError: (error) => toast.error(`提交失败: ${error.message}`),
});
useEffect(() => {
if (!selectedMatchId && listQuery.data?.length) {
setSelectedMatchId(listQuery.data[0].id);
}
}, [listQuery.data, selectedMatchId]);
const selectedMatch = detailQuery.data;
const myResult = useMemo(() => getParticipantResult(selectedMatch || {}, user?.id), [selectedMatch, user?.id]);
if (statsQuery.isLoading || listQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-28 w-full" />
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((item) => <Skeleton key={item} className="h-28 w-full" />)}
</div>
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="space-y-6">
<section className="rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top_left,_rgba(245,158,11,0.12),_transparent_28%),linear-gradient(180deg,rgba(255,255,255,1),rgba(248,250,252,0.96))] p-6 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
</p>
</div>
{selectedMatch ? (
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{getMatchModeLabel(selectedMatch.matchMode)}</Badge>
<Badge variant="outline">{getMatchStatusLabel(selectedMatch.workflowStatus)}</Badge>
{myResult ? <Badge variant="outline">{myResult}</Badge> : null}
</div>
) : null}
</div>
</section>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
icon={<Swords className="h-5 w-5" />}
label="我的比赛"
value={statsQuery.data?.total || 0}
hint={`日常 ${statsQuery.data?.daily || 0} 场 · 竞赛 ${statsQuery.data?.competitive || 0}`}
/>
<StatCard
icon={<ClipboardCheck className="h-5 w-5" />}
label="已入库"
value={statsQuery.data?.finalized || 0}
hint={`待审核/待结算 ${statsQuery.data?.reviewPending || 0}`}
/>
<StatCard
icon={<Trophy className="h-5 w-5" />}
label="竞赛胜场"
value={statsQuery.data?.competitiveWins || 0}
hint={`已绑定机位 ${statsQuery.data?.camerasBound || 0}`}
/>
</div>
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as StatusFilter)} className="space-y-4">
<TabsList className="grid w-full grid-cols-4 lg:w-auto">
{statusOptions.map((option) => (
<TabsTrigger key={option.key} value={option.key}>{option.label}</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>访</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(!listQuery.data || listQuery.data.length === 0) ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
</div>
) : (
listQuery.data.map((match: any) => {
const playerA = getMatchParticipant(match, "player_a");
const playerB = getMatchParticipant(match, "player_b");
return (
<button
key={match.id}
type="button"
onClick={() => setSelectedMatchId(match.id)}
className={`w-full rounded-2xl border p-4 text-left transition ${selectedMatchId === match.id ? "border-primary bg-primary/5" : "border-border/60 bg-muted/20 hover:bg-muted/40"}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{match.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{playerA?.userName || "A"} vs {playerB?.userName || "B"}
</div>
</div>
<Badge variant={match.workflowStatus === "finalized" ? "secondary" : "outline"}>
{getMatchStatusLabel(match.workflowStatus)}
</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{getMatchModeLabel(match.matchMode)}</span>
<span>·</span>
<span>{formatDateShanghai(match.scheduledAt || match.createdAt)}</span>
<span>·</span>
<span>{formatMatchScore(match)}</span>
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>线</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{!selectedMatchId ? (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
</div>
) : detailQuery.isLoading ? (
<Skeleton className="h-80 w-full" />
) : !selectedMatch ? (
<div className="rounded-2xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
</div>
) : (
<>
<div className="flex flex-col gap-4 rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">{selectedMatch.title}</h2>
<Badge variant="secondary">{getMatchModeLabel(selectedMatch.matchMode)}</Badge>
<Badge variant="outline">{getMatchStatusLabel(selectedMatch.workflowStatus)}</Badge>
{myResult ? <Badge variant="outline">{myResult}</Badge> : null}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{selectedMatch.courtName || "未填写"} · {selectedMatch.durationMinutes || 90} · {formatDateTimeShanghai(selectedMatch.scheduledAt || selectedMatch.createdAt)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => requestSuggestionMutation.mutate({ matchId: selectedMatch.id })}
disabled={requestSuggestionMutation.isPending || selectedMatch.workflowStatus === "finalized" || selectedMatch.workflowStatus === "cancelled"}
>
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
{formatMatchScore(selectedMatch)} · {getWinnerName(selectedMatch)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{(["player_a", "player_b"] as const).map((slot) => {
const participant = getMatchParticipant(selectedMatch, slot);
const stats = participant?.finalStats || participant?.suggestedStats || {};
return (
<Card key={slot} className="border border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base">{participant?.userName || slot}</CardTitle>
<CardDescription>
{slot === "player_a" ? "主位 A" : "主位 B"} · {participant?.cameraSlot || "-"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-2xl bg-muted/30 px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="inline-flex items-center gap-1 font-medium">
<Camera className="h-4 w-4 text-primary" />
{participant?.cameraStatus || "pending"}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{participant?.finalGamesWon ?? participant?.suggestedGamesWon ?? 0}</div>
</div>
<div className="rounded-2xl bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{participant?.finalSetsWon ?? participant?.suggestedSetsWon ?? 0}</div>
</div>
<div className="rounded-2xl bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{participant?.finalPointsWon ?? participant?.suggestedPointsWon ?? 0}</div>
</div>
<div className="rounded-2xl bg-muted/30 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-lg font-semibold">{Number((stats as any).firstServePct || 0).toFixed(1)}%</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div className="rounded-xl bg-muted/20 p-2">ACE {(stats as any).aces || 0}</div>
<div className="rounded-xl bg-muted/20 p-2"> {(stats as any).winners || 0}</div>
<div className="rounded-xl bg-muted/20 p-2"> {(stats as any).unforcedErrors || 0}</div>
</div>
{participant?.isWinner === 1 ? (
<div className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
) : null}
</CardContent>
</Card>
);
})}
</div>
<Card className="border border-border/60 shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base">线</CardTitle>
<CardDescription>
{selectedMatch.events?.length || 0}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{(!selectedMatch.events || selectedMatch.events.length === 0) ? (
<div className="rounded-2xl border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground">
</div>
) : (
selectedMatch.events.map((event: any) => (
<div key={event.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline">#{event.eventIndex}</Badge>
<span className="font-medium">{event.eventType}</span>
<Badge variant="secondary">{event.source}</Badge>
{event.winnerSlot ? <Badge variant="outline"> {event.winnerSlot}</Badge> : null}
</div>
<div className="text-xs text-muted-foreground">
{event.matchSecond != null ? `${event.matchSecond} 秒 · ` : ""}
{Number(event.confidence || 0).toFixed(2)}
</div>
</div>
<pre className="mt-3 overflow-x-auto rounded-xl bg-background/80 p-3 text-xs text-muted-foreground">
{JSON.stringify(event.payload ?? {}, null, 2)}
</pre>
</div>
))
)}
</CardContent>
</Card>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}

查看文件

@@ -135,16 +135,16 @@ const ASSESSMENT_FIELDS = [
{
key: "enduranceScore",
label: "耐力",
method: "按持续跑动、连续多球、间歇训练后的状态打分。",
hint: "重点看 30-60 分钟训练后是否还能保持节奏。",
benchmarkLabel: "12 分钟跑参考",
benchmarkNote: "如无法跑场,可用多球折返跑的持续能力做近似判断,距离越长越好。",
method: "优先做 12 分钟跑:热身后连续跑 12 分钟,记录总距离。没有跑道或计距条件时,再用 20 分钟连续多球或折返跑后半段状态做替代判断。",
hint: "填写时尽量写清测试类型和结果,例如“12 分钟跑 2350 米”或“20 分钟多球后最后 5 分钟明显掉速”。不要只按主观感觉随意打分。",
benchmarkLabel: "优先参考:12 分钟跑",
benchmarkNote: "分数优先按 12 分钟跑总距离判断。只有无法测距离时,才参考连续多球/折返跑在中后段是否明显掉速,手动选择最接近的档位。",
scoreGuide: [
{ score: "5", range: "≥ 2900 米", note: "长时间训练后仍有充足余量" },
{ score: "4", range: "2600 - 2899 米", note: "耐力较好" },
{ score: "3", range: "2200 - 2599 米", note: "基础达标" },
{ score: "2", range: "1800 - 2199 米", note: "训练后后程掉速明显" },
{ score: "1", range: "< 1800 米", note: "耐力偏弱,需优先提升" },
{ score: "5", range: "≥ 2900 米", note: "末段仍能稳住配速,长时间训练后还有余量" },
{ score: "4", range: "2600 - 2899 米", note: "整体耐力较好,后程有轻微下降但节奏稳定" },
{ score: "3", range: "2200 - 2599 米", note: "基础达标,30-45 分钟训练后还能维持常规节奏" },
{ score: "2", range: "1800 - 2199 米", note: "中后段掉速明显,连续多球或折返跑容易顶不住" },
{ score: "1", range: "< 1800 米", note: "耐力偏弱,短时间内就出现明显喘和失速,需优先提升" },
],
},
{

查看文件

@@ -5,8 +5,10 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { Slider } from "@/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { formatDateShanghai, formatDateTimeShanghai } from "@/lib/time";
@@ -510,287 +512,383 @@ export default function Videos() {
)}
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="max-h-[92vh] max-w-5xl overflow-y-auto">
<DialogHeader>
<DialogContent className="max-h-[92vh] max-w-6xl overflow-hidden p-0">
<DialogHeader className="border-b border-border/60 bg-background/95 px-6 py-5">
<DialogTitle className="flex items-center gap-2">
<Scissors className="h-5 w-5 text-primary" />
PC
</DialogTitle>
<DialogDescription>
/稿 JSON
<DialogDescription className="max-w-2xl">
稿
</DialogDescription>
</DialogHeader>
{selectedVideo ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.9fr)]">
<section className="space-y-4">
<div className="overflow-hidden rounded-3xl border border-border/60 bg-black">
<video
ref={previewRef}
src={selectedVideo.url}
className="aspect-video w-full object-contain"
controls
playsInline
onLoadedMetadata={(event) => {
const duration = event.currentTarget.duration || 0;
setVideoDurationSec(duration);
setClipRange([0, Math.min(duration, 5)]);
}}
onTimeUpdate={(event) => setPlaybackSec(event.currentTarget.currentTime || 0)}
/>
</div>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">稿</div>
<div className="mt-2 text-lg font-semibold">{clipDrafts.length}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{formatSeconds(totalClipDurationSec)}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{suggestedClips.length}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{formatSeconds(playbackSec)}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[0])}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-lg font-semibold">{formatSeconds(clipRange[1])}</div>
</div>
</div>
{timelineDurationSec > 0 ? (
<Slider
value={clipRange}
min={0}
max={timelineDurationSec}
step={0.1}
onValueChange={(value) => {
if (value.length === 2) {
setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]);
}
}}
/>
) : null}
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setClipRange(([_, end]) => [clamp(playbackSec, 0, Math.max(0, end - 0.5)), end])}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setClipRange(([start]) => [start, clamp(playbackSec, start + 0.5, timelineDurationSec || playbackSec + 0.5)])}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (previewRef.current) previewRef.current.currentTime = clipRange[0];
}}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
if (!previewRef.current) return;
setActivePreviewRange([clipRange[0], clipRange[1]]);
previewRef.current.currentTime = clipRange[0];
await previewRef.current.play().catch(() => undefined);
}}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setActivePreviewRange(null);
previewRef.current?.pause();
}}
>
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input
value={clipLabel}
onChange={(event) => setClipLabel(event.target.value)}
placeholder="片段名称,例如:正手节奏稳定段"
className="h-11 rounded-2xl"
/>
<Button onClick={() => addClip("manual")} className="h-11 rounded-2xl gap-2">
<Scissors className="h-4 w-4" />
稿
</Button>
</div>
<Textarea
value={clipNotes}
onChange={(event) => setClipNotes(event.target.value)}
placeholder="记录这个片段为什么要保留,或后续想怎么讲解"
className="min-h-24 rounded-2xl"
<div className="overflow-y-auto px-6 py-5">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.45fr)_360px]">
<section className="space-y-5">
<div className="overflow-hidden rounded-[28px] border border-border/60 bg-[#05070b] shadow-[0_24px_60px_rgba(5,7,11,0.18)]">
<video
ref={previewRef}
src={selectedVideo.url}
className="aspect-video w-full object-contain"
controls
playsInline
onLoadedMetadata={(event) => {
const duration = event.currentTarget.duration || 0;
setVideoDurationSec(duration);
setClipRange([0, Math.min(duration, 5)]);
}}
onTimeUpdate={(event) => setPlaybackSec(event.currentTarget.currentTime || 0)}
/>
</CardContent>
</Card>
</section>
<aside className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>稿</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{suggestedClips.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
<div className="grid gap-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(14,22,32,0.95),rgba(8,12,18,0.98))] px-5 py-4 text-white sm:grid-cols-[minmax(0,1fr)_280px]">
<div>
<div className="text-[11px] uppercase tracking-[0.24em] text-white/55"></div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge className="rounded-full bg-white/12 px-3 py-1 text-white hover:bg-white/12">
{formatSeconds(clipRange[0])} - {formatSeconds(clipRange[1])}
</Badge>
<Badge className="rounded-full bg-emerald-400/15 px-3 py-1 text-emerald-200 hover:bg-emerald-400/15">
{formatSeconds(Math.max(0, clipRange[1] - clipRange[0]))}
</Badge>
{activePreviewRange ? (
<Badge className="rounded-full bg-amber-400/15 px-3 py-1 text-amber-200 hover:bg-amber-400/15">
</Badge>
) : null}
</div>
<p className="mt-3 text-sm leading-6 text-white/65">
</p>
</div>
) : (
suggestedClips.map((clip: ClipDraft) => (
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="font-medium">{clip.label}</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-lg font-semibold tabular-nums">{formatSeconds(playbackSec)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-lg font-semibold tabular-nums">{formatSeconds(clipRange[0])}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45"></div>
<div className="mt-2 text-lg font-semibold tabular-nums">{formatSeconds(clipRange[1])}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/45">稿</div>
<div className="mt-2 text-lg font-semibold tabular-nums">{clipDrafts.length}</div>
</div>
</div>
</div>
</div>
<Card className="border border-border/60 shadow-sm">
<CardHeader className="pb-4">
<CardTitle className="text-base"></CardTitle>
<CardDescription>线</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-[24px] border border-border/60 bg-muted/[0.16] px-4 py-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium">线</div>
<div className="text-xs text-muted-foreground">
{formatSeconds(timelineDurationSec || videoDurationSec)}
</div>
<div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div>
<div className="mt-3 flex gap-2">
</div>
{timelineDurationSec > 0 ? (
<Slider
value={clipRange}
min={0}
max={timelineDurationSec}
step={0.1}
onValueChange={(value) => {
if (value.length === 2) {
setClipRange([value[0] || 0, value[1] || Math.max(0.5, timelineDurationSec)]);
}
}}
/>
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="rounded-[24px] border border-border/60 bg-muted/[0.16] p-4">
<div className="mb-3 text-sm font-medium"></div>
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
size="sm"
className="justify-start rounded-xl"
onClick={() => setClipRange(([_, end]) => [clamp(playbackSec, 0, Math.max(0, end - 0.5)), end])}
>
</Button>
<Button
variant="outline"
size="sm"
className="justify-start rounded-xl"
onClick={() => setClipRange(([start]) => [start, clamp(playbackSec, start + 0.5, timelineDurationSec || playbackSec + 0.5)])}
>
</Button>
<Button
variant="outline"
size="sm"
className="justify-start rounded-xl"
onClick={() => {
setClipRange([clip.startSec, clip.endSec]);
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
if (previewRef.current) previewRef.current.currentTime = clipRange[0];
}}
>
</Button>
<div className="flex items-center rounded-xl border border-dashed border-border/60 px-3 text-xs text-muted-foreground">
</div>
</div>
</div>
<div className="rounded-[24px] border border-border/60 bg-muted/[0.16] p-4">
<div className="mb-3 text-sm font-medium"></div>
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
size="sm"
className="justify-start rounded-xl"
onClick={async () => {
if (!previewRef.current) return;
setActivePreviewRange([clipRange[0], clipRange[1]]);
previewRef.current.currentTime = clipRange[0];
await previewRef.current.play().catch(() => undefined);
}}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
setActivePreviewRange([clip.startSec, clip.endSec]);
if (previewRef.current) {
previewRef.current.currentTime = clip.startSec;
await previewRef.current.play().catch(() => undefined);
}
className="justify-start rounded-xl"
onClick={() => {
setActivePreviewRange(null);
previewRef.current?.pause();
}}
>
</Button>
<Button size="sm" onClick={() => addClip("suggested", clip)}>稿</Button>
<div className="col-span-full rounded-xl border border-dashed border-border/60 px-3 py-2 text-xs leading-5 text-muted-foreground">
/
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base">稿</CardTitle>
<CardDescription>稿使</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{clipDrafts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-8 text-center text-sm text-muted-foreground">
稿
</div>
) : (
clipDrafts.map((clip: ClipDraft) => (
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/20 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{clip.label}</span>
<Badge variant="outline">{clip.source === "manual" ? "手动" : "建议"}</Badge>
<Badge variant="secondary">{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setClipRange([clip.startSec, clip.endSec]);
setClipLabel(clip.label);
setClipNotes(clip.notes);
if (previewRef.current) {
previewRef.current.currentTime = clip.startSec;
}
}}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
if (!navigator.clipboard) {
toast.error("当前浏览器不支持剪贴板复制");
return;
}
void navigator.clipboard.writeText(value).then(
() => toast.success("片段信息已复制"),
() => toast.error("片段复制失败"),
);
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{clip.notes ? <div className="mt-2 text-sm text-muted-foreground">{clip.notes}</div> : null}
<div className="space-y-3 rounded-[24px] border border-border/60 bg-background p-4">
<div className="text-sm font-medium">稿</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px]">
<Input
value={clipLabel}
onChange={(event) => setClipLabel(event.target.value)}
placeholder="片段名称,例如:正手节奏稳定段"
className="h-11 rounded-2xl"
/>
<Button onClick={() => addClip("manual")} className="h-11 rounded-2xl gap-2">
<Scissors className="h-4 w-4" />
稿
</Button>
</div>
))
)}
</CardContent>
</Card>
</aside>
<Textarea
value={clipNotes}
onChange={(event) => setClipNotes(event.target.value)}
placeholder="记录这个片段为什么要保留,或后续想怎么讲解"
className="min-h-24 rounded-2xl"
/>
</div>
</CardContent>
</Card>
</section>
<aside className="space-y-4">
<Card className="overflow-hidden border border-border/60 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(244,247,250,0.96))] shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</div>
<Badge variant="outline" className="rounded-full">稿</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<div className="rounded-2xl border border-border/60 bg-background/80 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">稿</div>
<div className="mt-2 text-2xl font-semibold tabular-nums">{clipDrafts.length}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-background/80 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold tabular-nums">{formatSeconds(totalClipDurationSec)}</div>
</div>
<div className="rounded-2xl border border-border/60 bg-background/80 p-4">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground"></div>
<div className="mt-2 text-2xl font-semibold tabular-nums">{suggestedClips.length}</div>
</div>
</CardContent>
</Card>
<Tabs defaultValue="suggested" className="overflow-hidden rounded-[28px] border border-border/60 bg-background shadow-sm">
<div className="flex flex-col gap-4 border-b border-border/60 px-5 py-4">
<div>
<div className="text-base font-semibold"></div>
<p className="mt-1 text-sm text-muted-foreground">
稿
</p>
</div>
<TabsList className="grid h-auto w-full grid-cols-2 rounded-2xl bg-muted/70 p-1">
<TabsTrigger value="suggested" className="rounded-xl py-2">
{suggestedClips.length}
</TabsTrigger>
<TabsTrigger value="drafts" className="rounded-xl py-2">
稿 {clipDrafts.length}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="suggested" className="m-0">
<ScrollArea className="h-[420px]">
<div className="space-y-3 p-5">
{suggestedClips.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
</div>
) : (
suggestedClips.map((clip: ClipDraft) => (
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/[0.16] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{clip.label}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}</span>
<Badge variant="secondary" className="rounded-full">
{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}
</Badge>
</div>
</div>
<Badge variant="outline" className="rounded-full"></Badge>
</div>
<div className="mt-2 text-sm leading-6 text-muted-foreground">{clip.notes}</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => {
setClipRange([clip.startSec, clip.endSec]);
if (previewRef.current) previewRef.current.currentTime = clip.startSec;
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={async () => {
setActivePreviewRange([clip.startSec, clip.endSec]);
if (previewRef.current) {
previewRef.current.currentTime = clip.startSec;
await previewRef.current.play().catch(() => undefined);
}
}}
>
<Play className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" className="rounded-xl" onClick={() => addClip("suggested", clip)}>
稿
</Button>
</div>
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="drafts" className="m-0">
<ScrollArea className="h-[420px]">
<div className="space-y-3 p-5">
{clipDrafts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 px-4 py-10 text-center text-sm text-muted-foreground">
稿
</div>
) : (
clipDrafts.map((clip: ClipDraft) => (
<div key={clip.id} className="rounded-2xl border border-border/60 bg-muted/[0.16] p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{clip.label}</span>
<Badge variant="outline" className="rounded-full">{clip.source === "manual" ? "手动" : "建议"}</Badge>
<Badge variant="secondary" className="rounded-full">
{formatSeconds(Math.max(0, clip.endSec - clip.startSec))}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatSeconds(clip.startSec)} - {formatSeconds(clip.endSec)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setClipRange([clip.startSec, clip.endSec]);
setClipLabel(clip.label);
setClipNotes(clip.notes);
if (previewRef.current) {
previewRef.current.currentTime = clip.startSec;
}
}}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
const value = `${clip.label} ${formatSeconds(clip.startSec)}-${formatSeconds(clip.endSec)} ${clip.notes || ""}`.trim();
if (!navigator.clipboard) {
toast.error("当前浏览器不支持剪贴板复制");
return;
}
void navigator.clipboard.writeText(value).then(
() => toast.success("片段信息已复制"),
() => toast.error("片段复制失败"),
);
}}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setClipDrafts((current) => current.filter((item) => item.id !== clip.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{clip.notes ? <div className="mt-2 text-sm leading-6 text-muted-foreground">{clip.notes}</div> : null}
</div>
))
)}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</aside>
</div>
</div>
) : null}
<DialogFooter className="flex gap-2">
<DialogFooter className="border-t border-border/60 bg-background/95 px-6 py-4">
<Button
variant="outline"
onClick={() => {

查看文件

@@ -1,5 +1,47 @@
# Tennis Training Hub - 变更日志
## 2026.03.27-match-review-hub (2026-03-27)
### 功能更新
- 新增 `/matches` 比赛入库板块和侧边栏入口,用户可以查看自己绑定的双人双摄比赛、自动计分事件、审核状态与正式入库结果
- 服务端新增 `match_sessions``match_participants``match_score_events` 数据表,并把“自动计分建议”“正式结算”接入现有 `background_tasks` worker 异步链路
- 补齐 `0012_market_watch``0013_match_hub` 的 Drizzle statement breakpoint,让 MySQL 生产迁移可以顺序执行球拍行情与比赛入库表结构并完成上线
- 管理后台新增“比赛入库”工作台,H1 / 管理员可以创建日常或竞赛比赛、固定两位用户与对应双机位、刷新自动计分建议、提交审核比分并发起正式结算
- 排行榜新增训练榜 / 竞赛榜双视图;竞赛榜只统计已正式结算的竞赛比赛,不会把未审核自动计分直接计入正式名次
- 正式结算后会把比赛写入用户训练记录;日常比赛同步累计训练型指标,竞赛比赛单独进入正式比赛统计与排行榜,避免未审核比赛直接影响 NTRP
### 测试
- `pnpm check`
- `pnpm exec vitest run server/match.test.ts server/features.test.ts`
- `pnpm build`
- `docker compose up -d --build migrate app app-worker`
- 线上 smoke公开站点部署前仍停留在 2026-03-17 的旧 revision;重新部署后已切到包含比赛入库与更新日志修正的新构建,Playwright 真实登录 `H1` 验证 `/matches``/admin` 的“比赛入库”工作台、`/leaderboard` 的“训练榜 / 竞赛榜”以及 `/changelog` 最新条目均可访问;当前仅剩 `/favicon.ico` 404,不影响功能使用
### 仓库版本
- `495da60`
## 2026.03.23-racket-market-watch (2026-03-23)
### 功能更新
- 新增 `/market` 球拍行情板块和侧边栏入口,集中展示低价雷达、监控规则、命中记录以及相关后台任务状态
- 服务端新增球拍行情数据表、用户监控规则和命中历史,并把市场刷新、来源抓取、飞书推送接入现有 `background_tasks` worker 流程
- 抓取结果会为球拍补齐品牌、型号、系列、品类、重量、成色与价格分级;命中用户目标价后,会先写入站内记录,再按去重规则推送到默认飞书 webhook
- 全局设置新增默认飞书 webhook、抓取超时、重试次数、闲鱼/京东/转转抓取 UA/Cookie 与转转搜索模板配置,管理员可在后台直接调整
### 测试
- `pnpm check`
- `pnpm exec vitest run server/market.test.ts server/market.routes.test.ts`
- `pnpm build`
### 仓库版本
- `495da60`
## 2026.03.17-live-camera-relay-mp4-hardening (2026-03-17)
### 功能更新

查看文件

@@ -60,6 +60,18 @@
- media service 暂时抖动时,不会把前台提交动作直接拖成超时
- 真正的会话校验、归档、回放可用性判断都在 worker 中执行
### 1.4 media-worker 会话刷新
`media-worker` 现在每轮都会从磁盘重新读取 `/data/media/sessions/*/session.json`
这样做是为了避免独立 worker 只在启动时加载一次会话,导致:
- 新创建的录制会话虽然已经写盘,但 worker 看不到
- `archiveStatus = queued` 的会话长期卡住
- `media_finalize` 任务反复重试直到耗尽次数
如果线上出现“归档一直 queued,但 media-worker 没报错”的情况,优先确认当前镜像是否已经包含这项修复。
## 2. 前端任务观测与降级
### 2.1 任务中心
@@ -169,6 +181,34 @@ docker compose logs --tail=80 app-worker
curl http://127.0.0.1:8081/media/health
```
如问题涉及训练计划超时或录制归档卡住,优先使用全链路重建,而不是只重启单个容器:
```bash
docker compose up -d --build migrate app app-worker media media-worker
```
重启后建议额外确认:
- `docker compose logs --tail=120 app-worker` 中不再出现旧的 `Request timed out after 45000ms`
- `docker compose logs --tail=120 media-worker` 中 worker 已正常启动
- 容器内 `dist/worker.js` 已包含新的训练计划超时/自动重试逻辑
## 6.1 损坏 media 会话处理
如果 `media-data` 中出现 `0` 字节或无法解析的 `session.json`,不要继续保留在 `sessions/*` 主目录。
建议处理方式:
1. 将损坏会话目录移动到隔离目录,例如 `sessions_broken/<session_id>`
2. 保留原始 `segments/*` 作为排障材料
3. 不直接猜测补写 `session.json`
原因:
-`session.json` 往往意味着写盘被中断
- 这类会话即使继续重试,也无法可靠恢复业务语义
- 将其移出主扫描路径可以避免后续 worker 误处理
## 7. 线上 Smoke Check
全量重启后建议至少执行:

查看文件

@@ -0,0 +1,39 @@
ALTER TABLE `tutorial_videos`
ADD `slug` varchar(128),
ADD `topicArea` varchar(32) DEFAULT 'tennis_skill',
ADD `contentFormat` varchar(16) DEFAULT 'video',
ADD `sourcePlatform` varchar(16) DEFAULT 'none',
ADD `heroSummary` text,
ADD `externalUrl` text,
ADD `platformVideoId` varchar(64),
ADD `estimatedEffortMinutes` int,
ADD `prerequisites` json,
ADD `learningObjectives` json,
ADD `stepSections` json,
ADD `deliverables` json,
ADD `relatedDocPaths` json,
ADD `viewCount` int,
ADD `commentCount` int,
ADD `metricsFetchedAt` timestamp NULL,
ADD `completionAchievementKey` varchar(64),
ADD `isFeatured` int DEFAULT 0,
ADD `featuredOrder` int DEFAULT 0;
--> statement-breakpoint
ALTER TABLE `tutorial_progress`
ADD `completed` int DEFAULT 0,
ADD `completedAt` timestamp NULL;
--> statement-breakpoint
UPDATE `tutorial_videos`
SET
`topicArea` = COALESCE(`topicArea`, 'tennis_skill'),
`contentFormat` = COALESCE(`contentFormat`, 'video'),
`sourcePlatform` = COALESCE(`sourcePlatform`, 'none'),
`heroSummary` = COALESCE(`heroSummary`, `description`),
`estimatedEffortMinutes` = COALESCE(`estimatedEffortMinutes`, CASE WHEN `duration` IS NULL THEN NULL ELSE ROUND(`duration` / 60) END),
`isFeatured` = COALESCE(`isFeatured`, 0),
`featuredOrder` = COALESCE(`featuredOrder`, 0);
--> statement-breakpoint
UPDATE `tutorial_progress`
SET
`completed` = CASE WHEN `watched` = 1 THEN 1 ELSE COALESCE(`completed`, 0) END,
`completedAt` = CASE WHEN `watched` = 1 AND `completedAt` IS NULL THEN `updatedAt` ELSE `completedAt` END;

查看文件

@@ -0,0 +1,14 @@
ALTER TABLE `users`
ADD `manualNtrpRating` float,
ADD `manualNtrpCapturedAt` timestamp NULL,
ADD `heightCm` float,
ADD `weightKg` float,
ADD `sprintSpeedScore` int,
ADD `explosivePowerScore` int,
ADD `agilityScore` int,
ADD `enduranceScore` int,
ADD `flexibilityScore` int,
ADD `coreStabilityScore` int,
ADD `shoulderMobilityScore` int,
ADD `hipMobilityScore` int,
ADD `assessmentNotes` text;

查看文件

@@ -0,0 +1,86 @@
CREATE TABLE `racket_listings` (
`id` int AUTO_INCREMENT NOT NULL,
`source` enum('xianyu','jd','zhuanzhuan') NOT NULL,
`sourceListingId` varchar(128) NOT NULL,
`title` varchar(512) NOT NULL,
`description` text,
`listingUrl` text NOT NULL,
`imageUrl` text,
`price` float NOT NULL,
`originalPrice` float,
`sellerName` varchar(128),
`location` varchar(128),
`publishedAtRaw` varchar(128),
`brand` varchar(64),
`model` varchar(128),
`series` varchar(128),
`category` varchar(64),
`weightGram` float,
`conditionLevel` enum('brand_new','almost_new','used_good','used_fair','unknown') NOT NULL DEFAULT 'unknown',
`gradeLevel` enum('high_value','standard','overpriced','pending_review') NOT NULL DEFAULT 'pending_review',
`gradeReason` text,
`isLowPriceCandidate` int NOT NULL DEFAULT 0,
`fingerprint` varchar(128) NOT NULL,
`extra` json,
`fetchedAt` timestamp NOT NULL DEFAULT (now()),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `racket_listings_id` PRIMARY KEY(`id`),
CONSTRAINT `racket_listings_source_listing_idx` UNIQUE(`source`,`sourceListingId`),
CONSTRAINT `racket_listings_fingerprint_idx` UNIQUE(`fingerprint`)
);
--> statement-breakpoint
CREATE TABLE `racket_watch_rules` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`title` varchar(256) NOT NULL,
`brand` varchar(64) NOT NULL,
`modelKeyword` varchar(128),
`seriesKeyword` varchar(128),
`category` varchar(64),
`weightMinGram` float,
`weightMaxGram` float,
`targetPrice` float NOT NULL,
`pushEnabled` int NOT NULL DEFAULT 1,
`isActive` int NOT NULL DEFAULT 1,
`lastCheckedAt` timestamp,
`lastMatchedAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `racket_watch_rules_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `racket_watch_hits` (
`id` int AUTO_INCREMENT NOT NULL,
`watchRuleId` int NOT NULL,
`userId` int NOT NULL,
`listingId` int NOT NULL,
`matchedPrice` float NOT NULL,
`status` enum('matched','push_queued','pushed','suppressed') NOT NULL DEFAULT 'matched',
`firstMatchedAt` timestamp NOT NULL DEFAULT (now()),
`lastMatchedAt` timestamp NOT NULL DEFAULT (now()),
`lastPushPrice` float,
`pushedAt` timestamp,
`pushCount` int NOT NULL DEFAULT 0,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `racket_watch_hits_id` PRIMARY KEY(`id`),
CONSTRAINT `racket_watch_hits_rule_listing_idx` UNIQUE(`watchRuleId`,`listingId`)
);
--> statement-breakpoint
ALTER TABLE `background_tasks`
MODIFY COLUMN `type` enum(
'media_finalize',
'training_plan_generate',
'training_plan_adjust',
'analysis_corrections',
'pose_correction_multimodal',
'ntrp_refresh_user',
'ntrp_refresh_all',
'market_source_sync',
'market_watch_refresh',
'market_push_delivery'
) NOT NULL;

90
drizzle/0013_match_hub.sql 普通文件
查看文件

@@ -0,0 +1,90 @@
CREATE TABLE `match_sessions` (
`id` int AUTO_INCREMENT NOT NULL,
`createdByUserId` int NOT NULL,
`matchMode` enum('daily','competitive') NOT NULL DEFAULT 'daily',
`workflowStatus` enum('draft','recording','review_pending','reviewed','finalizing','finalized','cancelled') NOT NULL DEFAULT 'draft',
`title` varchar(256) NOT NULL,
`courtName` varchar(128),
`notes` text,
`durationMinutes` int NOT NULL DEFAULT 90,
`scheduledAt` timestamp,
`startedAt` timestamp,
`endedAt` timestamp,
`suggestionStatus` enum('idle','queued','ready','failed') NOT NULL DEFAULT 'idle',
`suggestionTaskId` varchar(64),
`suggestedScore` json,
`suggestedMetrics` json,
`finalScore` json,
`finalMetrics` json,
`reviewNotes` text,
`reviewSubmittedAt` timestamp,
`reviewedByUserId` int,
`reviewedAt` timestamp,
`finalizedByUserId` int,
`finalizedAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `match_sessions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `match_participants` (
`id` int AUTO_INCREMENT NOT NULL,
`matchId` int NOT NULL,
`userId` int NOT NULL,
`playerSlot` enum('player_a','player_b') NOT NULL,
`cameraSlot` enum('camera_a','camera_b') NOT NULL,
`cameraStatus` enum('pending','bound','active','completed','failed') NOT NULL DEFAULT 'pending',
`cameraLabel` varchar(128),
`cameraVideoId` int,
`cameraVideoUrl` text,
`cameraSnapshot` json,
`isWinner` int NOT NULL DEFAULT 0,
`suggestedSetsWon` int NOT NULL DEFAULT 0,
`suggestedGamesWon` int NOT NULL DEFAULT 0,
`suggestedPointsWon` int NOT NULL DEFAULT 0,
`finalSetsWon` int NOT NULL DEFAULT 0,
`finalGamesWon` int NOT NULL DEFAULT 0,
`finalPointsWon` int NOT NULL DEFAULT 0,
`suggestedStats` json,
`finalStats` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `match_participants_id` PRIMARY KEY(`id`),
CONSTRAINT `match_participants_match_player_idx` UNIQUE(`matchId`,`playerSlot`),
CONSTRAINT `match_participants_match_user_idx` UNIQUE(`matchId`,`userId`)
);
--> statement-breakpoint
CREATE TABLE `match_score_events` (
`id` int AUTO_INCREMENT NOT NULL,
`matchId` int NOT NULL,
`eventIndex` int NOT NULL,
`source` enum('camera_a','camera_b','system','admin') NOT NULL DEFAULT 'system',
`eventType` enum('point','game','set','metric','score_suggestion','review_adjustment','finalized') NOT NULL,
`winnerSlot` enum('player_a','player_b'),
`matchSecond` int,
`confidence` float,
`payload` json,
`createdByUserId` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `match_score_events_id` PRIMARY KEY(`id`),
CONSTRAINT `match_score_events_match_event_idx` UNIQUE(`matchId`,`eventIndex`)
);
--> statement-breakpoint
ALTER TABLE `background_tasks`
MODIFY COLUMN `type` enum(
'media_finalize',
'training_plan_generate',
'training_plan_adjust',
'analysis_corrections',
'pose_correction_multimodal',
'ntrp_refresh_user',
'ntrp_refresh_all',
'market_source_sync',
'market_watch_refresh',
'market_push_delivery',
'match_score_suggest',
'match_finalize'
) NOT NULL;

查看文件

@@ -85,6 +85,20 @@
"when": 1773691200000,
"tag": "0011_live_analysis_runtime",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1773955200000,
"tag": "0012_market_watch",
"breakpoints": true
},
{
"idx": 13,
"version": "5",
"when": 1774569600000,
"tag": "0013_match_hub",
"breakpoints": true
}
]
}

查看文件

@@ -280,6 +280,95 @@ export const liveActionSegments = mysqlTable("live_action_segments", {
export type LiveActionSegment = typeof liveActionSegments.$inferSelect;
export type InsertLiveActionSegment = typeof liveActionSegments.$inferInsert;
/**
* Dual-player match sessions with admin-reviewed score settlement.
*/
export const matchSessions = mysqlTable("match_sessions", {
id: int("id").autoincrement().primaryKey(),
createdByUserId: int("createdByUserId").notNull(),
matchMode: mysqlEnum("matchMode", ["daily", "competitive"]).default("daily").notNull(),
workflowStatus: mysqlEnum("workflowStatus", ["draft", "recording", "review_pending", "reviewed", "finalizing", "finalized", "cancelled"]).default("draft").notNull(),
title: varchar("title", { length: 256 }).notNull(),
courtName: varchar("courtName", { length: 128 }),
notes: text("notes"),
durationMinutes: int("durationMinutes").default(90).notNull(),
scheduledAt: timestamp("scheduledAt"),
startedAt: timestamp("startedAt"),
endedAt: timestamp("endedAt"),
suggestionStatus: mysqlEnum("suggestionStatus", ["idle", "queued", "ready", "failed"]).default("idle").notNull(),
suggestionTaskId: varchar("suggestionTaskId", { length: 64 }),
suggestedScore: json("suggestedScore"),
suggestedMetrics: json("suggestedMetrics"),
finalScore: json("finalScore"),
finalMetrics: json("finalMetrics"),
reviewNotes: text("reviewNotes"),
reviewSubmittedAt: timestamp("reviewSubmittedAt"),
reviewedByUserId: int("reviewedByUserId"),
reviewedAt: timestamp("reviewedAt"),
finalizedByUserId: int("finalizedByUserId"),
finalizedAt: timestamp("finalizedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type MatchSession = typeof matchSessions.$inferSelect;
export type InsertMatchSession = typeof matchSessions.$inferInsert;
/**
* Match-bound participants and their dedicated camera slot.
*/
export const matchParticipants = mysqlTable("match_participants", {
id: int("id").autoincrement().primaryKey(),
matchId: int("matchId").notNull(),
userId: int("userId").notNull(),
playerSlot: mysqlEnum("playerSlot", ["player_a", "player_b"]).notNull(),
cameraSlot: mysqlEnum("cameraSlot", ["camera_a", "camera_b"]).notNull(),
cameraStatus: mysqlEnum("cameraStatus", ["pending", "bound", "active", "completed", "failed"]).default("pending").notNull(),
cameraLabel: varchar("cameraLabel", { length: 128 }),
cameraVideoId: int("cameraVideoId"),
cameraVideoUrl: text("cameraVideoUrl"),
cameraSnapshot: json("cameraSnapshot"),
isWinner: int("isWinner").default(0).notNull(),
suggestedSetsWon: int("suggestedSetsWon").default(0).notNull(),
suggestedGamesWon: int("suggestedGamesWon").default(0).notNull(),
suggestedPointsWon: int("suggestedPointsWon").default(0).notNull(),
finalSetsWon: int("finalSetsWon").default(0).notNull(),
finalGamesWon: int("finalGamesWon").default(0).notNull(),
finalPointsWon: int("finalPointsWon").default(0).notNull(),
suggestedStats: json("suggestedStats"),
finalStats: json("finalStats"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
matchPlayerUnique: uniqueIndex("match_participants_match_player_idx").on(table.matchId, table.playerSlot),
matchUserUnique: uniqueIndex("match_participants_match_user_idx").on(table.matchId, table.userId),
}));
export type MatchParticipant = typeof matchParticipants.$inferSelect;
export type InsertMatchParticipant = typeof matchParticipants.$inferInsert;
/**
* Match score and metric events from camera automation or admin review.
*/
export const matchScoreEvents = mysqlTable("match_score_events", {
id: int("id").autoincrement().primaryKey(),
matchId: int("matchId").notNull(),
eventIndex: int("eventIndex").notNull(),
source: mysqlEnum("source", ["camera_a", "camera_b", "system", "admin"]).default("system").notNull(),
eventType: mysqlEnum("eventType", ["point", "game", "set", "metric", "score_suggestion", "review_adjustment", "finalized"]).notNull(),
winnerSlot: mysqlEnum("winnerSlot", ["player_a", "player_b"]),
matchSecond: int("matchSecond"),
confidence: float("confidence"),
payload: json("payload"),
createdByUserId: int("createdByUserId"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}, (table) => ({
matchEventUnique: uniqueIndex("match_score_events_match_event_idx").on(table.matchId, table.eventIndex),
}));
export type MatchScoreEvent = typeof matchScoreEvents.$inferSelect;
export type InsertMatchScoreEvent = typeof matchScoreEvents.$inferInsert;
/**
* Daily training aggregate used for streaks, achievements and daily NTRP refresh.
*/
@@ -523,6 +612,93 @@ export const notificationLog = mysqlTable("notification_log", {
export type NotificationLogEntry = typeof notificationLog.$inferSelect;
export type InsertNotificationLog = typeof notificationLog.$inferInsert;
/**
* Normalized racket market listings aggregated from multiple public sources.
*/
export const racketListings = mysqlTable("racket_listings", {
id: int("id").autoincrement().primaryKey(),
source: mysqlEnum("source", ["xianyu", "jd", "zhuanzhuan"]).notNull(),
sourceListingId: varchar("sourceListingId", { length: 128 }).notNull(),
title: varchar("title", { length: 512 }).notNull(),
description: text("description"),
listingUrl: text("listingUrl").notNull(),
imageUrl: text("imageUrl"),
price: float("price").notNull(),
originalPrice: float("originalPrice"),
sellerName: varchar("sellerName", { length: 128 }),
location: varchar("location", { length: 128 }),
publishedAtRaw: varchar("publishedAtRaw", { length: 128 }),
brand: varchar("brand", { length: 64 }),
model: varchar("model", { length: 128 }),
series: varchar("series", { length: 128 }),
category: varchar("category", { length: 64 }),
weightGram: float("weightGram"),
conditionLevel: mysqlEnum("conditionLevel", ["brand_new", "almost_new", "used_good", "used_fair", "unknown"]).default("unknown").notNull(),
gradeLevel: mysqlEnum("gradeLevel", ["high_value", "standard", "overpriced", "pending_review"]).default("pending_review").notNull(),
gradeReason: text("gradeReason"),
isLowPriceCandidate: int("isLowPriceCandidate").default(0).notNull(),
fingerprint: varchar("fingerprint", { length: 128 }).notNull(),
extra: json("extra"),
fetchedAt: timestamp("fetchedAt").defaultNow().notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
sourceListingUnique: uniqueIndex("racket_listings_source_listing_idx").on(table.source, table.sourceListingId),
fingerprintUnique: uniqueIndex("racket_listings_fingerprint_idx").on(table.fingerprint),
}));
export type RacketListing = typeof racketListings.$inferSelect;
export type InsertRacketListing = typeof racketListings.$inferInsert;
/**
* User-defined racket watch rules for brand/model/price monitoring.
*/
export const racketWatchRules = mysqlTable("racket_watch_rules", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
title: varchar("title", { length: 256 }).notNull(),
brand: varchar("brand", { length: 64 }).notNull(),
modelKeyword: varchar("modelKeyword", { length: 128 }),
seriesKeyword: varchar("seriesKeyword", { length: 128 }),
category: varchar("category", { length: 64 }),
weightMinGram: float("weightMinGram"),
weightMaxGram: float("weightMaxGram"),
targetPrice: float("targetPrice").notNull(),
pushEnabled: int("pushEnabled").default(1).notNull(),
isActive: int("isActive").default(1).notNull(),
lastCheckedAt: timestamp("lastCheckedAt"),
lastMatchedAt: timestamp("lastMatchedAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type RacketWatchRule = typeof racketWatchRules.$inferSelect;
export type InsertRacketWatchRule = typeof racketWatchRules.$inferInsert;
/**
* Historical watch hits to dedupe and manage push delivery state.
*/
export const racketWatchHits = mysqlTable("racket_watch_hits", {
id: int("id").autoincrement().primaryKey(),
watchRuleId: int("watchRuleId").notNull(),
userId: int("userId").notNull(),
listingId: int("listingId").notNull(),
matchedPrice: float("matchedPrice").notNull(),
status: mysqlEnum("status", ["matched", "push_queued", "pushed", "suppressed"]).default("matched").notNull(),
firstMatchedAt: timestamp("firstMatchedAt").defaultNow().notNull(),
lastMatchedAt: timestamp("lastMatchedAt").defaultNow().notNull(),
lastPushPrice: float("lastPushPrice"),
pushedAt: timestamp("pushedAt"),
pushCount: int("pushCount").default(0).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
watchRuleListingUnique: uniqueIndex("racket_watch_hits_rule_listing_idx").on(table.watchRuleId, table.listingId),
}));
export type RacketWatchHit = typeof racketWatchHits.$inferSelect;
export type InsertRacketWatchHit = typeof racketWatchHits.$inferInsert;
/**
* Background task queue for long-running or retryable work.
*/
@@ -537,6 +713,11 @@ export const backgroundTasks = mysqlTable("background_tasks", {
"pose_correction_multimodal",
"ntrp_refresh_user",
"ntrp_refresh_all",
"market_source_sync",
"market_watch_refresh",
"market_push_delivery",
"match_score_suggest",
"match_finalize",
]).notNull(),
status: mysqlEnum("status", ["queued", "running", "succeeded", "failed"]).notNull().default("queued"),
title: varchar("title", { length: 256 }).notNull(),

查看文件

@@ -39,10 +39,12 @@ export function getSessionCookieOptions(
// ? hostname
// : undefined;
const secure = isSecureRequest(req);
return {
httpOnly: true,
path: "/",
sameSite: "none",
secure: isSecureRequest(req),
sameSite: secure ? "none" : "lax",
secure,
};
}

29
server/_core/fetch.test.ts 普通文件
查看文件

@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchWithTimeout } from "./fetch";
describe("fetchWithTimeout", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("retries timeout-like errors for allowed methods", async () => {
const fetchMock = vi.fn()
.mockRejectedValueOnce(new Error("Request timed out after 100ms"))
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const response = await fetchWithTimeout("https://example.com", {
method: "POST",
}, {
timeoutMs: 100,
retries: 1,
retryMethods: ["POST"],
baseDelayMs: 1,
});
expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

查看文件

@@ -25,7 +25,12 @@ function shouldRetryError(method: string, error: unknown, options: FetchRetryOpt
}
if (error instanceof Error) {
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch");
return (
error.name === "AbortError" ||
error.name === "TimeoutError" ||
error.message.startsWith("Request timed out after ") ||
error.message.includes("fetch")
);
}
return false;

查看文件

@@ -9,7 +9,7 @@ import { appRouter } from "../routers";
import { createContext } from "./context";
import { registerMediaProxy } from "./mediaProxy";
import { serveStatic } from "./static";
import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
import { createBackgroundTask, getAdminUserId, getAppSettingValue, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
import { nanoid } from "nanoid";
import { syncTutorialImages } from "../tutorialImages";
@@ -64,6 +64,32 @@ async function scheduleDailyNtrpRefresh() {
});
}
async function scheduleMarketWatchRefresh() {
const intervalMinutes = Math.max(5, await getAppSettingValue("market_watch_refresh_interval_minutes", 30));
const since = new Date(Date.now() - intervalMinutes * 60_000);
const exists = await hasRecentBackgroundTaskOfType("market_watch_refresh", since);
if (exists) {
return;
}
const adminUserId = await getAdminUserId();
if (!adminUserId) {
return;
}
const taskId = nanoid();
await createBackgroundTask({
id: taskId,
userId: adminUserId,
type: "market_watch_refresh",
title: "全网球拍行情刷新",
message: "系统已自动创建球拍行情刷新任务",
payload: { scope: "all_users", trigger: "scheduler" },
progress: 0,
maxAttempts: 3,
});
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
@@ -129,6 +155,9 @@ async function startServer() {
void scheduleDailyNtrpRefresh().catch((error) => {
console.error("[scheduler] failed to schedule NTRP refresh", error);
});
void scheduleMarketWatchRefresh().catch((error) => {
console.error("[scheduler] failed to schedule market refresh", error);
});
}, 60_000);
}

查看文件

@@ -70,6 +70,8 @@ export type InvokeParams = {
output_schema?: OutputSchema;
responseFormat?: ResponseFormat;
response_format?: ResponseFormat;
timeoutMs?: number;
retryCount?: number;
};
export type ToolCall = {
@@ -286,6 +288,8 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
output_schema,
responseFormat,
response_format,
timeoutMs,
retryCount,
} = params;
const payload: Record<string, unknown> = {
@@ -332,8 +336,8 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
},
body: JSON.stringify(payload),
}, {
timeoutMs: ENV.llmTimeoutMs,
retries: ENV.llmRetryCount,
timeoutMs: timeoutMs ?? ENV.llmTimeoutMs,
retries: retryCount ?? ENV.llmRetryCount,
retryMethods: ["POST"],
});

查看文件

@@ -1,4 +1,4 @@
import { eq, desc, and, asc, lte, gte, or, sql } from "drizzle-orm";
import { eq, desc, and, asc, lte, gte, or, sql, like, inArray } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser, users,
@@ -21,6 +21,9 @@ import {
tutorialProgress, InsertTutorialProgress,
trainingReminders, InsertTrainingReminder,
notificationLog, InsertNotificationLog,
racketListings, InsertRacketListing,
racketWatchRules, InsertRacketWatchRule,
racketWatchHits, InsertRacketWatchHit,
backgroundTasks, InsertBackgroundTask,
adminAuditLogs, InsertAdminAuditLog,
appSettings, InsertAppSetting,
@@ -77,6 +80,93 @@ export const DEFAULT_APP_SETTINGS: Omit<InsertAppSetting, "id" | "createdAt" | "
description: "每天异步刷新 NTRP 的小时数。",
value: { value: 0, type: "number" },
},
{
settingKey: "market_default_feishu_webhook",
label: "球拍行情默认飞书 Webhook",
description: "低价监控命中后默认推送到这个飞书机器人地址。",
value: {
value: "https://open.larksuite.com/open-apis/bot/v2/hook/9acc398a-1380-43e4-9291-3e2b94016dfe",
type: "string",
},
},
{
settingKey: "market_watch_refresh_interval_minutes",
label: "球拍行情刷新间隔",
description: "系统按该分钟间隔自动入队一次全量球拍行情刷新任务。",
value: { value: 30, type: "number" },
},
{
settingKey: "market_price_repush_delta",
label: "球拍行情再次推送降价阈值",
description: "同一商品相较上次推送再降到该金额以上时,允许再次推送。",
value: { value: 20, type: "number" },
},
{
settingKey: "market_source_timeout_ms",
label: "球拍行情抓取超时",
description: "单次来源抓取请求的超时时间,单位毫秒。",
value: { value: 12000, type: "number" },
},
{
settingKey: "market_source_retry_count",
label: "球拍行情抓取重试次数",
description: "来源抓取发生超时或网关错误后的自动重试次数。",
value: { value: 1, type: "number" },
},
{
settingKey: "market_xianyu_cookie",
label: "闲鱼抓取 Cookie",
description: "可选。填写后可用于提升闲鱼搜索接口的可用性并降低风控触发概率。",
value: { value: "", type: "string" },
},
{
settingKey: "market_xianyu_user_agent",
label: "闲鱼抓取 User-Agent",
description: "闲鱼来源抓取时使用的默认 User-Agent。",
value: {
value:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
type: "string",
},
},
{
settingKey: "market_jd_cookie",
label: "京东抓取 Cookie",
description: "可选。用于减少京东搜索接口返回风控页的概率。",
value: { value: "", type: "string" },
},
{
settingKey: "market_jd_user_agent",
label: "京东抓取 User-Agent",
description: "京东来源抓取时使用的默认移动端 User-Agent。",
value: {
value:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
type: "string",
},
},
{
settingKey: "market_zhuanzhuan_cookie",
label: "转转抓取 Cookie",
description: "可选。用于配置转转抓取请求时附带的 Cookie。",
value: { value: "", type: "string" },
},
{
settingKey: "market_zhuanzhuan_user_agent",
label: "转转抓取 User-Agent",
description: "转转来源抓取时使用的默认移动端 User-Agent。",
value: {
value:
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
type: "string",
},
},
{
settingKey: "market_zhuanzhuan_search_url_template",
label: "转转搜索 URL 模板",
description: "当公开搜索页路径调整时,可在这里配置转转搜索模板,使用 {query} 作为占位符。",
value: { value: "", type: "string" },
},
];
export const ACHIEVEMENT_DEFINITION_SEED_DATA: Omit<InsertAchievementDefinition, "id" | "createdAt" | "updatedAt">[] = [
@@ -134,6 +224,22 @@ export async function listAppSettings() {
return db.select().from(appSettings).orderBy(asc(appSettings.settingKey));
}
export async function getAppSettingByKey(settingKey: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(appSettings).where(eq(appSettings.settingKey, settingKey)).limit(1);
return result[0];
}
export async function getAppSettingValue<T>(settingKey: string, fallback: T): Promise<T> {
const setting = await getAppSettingByKey(settingKey);
const value = setting?.value as { value?: T } | T | null | undefined;
if (value && typeof value === "object" && "value" in value) {
return (value.value ?? fallback) as T;
}
return (value ?? fallback) as T;
}
export async function updateAppSetting(settingKey: string, value: unknown) {
const db = await getDb();
if (!db) return;
@@ -244,7 +350,7 @@ export async function listAllBackgroundTasks(limit = 100) {
}
export async function hasRecentBackgroundTaskOfType(
type: "ntrp_refresh_user" | "ntrp_refresh_all",
type: "ntrp_refresh_user" | "ntrp_refresh_all" | "market_watch_refresh",
since: Date,
) {
const db = await getDb();
@@ -2006,6 +2112,272 @@ export async function getUnreadNotificationCount(userId: number) {
return result[0]?.count || 0;
}
// ===== RACKET MARKET OPERATIONS =====
export async function listRacketListings(params?: {
source?: "xianyu" | "jd" | "zhuanzhuan";
brand?: string;
category?: string;
keyword?: string;
lowPriceOnly?: boolean;
limit?: number;
}) {
const db = await getDb();
if (!db) return [];
const conditions = [];
if (params?.source) conditions.push(eq(racketListings.source, params.source));
if (params?.brand) conditions.push(eq(racketListings.brand, params.brand));
if (params?.category) conditions.push(eq(racketListings.category, params.category));
if (params?.lowPriceOnly) conditions.push(eq(racketListings.isLowPriceCandidate, 1));
if (params?.keyword) {
const keyword = `%${params.keyword.trim()}%`;
conditions.push(or(
like(racketListings.title, keyword),
like(racketListings.model, keyword),
like(racketListings.series, keyword),
like(racketListings.brand, keyword),
)!);
}
const limit = params?.limit ?? 50;
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const baseQuery = db.select().from(racketListings);
return whereClause
? baseQuery.where(whereClause).orderBy(desc(racketListings.fetchedAt), desc(racketListings.id)).limit(limit)
: baseQuery.orderBy(desc(racketListings.fetchedAt), desc(racketListings.id)).limit(limit);
}
export async function countRecentRacketListings(hours = 24) {
const db = await getDb();
if (!db) return 0;
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
const result = await db.select({ count: sql<number>`count(*)` }).from(racketListings)
.where(gte(racketListings.fetchedAt, since));
return result[0]?.count || 0;
}
export async function listRecentComparableRacketListings(params: {
brand?: string | null;
model?: string | null;
excludeId?: number | null;
limit?: number;
}) {
const db = await getDb();
if (!db || !params.brand || !params.model) return [];
const conditions = [
eq(racketListings.brand, params.brand),
eq(racketListings.model, params.model),
];
if (params.excludeId != null) {
conditions.push(sql`${racketListings.id} <> ${params.excludeId}`);
}
return db.select().from(racketListings)
.where(and(...conditions))
.orderBy(desc(racketListings.fetchedAt), desc(racketListings.id))
.limit(params.limit ?? 8);
}
export async function getRacketListingById(listingId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(racketListings).where(eq(racketListings.id, listingId)).limit(1);
return result[0];
}
export async function upsertRacketListing(listing: InsertRacketListing) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const existingBySource = await db.select().from(racketListings)
.where(and(eq(racketListings.source, listing.source!), eq(racketListings.sourceListingId, listing.sourceListingId!)))
.limit(1);
const existing = existingBySource[0] ?? (await db.select().from(racketListings)
.where(eq(racketListings.fingerprint, listing.fingerprint!))
.limit(1))[0];
if (existing) {
await db.update(racketListings).set({
...listing,
fetchedAt: listing.fetchedAt ?? new Date(),
}).where(eq(racketListings.id, existing.id));
return getRacketListingById(existing.id);
}
const result = await db.insert(racketListings).values({
...listing,
fetchedAt: listing.fetchedAt ?? new Date(),
});
return getRacketListingById(result[0].insertId);
}
export async function updateRacketListing(listingId: number, data: Partial<InsertRacketListing>) {
const db = await getDb();
if (!db) return;
await db.update(racketListings).set(data).where(eq(racketListings.id, listingId));
}
export async function listUserRacketWatchRules(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(racketWatchRules)
.where(eq(racketWatchRules.userId, userId))
.orderBy(desc(racketWatchRules.updatedAt), desc(racketWatchRules.id));
}
export async function listActiveRacketWatchRules(params?: {
userId?: number;
ruleIds?: number[];
}) {
const db = await getDb();
if (!db) return [];
const conditions = [eq(racketWatchRules.isActive, 1)];
if (params?.userId != null) conditions.push(eq(racketWatchRules.userId, params.userId));
if (params?.ruleIds?.length) conditions.push(inArray(racketWatchRules.id, params.ruleIds));
return db.select().from(racketWatchRules)
.where(and(...conditions))
.orderBy(desc(racketWatchRules.updatedAt), desc(racketWatchRules.id));
}
export async function getUserRacketWatchRuleById(userId: number, ruleId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(racketWatchRules)
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)))
.limit(1);
return result[0];
}
export async function createRacketWatchRule(rule: InsertRacketWatchRule) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(racketWatchRules).values(rule);
return result[0].insertId;
}
export async function updateRacketWatchRule(userId: number, ruleId: number, data: Partial<InsertRacketWatchRule>) {
const db = await getDb();
if (!db) return;
await db.update(racketWatchRules).set(data)
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)));
}
export async function deleteRacketWatchRule(userId: number, ruleId: number) {
const db = await getDb();
if (!db) return;
await db.delete(racketWatchRules)
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)));
}
export async function toggleRacketWatchRule(userId: number, ruleId: number, isActive: number) {
const db = await getDb();
if (!db) return;
await db.update(racketWatchRules).set({ isActive })
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)));
}
export async function getRacketWatchHitByRuleAndListing(watchRuleId: number, listingId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(racketWatchHits)
.where(and(eq(racketWatchHits.watchRuleId, watchRuleId), eq(racketWatchHits.listingId, listingId)))
.limit(1);
return result[0];
}
export async function createRacketWatchHit(hit: InsertRacketWatchHit) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(racketWatchHits).values(hit);
const inserted = await db.select().from(racketWatchHits).where(eq(racketWatchHits.id, result[0].insertId)).limit(1);
return inserted[0];
}
export async function updateRacketWatchHit(hitId: number, data: Partial<InsertRacketWatchHit>) {
const db = await getDb();
if (!db) return;
await db.update(racketWatchHits).set(data).where(eq(racketWatchHits.id, hitId));
}
export async function listUserRacketWatchHits(userId: number, limit = 50) {
const db = await getDb();
if (!db) return [];
return db.select({
id: racketWatchHits.id,
userId: racketWatchHits.userId,
watchRuleId: racketWatchHits.watchRuleId,
listingId: racketWatchHits.listingId,
matchedPrice: racketWatchHits.matchedPrice,
status: racketWatchHits.status,
firstMatchedAt: racketWatchHits.firstMatchedAt,
lastMatchedAt: racketWatchHits.lastMatchedAt,
lastPushPrice: racketWatchHits.lastPushPrice,
pushedAt: racketWatchHits.pushedAt,
pushCount: racketWatchHits.pushCount,
ruleTitle: racketWatchRules.title,
ruleBrand: racketWatchRules.brand,
listingTitle: racketListings.title,
listingSource: racketListings.source,
listingUrl: racketListings.listingUrl,
listingBrand: racketListings.brand,
listingModel: racketListings.model,
listingCategory: racketListings.category,
listingWeightGram: racketListings.weightGram,
listingGradeLevel: racketListings.gradeLevel,
listingPrice: racketListings.price,
listingFetchedAt: racketListings.fetchedAt,
}).from(racketWatchHits)
.innerJoin(racketWatchRules, eq(racketWatchRules.id, racketWatchHits.watchRuleId))
.innerJoin(racketListings, eq(racketListings.id, racketWatchHits.listingId))
.where(eq(racketWatchHits.userId, userId))
.orderBy(desc(racketWatchHits.lastMatchedAt), desc(racketWatchHits.id))
.limit(limit);
}
export async function getRacketWatchHitDeliveryPayload(hitId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select({
id: racketWatchHits.id,
userId: racketWatchHits.userId,
matchedPrice: racketWatchHits.matchedPrice,
status: racketWatchHits.status,
lastPushPrice: racketWatchHits.lastPushPrice,
pushCount: racketWatchHits.pushCount,
ruleId: racketWatchRules.id,
ruleTitle: racketWatchRules.title,
ruleBrand: racketWatchRules.brand,
ruleModelKeyword: racketWatchRules.modelKeyword,
ruleTargetPrice: racketWatchRules.targetPrice,
listingId: racketListings.id,
listingSource: racketListings.source,
listingTitle: racketListings.title,
listingUrl: racketListings.listingUrl,
listingBrand: racketListings.brand,
listingModel: racketListings.model,
listingCategory: racketListings.category,
listingWeightGram: racketListings.weightGram,
listingGradeLevel: racketListings.gradeLevel,
listingGradeReason: racketListings.gradeReason,
listingPrice: racketListings.price,
listingFetchedAt: racketListings.fetchedAt,
}).from(racketWatchHits)
.innerJoin(racketWatchRules, eq(racketWatchRules.id, racketWatchHits.watchRuleId))
.innerJoin(racketListings, eq(racketListings.id, racketWatchHits.listingId))
.where(eq(racketWatchHits.id, hitId))
.limit(1);
return result[0];
}
export async function countUserActiveRacketWatchRules(userId: number) {
const db = await getDb();
if (!db) return 0;
const result = await db.select({ count: sql<number>`count(*)` }).from(racketWatchRules)
.where(and(eq(racketWatchRules.userId, userId), eq(racketWatchRules.isActive, 1)));
return result[0]?.count || 0;
}
// ===== BACKGROUND TASK OPERATIONS =====
export async function createBackgroundTask(task: InsertBackgroundTask) {

查看文件

@@ -3,6 +3,7 @@ import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
import * as matchStore from "./matchStore";
import * as trainingAutomation from "./trainingAutomation";
import { ENV } from "./_core/env";
import { sdk } from "./_core/sdk";
@@ -831,14 +832,28 @@ describe("leaderboard.get", () => {
await expect(caller.leaderboard.get()).rejects.toThrow();
});
it("accepts sortBy parameter", async () => {
it("accepts training sortBy parameter", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
for (const sortBy of ["ntrpRating", "totalMinutes", "totalSessions", "totalShots"] as const) {
try {
await caller.leaderboard.get({ sortBy, limit: 10 });
await caller.leaderboard.get({ scope: "training", sortBy, limit: 10 });
} catch (e: any) {
expect(e.message).not.toContain("invalid_enum_value");
}
}
});
it("accepts competitive sortBy parameter", async () => {
const user = createTestUser();
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
for (const sortBy of ["wins", "winRate", "setsWon", "pointsWon", "matches"] as const) {
try {
await caller.leaderboard.get({ scope: "competitive", sortBy, limit: 10 });
} catch (e: any) {
expect(e.message).not.toContain("invalid_enum_value");
}
@@ -856,6 +871,94 @@ describe("leaderboard.get", () => {
});
});
describe("match router", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("requires authentication for listing matches", async () => {
const { ctx } = createMockContext(null);
const caller = appRouter.createCaller(ctx);
await expect(caller.match.list()).rejects.toThrow();
});
it("rejects creating a match that does not include the current user when not admin", async () => {
const user = createTestUser({ id: 99, name: "Viewer" });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
vi.spyOn(db, "getUserById")
.mockResolvedValueOnce(createTestUser({ id: 1, name: "PlayerA" }))
.mockResolvedValueOnce(createTestUser({ id: 2, name: "PlayerB" }));
await expect(caller.match.create({
title: "League Match",
matchMode: "competitive",
playerAUserId: 1,
playerBUserId: 2,
durationMinutes: 90,
})).rejects.toThrow("只能创建包含自己的比赛");
});
it("rejects reading a match when the user is not an admin or participant", async () => {
const user = createTestUser({ id: 77, name: "Outside" });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
vi.spyOn(matchStore, "getMatchDetail").mockResolvedValueOnce({
id: 12,
createdByUserId: 1,
matchMode: "daily",
workflowStatus: "review_pending",
title: "Morning Match",
courtName: "Court 1",
notes: null,
durationMinutes: 90,
scheduledAt: new Date(),
startedAt: null,
endedAt: null,
suggestionStatus: "ready",
suggestionTaskId: "task-1",
suggestedScore: null,
suggestedMetrics: null,
finalScore: null,
finalMetrics: null,
reviewNotes: null,
reviewSubmittedAt: null,
reviewedByUserId: null,
reviewedAt: null,
finalizedByUserId: null,
finalizedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
participants: [
{ userId: 1, playerSlot: "player_a" },
{ userId: 2, playerSlot: "player_b" },
] as any,
events: [],
eventCount: 0,
} as any);
await expect(caller.match.get({ matchId: 12 })).rejects.toThrow("当前账号不能访问这场比赛");
});
it("requires admin permission for review submission", async () => {
const user = createTestUser({ id: 7, role: "user" });
const { ctx } = createMockContext(user);
const caller = appRouter.createCaller(ctx);
await expect(caller.match.reviewSubmit({
matchId: 1,
reviewNotes: "ok",
finalScore: {
sets: { player_a: 2, player_b: 0 },
games: { player_a: 12, player_b: 6 },
points: { player_a: 60, player_b: 42 },
},
})).rejects.toThrow();
});
});
// ===== BADGE DEFINITIONS UNIT TESTS =====
describe("BADGE_DEFINITIONS via badge.definitions endpoint", () => {

133
server/market.routes.test.ts 普通文件
查看文件

@@ -0,0 +1,133 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { appRouter } from "./routers";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUser {
return {
id: 7,
openId: "market-user-7",
email: "market@example.com",
name: "MarketTester",
loginMethod: "username",
role: "user",
skillLevel: "beginner",
trainingGoals: null,
ntrpRating: 1.5,
manualNtrpRating: null,
manualNtrpCapturedAt: null,
heightCm: null,
weightKg: null,
sprintSpeedScore: null,
explosivePowerScore: null,
agilityScore: null,
enduranceScore: null,
flexibilityScore: null,
coreStabilityScore: null,
shoulderMobilityScore: null,
hipMobilityScore: null,
assessmentNotes: null,
totalSessions: 0,
totalMinutes: 0,
totalShots: 0,
currentStreak: 0,
longestStreak: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
...overrides,
};
}
function createMockContext(user: AuthenticatedUser | null): TrpcContext {
return {
user,
sessionSid: user ? "market-session" : null,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
cookie: vi.fn(),
} as TrpcContext["res"],
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("market.watchRuleCreate", () => {
it("creates a rule and queues a refresh task", async () => {
const user = createTestUser();
const caller = appRouter.createCaller(createMockContext(user));
vi.spyOn(db, "createRacketWatchRule").mockResolvedValueOnce(88);
vi.spyOn(db, "createBackgroundTask").mockResolvedValueOnce("task-market-1");
vi.spyOn(db, "getBackgroundTaskById").mockResolvedValueOnce({
id: "task-market-1",
userId: user.id,
type: "market_watch_refresh",
status: "queued",
title: "Yonex ≤ ¥500 刷新",
message: "监控规则已创建,后台开始抓取对应平台价格",
progress: 0,
payload: {},
result: null,
error: null,
attempts: 0,
maxAttempts: 3,
workerId: null,
runAfter: new Date(),
lockedAt: null,
startedAt: null,
completedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
} as Awaited<ReturnType<typeof db.getBackgroundTaskById>>);
const result = await caller.market.watchRuleCreate({
brand: "Yonex",
targetPrice: 500,
modelKeyword: "98",
pushEnabled: true,
});
expect(result.ruleId).toBe(88);
expect(result.taskId).toBeTruthy();
expect(db.createRacketWatchRule).toHaveBeenCalledWith(expect.objectContaining({
userId: user.id,
brand: "Yonex",
targetPrice: 500,
pushEnabled: 1,
isActive: 1,
}));
expect(db.createBackgroundTask).toHaveBeenCalledWith(expect.objectContaining({
userId: user.id,
type: "market_watch_refresh",
}));
});
});
describe("market.pushConfigUpdate", () => {
it("updates the default webhook for admins", async () => {
const admin = createTestUser({ role: "admin", id: 1, name: "Admin" });
const caller = appRouter.createCaller(createMockContext(admin));
vi.spyOn(db, "updateAppSetting").mockResolvedValueOnce(undefined);
vi.spyOn(db, "createAdminAuditLog").mockResolvedValueOnce(undefined);
const result = await caller.market.pushConfigUpdate({
webhookUrl: "https://open.larksuite.com/open-apis/bot/v2/hook/demo",
});
expect(result).toEqual({ success: true });
expect(db.updateAppSetting).toHaveBeenCalledWith("market_default_feishu_webhook", {
value: "https://open.larksuite.com/open-apis/bot/v2/hook/demo",
type: "string",
});
});
});

93
server/market.test.ts 普通文件
查看文件

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import {
buildMarketSearchQuery,
enrichRacketListing,
formatMarketPushText,
listingMatchesWatchRule,
maskWebhookUrl,
} from "./market";
describe("market enrichment", () => {
it("extracts brand, model, weight, category and grade from a racket listing", () => {
const listing = enrichRacketListing({
source: "xianyu",
sourceListingId: "xy-1",
title: "Yonex Ezone 98 305g 95新 网球拍",
listingUrl: "https://www.goofish.com/item?id=xy-1",
price: 480,
originalPrice: 1200,
description: "尤尼克斯 Ezone 98 裸拍约305g,正常使用,无裂纹",
imageUrl: null,
sellerName: "seller-a",
location: "上海",
publishedAtRaw: "1小时前",
extra: null,
});
expect(listing.brand).toBe("Yonex");
expect(listing.series).toBe("Ezone");
expect(listing.model).toContain("98");
expect(listing.weightGram).toBe(305);
expect(listing.category).toBe("competitive");
expect(listing.gradeLevel).toBe("high_value");
expect(listing.isLowPriceCandidate).toBe(1);
});
});
describe("market watch matching", () => {
it("matches a listing when price and keywords satisfy the rule", () => {
const matched = listingMatchesWatchRule({
price: 520,
brand: "Wilson",
title: "Wilson Blade 98 V8 网球拍",
model: "98 V8",
series: "Blade",
category: "competitive",
weightGram: 305,
}, {
brand: "Wilson",
modelKeyword: "98",
seriesKeyword: "Blade",
category: "competitive",
weightMinGram: 300,
weightMaxGram: 310,
targetPrice: 550,
});
expect(matched).toBe(true);
expect(buildMarketSearchQuery({
brand: "Wilson",
modelKeyword: "98",
seriesKeyword: "Blade",
category: "competitive",
})).toContain("Wilson");
});
});
describe("market push formatting", () => {
it("masks webhook urls and formats notification text", () => {
expect(maskWebhookUrl("https://open.larksuite.com/open-apis/bot/v2/hook/1234567890abcdef"))
.toContain("...");
const text = formatMarketPushText({
ruleTitle: "Yonex Ezone ≤ ¥500",
source: "xianyu",
title: "Yonex Ezone 98 305g",
price: 480,
targetPrice: 500,
brand: "Yonex",
model: "98",
category: "competitive",
weightGram: 305,
gradeLevel: "high_value",
gradeReason: "品牌 Yonex · 当前价格落在低价区间",
listingUrl: "https://www.goofish.com/item?id=1",
fetchedAt: "2026-03-23T08:00:00.000Z",
});
expect(text).toContain("命中监控");
expect(text).toContain("闲鱼");
expect(text).toContain("Yonex");
expect(text).toContain("https://www.goofish.com/item?id=1");
});
});

777
server/market.ts 普通文件
查看文件

@@ -0,0 +1,777 @@
import crypto from "node:crypto";
import type { InsertRacketListing, RacketWatchRule } from "../drizzle/schema";
import * as db from "./db";
import { fetchWithTimeout } from "./_core/fetch";
export const MARKET_SOURCES = ["xianyu", "jd", "zhuanzhuan"] as const;
export type MarketSource = typeof MARKET_SOURCES[number];
export const MARKET_SOURCE_LABELS: Record<MarketSource, string> = {
xianyu: "闲鱼",
jd: "京东",
zhuanzhuan: "转转",
};
export const MARKET_CATEGORY_LABELS = {
adult: "成人球拍",
junior: "儿童球拍",
competitive: "比赛拍",
recreational: "娱乐拍",
unknown: "未知",
} as const;
export type MarketCategory = keyof typeof MARKET_CATEGORY_LABELS;
export const MARKET_CONDITION_LABELS = {
brand_new: "全新",
almost_new: "几乎全新",
used_good: "正常使用",
used_fair: "磕碰明显",
unknown: "未知",
} as const;
export type MarketConditionLevel = keyof typeof MARKET_CONDITION_LABELS;
export const MARKET_GRADE_LABELS = {
high_value: "高性价比",
standard: "标准价",
overpriced: "偏高",
pending_review: "待确认",
} as const;
export type MarketGradeLevel = keyof typeof MARKET_GRADE_LABELS;
export type RawSourceListing = {
source: MarketSource;
sourceListingId: string;
title: string;
listingUrl: string;
description?: string | null;
imageUrl?: string | null;
price: number;
originalPrice?: number | null;
sellerName?: string | null;
location?: string | null;
publishedAtRaw?: string | null;
extra?: Record<string, unknown> | null;
};
export type SearchSourceResult = {
source: MarketSource;
query: string;
ok: boolean;
blocked: boolean;
message: string;
listings: RawSourceListing[];
};
export type MarketConfig = {
defaultFeishuWebhook: string;
refreshIntervalMinutes: number;
repushDelta: number;
sourceTimeoutMs: number;
sourceRetryCount: number;
xianyuCookie: string;
xianyuUserAgent: string;
jdCookie: string;
jdUserAgent: string;
zhuanzhuanCookie: string;
zhuanzhuanUserAgent: string;
zhuanzhuanSearchUrlTemplate: string;
};
const XIANYU_API = "mtop.taobao.idlemtopsearch.pc.search";
const XIANYU_APP_KEY = "34839810";
const BRAND_ALIASES: Array<{ canonical: string; patterns: RegExp[] }> = [
{ canonical: "Yonex", patterns: [/\byonex\b/i, /尤尼克斯/i, /yonex/i, /yy\b/i] },
{ canonical: "Wilson", patterns: [/\bwilson\b/i, /威尔胜/i] },
{ canonical: "Babolat", patterns: [/\bbabolat\b/i, /百保力/i] },
{ canonical: "Head", patterns: [/\bhead\b/i, /海德/i] },
{ canonical: "Tecnifibre", patterns: [/\btecnifibre\b/i, /泰克尼纤维/i, /tfight/i] },
{ canonical: "Prince", patterns: [/\bprince\b/i, /王子/i] },
{ canonical: "Dunlop", patterns: [/\bdunlop\b/i, /登禄普/i] },
{ canonical: "Li-Ning", patterns: [/\bli-ning\b/i, /李宁/i] },
{ canonical: "Kawasaki", patterns: [/\bkawasaki\b/i, /川崎/i] },
{ canonical: "Teloon", patterns: [/\bteloon\b/i, /天龙/i] },
];
const SERIES_PATTERNS: Array<{ series: string; pattern: RegExp }> = [
{ series: "Ezone", pattern: /\bezone\b/i },
{ series: "Vcore", pattern: /\bv-?core\b/i },
{ series: "Percept", pattern: /\bpercept\b/i },
{ series: "Blade", pattern: /\bblade\b/i },
{ series: "Clash", pattern: /\bclash\b/i },
{ series: "Ultra", pattern: /\bultra\b/i },
{ series: "Pro Staff", pattern: /\bpro\s?staff\b/i },
{ series: "Burn", pattern: /\bburn\b/i },
{ series: "Pure Drive", pattern: /\bpure\s?drive\b/i },
{ series: "Pure Aero", pattern: /\bpure\s?aero\b/i },
{ series: "Pure Strike", pattern: /\bpure\s?strike\b/i },
{ series: "Boom", pattern: /\bboom\b/i },
{ series: "Radical", pattern: /\bradical\b/i },
{ series: "Speed", pattern: /\bspeed\b/i },
{ series: "Gravity", pattern: /\bgravity\b/i },
{ series: "Extreme", pattern: /\bextreme\b/i },
{ series: "TFight", pattern: /\btfight\b/i },
];
const BRAND_TIER: Record<string, "elite" | "mainstream" | "value"> = {
Yonex: "elite",
Wilson: "elite",
Babolat: "elite",
Head: "elite",
Tecnifibre: "elite",
Prince: "mainstream",
Dunlop: "mainstream",
"Li-Ning": "mainstream",
Kawasaki: "value",
Teloon: "value",
};
const POSITIVE_RACKET_PATTERN = /(网球拍|球拍|tennis|racquet|racket)/i;
const NEGATIVE_ACCESSORY_PATTERN = /(拍包|背包|线|拍线|手胶|减震器|护线|包邮补差|网球包|球线)/i;
function normalizeWhitespace(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function stripHtml(value: string) {
return normalizeWhitespace(value.replace(/<[^>]+>/g, " "));
}
function toNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const match = value.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/);
if (!match) return null;
const parsed = Number.parseFloat(match[1]);
return Number.isFinite(parsed) ? parsed : null;
}
if (value && typeof value === "object") {
const obj = value as Record<string, unknown>;
return toNumber(obj.value ?? obj.price ?? obj.amount ?? obj.priceText);
}
return null;
}
function firstString(values: unknown[]) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return normalizeWhitespace(value);
}
}
return null;
}
function findValuesByKey(
node: unknown,
matcher: RegExp,
depth = 0,
seen = new Set<unknown>(),
): unknown[] {
if (node == null || depth > 6) return [];
if (typeof node !== "object") return [];
if (seen.has(node)) return [];
seen.add(node);
const results: unknown[] = [];
if (Array.isArray(node)) {
for (const item of node) {
results.push(...findValuesByKey(item, matcher, depth + 1, seen));
}
return results;
}
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (matcher.test(key)) {
results.push(value);
}
results.push(...findValuesByKey(value, matcher, depth + 1, seen));
}
return results;
}
function collectObjectArrays(node: unknown, depth = 0, seen = new Set<unknown>()): Array<Record<string, unknown>[]> {
if (node == null || depth > 6) return [];
if (typeof node !== "object") return [];
if (seen.has(node)) return [];
seen.add(node);
const arrays: Array<Record<string, unknown>[]> = [];
if (Array.isArray(node)) {
if (node.length > 0 && node.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
arrays.push(node as Array<Record<string, unknown>>);
}
for (const item of node) {
arrays.push(...collectObjectArrays(item, depth + 1, seen));
}
return arrays;
}
for (const value of Object.values(node as Record<string, unknown>)) {
arrays.push(...collectObjectArrays(value, depth + 1, seen));
}
return arrays;
}
function guessUrlFromValue(value: string | null, source: MarketSource, sourceListingId: string) {
if (value && /^https?:\/\//i.test(value)) return value;
if (value && value.startsWith("/")) {
switch (source) {
case "xianyu":
return `https://www.goofish.com${value}`;
case "jd":
return `https://item.jd.com${value}`;
case "zhuanzhuan":
return `https://www.zhuanzhuan.com${value}`;
}
}
switch (source) {
case "xianyu":
return `https://www.goofish.com/item?id=${encodeURIComponent(sourceListingId)}`;
case "jd":
return `https://item.jd.com/${encodeURIComponent(sourceListingId)}.html`;
case "zhuanzhuan":
return `https://www.zhuanzhuan.com/`;
}
}
function looksLikeRacketTitle(title: string) {
const normalized = normalizeWhitespace(title);
return POSITIVE_RACKET_PATTERN.test(normalized) && !NEGATIVE_ACCESSORY_PATTERN.test(normalized);
}
export function detectBrand(text: string) {
for (const item of BRAND_ALIASES) {
if (item.patterns.some((pattern) => pattern.test(text))) {
return item.canonical;
}
}
return null;
}
export function detectSeries(text: string) {
const hit = SERIES_PATTERNS.find((item) => item.pattern.test(text));
return hit?.series ?? null;
}
export function detectWeightGram(text: string) {
const direct = text.match(/(?:^|[^0-9])([23]\d{2})(?:g|克)\b/i);
if (direct) {
const weight = Number.parseInt(direct[1], 10);
if (weight >= 230 && weight <= 340) return weight;
}
const unstrung = text.match(/([23]\d{2})\s*\/?\s*(?:unstrung|裸拍)/i);
if (unstrung) {
const weight = Number.parseInt(unstrung[1], 10);
if (weight >= 230 && weight <= 340) return weight;
}
return null;
}
export function detectConditionLevel(text: string): MarketConditionLevel {
if (/(全新未拆|全新|仅拆封|吊牌)/i.test(text)) return "brand_new";
if (/(99新|98新|95新|几乎全新|微瑕|试打|试用)/i.test(text)) return "almost_new";
if (/(正常使用|二手|使用痕迹|轻微磕碰|轻微掉漆)/i.test(text)) return "used_good";
if (/(磕碰|掉漆|裂纹|修补|明显使用痕迹)/i.test(text)) return "used_fair";
return "unknown";
}
export function detectCategory(text: string, weightGram: number | null): MarketCategory {
if (/(junior|jr|儿童|青少年|25寸|26寸)/i.test(text)) return "junior";
if (/(pro|tour|比赛|竞技|98\b|97\b|95\b)/i.test(text) || (weightGram != null && weightGram >= 295)) {
return "competitive";
}
if (/(初学|入门|娱乐|练习)/i.test(text) || (weightGram != null && weightGram <= 285)) {
return "recreational";
}
if (POSITIVE_RACKET_PATTERN.test(text)) return "adult";
return "unknown";
}
function deriveModel(text: string, brand: string | null, series: string | null) {
let value = text;
if (brand) {
value = value.replace(new RegExp(brand, "ig"), " ");
}
if (series) {
value = value.replace(new RegExp(series.replace(/\s+/g, "\\s*"), "ig"), " ");
}
value = value.replace(/(网球拍|球拍|tennis|racquet|racket|全新|二手|99新|95新|98新)/ig, " ");
value = value.replace(/([23]\d{2})(?:g|克)/ig, " ");
value = normalizeWhitespace(value);
return value ? value.slice(0, 96) : null;
}
function fingerprintForListing(source: MarketSource, sourceListingId: string, title: string, price: number) {
return crypto
.createHash("sha1")
.update(`${source}:${sourceListingId}:${title}:${price}`)
.digest("hex");
}
export function gradeRacketListing(input: {
brand: string | null;
price: number;
conditionLevel: MarketConditionLevel;
category: MarketCategory;
weightGram: number | null;
}) {
const tier = input.brand ? (BRAND_TIER[input.brand] ?? "value") : "value";
const conditionBand = {
brand_new: tier === "elite" ? 1200 : tier === "mainstream" ? 800 : 500,
almost_new: tier === "elite" ? 900 : tier === "mainstream" ? 650 : 400,
used_good: tier === "elite" ? 650 : tier === "mainstream" ? 480 : 280,
used_fair: tier === "elite" ? 450 : tier === "mainstream" ? 320 : 200,
unknown: tier === "elite" ? 700 : tier === "mainstream" ? 500 : 280,
}[input.conditionLevel];
const standardBand = Math.round(conditionBand * 1.45);
const reasons: string[] = [];
if (input.brand) reasons.push(`品牌 ${input.brand}`);
reasons.push(`成色 ${MARKET_CONDITION_LABELS[input.conditionLevel]}`);
if (input.weightGram != null) reasons.push(`重量 ${Math.round(input.weightGram)}g`);
if (input.category !== "unknown") reasons.push(MARKET_CATEGORY_LABELS[input.category]);
let gradeLevel: MarketGradeLevel = "pending_review";
if (input.price <= conditionBand) {
gradeLevel = "high_value";
reasons.push("当前价格落在低价区间");
} else if (input.price <= standardBand) {
gradeLevel = "standard";
reasons.push("当前价格处于常见成交区间");
} else if (input.price > standardBand) {
gradeLevel = "overpriced";
reasons.push("当前价格高于常见区间");
}
return {
gradeLevel,
gradeReason: reasons.join(" · "),
isLowPriceCandidate: gradeLevel === "high_value",
};
}
export function buildMarketSearchQuery(rule: Pick<RacketWatchRule, "brand" | "modelKeyword" | "seriesKeyword" | "category">) {
const parts = [
rule.brand,
rule.seriesKeyword ?? "",
rule.modelKeyword ?? "",
rule.category === "junior" ? "儿童" : "",
"网球拍",
];
return normalizeWhitespace(parts.filter(Boolean).join(" "));
}
export function deriveWatchRuleTitle(input: {
title?: string | null;
brand: string;
modelKeyword?: string | null;
seriesKeyword?: string | null;
targetPrice: number;
}) {
const trimmed = input.title?.trim();
if (trimmed) return trimmed;
const core = normalizeWhitespace([input.brand, input.seriesKeyword ?? "", input.modelKeyword ?? ""].filter(Boolean).join(" "));
return `${core || input.brand} ≤ ¥${Math.round(input.targetPrice)}`;
}
export async function loadMarketConfig(): Promise<MarketConfig> {
return {
defaultFeishuWebhook: await db.getAppSettingValue("market_default_feishu_webhook", ""),
refreshIntervalMinutes: await db.getAppSettingValue("market_watch_refresh_interval_minutes", 30),
repushDelta: await db.getAppSettingValue("market_price_repush_delta", 20),
sourceTimeoutMs: await db.getAppSettingValue("market_source_timeout_ms", 12000),
sourceRetryCount: await db.getAppSettingValue("market_source_retry_count", 1),
xianyuCookie: await db.getAppSettingValue("market_xianyu_cookie", ""),
xianyuUserAgent: await db.getAppSettingValue(
"market_xianyu_user_agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
),
jdCookie: await db.getAppSettingValue("market_jd_cookie", ""),
jdUserAgent: await db.getAppSettingValue(
"market_jd_user_agent",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
),
zhuanzhuanCookie: await db.getAppSettingValue("market_zhuanzhuan_cookie", ""),
zhuanzhuanUserAgent: await db.getAppSettingValue(
"market_zhuanzhuan_user_agent",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
),
zhuanzhuanSearchUrlTemplate: await db.getAppSettingValue("market_zhuanzhuan_search_url_template", ""),
};
}
function buildHeaders(userAgent: string, cookie?: string) {
const headers: Record<string, string> = {
"accept-language": "zh-CN,zh;q=0.9",
"content-type": "application/json",
"user-agent": userAgent,
};
if (cookie?.trim()) headers.cookie = cookie.trim();
return headers;
}
function pickFirstUrl(candidate: Record<string, unknown>) {
const url = firstString(findValuesByKey(candidate, /(url|href|jumpUrl|itemUrl|detailUrl)/i));
if (!url) return null;
return url;
}
function pickImageUrl(candidate: Record<string, unknown>) {
const image = firstString(findValuesByKey(candidate, /(image|img|pic|cover)/i));
if (!image) return null;
if (/^https?:\/\//i.test(image)) return image;
if (image.startsWith("//")) return `https:${image}`;
return image;
}
function pickSellerName(candidate: Record<string, unknown>) {
const keys = /(seller|nick|owner|userName|shopName|publisher|author)/i;
return firstString(findValuesByKey(candidate, keys));
}
function mapGenericCandidate(source: MarketSource, candidate: Record<string, unknown>) {
const sourceListingId = firstString(findValuesByKey(candidate, /^(itemId|id|spuId|wareId|item_id)$/i));
const title = firstString(findValuesByKey(candidate, /(title|itemTitle|name|summary|subject)/i));
const priceCandidates = findValuesByKey(candidate, /(price|amount|showPrice|salePrice|soldPrice)/i)
.map((value) => toNumber(value))
.filter((value): value is number => value != null && value > 0);
const price = priceCandidates[0] ?? null;
if (!sourceListingId || !title || price == null || !looksLikeRacketTitle(title)) {
return null;
}
const originalPrice = priceCandidates.find((value) => value > price) ?? null;
const listingUrl = guessUrlFromValue(pickFirstUrl(candidate), source, sourceListingId);
const sellerName = pickSellerName(candidate);
const location = firstString(findValuesByKey(candidate, /(location|city|area|province|region)/i));
const publishedAtRaw = firstString(findValuesByKey(candidate, /(publish|createTime|time|publishAt|gmtCreate)/i));
return {
source,
sourceListingId,
title,
listingUrl,
imageUrl: pickImageUrl(candidate),
price,
originalPrice,
sellerName,
location,
publishedAtRaw,
extra: candidate,
} satisfies RawSourceListing;
}
function extractCandidatesFromPayload(source: MarketSource, payload: unknown) {
const arrays = collectObjectArrays(payload);
const mapped: RawSourceListing[] = [];
for (const array of arrays) {
for (const candidate of array) {
const listing = mapGenericCandidate(source, candidate);
if (listing) mapped.push(listing);
}
}
const unique = new Map<string, RawSourceListing>();
for (const item of mapped) {
unique.set(`${item.source}:${item.sourceListingId}`, item);
}
return Array.from(unique.values()).slice(0, 30);
}
function parseJsonSafely(text: string) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
async function fetchJson(url: string, init: RequestInit, timeoutMs: number, retries: number) {
const response = await fetchWithTimeout(url, init, {
timeoutMs,
retries,
retryStatuses: [408, 425, 429, 500, 502, 503, 504],
});
const text = await response.text();
return {
response,
text,
json: parseJsonSafely(text),
};
}
async function searchXianyu(query: string, config: MarketConfig): Promise<SearchSourceResult> {
const endpoint = "https://acs.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/";
const data = JSON.stringify({
pageNumber: 1,
keyword: query,
rowsPerPage: 30,
searchReqFromPage: "pcSearch",
});
const tokenMatch = config.xianyuCookie.match(/_m_h5_tk=([^_;]+)/);
const t = Date.now().toString();
const sign = tokenMatch
? crypto.createHash("md5").update(`${tokenMatch[1]}&${t}&${XIANYU_APP_KEY}&${data}`).digest("hex")
: "";
const url = `${endpoint}?jsv=2.7.2&appKey=${XIANYU_APP_KEY}&t=${t}&sign=${sign}&api=${XIANYU_API}&v=1.0&type=originaljson&dataType=json&timeout=20000&data=${encodeURIComponent(data)}`;
const { response, text, json } = await fetchJson(url, {
method: "GET",
headers: buildHeaders(config.xianyuUserAgent, config.xianyuCookie),
}, config.sourceTimeoutMs, config.sourceRetryCount);
const blocked = response.status >= 400
|| /RGV587_ERROR|FAIL_SYS_ILLEGAL_ACCESS|passport\.goofish\.com|被挤爆|安全校验/i.test(text);
if (blocked) {
return {
source: "xianyu",
query,
ok: false,
blocked: true,
message: "闲鱼搜索接口返回登录或风控校验,需要补充有效 Cookie 或降低请求频率。",
listings: [],
};
}
const listings = extractCandidatesFromPayload("xianyu", json);
return {
source: "xianyu",
query,
ok: true,
blocked: false,
message: listings.length > 0 ? `闲鱼返回 ${listings.length} 条结果` : "闲鱼接口成功但未提取到可用商品数据",
listings,
};
}
async function searchJd(query: string, config: MarketConfig): Promise<SearchSourceResult> {
const url = `https://so.m.jd.com/ware/searchList.action?_format_=json&keyword=${encodeURIComponent(query)}&page=1`;
const { response, text, json } = await fetchJson(url, {
method: "GET",
headers: buildHeaders(config.jdUserAgent, config.jdCookie),
}, config.sourceTimeoutMs, config.sourceRetryCount);
const blocked = response.status >= 400 || /403 Forbidden|京东验证|risk_handler|JDR_shields/i.test(text);
if (blocked) {
return {
source: "jd",
query,
ok: false,
blocked: true,
message: "京东搜索接口当前返回风控页或 403,需补充 Cookie 或降低抓取频率。",
listings: [],
};
}
const listings = extractCandidatesFromPayload("jd", json);
return {
source: "jd",
query,
ok: true,
blocked: false,
message: listings.length > 0 ? `京东返回 ${listings.length} 条结果` : "京东接口成功但未提取到可用商品数据",
listings,
};
}
async function searchZhuanzhuan(query: string, config: MarketConfig): Promise<SearchSourceResult> {
if (!config.zhuanzhuanSearchUrlTemplate.includes("{query}")) {
return {
source: "zhuanzhuan",
query,
ok: false,
blocked: false,
message: "转转搜索 URL 模板尚未配置,当前来源处于待接线状态。",
listings: [],
};
}
const url = config.zhuanzhuanSearchUrlTemplate.replace(/\{query\}/g, encodeURIComponent(query));
const { response, text, json } = await fetchJson(url, {
method: "GET",
headers: buildHeaders(config.zhuanzhuanUserAgent, config.zhuanzhuanCookie),
}, config.sourceTimeoutMs, config.sourceRetryCount);
if (response.status >= 400) {
return {
source: "zhuanzhuan",
query,
ok: false,
blocked: response.status === 403,
message: `转转搜索模板返回 ${response.status},请检查配置的模板地址。`,
listings: [],
};
}
const payload = json ?? text;
const listings = extractCandidatesFromPayload("zhuanzhuan", payload);
return {
source: "zhuanzhuan",
query,
ok: true,
blocked: false,
message: listings.length > 0 ? `转转返回 ${listings.length} 条结果` : "转转响应成功但未提取到可用商品数据",
listings,
};
}
export async function searchMarketSource(source: MarketSource, query: string, config: MarketConfig): Promise<SearchSourceResult> {
try {
switch (source) {
case "xianyu":
return await searchXianyu(query, config);
case "jd":
return await searchJd(query, config);
case "zhuanzhuan":
return await searchZhuanzhuan(query, config);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown source error";
return {
source,
query,
ok: false,
blocked: /403|校验|risk|timeout/i.test(message),
message,
listings: [],
};
}
}
export function enrichRacketListing(raw: RawSourceListing): InsertRacketListing {
const material = normalizeWhitespace([raw.title, raw.description ?? ""].join(" "));
const brand = detectBrand(material);
const series = detectSeries(material);
const weightGram = detectWeightGram(material);
const category = detectCategory(material, weightGram);
const conditionLevel = detectConditionLevel(material);
const { gradeLevel, gradeReason, isLowPriceCandidate } = gradeRacketListing({
brand,
price: raw.price,
conditionLevel,
category,
weightGram,
});
return {
source: raw.source,
sourceListingId: raw.sourceListingId,
title: raw.title.slice(0, 512),
description: raw.description ?? null,
listingUrl: raw.listingUrl,
imageUrl: raw.imageUrl ?? null,
price: raw.price,
originalPrice: raw.originalPrice ?? null,
sellerName: raw.sellerName ?? null,
location: raw.location ?? null,
publishedAtRaw: raw.publishedAtRaw ?? null,
brand,
model: deriveModel(material, brand, series),
series,
category,
weightGram,
conditionLevel,
gradeLevel,
gradeReason,
isLowPriceCandidate: isLowPriceCandidate ? 1 : 0,
fingerprint: fingerprintForListing(raw.source, raw.sourceListingId, raw.title, raw.price),
extra: raw.extra ?? null,
fetchedAt: new Date(),
};
}
export function listingMatchesWatchRule(
listing: Pick<InsertRacketListing, "price" | "brand" | "title" | "model" | "series" | "category" | "weightGram">,
rule: Pick<RacketWatchRule, "brand" | "modelKeyword" | "seriesKeyword" | "category" | "weightMinGram" | "weightMaxGram" | "targetPrice">,
) {
if ((listing.price ?? Number.MAX_SAFE_INTEGER) > rule.targetPrice) return false;
const haystack = normalizeWhitespace([
listing.brand ?? "",
listing.title ?? "",
listing.model ?? "",
listing.series ?? "",
].join(" ")).toLowerCase();
if (rule.brand && !haystack.includes(rule.brand.toLowerCase())) return false;
if (rule.modelKeyword && !haystack.includes(rule.modelKeyword.toLowerCase())) return false;
if (rule.seriesKeyword && !haystack.includes(rule.seriesKeyword.toLowerCase())) return false;
if (rule.category && listing.category && rule.category !== listing.category) return false;
if (rule.weightMinGram != null && listing.weightGram != null && listing.weightGram < rule.weightMinGram) return false;
if (rule.weightMaxGram != null && listing.weightGram != null && listing.weightGram > rule.weightMaxGram) return false;
return true;
}
export function applyComparablePriceBenchmark(
listing: InsertRacketListing,
comparablePrices: number[],
) {
if (comparablePrices.length < 2) return listing;
const avg = comparablePrices.reduce((sum, value) => sum + value, 0) / comparablePrices.length;
if (listing.price <= avg * 0.85) {
return {
...listing,
isLowPriceCandidate: 1,
gradeReason: normalizeWhitespace(`${listing.gradeReason ?? ""} · 相比站内同型号样本均价更低`),
};
}
return listing;
}
export function maskWebhookUrl(url: string) {
if (!url.trim()) return "";
if (url.length <= 20) return url;
return `${url.slice(0, 38)}...${url.slice(-8)}`;
}
export function formatMarketPushText(payload: {
ruleTitle: string;
source: MarketSource;
title: string;
price: number;
targetPrice: number;
brand?: string | null;
model?: string | null;
category?: string | null;
weightGram?: number | null;
gradeLevel?: MarketGradeLevel | null;
gradeReason?: string | null;
listingUrl: string;
fetchedAt?: string | Date | null;
}) {
const lines = [
`命中监控: ${payload.ruleTitle}`,
`来源: ${MARKET_SOURCE_LABELS[payload.source]}`,
`标题: ${payload.title}`,
`价格: ¥${payload.price.toFixed(0)} / 目标 ¥${payload.targetPrice.toFixed(0)}`,
];
const tags = [
payload.brand ? `品牌 ${payload.brand}` : "",
payload.model ? `型号 ${payload.model}` : "",
payload.category ? `品类 ${MARKET_CATEGORY_LABELS[payload.category as MarketCategory] ?? payload.category}` : "",
payload.weightGram != null ? `重量 ${Math.round(payload.weightGram)}g` : "",
payload.gradeLevel ? `分级 ${MARKET_GRADE_LABELS[payload.gradeLevel]}` : "",
].filter(Boolean);
if (tags.length > 0) {
lines.push(`标签: ${tags.join(" · ")}`);
}
if (payload.gradeReason) {
lines.push(`判断: ${payload.gradeReason}`);
}
if (payload.fetchedAt) {
lines.push(`抓取时间: ${new Date(payload.fetchedAt).toISOString()}`);
}
lines.push(`链接: ${payload.listingUrl}`);
return lines.join("\n");
}

95
server/match.test.ts 普通文件
查看文件

@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { buildParticipantSettlement, deriveSuggestedMatchState } from "./match";
describe("deriveSuggestedMatchState", () => {
it("aggregates point, game and set events into a suggested scoreboard", () => {
const result = deriveSuggestedMatchState([
{ eventIndex: 1, eventType: "point", source: "camera_a", winnerSlot: "player_a", confidence: 0.6, payload: { rallyCount: 4, isWinner: true } },
{ eventIndex: 2, eventType: "point", source: "camera_b", winnerSlot: "player_a", confidence: 0.7, payload: { rallyCount: 7, isAce: true, firstServeIn: true, firstServeAttempt: true } },
{ eventIndex: 3, eventType: "game", source: "camera_a", winnerSlot: "player_a", confidence: 0.65 },
{ eventIndex: 4, eventType: "set", source: "camera_a", winnerSlot: "player_a", confidence: 0.8 },
]);
expect(result.score.sets.player_a).toBe(1);
expect(result.score.games.player_a).toBe(1);
expect(result.score.points.player_a).toBe(0);
expect(result.score.winnerSlot).toBe("player_a");
expect(result.metrics.players.player_a.pointsWon).toBe(2);
expect(result.metrics.players.player_a.aces).toBe(1);
expect(result.metrics.players.player_a.firstServePct).toBe(100);
expect(result.metrics.longestRally).toBe(7);
expect(result.sourceCount).toBe(2);
});
it("prefers later reviewed score snapshots while keeping metric snapshots", () => {
const result = deriveSuggestedMatchState([
{
eventIndex: 1,
eventType: "score_suggestion",
source: "system",
confidence: 0.78,
payload: {
score: {
sets: { player_a: 1, player_b: 0 },
games: { player_a: 6, player_b: 4 },
points: { player_a: 0, player_b: 0 },
},
metrics: {
player_a: { winners: 14, pointsWon: 32 },
player_b: { winners: 10, pointsWon: 25 },
totalRallies: 18,
},
},
},
{
eventIndex: 2,
eventType: "review_adjustment",
source: "admin",
confidence: 1,
payload: {
score: {
sets: { player_a: 2, player_b: 0 },
games: { player_a: 12, player_b: 6 },
points: { player_a: 68, player_b: 53 },
winnerSlot: "player_a",
},
},
},
]);
expect(result.score.sets.player_a).toBe(2);
expect(result.score.games.player_b).toBe(6);
expect(result.score.points.player_a).toBe(68);
expect(result.score.winnerSlot).toBe("player_a");
expect(result.metrics.players.player_a.winners).toBe(14);
expect(result.metrics.totalRallies).toBe(18);
});
});
describe("buildParticipantSettlement", () => {
it("projects scoreboard and metrics into per-player settlement rows", () => {
const settlement = buildParticipantSettlement(
{
sets: { player_a: 2, player_b: 1 },
games: { player_a: 13, player_b: 11 },
points: { player_a: 74, player_b: 68 },
winnerSlot: "player_a",
},
{
players: {
player_a: { aces: 6, firstServeIn: 32, firstServeAttempts: 40, pointsWon: 74 },
player_b: { aces: 4, firstServeIn: 28, firstServeAttempts: 41, pointsWon: 68 },
},
totalRallies: 22,
longestRally: 11,
},
);
expect(settlement.winnerSlot).toBe("player_a");
expect(settlement.players.player_a.isWinner).toBe(true);
expect(settlement.players.player_a.gamesWon).toBe(13);
expect(settlement.players.player_a.stats.firstServePct).toBe(80);
expect(settlement.players.player_b.stats.firstServePct).toBeCloseTo(68.3, 1);
expect(settlement.summary.longestRally).toBe(11);
});
});

440
server/match.ts 普通文件
查看文件

@@ -0,0 +1,440 @@
export const MATCH_PLAYER_SLOTS = ["player_a", "player_b"] as const;
export type MatchPlayerSlot = (typeof MATCH_PLAYER_SLOTS)[number];
export type SlotMap<T> = Record<MatchPlayerSlot, T>;
export type MatchScoreboard = {
sets: SlotMap<number>;
games: SlotMap<number>;
points: SlotMap<number>;
winnerSlot: MatchPlayerSlot | null;
confidence: number;
};
export type MatchPlayerMetrics = {
pointsWon: number;
aces: number;
doubleFaults: number;
winners: number;
unforcedErrors: number;
breakPointsWon: number;
breakPointsTotal: number;
firstServeIn: number;
firstServeAttempts: number;
firstServePct: number;
maxServeKph: number;
longestRally: number;
};
export type MatchMetrics = {
players: SlotMap<MatchPlayerMetrics>;
totalRallies: number;
longestRally: number;
sourceCount: number;
};
export type MatchSettlement = {
winnerSlot: MatchPlayerSlot | null;
players: SlotMap<{
setsWon: number;
gamesWon: number;
pointsWon: number;
isWinner: boolean;
stats: MatchPlayerMetrics;
}>;
summary: {
totalRallies: number;
longestRally: number;
};
};
export type MatchEventInput = {
eventType: string;
winnerSlot?: MatchPlayerSlot | null;
confidence?: number | null;
payload?: unknown;
source?: string | null;
eventIndex?: number | null;
};
function clampNumber(value: unknown, fallback = 0) {
if (typeof value !== "number" || Number.isNaN(value)) {
return fallback;
}
return value;
}
function toSlotMap<T>(factory: () => T): SlotMap<T> {
return {
player_a: factory(),
player_b: factory(),
};
}
export function createEmptyMatchPlayerMetrics(): MatchPlayerMetrics {
return {
pointsWon: 0,
aces: 0,
doubleFaults: 0,
winners: 0,
unforcedErrors: 0,
breakPointsWon: 0,
breakPointsTotal: 0,
firstServeIn: 0,
firstServeAttempts: 0,
firstServePct: 0,
maxServeKph: 0,
longestRally: 0,
};
}
export function createEmptyMatchMetrics(): MatchMetrics {
return {
players: toSlotMap(() => createEmptyMatchPlayerMetrics()),
totalRallies: 0,
longestRally: 0,
sourceCount: 0,
};
}
export function createEmptyMatchScoreboard(): MatchScoreboard {
return {
sets: toSlotMap(() => 0),
games: toSlotMap(() => 0),
points: toSlotMap(() => 0),
winnerSlot: null,
confidence: 0,
};
}
function normalizePlayerMetrics(raw: unknown): MatchPlayerMetrics {
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const firstServeIn = clampNumber(source.firstServeIn);
const firstServeAttempts = clampNumber(source.firstServeAttempts);
const firstServePct = firstServeAttempts > 0
? Math.round((firstServeIn / firstServeAttempts) * 1000) / 10
: clampNumber(source.firstServePct);
return {
pointsWon: clampNumber(source.pointsWon),
aces: clampNumber(source.aces),
doubleFaults: clampNumber(source.doubleFaults),
winners: clampNumber(source.winners),
unforcedErrors: clampNumber(source.unforcedErrors),
breakPointsWon: clampNumber(source.breakPointsWon),
breakPointsTotal: clampNumber(source.breakPointsTotal),
firstServeIn,
firstServeAttempts,
firstServePct,
maxServeKph: clampNumber(source.maxServeKph),
longestRally: clampNumber(source.longestRally),
};
}
export function normalizeMatchMetrics(raw: unknown): MatchMetrics {
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const playersRaw = (source.players && typeof source.players === "object" ? source.players : source) as Record<string, unknown>;
const players = {
player_a: normalizePlayerMetrics(playersRaw.player_a),
player_b: normalizePlayerMetrics(playersRaw.player_b),
};
const totalRallies = clampNumber(source.totalRallies);
const longestRally = Math.max(
clampNumber(source.longestRally),
players.player_a.longestRally,
players.player_b.longestRally,
);
const sourceCount = clampNumber(source.sourceCount);
return {
players,
totalRallies,
longestRally,
sourceCount,
};
}
export function normalizeMatchScoreboard(raw: unknown): MatchScoreboard {
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
const sets = (source.sets && typeof source.sets === "object" ? source.sets : source) as Record<string, unknown>;
const games = (source.games && typeof source.games === "object" ? source.games : source) as Record<string, unknown>;
const points = (source.points && typeof source.points === "object" ? source.points : source) as Record<string, unknown>;
const winnerSlot = source.winnerSlot === "player_a" || source.winnerSlot === "player_b"
? source.winnerSlot
: null;
const normalized = {
sets: {
player_a: clampNumber(sets.player_a ?? source.playerASetCount),
player_b: clampNumber(sets.player_b ?? source.playerBSetCount),
},
games: {
player_a: clampNumber(games.player_a ?? source.playerAGameCount),
player_b: clampNumber(games.player_b ?? source.playerBGameCount),
},
points: {
player_a: clampNumber(points.player_a ?? source.playerAPointCount),
player_b: clampNumber(points.player_b ?? source.playerBPointCount),
},
winnerSlot,
confidence: Math.max(0, Math.min(1, clampNumber(source.confidence))),
} satisfies MatchScoreboard;
return applyWinnerFallback(normalized);
}
function applyWinnerFallback(scoreboard: MatchScoreboard): MatchScoreboard {
if (scoreboard.winnerSlot) {
return scoreboard;
}
const dimensions: Array<keyof Pick<MatchScoreboard, "sets" | "games" | "points">> = ["sets", "games", "points"];
for (const key of dimensions) {
const a = scoreboard[key].player_a;
const b = scoreboard[key].player_b;
if (a > b) {
return { ...scoreboard, winnerSlot: "player_a" };
}
if (b > a) {
return { ...scoreboard, winnerSlot: "player_b" };
}
}
return scoreboard;
}
function addMetricPatch(base: MatchPlayerMetrics, patch: Partial<MatchPlayerMetrics>) {
base.pointsWon += clampNumber(patch.pointsWon);
base.aces += clampNumber(patch.aces);
base.doubleFaults += clampNumber(patch.doubleFaults);
base.winners += clampNumber(patch.winners);
base.unforcedErrors += clampNumber(patch.unforcedErrors);
base.breakPointsWon += clampNumber(patch.breakPointsWon);
base.breakPointsTotal += clampNumber(patch.breakPointsTotal);
base.firstServeIn += clampNumber(patch.firstServeIn);
base.firstServeAttempts += clampNumber(patch.firstServeAttempts);
base.maxServeKph = Math.max(base.maxServeKph, clampNumber(patch.maxServeKph));
base.longestRally = Math.max(base.longestRally, clampNumber(patch.longestRally));
}
function mergeMetricSnapshot(base: MatchPlayerMetrics, snapshot: MatchPlayerMetrics) {
base.pointsWon = Math.max(base.pointsWon, snapshot.pointsWon);
base.aces = Math.max(base.aces, snapshot.aces);
base.doubleFaults = Math.max(base.doubleFaults, snapshot.doubleFaults);
base.winners = Math.max(base.winners, snapshot.winners);
base.unforcedErrors = Math.max(base.unforcedErrors, snapshot.unforcedErrors);
base.breakPointsWon = Math.max(base.breakPointsWon, snapshot.breakPointsWon);
base.breakPointsTotal = Math.max(base.breakPointsTotal, snapshot.breakPointsTotal);
base.firstServeIn = Math.max(base.firstServeIn, snapshot.firstServeIn);
base.firstServeAttempts = Math.max(base.firstServeAttempts, snapshot.firstServeAttempts);
base.maxServeKph = Math.max(base.maxServeKph, snapshot.maxServeKph);
base.longestRally = Math.max(base.longestRally, snapshot.longestRally);
}
function finalizeMetrics(metrics: MatchMetrics) {
for (const slot of MATCH_PLAYER_SLOTS) {
const row = metrics.players[slot];
row.firstServePct = row.firstServeAttempts > 0
? Math.round((row.firstServeIn / row.firstServeAttempts) * 1000) / 10
: 0;
metrics.longestRally = Math.max(metrics.longestRally, row.longestRally);
}
return metrics;
}
function applyScoreSnapshot(current: MatchScoreboard, raw: unknown, confidence: number) {
const next = normalizeMatchScoreboard(raw);
if (next.confidence <= 0) {
next.confidence = confidence;
}
if (next.confidence >= current.confidence) {
return applyWinnerFallback(next);
}
return current;
}
export function deriveSuggestedMatchState(events: MatchEventInput[]): {
score: MatchScoreboard;
metrics: MatchMetrics;
eventCount: number;
sourceCount: number;
} {
const ordered = [...events].sort((a, b) => (a.eventIndex ?? 0) - (b.eventIndex ?? 0));
const score = createEmptyMatchScoreboard();
const metrics = createEmptyMatchMetrics();
const sources = new Set<string>();
for (const event of ordered) {
if (event.source) {
sources.add(event.source);
}
const confidence = Math.max(0, Math.min(1, event.confidence ?? 0.55));
const payload = (event.payload && typeof event.payload === "object" ? event.payload : {}) as Record<string, unknown>;
switch (event.eventType) {
case "point": {
if (event.winnerSlot) {
score.points[event.winnerSlot] += 1;
metrics.players[event.winnerSlot].pointsWon += 1;
}
const rallyCount = clampNumber(payload.rallyCount);
if (rallyCount > 0) {
metrics.totalRallies += 1;
metrics.longestRally = Math.max(metrics.longestRally, rallyCount);
if (event.winnerSlot) {
metrics.players[event.winnerSlot].longestRally = Math.max(
metrics.players[event.winnerSlot].longestRally,
rallyCount,
);
}
}
if (event.winnerSlot) {
addMetricPatch(metrics.players[event.winnerSlot], {
aces: payload.isAce ? 1 : 0,
winners: payload.isWinner ? 1 : 0,
breakPointsWon: payload.isBreakPoint ? 1 : 0,
breakPointsTotal: payload.isBreakPoint ? 1 : 0,
firstServeIn: payload.firstServeIn ? 1 : 0,
firstServeAttempts: payload.firstServeAttempt ? 1 : 0,
maxServeKph: clampNumber(payload.serveSpeedKph),
longestRally: rallyCount,
});
}
if (payload.doubleFaultBy === "player_a" || payload.doubleFaultBy === "player_b") {
metrics.players[payload.doubleFaultBy].doubleFaults += 1;
}
if (payload.scoreboard) {
const next = applyScoreSnapshot(score, payload.scoreboard, confidence);
score.sets = next.sets;
score.games = next.games;
score.points = next.points;
score.winnerSlot = next.winnerSlot;
score.confidence = next.confidence;
}
break;
}
case "game": {
if (event.winnerSlot) {
score.games[event.winnerSlot] += 1;
score.points.player_a = 0;
score.points.player_b = 0;
}
if (payload.scoreboard) {
const next = applyScoreSnapshot(score, payload.scoreboard, confidence);
score.sets = next.sets;
score.games = next.games;
score.points = next.points;
score.winnerSlot = next.winnerSlot;
score.confidence = next.confidence;
}
break;
}
case "set": {
if (event.winnerSlot) {
score.sets[event.winnerSlot] += 1;
}
if (payload.scoreboard) {
const next = applyScoreSnapshot(score, payload.scoreboard, confidence);
score.sets = next.sets;
score.games = next.games;
score.points = next.points;
score.winnerSlot = next.winnerSlot;
score.confidence = next.confidence;
}
break;
}
case "metric": {
const slot: MatchPlayerSlot | null = payload.playerSlot === "player_a" || payload.playerSlot === "player_b"
? payload.playerSlot
: event.winnerSlot ?? null;
const metricMode = payload.metricMode === "delta" ? "delta" : "snapshot";
if (slot) {
const normalized = normalizePlayerMetrics(payload.metrics);
if (metricMode === "delta") {
addMetricPatch(metrics.players[slot], normalized);
} else {
mergeMetricSnapshot(metrics.players[slot], normalized);
}
}
if (typeof payload.totalRallies === "number") {
metrics.totalRallies = Math.max(metrics.totalRallies, payload.totalRallies);
}
if (typeof payload.longestRally === "number") {
metrics.longestRally = Math.max(metrics.longestRally, payload.longestRally);
}
break;
}
case "score_suggestion":
case "review_adjustment":
case "finalized": {
const next = applyScoreSnapshot(score, payload.score ?? payload.scoreboard ?? payload, confidence);
score.sets = next.sets;
score.games = next.games;
score.points = next.points;
score.winnerSlot = next.winnerSlot;
score.confidence = event.eventType === "finalized" ? 1 : next.confidence;
const metricSnapshot = normalizeMatchMetrics(payload.metrics ?? payload.playerMetrics ?? payload);
for (const slot of MATCH_PLAYER_SLOTS) {
mergeMetricSnapshot(metrics.players[slot], metricSnapshot.players[slot]);
}
metrics.totalRallies = Math.max(metrics.totalRallies, metricSnapshot.totalRallies);
metrics.longestRally = Math.max(metrics.longestRally, metricSnapshot.longestRally);
break;
}
default:
break;
}
}
metrics.sourceCount = sources.size;
score.confidence = Math.max(score.confidence, ordered.length > 0 ? Math.min(0.98, 0.45 + ordered.length * 0.05) : 0);
return {
score: applyWinnerFallback(score),
metrics: finalizeMetrics(metrics),
eventCount: ordered.length,
sourceCount: sources.size,
};
}
export function buildParticipantSettlement(scoreRaw: unknown, metricsRaw: unknown): MatchSettlement {
const score = normalizeMatchScoreboard(scoreRaw);
const metrics = finalizeMetrics(normalizeMatchMetrics(metricsRaw));
return {
winnerSlot: score.winnerSlot,
players: {
player_a: {
setsWon: score.sets.player_a,
gamesWon: score.games.player_a,
pointsWon: Math.max(score.points.player_a, metrics.players.player_a.pointsWon),
isWinner: score.winnerSlot === "player_a",
stats: metrics.players.player_a,
},
player_b: {
setsWon: score.sets.player_b,
gamesWon: score.games.player_b,
pointsWon: Math.max(score.points.player_b, metrics.players.player_b.pointsWon),
isWinner: score.winnerSlot === "player_b",
stats: metrics.players.player_b,
},
},
summary: {
totalRallies: metrics.totalRallies,
longestRally: metrics.longestRally,
},
};
}

889
server/matchStore.ts 普通文件
查看文件

@@ -0,0 +1,889 @@
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
import {
matchParticipants,
matchScoreEvents,
matchSessions,
users,
type InsertMatchParticipant,
type InsertMatchScoreEvent,
type InsertMatchSession,
} from "../drizzle/schema";
import {
createNotification,
getDateKey,
getDb,
refreshAchievementsForUser,
refreshUserTrainingSummary,
upsertDailyTrainingAggregate,
upsertTrainingRecordBySource,
} from "./db";
import {
MATCH_PLAYER_SLOTS,
buildParticipantSettlement,
deriveSuggestedMatchState,
normalizeMatchMetrics,
normalizeMatchScoreboard,
type MatchPlayerSlot,
} from "./match";
type MatchMode = "daily" | "competitive";
type WorkflowStatus = "draft" | "recording" | "review_pending" | "reviewed" | "finalizing" | "finalized" | "cancelled";
type SuggestionStatus = "idle" | "queued" | "ready" | "failed";
type CameraStatus = "pending" | "bound" | "active" | "completed" | "failed";
type EventSource = "camera_a" | "camera_b" | "system" | "admin";
type EventType = "point" | "game" | "set" | "metric" | "score_suggestion" | "review_adjustment" | "finalized";
type ParticipantRow = Awaited<ReturnType<typeof listMatchParticipants>>[number];
function orderByIds<T extends { id: number }>(rows: T[], ids: number[]) {
const order = new Map(ids.map((id, index) => [id, index]));
return [...rows].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
}
function indexBySlot(rows: ParticipantRow[]) {
return {
player_a: rows.find((row) => row.playerSlot === "player_a") ?? null,
player_b: rows.find((row) => row.playerSlot === "player_b") ?? null,
};
}
async function loadParticipantsMap(matchIds: number[]) {
if (matchIds.length === 0) {
return new Map<number, ParticipantRow[]>();
}
const db = await getDb();
if (!db) return new Map<number, ParticipantRow[]>();
const rows = await db.select({
id: matchParticipants.id,
matchId: matchParticipants.matchId,
userId: matchParticipants.userId,
userName: users.name,
playerSlot: matchParticipants.playerSlot,
cameraSlot: matchParticipants.cameraSlot,
cameraStatus: matchParticipants.cameraStatus,
cameraLabel: matchParticipants.cameraLabel,
cameraVideoId: matchParticipants.cameraVideoId,
cameraVideoUrl: matchParticipants.cameraVideoUrl,
cameraSnapshot: matchParticipants.cameraSnapshot,
isWinner: matchParticipants.isWinner,
suggestedSetsWon: matchParticipants.suggestedSetsWon,
suggestedGamesWon: matchParticipants.suggestedGamesWon,
suggestedPointsWon: matchParticipants.suggestedPointsWon,
finalSetsWon: matchParticipants.finalSetsWon,
finalGamesWon: matchParticipants.finalGamesWon,
finalPointsWon: matchParticipants.finalPointsWon,
suggestedStats: matchParticipants.suggestedStats,
finalStats: matchParticipants.finalStats,
createdAt: matchParticipants.createdAt,
updatedAt: matchParticipants.updatedAt,
}).from(matchParticipants)
.leftJoin(users, eq(users.id, matchParticipants.userId))
.where(inArray(matchParticipants.matchId, matchIds))
.orderBy(asc(matchParticipants.matchId), asc(matchParticipants.id));
const grouped = new Map<number, ParticipantRow[]>();
for (const row of rows) {
const list = grouped.get(row.matchId) ?? [];
list.push(row);
grouped.set(row.matchId, list);
}
return grouped;
}
async function loadEventCounts(matchIds: number[]) {
if (matchIds.length === 0) {
return new Map<number, number>();
}
const db = await getDb();
if (!db) return new Map<number, number>();
const rows = await db.select({
matchId: matchScoreEvents.matchId,
}).from(matchScoreEvents).where(inArray(matchScoreEvents.matchId, matchIds));
const counts = new Map<number, number>();
for (const row of rows) {
counts.set(row.matchId, (counts.get(row.matchId) ?? 0) + 1);
}
return counts;
}
async function hydrateMatches(sessionIds: number[]) {
const db = await getDb();
if (!db || sessionIds.length === 0) return [];
const sessions = await db.select().from(matchSessions)
.where(inArray(matchSessions.id, sessionIds));
const participantsMap = await loadParticipantsMap(sessionIds);
const eventCounts = await loadEventCounts(sessionIds);
return orderByIds(sessions, sessionIds).map((session) => ({
...session,
participants: participantsMap.get(session.id) ?? [],
eventCount: eventCounts.get(session.id) ?? 0,
}));
}
export async function getMatchSessionById(matchId: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(matchSessions).where(eq(matchSessions.id, matchId)).limit(1);
return result[0];
}
export async function getMatchParticipantBySlot(matchId: number, playerSlot: MatchPlayerSlot) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(matchParticipants)
.where(and(eq(matchParticipants.matchId, matchId), eq(matchParticipants.playerSlot, playerSlot)))
.limit(1);
return result[0];
}
export async function listMatchParticipants(matchId: number) {
const db = await getDb();
if (!db) return [];
return db.select({
id: matchParticipants.id,
matchId: matchParticipants.matchId,
userId: matchParticipants.userId,
userName: users.name,
playerSlot: matchParticipants.playerSlot,
cameraSlot: matchParticipants.cameraSlot,
cameraStatus: matchParticipants.cameraStatus,
cameraLabel: matchParticipants.cameraLabel,
cameraVideoId: matchParticipants.cameraVideoId,
cameraVideoUrl: matchParticipants.cameraVideoUrl,
cameraSnapshot: matchParticipants.cameraSnapshot,
isWinner: matchParticipants.isWinner,
suggestedSetsWon: matchParticipants.suggestedSetsWon,
suggestedGamesWon: matchParticipants.suggestedGamesWon,
suggestedPointsWon: matchParticipants.suggestedPointsWon,
finalSetsWon: matchParticipants.finalSetsWon,
finalGamesWon: matchParticipants.finalGamesWon,
finalPointsWon: matchParticipants.finalPointsWon,
suggestedStats: matchParticipants.suggestedStats,
finalStats: matchParticipants.finalStats,
createdAt: matchParticipants.createdAt,
updatedAt: matchParticipants.updatedAt,
}).from(matchParticipants)
.leftJoin(users, eq(users.id, matchParticipants.userId))
.where(eq(matchParticipants.matchId, matchId))
.orderBy(asc(matchParticipants.id));
}
export async function listMatchScoreEvents(matchId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(matchScoreEvents)
.where(eq(matchScoreEvents.matchId, matchId))
.orderBy(asc(matchScoreEvents.eventIndex), asc(matchScoreEvents.id));
}
export async function getMatchDetail(matchId: number) {
const session = await getMatchSessionById(matchId);
if (!session) return undefined;
const participants = await listMatchParticipants(matchId);
const events = await listMatchScoreEvents(matchId);
return {
...session,
participants,
events,
eventCount: events.length,
};
}
export async function listAccessibleMatchSessions(params: {
viewerUserId: number;
isAdmin: boolean;
limit?: number;
workflowStatus?: WorkflowStatus | "all";
matchMode?: MatchMode | "all";
}) {
const db = await getDb();
if (!db) return [];
const limit = params.limit ?? 50;
const filters = [];
if (params.workflowStatus && params.workflowStatus !== "all") {
filters.push(eq(matchSessions.workflowStatus, params.workflowStatus));
}
if (params.matchMode && params.matchMode !== "all") {
filters.push(eq(matchSessions.matchMode, params.matchMode));
}
if (params.isAdmin) {
const sessions = await db.select({
id: matchSessions.id,
}).from(matchSessions)
.where(filters.length > 0 ? and(...filters) : undefined)
.orderBy(desc(matchSessions.updatedAt), desc(matchSessions.id))
.limit(limit);
return hydrateMatches(sessions.map((row) => row.id));
}
const conditions = [
eq(matchParticipants.userId, params.viewerUserId),
...filters,
];
const sessions = await db.select({
id: matchSessions.id,
}).from(matchParticipants)
.innerJoin(matchSessions, eq(matchSessions.id, matchParticipants.matchId))
.where(and(...conditions))
.orderBy(desc(matchSessions.updatedAt), desc(matchSessions.id))
.limit(limit);
return hydrateMatches(sessions.map((row) => row.id));
}
export async function createMatchSession(input: {
createdByUserId: number;
matchMode: MatchMode;
title: string;
courtName?: string | null;
notes?: string | null;
durationMinutes: number;
scheduledAt?: Date | null;
participantUserIds: [number, number];
}) {
if (input.participantUserIds[0] === input.participantUserIds[1]) {
throw new Error("两位参赛用户必须不同");
}
const db = await getDb();
if (!db) throw new Error("Database not available");
const matchId = await db.transaction(async (tx) => {
const sessionValues: InsertMatchSession = {
createdByUserId: input.createdByUserId,
matchMode: input.matchMode,
workflowStatus: "draft",
title: input.title,
courtName: input.courtName ?? null,
notes: input.notes ?? null,
durationMinutes: input.durationMinutes,
scheduledAt: input.scheduledAt ?? null,
suggestionStatus: "idle",
suggestionTaskId: null,
suggestedScore: null,
suggestedMetrics: null,
finalScore: null,
finalMetrics: null,
reviewNotes: null,
reviewSubmittedAt: null,
reviewedByUserId: null,
reviewedAt: null,
finalizedByUserId: null,
finalizedAt: null,
};
const inserted = await tx.insert(matchSessions).values(sessionValues);
const createdMatchId = inserted[0].insertId;
const participantValues: InsertMatchParticipant[] = [
{
matchId: createdMatchId,
userId: input.participantUserIds[0],
playerSlot: "player_a",
cameraSlot: "camera_a",
cameraStatus: "pending",
cameraLabel: null,
cameraVideoId: null,
cameraVideoUrl: null,
cameraSnapshot: null,
isWinner: 0,
suggestedSetsWon: 0,
suggestedGamesWon: 0,
suggestedPointsWon: 0,
finalSetsWon: 0,
finalGamesWon: 0,
finalPointsWon: 0,
suggestedStats: null,
finalStats: null,
},
{
matchId: createdMatchId,
userId: input.participantUserIds[1],
playerSlot: "player_b",
cameraSlot: "camera_b",
cameraStatus: "pending",
cameraLabel: null,
cameraVideoId: null,
cameraVideoUrl: null,
cameraSnapshot: null,
isWinner: 0,
suggestedSetsWon: 0,
suggestedGamesWon: 0,
suggestedPointsWon: 0,
finalSetsWon: 0,
finalGamesWon: 0,
finalPointsWon: 0,
suggestedStats: null,
finalStats: null,
},
];
await tx.insert(matchParticipants).values(participantValues);
return createdMatchId;
});
return getMatchDetail(matchId);
}
export async function updateMatchSession(matchId: number, patch: Partial<InsertMatchSession>) {
const db = await getDb();
if (!db) return;
await db.update(matchSessions).set(patch).where(eq(matchSessions.id, matchId));
}
export async function bindMatchCamera(input: {
matchId: number;
playerSlot: MatchPlayerSlot;
cameraStatus: CameraStatus;
cameraLabel?: string | null;
cameraVideoId?: number | null;
cameraVideoUrl?: string | null;
cameraSnapshot?: unknown;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const participant = await getMatchParticipantBySlot(input.matchId, input.playerSlot);
if (!participant) {
throw new Error("Match participant not found");
}
await db.update(matchParticipants).set({
cameraStatus: input.cameraStatus,
cameraLabel: input.cameraLabel === undefined ? participant.cameraLabel : input.cameraLabel,
cameraVideoId: input.cameraVideoId === undefined ? participant.cameraVideoId : input.cameraVideoId,
cameraVideoUrl: input.cameraVideoUrl === undefined ? participant.cameraVideoUrl : input.cameraVideoUrl,
cameraSnapshot: input.cameraSnapshot === undefined ? participant.cameraSnapshot : input.cameraSnapshot,
}).where(eq(matchParticipants.id, participant.id));
const session = await getMatchSessionById(input.matchId);
if (session?.workflowStatus === "draft" && input.cameraStatus !== "pending") {
await updateMatchSession(input.matchId, {
workflowStatus: "recording",
});
}
return getMatchDetail(input.matchId);
}
async function getNextEventIndex(matchId: number) {
const db = await getDb();
if (!db) return 1;
const result = await db.select({
maxEventIndex: sql<number>`coalesce(max(${matchScoreEvents.eventIndex}), 0)`,
}).from(matchScoreEvents).where(eq(matchScoreEvents.matchId, matchId));
return (result[0]?.maxEventIndex ?? 0) + 1;
}
export async function insertMatchScoreEvent(input: {
matchId: number;
source: EventSource;
eventType: EventType;
winnerSlot?: MatchPlayerSlot | null;
matchSecond?: number | null;
confidence?: number | null;
payload?: unknown;
createdByUserId?: number | null;
}) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const eventIndex = await getNextEventIndex(input.matchId);
const values: InsertMatchScoreEvent = {
matchId: input.matchId,
eventIndex,
source: input.source,
eventType: input.eventType,
winnerSlot: input.winnerSlot ?? null,
matchSecond: input.matchSecond ?? null,
confidence: input.confidence ?? null,
payload: input.payload ?? null,
createdByUserId: input.createdByUserId ?? null,
};
const inserted = await db.insert(matchScoreEvents).values(values);
const rows = await db.select().from(matchScoreEvents).where(eq(matchScoreEvents.id, inserted[0].insertId)).limit(1);
const session = await getMatchSessionById(input.matchId);
if (session && session.workflowStatus === "draft") {
await updateMatchSession(input.matchId, {
workflowStatus: "recording",
});
}
return rows[0];
}
export async function saveMatchSuggestion(matchId: number, params: {
suggestionStatus: SuggestionStatus;
suggestionTaskId?: string | null;
suggestedScore?: unknown;
suggestedMetrics?: unknown;
}) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
const score = params.suggestedScore ? normalizeMatchScoreboard(params.suggestedScore) : normalizeMatchScoreboard(session.suggestedScore);
const metrics = params.suggestedMetrics ? normalizeMatchMetrics(params.suggestedMetrics) : normalizeMatchMetrics(session.suggestedMetrics);
const settlement = buildParticipantSettlement(score, metrics);
const participants = await listMatchParticipants(matchId);
await updateMatchSession(matchId, {
suggestionStatus: params.suggestionStatus,
suggestionTaskId: params.suggestionTaskId === undefined ? session.suggestionTaskId : params.suggestionTaskId,
suggestedScore: params.suggestedScore === undefined ? session.suggestedScore : score,
suggestedMetrics: params.suggestedMetrics === undefined ? session.suggestedMetrics : metrics,
workflowStatus: session.workflowStatus === "draft" || session.workflowStatus === "recording"
? (params.suggestionStatus === "ready" ? "review_pending" : session.workflowStatus)
: session.workflowStatus,
});
const rowsBySlot = indexBySlot(participants);
for (const slot of MATCH_PLAYER_SLOTS) {
const row = rowsBySlot[slot];
if (!row) continue;
const player = settlement.players[slot];
const dbConn = await getDb();
if (!dbConn) continue;
await dbConn.update(matchParticipants).set({
suggestedSetsWon: player.setsWon,
suggestedGamesWon: player.gamesWon,
suggestedPointsWon: player.pointsWon,
suggestedStats: player.stats,
}).where(eq(matchParticipants.id, row.id));
}
return getMatchDetail(matchId);
}
export async function submitMatchReview(input: {
matchId: number;
reviewedByUserId: number;
reviewNotes?: string | null;
finalScore?: unknown;
finalMetrics?: unknown;
}) {
const session = await getMatchSessionById(input.matchId);
if (!session) {
throw new Error("Match session not found");
}
const score = normalizeMatchScoreboard(input.finalScore ?? session.suggestedScore);
const metrics = normalizeMatchMetrics(input.finalMetrics ?? session.suggestedMetrics);
const settlement = buildParticipantSettlement(score, metrics);
const participants = await listMatchParticipants(input.matchId);
const db = await getDb();
if (!db) throw new Error("Database not available");
await updateMatchSession(input.matchId, {
finalScore: score,
finalMetrics: metrics,
reviewNotes: input.reviewNotes ?? session.reviewNotes,
reviewSubmittedAt: new Date(),
reviewedByUserId: input.reviewedByUserId,
reviewedAt: new Date(),
workflowStatus: "reviewed",
});
const rowsBySlot = indexBySlot(participants);
for (const slot of MATCH_PLAYER_SLOTS) {
const row = rowsBySlot[slot];
if (!row) continue;
const player = settlement.players[slot];
await db.update(matchParticipants).set({
isWinner: player.isWinner ? 1 : 0,
finalSetsWon: player.setsWon,
finalGamesWon: player.gamesWon,
finalPointsWon: player.pointsWon,
finalStats: player.stats,
}).where(eq(matchParticipants.id, row.id));
}
await insertMatchScoreEvent({
matchId: input.matchId,
source: "admin",
eventType: "review_adjustment",
winnerSlot: settlement.winnerSlot,
confidence: 1,
payload: {
score,
metrics,
reviewNotes: input.reviewNotes ?? null,
},
createdByUserId: input.reviewedByUserId,
});
return getMatchDetail(input.matchId);
}
export async function markMatchSuggestionQueued(matchId: number, taskId: string) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
await updateMatchSession(matchId, {
suggestionStatus: "queued",
suggestionTaskId: taskId,
workflowStatus: session.workflowStatus === "draft" ? "recording" : session.workflowStatus,
});
}
export async function markMatchFinalizing(matchId: number) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
await updateMatchSession(matchId, {
workflowStatus: "finalizing",
});
}
export async function cancelMatchSession(matchId: number, notes?: string | null) {
const session = await getMatchSessionById(matchId);
if (!session) {
throw new Error("Match session not found");
}
await updateMatchSession(matchId, {
workflowStatus: "cancelled",
notes: notes === undefined ? session.notes : notes,
});
return getMatchDetail(matchId);
}
export async function generateSuggestedMatchState(matchId: number) {
const detail = await getMatchDetail(matchId);
if (!detail) {
throw new Error("Match session not found");
}
const suggestion = deriveSuggestedMatchState(detail.events.map((event) => ({
eventType: event.eventType,
winnerSlot: event.winnerSlot,
confidence: event.confidence,
payload: event.payload,
source: event.source,
eventIndex: event.eventIndex,
})));
await saveMatchSuggestion(matchId, {
suggestionStatus: "ready",
suggestionTaskId: detail.suggestionTaskId,
suggestedScore: suggestion.score,
suggestedMetrics: suggestion.metrics,
});
await insertMatchScoreEvent({
matchId,
source: "system",
eventType: "score_suggestion",
winnerSlot: suggestion.score.winnerSlot,
confidence: suggestion.score.confidence,
payload: {
score: suggestion.score,
metrics: suggestion.metrics,
eventCount: suggestion.eventCount,
sourceCount: suggestion.sourceCount,
},
});
return {
score: suggestion.score,
metrics: suggestion.metrics,
eventCount: suggestion.eventCount,
sourceCount: suggestion.sourceCount,
};
}
export async function finalizeMatchSettlement(matchId: number, finalizedByUserId: number) {
const detail = await getMatchDetail(matchId);
if (!detail) {
throw new Error("Match session not found");
}
if (detail.workflowStatus === "finalized") {
return detail;
}
if (detail.workflowStatus === "cancelled") {
throw new Error("已取消的比赛不能结算");
}
const score = normalizeMatchScoreboard(detail.finalScore ?? detail.suggestedScore);
const metrics = normalizeMatchMetrics(detail.finalMetrics ?? detail.suggestedMetrics);
const settlement = buildParticipantSettlement(score, metrics);
const occurredAt = detail.endedAt ?? detail.reviewSubmittedAt ?? detail.startedAt ?? detail.scheduledAt ?? new Date();
const trainingDateKey = getDateKey(occurredAt);
const durationMinutes = Math.max(1, detail.durationMinutes || 90);
const participantsBySlot = indexBySlot(detail.participants);
const db = await getDb();
if (!db) {
throw new Error("Database not available");
}
await updateMatchSession(matchId, {
finalScore: score,
finalMetrics: metrics,
workflowStatus: "finalizing",
reviewedByUserId: detail.reviewedByUserId ?? finalizedByUserId,
reviewedAt: detail.reviewedAt ?? new Date(),
reviewSubmittedAt: detail.reviewSubmittedAt ?? new Date(),
});
for (const slot of MATCH_PLAYER_SLOTS) {
const participant = participantsBySlot[slot];
if (!participant) continue;
const opponent = MATCH_PLAYER_SLOTS
.filter((item) => item !== slot)
.map((item) => participantsBySlot[item])
.find(Boolean) ?? null;
const player = settlement.players[slot];
const matchLabel = detail.matchMode === "competitive" ? "竞赛双人比赛" : "日常双人比赛";
const sourceType = detail.matchMode === "competitive" ? "match_competitive" : "match_daily";
const resultLabel = settlement.winnerSlot == null ? "已确认" : player.isWinner ? "获胜" : "失利";
await db.update(matchParticipants).set({
isWinner: player.isWinner ? 1 : 0,
finalSetsWon: player.setsWon,
finalGamesWon: player.gamesWon,
finalPointsWon: player.pointsWon,
finalStats: player.stats,
cameraStatus: participant.cameraStatus === "active" ? "completed" : participant.cameraStatus,
}).where(eq(matchParticipants.id, participant.id));
const upsertResult = await upsertTrainingRecordBySource({
userId: participant.userId,
planId: null,
linkedPlanId: null,
matchConfidence: detail.suggestionStatus === "ready" ? score.confidence : null,
exerciseName: matchLabel,
exerciseType: "match",
sourceType,
sourceId: `${sourceType}:${matchId}:${slot}`,
videoId: participant.cameraVideoId ?? null,
actionCount: player.pointsWon,
durationMinutes,
completed: 1,
notes: `${detail.title} · ${resultLabel}`,
poseScore: null,
trainingDate: occurredAt,
metadata: {
matchId,
matchMode: detail.matchMode,
playerSlot: slot,
opponentUserId: opponent?.userId ?? null,
winnerSlot: settlement.winnerSlot,
finalScore: score,
finalMetrics: metrics,
courtName: detail.courtName ?? null,
cameraStatus: participant.cameraStatus,
},
});
if (detail.matchMode === "daily" && upsertResult.isNew) {
await upsertDailyTrainingAggregate({
userId: participant.userId,
trainingDate: trainingDateKey,
deltaMinutes: durationMinutes,
deltaSessions: 1,
deltaTotalActions: player.pointsWon,
deltaEffectiveActions: player.pointsWon,
metadata: {
latestDailyMatchId: matchId,
latestDailyMatchResult: resultLabel,
},
});
}
if (detail.matchMode === "competitive") {
await refreshUserTrainingSummary(participant.userId);
}
await createNotification({
userId: participant.userId,
notificationType: "match_settlement",
title: `比赛已入库 · ${detail.title}`.slice(0, 256),
message: `${matchLabel} 已完成审核入库,结果:${resultLabel},比分 ${player.setsWon}:${settlement.players[slot === "player_a" ? "player_b" : "player_a"].setsWon} 盘 / ${player.gamesWon}:${settlement.players[slot === "player_a" ? "player_b" : "player_a"].gamesWon} 局。`,
isRead: 0,
});
await refreshAchievementsForUser(participant.userId);
}
await insertMatchScoreEvent({
matchId,
source: "admin",
eventType: "finalized",
winnerSlot: settlement.winnerSlot,
confidence: 1,
payload: {
score,
metrics,
finalizedByUserId,
finalizedAt: new Date().toISOString(),
},
createdByUserId: finalizedByUserId,
});
await updateMatchSession(matchId, {
workflowStatus: "finalized",
finalizedByUserId,
finalizedAt: new Date(),
});
return getMatchDetail(matchId);
}
export async function getAccessibleMatchSummary(params: {
viewerUserId: number;
isAdmin: boolean;
}) {
const rows = await listAccessibleMatchSessions({
viewerUserId: params.viewerUserId,
isAdmin: params.isAdmin,
limit: 200,
});
let competitiveWins = 0;
let camerasBound = 0;
for (const match of rows) {
for (const participant of match.participants) {
if (participant.cameraStatus !== "pending") {
camerasBound += 1;
}
if (!params.isAdmin && participant.userId !== params.viewerUserId) {
continue;
}
if (match.matchMode === "competitive" && match.workflowStatus === "finalized" && participant.isWinner === 1) {
competitiveWins += 1;
}
}
}
return {
total: rows.length,
draft: rows.filter((row) => row.workflowStatus === "draft").length,
reviewPending: rows.filter((row) => row.workflowStatus === "review_pending" || row.workflowStatus === "reviewed").length,
finalized: rows.filter((row) => row.workflowStatus === "finalized").length,
daily: rows.filter((row) => row.matchMode === "daily").length,
competitive: rows.filter((row) => row.matchMode === "competitive").length,
competitiveWins,
camerasBound,
};
}
export async function getCompetitiveLeaderboard(sortBy: "wins" | "winRate" | "setsWon" | "pointsWon" | "matches" = "wins", limit = 50) {
const db = await getDb();
if (!db) return [];
const rows = await db.select({
matchId: matchParticipants.matchId,
userId: matchParticipants.userId,
userName: users.name,
skillLevel: users.skillLevel,
ntrpRating: users.ntrpRating,
isWinner: matchParticipants.isWinner,
finalSetsWon: matchParticipants.finalSetsWon,
finalGamesWon: matchParticipants.finalGamesWon,
finalPointsWon: matchParticipants.finalPointsWon,
}).from(matchParticipants)
.innerJoin(matchSessions, eq(matchSessions.id, matchParticipants.matchId))
.innerJoin(users, eq(users.id, matchParticipants.userId))
.where(and(
eq(matchSessions.matchMode, "competitive"),
eq(matchSessions.workflowStatus, "finalized"),
))
.orderBy(desc(matchParticipants.matchId), asc(matchParticipants.id));
const matchRows = new Map<number, typeof rows>();
for (const row of rows) {
const list = matchRows.get(row.matchId) ?? [];
list.push(row);
matchRows.set(row.matchId, list);
}
const leaderboard = new Map<number, {
id: number;
name: string | null;
ntrpRating: number | null;
skillLevel: string | null;
matches: number;
wins: number;
losses: number;
winRate: number;
setsWon: number;
setsLost: number;
gamesWon: number;
gamesLost: number;
pointsWon: number;
pointsLost: number;
}>();
for (const rowsOfMatch of Array.from(matchRows.values())) {
const byUser = rowsOfMatch;
for (const row of byUser) {
const opponent = byUser.find((item: (typeof rows)[number]) => item.userId !== row.userId) ?? null;
const current = leaderboard.get(row.userId) ?? {
id: row.userId,
name: row.userName ?? null,
ntrpRating: row.ntrpRating ?? 1.5,
skillLevel: row.skillLevel ?? "beginner",
matches: 0,
wins: 0,
losses: 0,
winRate: 0,
setsWon: 0,
setsLost: 0,
gamesWon: 0,
gamesLost: 0,
pointsWon: 0,
pointsLost: 0,
};
current.matches += 1;
current.wins += row.isWinner === 1 ? 1 : 0;
current.losses += row.isWinner === 1 ? 0 : 1;
current.setsWon += row.finalSetsWon || 0;
current.setsLost += opponent?.finalSetsWon || 0;
current.gamesWon += row.finalGamesWon || 0;
current.gamesLost += opponent?.finalGamesWon || 0;
current.pointsWon += row.finalPointsWon || 0;
current.pointsLost += opponent?.finalPointsWon || 0;
current.winRate = current.matches > 0 ? Math.round((current.wins / current.matches) * 1000) / 10 : 0;
leaderboard.set(row.userId, current);
}
}
const sorted = Array.from(leaderboard.values()).sort((a, b) => {
const primary = {
wins: [b.wins - a.wins, b.winRate - a.winRate, b.setsWon - a.setsWon, b.pointsWon - a.pointsWon],
winRate: [b.winRate - a.winRate, b.wins - a.wins, b.setsWon - a.setsWon, b.pointsWon - a.pointsWon],
setsWon: [b.setsWon - a.setsWon, b.wins - a.wins, b.winRate - a.winRate, b.pointsWon - a.pointsWon],
pointsWon: [b.pointsWon - a.pointsWon, b.wins - a.wins, b.winRate - a.winRate, b.setsWon - a.setsWon],
matches: [b.matches - a.matches, b.wins - a.wins, b.winRate - a.winRate, b.pointsWon - a.pointsWon],
}[sortBy];
for (const value of primary) {
if (value !== 0) return value;
}
return a.id - b.id;
});
return sorted.slice(0, limit);
}

查看文件

@@ -17,6 +17,20 @@ type RecentAnalysis = {
fluidityScore: number | null;
};
type TrainingAssessmentSnapshot = {
heightCm: number | null;
weightKg: number | null;
sprintSpeedScore: number | null;
explosivePowerScore: number | null;
agilityScore: number | null;
enduranceScore: number | null;
flexibilityScore: number | null;
coreStabilityScore: number | null;
shoulderMobilityScore: number | null;
hipMobilityScore: number | null;
assessmentNotes: string | null;
};
function skillLevelLabel(skillLevel: "beginner" | "intermediate" | "advanced") {
switch (skillLevel) {
case "intermediate":
@@ -33,17 +47,43 @@ export function buildTrainingPlanPrompt(input: {
durationDays: number;
focusAreas?: string[];
recentScores: RecentScore[];
effectiveNtrpRating: number;
ntrpSource: "system" | "manual" | "default";
assessmentSnapshot: TrainingAssessmentSnapshot;
}) {
const assessmentLines = [
`- 身高:${input.assessmentSnapshot.heightCm ?? "未知"} cm`,
`- 体重:${input.assessmentSnapshot.weightKg ?? "未知"} kg`,
`- 速度:${input.assessmentSnapshot.sprintSpeedScore ?? "未知"}/5`,
`- 爆发力:${input.assessmentSnapshot.explosivePowerScore ?? "未知"}/5`,
`- 敏捷性:${input.assessmentSnapshot.agilityScore ?? "未知"}/5`,
`- 耐力:${input.assessmentSnapshot.enduranceScore ?? "未知"}/5`,
`- 柔韧性:${input.assessmentSnapshot.flexibilityScore ?? "未知"}/5`,
`- 核心稳定性:${input.assessmentSnapshot.coreStabilityScore ?? "未知"}/5`,
`- 肩部灵活性:${input.assessmentSnapshot.shoulderMobilityScore ?? "未知"}/5`,
`- 髋部灵活性:${input.assessmentSnapshot.hipMobilityScore ?? "未知"}/5`,
input.assessmentSnapshot.assessmentNotes?.trim()
? `- 额外备注:${input.assessmentSnapshot.assessmentNotes.trim()}`
: null,
].filter(Boolean);
return [
`你是一位专业网球教练。请为一位${skillLevelLabel(input.skillLevel)}水平的网球学员生成 ${input.durationDays} 天训练计划。`,
"训练条件与要求:",
"- 训练以个人可执行为主,可使用球拍、弹力带、标志盘、墙面等常见器材。",
"- 每天训练 30-60 分钟,结构要清晰:热身、专项、脚步、力量/稳定、放松。",
"- 输出内容要适合直接执行,不写空话,不写营销语,不写额外说明。",
"- 必须返回合法 JSON,title 不能为空,exercises 数组不能为空。",
`- exercises 总数至少 ${Math.max(input.durationDays * 3, 6)} 项,每一天至少 3 个训练项。`,
"- 每个训练项都必须包含 day、name、category、duration、description、tips、sets、reps。",
input.focusAreas?.length ? `- 重点关注:${input.focusAreas.join("、")}` : "- 如未指定重点,请自动平衡技术、脚步和体能。",
`- 当前 NTRP 参考值:${input.effectiveNtrpRating.toFixed(1)}(来源:${input.ntrpSource === "system" ? "系统判定" : input.ntrpSource === "manual" ? "人工基线" : "默认基线"}`,
"用户当前训练档案:",
...assessmentLines,
input.recentScores.length > 0
? `- 用户最近分析摘要:${JSON.stringify(input.recentScores)}`
: "- 暂无历史分析数据,请基于该水平的常见薄弱项设计。",
"请根据身体指标调整训练强度和训练比重:速度/敏捷不足时增加脚步与启动训练,核心或灵活性不足时增加稳定性与活动度训练。",
"每个训练项都要给出目标、动作描述、组次/次数、关键提示,避免重复堆砌。",
].join("\n");
}

查看文件

@@ -9,13 +9,27 @@ import { ENV } from "./_core/env";
import { storagePut } from "./storage";
import * as db from "./db";
import { nanoid } from "nanoid";
import { deriveWatchRuleTitle, MARKET_SOURCES, maskWebhookUrl } from "./market";
import * as matchStore from "./matchStore";
import { prepareCorrectionImageUrls } from "./taskWorker";
import { toPublicUrl } from "./publicUrl";
import { ACTION_LABELS, refreshUserNtrp, syncAnalysisTrainingData, syncLiveTrainingData } from "./trainingAutomation";
async function enqueueTask(params: {
userId: number;
type: "media_finalize" | "training_plan_generate" | "training_plan_adjust" | "analysis_corrections" | "pose_correction_multimodal" | "ntrp_refresh_user" | "ntrp_refresh_all";
type:
| "media_finalize"
| "training_plan_generate"
| "training_plan_adjust"
| "analysis_corrections"
| "pose_correction_multimodal"
| "ntrp_refresh_user"
| "ntrp_refresh_all"
| "market_source_sync"
| "market_watch_refresh"
| "market_push_delivery"
| "match_score_suggest"
| "match_finalize";
title: string;
payload: Record<string, unknown>;
message: string;
@@ -97,6 +111,73 @@ const liveRuntimeSnapshotSchema = z.object({
})).optional(),
}).passthrough();
const marketSourceSchema = z.enum(MARKET_SOURCES);
const marketCategorySchema = z.enum(["adult", "junior", "competitive", "recreational", "unknown"]);
const marketWatchRuleInputSchema = z.object({
title: z.string().trim().max(256).optional(),
brand: z.string().trim().min(1).max(64),
modelKeyword: z.string().trim().max(128).optional(),
seriesKeyword: z.string().trim().max(128).optional(),
category: marketCategorySchema.optional(),
weightMinGram: z.number().min(200).max(340).optional(),
weightMaxGram: z.number().min(200).max(340).optional(),
targetPrice: z.number().min(1).max(100000),
pushEnabled: z.boolean().default(true),
});
const matchModeSchema = z.enum(["daily", "competitive"]);
const matchWorkflowStatusSchema = z.enum(["draft", "recording", "review_pending", "reviewed", "finalizing", "finalized", "cancelled"]);
const matchPlayerSlotSchema = z.enum(["player_a", "player_b"]);
const matchCameraStatusSchema = z.enum(["pending", "bound", "active", "completed", "failed"]);
const matchEventSourceSchema = z.enum(["camera_a", "camera_b", "system", "admin"]);
const matchEventTypeSchema = z.enum(["point", "game", "set", "metric", "score_suggestion", "review_adjustment", "finalized"]);
const leaderboardScopeSchema = z.enum(["training", "competitive"]);
const leaderboardSortSchema = z.enum(["ntrpRating", "totalMinutes", "totalSessions", "totalShots", "wins", "winRate", "setsWon", "pointsWon", "matches"]);
const matchScorePayloadSchema = z.object({
sets: z.object({
player_a: z.number().min(0),
player_b: z.number().min(0),
}),
games: z.object({
player_a: z.number().min(0),
player_b: z.number().min(0),
}),
points: z.object({
player_a: z.number().min(0),
player_b: z.number().min(0),
}),
winnerSlot: matchPlayerSlotSchema.nullable().optional(),
confidence: z.number().min(0).max(1).optional(),
});
const matchPlayerMetricsPayloadSchema = z.object({
pointsWon: z.number().min(0).optional(),
aces: z.number().min(0).optional(),
doubleFaults: z.number().min(0).optional(),
winners: z.number().min(0).optional(),
unforcedErrors: z.number().min(0).optional(),
breakPointsWon: z.number().min(0).optional(),
breakPointsTotal: z.number().min(0).optional(),
firstServeIn: z.number().min(0).optional(),
firstServeAttempts: z.number().min(0).optional(),
firstServePct: z.number().min(0).optional(),
maxServeKph: z.number().min(0).optional(),
longestRally: z.number().min(0).optional(),
});
const matchMetricsPayloadSchema = z.object({
players: z.object({
player_a: matchPlayerMetricsPayloadSchema.optional(),
player_b: matchPlayerMetricsPayloadSchema.optional(),
}).optional(),
player_a: matchPlayerMetricsPayloadSchema.optional(),
player_b: matchPlayerMetricsPayloadSchema.optional(),
totalRallies: z.number().min(0).optional(),
longestRally: z.number().min(0).optional(),
sourceCount: z.number().min(0).optional(),
});
function getRuntimeOwnerSid(ctx: { sessionSid: string | null; user: { openId: string } }) {
return ctx.sessionSid || `legacy:${ctx.user.openId}`;
}
@@ -134,6 +215,53 @@ async function resolveLiveRuntimeRole(params: {
};
}
async function getAccessibleMatchDetailOrThrow(ctx: { user: { id: number; role: string } }, matchId: number) {
const detail = await matchStore.getMatchDetail(matchId);
if (!detail) {
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
}
if (ctx.user.role === "admin") {
return detail;
}
const isParticipant = detail.participants.some((participant) => participant.userId === ctx.user.id);
if (!isParticipant) {
throw new TRPCError({ code: "FORBIDDEN", message: "当前账号不能访问这场比赛" });
}
return detail;
}
async function enqueueMatchSuggestionIfNeeded(params: {
actorUserId: number;
matchId: number;
title: string;
}) {
const session = await matchStore.getMatchSessionById(params.matchId);
if (!session) {
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
}
if (session.suggestionStatus === "queued" && session.suggestionTaskId) {
const existingTask = await db.getBackgroundTaskById(session.suggestionTaskId);
if (existingTask && (existingTask.status === "queued" || existingTask.status === "running")) {
return { taskId: existingTask.id, task: existingTask, deduped: true };
}
}
const task = await enqueueTask({
userId: params.actorUserId,
type: "match_score_suggest",
title: `${params.title} 自动计分建议`,
message: "比赛自动计分建议已加入后台队列",
payload: { matchId: params.matchId },
});
await matchStore.markMatchSuggestionQueued(params.matchId, task.taskId);
return { ...task, deduped: false };
}
export const appRouter = router({
system: systemRouter,
@@ -860,6 +988,278 @@ export const appRouter = router({
}),
}),
match: router({
stats: protectedProcedure.query(async ({ ctx }) => {
return matchStore.getAccessibleMatchSummary({
viewerUserId: ctx.user.id,
isAdmin: ctx.user.role === "admin",
});
}),
list: protectedProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(50).optional(),
workflowStatus: matchWorkflowStatusSchema.or(z.literal("all")).optional(),
matchMode: matchModeSchema.or(z.literal("all")).optional(),
}).optional())
.query(async ({ ctx, input }) => {
return matchStore.listAccessibleMatchSessions({
viewerUserId: ctx.user.id,
isAdmin: ctx.user.role === "admin",
limit: input?.limit ?? 50,
workflowStatus: input?.workflowStatus ?? "all",
matchMode: input?.matchMode ?? "all",
});
}),
get: protectedProcedure
.input(z.object({ matchId: z.number() }))
.query(async ({ ctx, input }) => {
return getAccessibleMatchDetailOrThrow(ctx, input.matchId);
}),
create: protectedProcedure
.input(z.object({
title: z.string().trim().min(1).max(256),
matchMode: matchModeSchema.default("daily"),
courtName: z.string().trim().max(128).optional(),
notes: z.string().trim().max(2000).optional(),
durationMinutes: z.number().min(10).max(600).default(90),
scheduledAt: z.number().optional(),
playerAUserId: z.number(),
playerBUserId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
if (input.playerAUserId === input.playerBUserId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "两位参赛用户不能相同" });
}
const playerA = await db.getUserById(input.playerAUserId);
const playerB = await db.getUserById(input.playerBUserId);
if (!playerA || !playerB) {
throw new TRPCError({ code: "NOT_FOUND", message: "参赛用户不存在" });
}
if (ctx.user.role !== "admin" && ctx.user.id !== input.playerAUserId && ctx.user.id !== input.playerBUserId) {
throw new TRPCError({ code: "FORBIDDEN", message: "只能创建包含自己的比赛" });
}
const detail = await matchStore.createMatchSession({
createdByUserId: ctx.user.id,
matchMode: input.matchMode,
title: input.title,
courtName: input.courtName?.trim() || null,
notes: input.notes?.trim() || null,
durationMinutes: input.durationMinutes,
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
participantUserIds: [input.playerAUserId, input.playerBUserId],
});
if (ctx.user.role === "admin") {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "match_create",
entityType: "match_session",
entityId: String(detail?.id ?? ""),
payload: {
matchMode: input.matchMode,
playerAUserId: input.playerAUserId,
playerBUserId: input.playerBUserId,
},
});
}
return detail;
}),
bindCamera: protectedProcedure
.input(z.object({
matchId: z.number(),
playerSlot: matchPlayerSlotSchema,
cameraStatus: matchCameraStatusSchema.default("bound"),
cameraLabel: z.string().trim().max(128).optional(),
cameraVideoId: z.number().optional(),
cameraVideoUrl: z.string().trim().min(1).optional(),
cameraSnapshot: z.any().optional(),
}))
.mutation(async ({ ctx, input }) => {
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
const participant = detail.participants.find((item) => item.playerSlot === input.playerSlot);
if (!participant) {
throw new TRPCError({ code: "NOT_FOUND", message: "参赛席位不存在" });
}
if (ctx.user.role !== "admin" && participant.userId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "只能绑定自己的机位" });
}
return matchStore.bindMatchCamera({
matchId: input.matchId,
playerSlot: input.playerSlot,
cameraStatus: input.cameraStatus,
cameraLabel: input.cameraLabel?.trim() || null,
cameraVideoId: input.cameraVideoId ?? null,
cameraVideoUrl: input.cameraVideoUrl?.trim() || null,
cameraSnapshot: input.cameraSnapshot,
});
}),
appendEvent: protectedProcedure
.input(z.object({
matchId: z.number(),
source: matchEventSourceSchema,
eventType: matchEventTypeSchema,
winnerSlot: matchPlayerSlotSchema.nullable().optional(),
matchSecond: z.number().min(0).optional(),
confidence: z.number().min(0).max(1).optional(),
payload: z.any().optional(),
}))
.mutation(async ({ ctx, input }) => {
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
if (detail.workflowStatus === "finalized" || detail.workflowStatus === "cancelled") {
throw new TRPCError({ code: "BAD_REQUEST", message: "当前比赛状态不能继续追加事件" });
}
if (ctx.user.role !== "admin" && input.source === "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "只有管理员可以写入后台人工事件" });
}
const event = await matchStore.insertMatchScoreEvent({
matchId: input.matchId,
source: input.source,
eventType: input.eventType,
winnerSlot: input.winnerSlot ?? null,
matchSecond: input.matchSecond ?? null,
confidence: input.confidence ?? null,
payload: input.payload ?? null,
createdByUserId: ctx.user.id,
});
const suggestionTask = await enqueueMatchSuggestionIfNeeded({
actorUserId: ctx.user.id,
matchId: input.matchId,
title: detail.title,
});
return { event, suggestionTask };
}),
requestSuggestion: protectedProcedure
.input(z.object({ matchId: z.number() }))
.mutation(async ({ ctx, input }) => {
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
return enqueueMatchSuggestionIfNeeded({
actorUserId: ctx.user.id,
matchId: input.matchId,
title: detail.title,
});
}),
reviewSubmit: adminProcedure
.input(z.object({
matchId: z.number(),
reviewNotes: z.string().trim().max(4000).optional(),
finalScore: matchScorePayloadSchema.optional(),
finalMetrics: matchMetricsPayloadSchema.optional(),
}))
.mutation(async ({ ctx, input }) => {
const detail = await matchStore.getMatchDetail(input.matchId);
if (!detail) {
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
}
if (detail.workflowStatus === "cancelled" || detail.workflowStatus === "finalized") {
throw new TRPCError({ code: "BAD_REQUEST", message: "当前比赛状态不能再提交审核" });
}
const reviewed = await matchStore.submitMatchReview({
matchId: input.matchId,
reviewedByUserId: ctx.user.id,
reviewNotes: input.reviewNotes?.trim() || null,
finalScore: input.finalScore,
finalMetrics: input.finalMetrics,
});
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "match_review_submit",
entityType: "match_session",
entityId: String(input.matchId),
payload: {
reviewNotes: input.reviewNotes?.trim() || null,
finalScore: input.finalScore ?? null,
},
});
return reviewed;
}),
finalize: adminProcedure
.input(z.object({ matchId: z.number() }))
.mutation(async ({ ctx, input }) => {
const detail = await matchStore.getMatchDetail(input.matchId);
if (!detail) {
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
}
if (detail.workflowStatus === "cancelled") {
throw new TRPCError({ code: "BAD_REQUEST", message: "已取消的比赛不能正式结算" });
}
if (detail.workflowStatus === "finalized" || detail.workflowStatus === "finalizing") {
throw new TRPCError({ code: "BAD_REQUEST", message: "当前比赛已经在结算流程中" });
}
if (!detail.finalScore && !detail.suggestedScore) {
throw new TRPCError({ code: "BAD_REQUEST", message: "请先生成建议计分或提交审核比分" });
}
const task = await enqueueTask({
userId: ctx.user.id,
type: "match_finalize",
title: `${detail.title} 正式结算`,
message: "比赛正式结算任务已加入后台队列",
payload: {
matchId: input.matchId,
finalizedByUserId: ctx.user.id,
},
});
await matchStore.markMatchFinalizing(input.matchId);
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "match_finalize_enqueue",
entityType: "match_session",
entityId: String(input.matchId),
payload: { taskId: task.taskId },
});
return task;
}),
cancel: protectedProcedure
.input(z.object({
matchId: z.number(),
notes: z.string().trim().max(2000).optional(),
}))
.mutation(async ({ ctx, input }) => {
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
if (ctx.user.role !== "admin" && detail.createdByUserId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "只有创建者或管理员可以取消比赛" });
}
if (detail.workflowStatus === "finalized") {
throw new TRPCError({ code: "BAD_REQUEST", message: "已正式结算的比赛不能取消" });
}
const cancelled = await matchStore.cancelMatchSession(input.matchId, input.notes?.trim() || null);
if (ctx.user.role === "admin") {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "match_cancel",
entityType: "match_session",
entityId: String(input.matchId),
payload: { notes: input.notes?.trim() || null },
});
}
return cancelled;
}),
}),
// Training records
record: router({
create: protectedProcedure
@@ -1071,11 +1471,26 @@ export const appRouter = router({
leaderboard: router({
get: protectedProcedure
.input(z.object({
sortBy: z.enum(["ntrpRating", "totalMinutes", "totalSessions", "totalShots"]).default("ntrpRating"),
scope: leaderboardScopeSchema.default("training"),
sortBy: leaderboardSortSchema.default("ntrpRating"),
limit: z.number().default(50),
}).optional())
.query(async ({ input }) => {
return db.getLeaderboard(input?.sortBy || "ntrpRating", input?.limit || 50);
const scope = input?.scope || "training";
const sortBy = input?.sortBy || "ntrpRating";
const limit = input?.limit || 50;
if (scope === "competitive") {
const competitiveSort = ["wins", "winRate", "setsWon", "pointsWon", "matches"].includes(sortBy)
? sortBy as "wins" | "winRate" | "setsWon" | "pointsWon" | "matches"
: "wins";
return matchStore.getCompetitiveLeaderboard(competitiveSort, limit);
}
const trainingSort = ["ntrpRating", "totalMinutes", "totalSessions", "totalShots"].includes(sortBy)
? sortBy as "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots"
: "ntrpRating";
return db.getLeaderboard(trainingSort, limit);
}),
}),
@@ -1171,6 +1586,216 @@ export const appRouter = router({
}),
}),
market: router({
dashboard: protectedProcedure.query(async ({ ctx }) => {
const [recentListings, recentHits, activeRuleCount, recentListingCount, taskRows, webhookUrl] = await Promise.all([
db.listRacketListings({ limit: 120 }),
db.listUserRacketWatchHits(ctx.user.id, 8),
db.countUserActiveRacketWatchRules(ctx.user.id),
db.countRecentRacketListings(24),
db.listUserBackgroundTasks(ctx.user.id, 40),
db.getAppSettingValue("market_default_feishu_webhook", ""),
]);
const sourceSummary = MARKET_SOURCES.map((source) => {
const rows = recentListings.filter((item) => item.source === source);
return {
source,
total: rows.length,
lowPriceCount: rows.filter((item) => item.isLowPriceCandidate === 1).length,
latestFetchedAt: rows[0]?.fetchedAt ?? null,
};
});
return {
overview: {
activeRuleCount,
recentListingCount,
hitCount: recentHits.length,
hasWebhookConfigured: Boolean(webhookUrl),
},
spotlight: recentListings.filter((item) => item.isLowPriceCandidate === 1).slice(0, 8),
recentHits,
sourceSummary,
recentTasks: taskRows.filter((task) =>
task.type === "market_watch_refresh" ||
task.type === "market_source_sync" ||
task.type === "market_push_delivery"
).slice(0, 10),
};
}),
listings: protectedProcedure
.input(z.object({
source: marketSourceSchema.optional(),
brand: z.string().trim().max(64).optional(),
category: marketCategorySchema.optional(),
keyword: z.string().trim().max(128).optional(),
lowPriceOnly: z.boolean().optional(),
limit: z.number().min(1).max(100).default(50),
}).optional())
.query(async ({ input }) => {
return db.listRacketListings({
source: input?.source,
brand: input?.brand?.trim() || undefined,
category: input?.category,
keyword: input?.keyword?.trim() || undefined,
lowPriceOnly: input?.lowPriceOnly,
limit: input?.limit ?? 50,
});
}),
watchRuleList: protectedProcedure.query(async ({ ctx }) => {
return db.listUserRacketWatchRules(ctx.user.id);
}),
watchRuleCreate: protectedProcedure
.input(marketWatchRuleInputSchema)
.mutation(async ({ ctx, input }) => {
const title = deriveWatchRuleTitle(input);
const ruleId = await db.createRacketWatchRule({
userId: ctx.user.id,
title,
brand: input.brand,
modelKeyword: input.modelKeyword?.trim() || null,
seriesKeyword: input.seriesKeyword?.trim() || null,
category: input.category ?? null,
weightMinGram: input.weightMinGram ?? null,
weightMaxGram: input.weightMaxGram ?? null,
targetPrice: input.targetPrice,
pushEnabled: input.pushEnabled ? 1 : 0,
isActive: 1,
});
const queued = await enqueueTask({
userId: ctx.user.id,
type: "market_watch_refresh",
title: `${title} 刷新`,
message: "监控规则已创建,后台开始抓取对应平台价格",
payload: { scope: "user", ruleIds: [ruleId], trigger: "rule_create" },
});
return { ruleId, taskId: queued.taskId };
}),
watchRuleUpdate: protectedProcedure
.input(marketWatchRuleInputSchema.extend({
ruleId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
}
const title = deriveWatchRuleTitle(input);
await db.updateRacketWatchRule(ctx.user.id, input.ruleId, {
title,
brand: input.brand,
modelKeyword: input.modelKeyword?.trim() || null,
seriesKeyword: input.seriesKeyword?.trim() || null,
category: input.category ?? null,
weightMinGram: input.weightMinGram ?? null,
weightMaxGram: input.weightMaxGram ?? null,
targetPrice: input.targetPrice,
pushEnabled: input.pushEnabled ? 1 : 0,
});
const queued = await enqueueTask({
userId: ctx.user.id,
type: "market_watch_refresh",
title: `${title} 刷新`,
message: "监控规则已更新,后台开始重新抓取对应平台价格",
payload: { scope: "user", ruleIds: [input.ruleId], trigger: "rule_update" },
});
return { success: true, taskId: queued.taskId };
}),
watchRuleDelete: protectedProcedure
.input(z.object({ ruleId: z.number() }))
.mutation(async ({ ctx, input }) => {
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
}
await db.deleteRacketWatchRule(ctx.user.id, input.ruleId);
return { success: true };
}),
watchRuleToggle: protectedProcedure
.input(z.object({ ruleId: z.number(), isActive: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
}
await db.toggleRacketWatchRule(ctx.user.id, input.ruleId, input.isActive ? 1 : 0);
return { success: true };
}),
watchHits: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(50) }).optional())
.query(async ({ ctx, input }) => {
return db.listUserRacketWatchHits(ctx.user.id, input?.limit ?? 50);
}),
triggerRefresh: protectedProcedure
.input(z.object({
ruleId: z.number().optional(),
source: marketSourceSchema.optional(),
}).optional())
.mutation(async ({ ctx, input }) => {
if (input?.ruleId) {
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
}
}
return enqueueTask({
userId: ctx.user.id,
type: "market_watch_refresh",
title: input?.ruleId ? "单条监控规则刷新" : "球拍行情手动刷新",
message: "球拍行情刷新任务已加入后台队列",
payload: {
scope: "user",
ruleIds: input?.ruleId ? [input.ruleId] : undefined,
sources: input?.source ? [input.source] : undefined,
trigger: "manual",
},
});
}),
pushConfigGet: protectedProcedure.query(async ({ ctx }) => {
const webhookUrl = await db.getAppSettingValue("market_default_feishu_webhook", "");
return {
hasWebhookConfigured: Boolean(webhookUrl),
maskedWebhookUrl: maskWebhookUrl(webhookUrl),
canEdit: ctx.user.role === "admin",
};
}),
pushConfigUpdate: adminProcedure
.input(z.object({
webhookUrl: z.string().trim().url().max(2048),
}))
.mutation(async ({ ctx, input }) => {
await db.updateAppSetting("market_default_feishu_webhook", {
value: input.webhookUrl,
type: "string",
});
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "update_market_feishu_webhook",
entityType: "app_setting",
entityId: "market_default_feishu_webhook",
payload: { webhookUrl: input.webhookUrl },
});
return { success: true };
}),
}),
// Notifications
notification: router({
list: protectedProcedure

查看文件

@@ -2,7 +2,18 @@ import { nanoid } from "nanoid";
import { ENV } from "./_core/env";
import { invokeLLM, type Message } from "./_core/llm";
import * as db from "./db";
import * as matchStore from "./matchStore";
import { getRemoteMediaSession } from "./mediaService";
import {
applyComparablePriceBenchmark,
buildMarketSearchQuery,
enrichRacketListing,
formatMarketPushText,
listingMatchesWatchRule,
loadMarketConfig,
MARKET_SOURCES,
searchMarketSource,
} from "./market";
import {
buildAdjustedTrainingPlanPrompt,
buildMultimodalCorrectionPrompt,
@@ -562,6 +573,346 @@ async function runNtrpRefreshAllTask(task: NonNullable<TaskRow>) {
};
}
async function persistMarketListing(rawListing: Parameters<typeof enrichRacketListing>[0]) {
const enriched = enrichRacketListing(rawListing);
const saved = await db.upsertRacketListing(enriched);
if (!saved) {
throw new Error(`Failed to persist market listing: ${rawListing.source}:${rawListing.sourceListingId}`);
}
const comparable = await db.listRecentComparableRacketListings({
brand: saved.brand,
model: saved.model,
excludeId: saved.id,
limit: 8,
});
const benchmarked = applyComparablePriceBenchmark({
...enriched,
brand: saved.brand,
model: saved.model,
series: saved.series,
category: saved.category,
weightGram: saved.weightGram,
conditionLevel: saved.conditionLevel,
gradeLevel: saved.gradeLevel,
gradeReason: saved.gradeReason,
isLowPriceCandidate: saved.isLowPriceCandidate,
}, comparable.map((item) => item.price));
if (
benchmarked.isLowPriceCandidate !== saved.isLowPriceCandidate ||
benchmarked.gradeReason !== saved.gradeReason
) {
await db.updateRacketListing(saved.id, {
isLowPriceCandidate: benchmarked.isLowPriceCandidate,
gradeReason: benchmarked.gradeReason,
});
return (await db.getRacketListingById(saved.id)) ?? saved;
}
return saved;
}
async function queueMarketPushTask(userId: number, hitId: number, title: string) {
const taskId = nanoid();
await db.createBackgroundTask({
id: taskId,
userId,
type: "market_push_delivery",
title: `低价推送 · ${title}`.slice(0, 256),
message: "低价命中已加入飞书推送队列",
payload: { hitId },
progress: 0,
maxAttempts: 3,
});
return taskId;
}
async function recordMarketWatchHit(params: {
rule: Awaited<ReturnType<typeof db.listActiveRacketWatchRules>>[number];
listing: Awaited<ReturnType<typeof db.getRacketListingById>>;
repushDelta: number;
}) {
if (!params.listing) {
throw new Error("Listing is required to create a watch hit");
}
const now = new Date();
const existing = await db.getRacketWatchHitByRuleAndListing(params.rule.id, params.listing.id);
const pushEnabled = params.rule.pushEnabled === 1;
if (!existing) {
const created = await db.createRacketWatchHit({
watchRuleId: params.rule.id,
userId: params.rule.userId,
listingId: params.listing.id,
matchedPrice: params.listing.price,
status: pushEnabled ? "push_queued" : "suppressed",
firstMatchedAt: now,
lastMatchedAt: now,
lastPushPrice: null,
pushedAt: null,
pushCount: 0,
});
return {
hit: created,
shouldQueuePush: pushEnabled,
};
}
const lastPushPrice = existing.lastPushPrice ?? null;
const shouldQueuePush =
pushEnabled &&
existing.status !== "push_queued" &&
(
existing.pushCount === 0 ||
lastPushPrice == null ||
params.listing.price <= (lastPushPrice - params.repushDelta)
);
await db.updateRacketWatchHit(existing.id, {
matchedPrice: params.listing.price,
lastMatchedAt: now,
status: shouldQueuePush ? "push_queued" : (pushEnabled ? existing.status : "suppressed"),
});
const hit = await db.getRacketWatchHitByRuleAndListing(params.rule.id, params.listing.id);
return {
hit,
shouldQueuePush,
};
}
async function runMarketWatchRefreshTask(task: NonNullable<TaskRow>) {
const payload = task.payload as {
scope?: "user" | "all_users";
ruleIds?: number[];
sources?: Array<(typeof MARKET_SOURCES)[number]>;
trigger?: string;
};
const config = await loadMarketConfig();
const allowedSources = (payload.sources?.length
? payload.sources.filter((item): item is (typeof MARKET_SOURCES)[number] => MARKET_SOURCES.includes(item))
: [...MARKET_SOURCES]);
const rules = payload.scope === "all_users"
? await db.listActiveRacketWatchRules({ ruleIds: payload.ruleIds })
: await db.listActiveRacketWatchRules({ userId: task.userId, ruleIds: payload.ruleIds });
if (rules.length === 0) {
return {
kind: "market_watch_refresh" as const,
trigger: payload.trigger ?? "manual",
processedRules: 0,
listingsSaved: 0,
matchedHits: 0,
queuedPushes: 0,
sourceReports: [],
};
}
const sourceReports: Array<{
ruleId: number;
ruleTitle: string;
source: string;
ok: boolean;
blocked: boolean;
message: string;
listings: number;
}> = [];
let listingsSaved = 0;
let matchedHits = 0;
let queuedPushes = 0;
for (const rule of rules) {
const query = buildMarketSearchQuery(rule);
let latestMatchedAt: Date | undefined;
for (const source of allowedSources) {
const result = await searchMarketSource(source, query, config);
sourceReports.push({
ruleId: rule.id,
ruleTitle: rule.title,
source,
ok: result.ok,
blocked: result.blocked,
message: result.message,
listings: result.listings.length,
});
for (const rawListing of result.listings) {
const savedListing = await persistMarketListing(rawListing);
listingsSaved += 1;
if (!listingMatchesWatchRule(savedListing, rule)) {
continue;
}
latestMatchedAt = new Date();
matchedHits += 1;
const { hit, shouldQueuePush } = await recordMarketWatchHit({
rule,
listing: savedListing,
repushDelta: config.repushDelta,
});
if (hit && shouldQueuePush) {
await queueMarketPushTask(rule.userId, hit.id, rule.title);
queuedPushes += 1;
}
}
}
await db.updateRacketWatchRule(rule.userId, rule.id, {
lastCheckedAt: new Date(),
lastMatchedAt: latestMatchedAt,
});
}
return {
kind: "market_watch_refresh" as const,
trigger: payload.trigger ?? "manual",
processedRules: rules.length,
listingsSaved,
matchedHits,
queuedPushes,
sourceReports,
};
}
async function runMarketSourceSyncTask(task: NonNullable<TaskRow>) {
const payload = task.payload as {
source: (typeof MARKET_SOURCES)[number];
query: string;
};
const config = await loadMarketConfig();
const result = await searchMarketSource(payload.source, payload.query, config);
let savedCount = 0;
for (const rawListing of result.listings) {
await persistMarketListing(rawListing);
savedCount += 1;
}
return {
kind: "market_source_sync" as const,
source: payload.source,
query: payload.query,
ok: result.ok,
blocked: result.blocked,
message: result.message,
listingsSaved: savedCount,
};
}
async function runMarketPushDeliveryTask(task: NonNullable<TaskRow>) {
const payload = task.payload as { hitId: number };
const hit = await db.getRacketWatchHitDeliveryPayload(payload.hitId);
if (!hit) {
throw new Error("Market watch hit not found");
}
const config = await loadMarketConfig();
if (!config.defaultFeishuWebhook.trim()) {
throw new Error("Market Feishu webhook is not configured");
}
const text = formatMarketPushText({
ruleTitle: hit.ruleTitle,
source: hit.listingSource,
title: hit.listingTitle,
price: hit.matchedPrice,
targetPrice: hit.ruleTargetPrice,
brand: hit.listingBrand,
model: hit.listingModel,
category: hit.listingCategory,
weightGram: hit.listingWeightGram,
gradeLevel: hit.listingGradeLevel,
gradeReason: hit.listingGradeReason,
listingUrl: hit.listingUrl,
fetchedAt: hit.listingFetchedAt,
});
const response = await fetch(config.defaultFeishuWebhook, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
msg_type: "text",
content: {
text,
},
}),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`Feishu webhook failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
}
await db.updateRacketWatchHit(hit.id, {
status: "pushed",
lastPushPrice: hit.matchedPrice,
pushedAt: new Date(),
pushCount: (hit.pushCount ?? 0) + 1,
});
await db.createNotification({
userId: hit.userId,
notificationType: "racket_price_alert",
title: `低价命中 · ${hit.ruleTitle}`.slice(0, 256),
message: text,
isRead: 0,
});
return {
kind: "market_push_delivery" as const,
hitId: hit.id,
delivered: true,
destination: "feishu_webhook",
};
}
async function runMatchScoreSuggestTask(task: NonNullable<TaskRow>) {
const payload = task.payload as { matchId: number };
const session = await matchStore.getMatchSessionById(payload.matchId);
if (!session) {
throw new Error("Match session not found");
}
const suggestion = await matchStore.generateSuggestedMatchState(payload.matchId);
return {
kind: "match_score_suggest" as const,
matchId: payload.matchId,
workflowStatus: "review_pending",
score: suggestion.score,
metrics: suggestion.metrics,
eventCount: suggestion.eventCount,
sourceCount: suggestion.sourceCount,
};
}
async function runMatchFinalizeTask(task: NonNullable<TaskRow>) {
const payload = task.payload as {
matchId: number;
finalizedByUserId?: number;
};
const finalized = await matchStore.finalizeMatchSettlement(
payload.matchId,
payload.finalizedByUserId ?? task.userId,
);
return {
kind: "match_finalize" as const,
matchId: payload.matchId,
workflowStatus: finalized?.workflowStatus ?? "finalized",
finalizedAt: finalized?.finalizedAt ?? new Date(),
};
}
export async function processBackgroundTask(task: NonNullable<TaskRow>) {
switch (task.type) {
case "training_plan_generate":
@@ -578,6 +929,16 @@ export async function processBackgroundTask(task: NonNullable<TaskRow>) {
return runNtrpRefreshUserTask(task);
case "ntrp_refresh_all":
return runNtrpRefreshAllTask(task);
case "market_watch_refresh":
return runMarketWatchRefreshTask(task);
case "market_source_sync":
return runMarketSourceSyncTask(task);
case "market_push_delivery":
return runMarketPushDeliveryTask(task);
case "match_score_suggest":
return runMatchScoreSuggestTask(task);
case "match_finalize":
return runMatchFinalizeTask(task);
default:
throw new Error(`Unsupported task type: ${String(task.type)}`);
}

查看文件

@@ -64,6 +64,35 @@ describe("normalizeTrainingPlanResponse", () => {
});
expect(result.exercises[1]?.category).toBe("脚步移动");
});
it("derives a fallback exercise when a day section has no exercises array", () => {
const result = normalizeTrainingPlanResponse({
content: JSON.stringify({
day_1: {
duration_minutes: 35,
focus: "基础脚步与启动速度",
summary: "围绕启动速度和小碎步调整展开",
},
}),
fallbackTitle: "7天训练计划",
});
expect(result.exercises).toHaveLength(1);
expect(result.exercises[0]).toMatchObject({
day: 1,
name: "基础脚步与启动速度",
});
});
it("returns a readable error when no exercises can be derived", () => {
expect(() => normalizeTrainingPlanResponse({
content: JSON.stringify({
title: "空计划",
exercises: [],
}),
fallbackTitle: "fallback",
})).toThrow("训练计划结果为空,请重试或缩小训练重点后再生成。");
});
});
describe("normalizeAdjustedPlanResponse", () => {

查看文件

@@ -25,6 +25,7 @@ type NormalizedPlan = z.infer<typeof normalizedPlanSchema>;
type NormalizedAdjustedPlan = z.infer<typeof normalizedAdjustedPlanSchema>;
const dayKeyPattern = /^day[_\s-]?(\d+)$/i;
const EMPTY_PLAN_ERROR_MESSAGE = "训练计划结果为空,请重试或缩小训练重点后再生成。";
function extractTextContent(content: unknown) {
if (typeof content === "string") {
@@ -66,6 +67,15 @@ function toPositiveInteger(value: unknown, fallback: number) {
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function firstMeaningfulText(...values: Array<unknown>) {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function inferCategory(...values: Array<unknown>) {
const text = values
.filter((value): value is string => typeof value === "string")
@@ -118,6 +128,52 @@ function normalizeExercise(
};
}
function createFallbackExercise(day: number, section: Record<string, unknown>) {
const focus = firstMeaningfulText(section.focus, section.summary, section.title, section.theme);
if (!focus) return null;
return normalizeExercise(day, {
day,
name: focus,
description: firstMeaningfulText(section.summary, section.description, `${focus}训练`) ?? `${focus}训练`,
duration: section.duration ?? section.duration_minutes ?? 12,
tips: firstMeaningfulText(section.tips, section.notes, `重点关注:${focus}`) ?? `重点关注:${focus}`,
sets: 3,
reps: 10,
}, section);
}
function normalizeCanonicalPlan(
raw: Record<string, unknown>,
fallbackTitle: string,
): NormalizedPlan {
const rawExercises = Array.isArray(raw.exercises)
? raw.exercises.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === "object" && !Array.isArray(item),
)
: [];
const exercises = rawExercises.map((exercise, index) =>
normalizeExercise(
toPositiveInteger(exercise.day, index + 1),
exercise,
),
);
if (exercises.length === 0) {
throw new Error(EMPTY_PLAN_ERROR_MESSAGE);
}
return normalizedPlanSchema.parse({
title:
typeof raw.title === "string" && raw.title.trim().length > 0
? raw.title.trim()
: fallbackTitle,
exercises,
});
}
function normalizeDayMapPlan(
raw: Record<string, unknown>,
fallbackTitle: string
@@ -143,9 +199,21 @@ function normalizeDayMapPlan(
)
: [];
for (const exercise of sectionExercises) {
exercises.push(normalizeExercise(day, exercise, section));
if (sectionExercises.length > 0) {
for (const exercise of sectionExercises) {
exercises.push(normalizeExercise(day, exercise, section));
}
continue;
}
const fallbackExercise = createFallbackExercise(day, section);
if (fallbackExercise) {
exercises.push(fallbackExercise);
}
}
if (exercises.length === 0) {
throw new Error(EMPTY_PLAN_ERROR_MESSAGE);
}
return normalizedPlanSchema.parse({
@@ -164,7 +232,7 @@ export function normalizeTrainingPlanResponse(params: {
const raw = parseJsonContent(params.content);
if (Array.isArray(raw.exercises)) {
return normalizedPlanSchema.parse(raw);
return normalizeCanonicalPlan(raw, params.fallbackTitle);
}
return normalizeDayMapPlan(raw, params.fallbackTitle);
@@ -177,8 +245,9 @@ export function normalizeAdjustedPlanResponse(params: {
const raw = parseJsonContent(params.content);
if (Array.isArray(raw.exercises)) {
const normalized = normalizeCanonicalPlan(raw, params.fallbackTitle);
return normalizedAdjustedPlanSchema.parse({
...raw,
...normalized,
adjustmentNotes:
typeof raw.adjustmentNotes === "string" && raw.adjustmentNotes.trim().length > 0
? raw.adjustmentNotes.trim()

查看文件

@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchTutorialMetrics, shouldRefreshTutorialMetrics } from "./tutorialMetrics";
describe("shouldRefreshTutorialMetrics", () => {
it("returns false without a supported source", () => {
expect(shouldRefreshTutorialMetrics({ sourcePlatform: "site", platformVideoId: null })).toBe(false);
});
it("returns true when metrics are missing", () => {
expect(shouldRefreshTutorialMetrics({
sourcePlatform: "bilibili",
platformVideoId: "BV1test",
metricsFetchedAt: null,
})).toBe(true);
});
it("returns false for fresh cached metrics", () => {
expect(shouldRefreshTutorialMetrics({
sourcePlatform: "bilibili",
platformVideoId: "BV1test",
metricsFetchedAt: new Date(),
viewCount: 120,
commentCount: 8,
})).toBe(false);
});
});
describe("fetchTutorialMetrics", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("parses bilibili metrics payloads", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: {
pic: "http://i0.hdslb.com/demo.jpg",
stat: {
view: 3210,
reply: 42,
},
},
}),
}));
const result = await fetchTutorialMetrics("bilibili", "BV1demo");
expect(result).toMatchObject({
viewCount: 3210,
commentCount: 42,
thumbnailUrl: "https://i0.hdslb.com/demo.jpg",
});
});
});

112
server/tutorialMetrics.ts 普通文件
查看文件

@@ -0,0 +1,112 @@
import { ENV } from "./_core/env";
export type TutorialMetrics = {
viewCount?: number;
commentCount?: number;
thumbnailUrl?: string;
fetchedAt: Date;
};
export type TutorialMetricSource = {
sourcePlatform?: string | null;
platformVideoId?: string | null;
metricsFetchedAt?: Date | string | null;
viewCount?: number | null;
commentCount?: number | null;
};
const METRIC_CACHE_MS = 12 * 60 * 60 * 1000;
function parseCount(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
export function shouldRefreshTutorialMetrics(source: TutorialMetricSource) {
if (!source.sourcePlatform || !source.platformVideoId) return false;
if (source.sourcePlatform !== "bilibili" && source.sourcePlatform !== "youtube") return false;
const fetchedAt =
source.metricsFetchedAt instanceof Date
? source.metricsFetchedAt
: source.metricsFetchedAt
? new Date(source.metricsFetchedAt)
: null;
if (!fetchedAt || Number.isNaN(fetchedAt.getTime())) return true;
if (source.viewCount == null && source.commentCount == null) return true;
return (Date.now() - fetchedAt.getTime()) > METRIC_CACHE_MS;
}
export async function fetchTutorialMetrics(sourcePlatform: string, platformVideoId: string): Promise<TutorialMetrics | null> {
if (sourcePlatform === "bilibili") {
return fetchBilibiliMetrics(platformVideoId);
}
if (sourcePlatform === "youtube") {
return fetchYouTubeMetrics(platformVideoId);
}
return null;
}
async function fetchBilibiliMetrics(bvid: string): Promise<TutorialMetrics | null> {
const response = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`, {
headers: {
"accept": "application/json",
"user-agent": "tennis-training-hub/1.0",
},
});
if (!response.ok) {
throw new Error(`Bilibili metrics request failed with ${response.status}`);
}
const payload = await response.json();
const stat = payload?.data?.stat;
if (!payload?.data || !stat) return null;
return {
viewCount: parseCount(stat.view),
commentCount: parseCount(stat.reply),
thumbnailUrl: typeof payload.data.pic === "string" ? payload.data.pic.replace(/^http:\/\//, "https://") : undefined,
fetchedAt: new Date(),
};
}
async function fetchYouTubeMetrics(videoId: string): Promise<TutorialMetrics | null> {
if (!ENV.youtubeApiKey) return null;
const url = new URL("https://www.googleapis.com/youtube/v3/videos");
url.searchParams.set("part", "statistics,snippet");
url.searchParams.set("id", videoId);
url.searchParams.set("key", ENV.youtubeApiKey);
const response = await fetch(url.toString(), {
headers: {
"accept": "application/json",
"user-agent": "tennis-training-hub/1.0",
},
});
if (!response.ok) {
throw new Error(`YouTube metrics request failed with ${response.status}`);
}
const payload = await response.json();
const item = Array.isArray(payload?.items) ? payload.items[0] : null;
if (!item) return null;
return {
viewCount: parseCount(item.statistics?.viewCount),
commentCount: parseCount(item.statistics?.commentCount),
thumbnailUrl:
item.snippet?.thumbnails?.maxres?.url ||
item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url,
fetchedAt: new Date(),
};
}

查看文件

@@ -9,6 +9,20 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isRetriableBackgroundError(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.message.startsWith("Request timed out after ") ||
error.message.includes("fetch failed") ||
error.message.includes("ECONNRESET") ||
error.message.includes("ETIMEDOUT") ||
error.message.includes("429") ||
error.message.includes("502") ||
error.message.includes("503") ||
error.message.includes("504")
);
}
async function workOnce() {
await db.failExhaustedBackgroundTasks();
await db.requeueStaleBackgroundTasks(new Date(Date.now() - ENV.backgroundTaskStaleMs));
@@ -31,9 +45,21 @@ async function workOnce() {
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown background task error";
await db.failBackgroundTask(task.id, message);
await db.failVisionTestRun(task.id, message);
console.error(`[worker] task ${task.id} failed:`, error);
if (isRetriableBackgroundError(error) && task.attempts < task.maxAttempts) {
const nextAttempt = task.attempts + 1;
const delayMs = Math.min(30_000, 5_000 * task.attempts);
await db.rescheduleBackgroundTask(task.id, {
progress: 15,
message: `请求超时,已自动重试(第 ${nextAttempt}/${task.maxAttempts} 次尝试)`,
error: message,
delayMs,
});
console.warn(`[worker] task ${task.id} rescheduled after retriable error:`, error);
} else {
await db.failBackgroundTask(task.id, message);
await db.failVisionTestRun(task.id, message);
console.error(`[worker] task ${task.id} failed:`, error);
}
} finally {
clearInterval(heartbeatTimer);
}