Add market watch and match hub workflows

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

查看文件

@@ -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