文件
tennis-training-hub/server/routers.ts
2026-04-07 11:00:03 +08:00

1888 行
68 KiB
TypeScript

import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { TRPCError } from "@trpc/server";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { adminProcedure, publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { z } from "zod";
import { sdk } from "./_core/sdk";
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"
| "market_source_sync"
| "market_watch_refresh"
| "market_push_delivery"
| "match_score_suggest"
| "match_finalize";
title: string;
payload: Record<string, unknown>;
message: string;
}) {
const taskId = nanoid();
await db.createBackgroundTask({
id: taskId,
userId: params.userId,
type: params.type,
title: params.title,
message: params.message,
payload: params.payload,
progress: 0,
maxAttempts: params.type === "media_finalize" ? 90 : 3,
});
const task = await db.getBackgroundTaskById(taskId);
return { taskId, task };
}
async function auditAdminAction(params: {
adminUserId: number;
actionType: string;
entityType: string;
entityId?: string | null;
targetUserId?: number | null;
payload?: Record<string, unknown>;
}) {
await db.createAdminAuditLog({
adminUserId: params.adminUserId,
actionType: params.actionType,
entityType: params.entityType,
entityId: params.entityId ?? null,
targetUserId: params.targetUserId ?? null,
payload: params.payload ?? null,
});
}
const manualNtrpSchema = z.number().min(1).max(5);
const scoreSchema = z.number().int().min(1).max(5);
const trainingProfileUpdateSchema = z.object({
skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(),
trainingGoals: z.string().max(2000).optional(),
manualNtrpRating: manualNtrpSchema.nullable().optional(),
heightCm: z.number().min(100).max(240).nullable().optional(),
weightKg: z.number().min(30).max(250).nullable().optional(),
sprintSpeedScore: scoreSchema.nullable().optional(),
explosivePowerScore: scoreSchema.nullable().optional(),
agilityScore: scoreSchema.nullable().optional(),
enduranceScore: scoreSchema.nullable().optional(),
flexibilityScore: scoreSchema.nullable().optional(),
coreStabilityScore: scoreSchema.nullable().optional(),
shoulderMobilityScore: scoreSchema.nullable().optional(),
hipMobilityScore: scoreSchema.nullable().optional(),
assessmentNotes: z.string().max(2000).nullable().optional(),
});
const liveRuntimeSnapshotSchema = z.object({
phase: z.enum(["idle", "analyzing", "saving", "safe", "failed"]).optional(),
startedAt: z.number().optional(),
durationMs: z.number().optional(),
currentAction: z.string().optional(),
rawAction: z.string().optional(),
feedback: z.array(z.string()).optional(),
liveScore: z.record(z.string(), z.number()).nullable().optional(),
stabilityMeta: z.record(z.string(), z.any()).optional(),
visibleSegments: z.number().optional(),
unknownSegments: z.number().optional(),
archivedVideoCount: z.number().optional(),
recentSegments: z.array(z.object({
actionType: z.string(),
isUnknown: z.boolean().optional(),
startMs: z.number(),
endMs: z.number(),
durationMs: z.number(),
confidenceAvg: z.number().optional(),
score: z.number().optional(),
clipLabel: z.string().optional(),
})).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}`;
}
async function resolveLiveRuntimeRole(params: {
userId: number;
sessionSid: string;
}) {
let runtime = await db.getUserLiveAnalysisRuntime(params.userId);
if (!runtime) {
return { role: "idle" as const, runtimeSession: null };
}
const heartbeatAt = runtime.lastHeartbeatAt ?? runtime.updatedAt ?? runtime.startedAt;
const isStale =
runtime.status === "active" &&
(!heartbeatAt || (Date.now() - heartbeatAt.getTime()) > db.LIVE_ANALYSIS_RUNTIME_TIMEOUT_MS);
if (isStale) {
runtime = await db.endUserLiveAnalysisRuntime({
userId: params.userId,
runtimeId: runtime.id,
snapshot: runtime.snapshot,
}) ?? null as any;
return { role: "idle" as const, runtimeSession: null };
}
if (runtime.status !== "active") {
return { role: "idle" as const, runtimeSession: runtime };
}
return {
role: runtime.ownerSid === params.sessionSid ? "owner" as const : "viewer" as const,
runtimeSession: runtime,
};
}
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,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
// Username-based login
loginWithUsername: publicProcedure
.input(z.object({
username: z.string().trim().min(1).max(64),
inviteCode: z.string().trim().max(64).optional(),
}))
.mutation(async ({ ctx, input }) => {
const username = input.username.trim();
const existingUser = await db.getUserByUsername(username);
if (!existingUser && !db.isValidRegistrationInvite(input.inviteCode)) {
throw new TRPCError({ code: "FORBIDDEN", message: "新用户注册需要正确的邀请码" });
}
const { user, isNew } = await db.createUsernameAccount(username, input.inviteCode);
const sessionToken = await sdk.createSessionToken(user.openId, {
name: user.name || username,
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
return { user, isNew };
}),
}),
// User profile management
profile: router({
update: protectedProcedure
.input(trainingProfileUpdateSchema)
.mutation(async ({ ctx, input }) => {
await db.updateUserProfile(ctx.user.id, {
...input,
manualNtrpCapturedAt: input.manualNtrpRating != null ? new Date() : input.manualNtrpRating === null ? null : undefined,
});
return { success: true };
}),
stats: protectedProcedure.query(async ({ ctx }) => {
return db.getUserStats(ctx.user.id);
}),
}),
// Training plan management
plan: router({
generate: protectedProcedure
.input(z.object({
skillLevel: z.enum(["beginner", "intermediate", "advanced"]),
durationDays: z.number().min(1).max(30).default(7),
focusAreas: z.array(z.string()).optional(),
}))
.mutation(async ({ ctx, input }) => {
const currentUser = await db.getUserById(ctx.user.id);
if (!currentUser) {
throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
}
const latestSnapshot = await db.getLatestNtrpSnapshot(ctx.user.id);
const missingFields = db.getMissingTrainingProfileFields(currentUser, Boolean(latestSnapshot?.rating != null));
if (missingFields.length > 0) {
const missingLabels = missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、");
throw new TRPCError({
code: "BAD_REQUEST",
message: `训练计划生成前请先完善训练档案:${missingLabels}`,
});
}
return enqueueTask({
userId: ctx.user.id,
type: "training_plan_generate",
title: `${input.durationDays}天训练计划生成`,
message: "训练计划已加入后台队列",
payload: input,
});
}),
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserTrainingPlans(ctx.user.id);
}),
active: protectedProcedure.query(async ({ ctx }) => {
return db.getActivePlan(ctx.user.id);
}),
adjust: protectedProcedure
.input(z.object({ planId: z.number() }))
.mutation(async ({ ctx, input }) => {
const currentPlan = (await db.getUserTrainingPlans(ctx.user.id)).find(p => p.id === input.planId);
if (!currentPlan) throw new Error("Plan not found");
return enqueueTask({
userId: ctx.user.id,
type: "training_plan_adjust",
title: `${currentPlan.title} 调整`,
message: "训练计划调整任务已加入后台队列",
payload: input,
});
}),
}),
// Video management
video: router({
upload: protectedProcedure
.input(z.object({
title: z.string(),
format: z.string(),
fileSize: z.number(),
duration: z.number().optional(),
exerciseType: z.string().optional(),
fileBase64: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const fileBuffer = Buffer.from(input.fileBase64, "base64");
const fileKey = `videos/${ctx.user.id}/${nanoid()}.${input.format}`;
const contentType = input.format === "webm" ? "video/webm" : "video/mp4";
const { url } = await storagePut(fileKey, fileBuffer, contentType);
const publicUrl = toPublicUrl(url);
const videoId = await db.createVideo({
userId: ctx.user.id,
title: input.title,
fileKey,
url: publicUrl,
format: input.format,
fileSize: input.fileSize,
duration: input.duration ?? null,
exerciseType: input.exerciseType || null,
analysisStatus: input.exerciseType === "live_analysis" ? "completed" : "pending",
});
return { videoId, url: publicUrl };
}),
registerExternal: protectedProcedure
.input(z.object({
title: z.string().min(1).max(256),
url: z.string().min(1),
fileKey: z.string().min(1),
format: z.string().min(1).max(16),
fileSize: z.number().optional(),
duration: z.number().optional(),
exerciseType: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const publicUrl = toPublicUrl(input.url);
const videoId = await db.createVideo({
userId: ctx.user.id,
title: input.title,
fileKey: input.fileKey,
url: publicUrl,
format: input.format,
fileSize: input.fileSize ?? null,
duration: input.duration ?? null,
exerciseType: input.exerciseType || "recording",
analysisStatus: "completed",
});
return { videoId, url: publicUrl };
}),
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserVideos(ctx.user.id);
}),
get: protectedProcedure
.input(z.object({ videoId: z.number() }))
.query(async ({ ctx, input }) => {
const video = await db.getUserVideoById(ctx.user.id, input.videoId);
if (!video) {
throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" });
}
return video;
}),
updateStatus: protectedProcedure
.input(z.object({
videoId: z.number(),
status: z.enum(["pending", "analyzing", "completed", "failed"]),
}))
.mutation(async ({ input }) => {
await db.updateVideoStatus(input.videoId, input.status);
return { success: true };
}),
update: protectedProcedure
.input(z.object({
videoId: z.number(),
title: z.string().trim().min(1).max(256),
exerciseType: z.string().trim().max(64).optional(),
}))
.mutation(async ({ ctx, input }) => {
const updated = await db.updateUserVideo(ctx.user.id, input.videoId, {
title: input.title,
exerciseType: input.exerciseType?.trim() ? input.exerciseType.trim() : null,
});
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" });
}
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ videoId: z.number() }))
.mutation(async ({ ctx, input }) => {
const deleted = await db.deleteUserVideo(ctx.user.id, input.videoId);
if (!deleted) {
throw new TRPCError({ code: "NOT_FOUND", message: "视频不存在" });
}
return { success: true };
}),
}),
// Pose analysis
analysis: router({
save: protectedProcedure
.input(z.object({
videoId: z.number(),
overallScore: z.number().optional(),
poseMetrics: z.any().optional(),
detectedIssues: z.any().optional(),
corrections: z.any().optional(),
exerciseType: z.string().optional(),
framesAnalyzed: z.number().optional(),
shotCount: z.number().optional(),
avgSwingSpeed: z.number().optional(),
maxSwingSpeed: z.number().optional(),
totalMovementDistance: z.number().optional(),
strokeConsistency: z.number().optional(),
footworkScore: z.number().optional(),
fluidityScore: z.number().optional(),
keyMoments: z.any().optional(),
movementTrajectory: z.any().optional(),
}))
.mutation(async ({ ctx, input }) => {
const analysisId = await db.createPoseAnalysis({
...input,
userId: ctx.user.id,
});
await db.updateVideoStatus(input.videoId, "completed");
const syncResult = await syncAnalysisTrainingData({
userId: ctx.user.id,
videoId: input.videoId,
exerciseType: input.exerciseType,
overallScore: input.overallScore,
shotCount: input.shotCount,
framesAnalyzed: input.framesAnalyzed,
});
return { analysisId, trainingRecordId: syncResult.recordId };
}),
getByVideo: protectedProcedure
.input(z.object({ videoId: z.number() }))
.query(async ({ input }) => {
return db.getAnalysisByVideoId(input.videoId);
}),
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserAnalyses(ctx.user.id);
}),
liveSessionSave: protectedProcedure
.input(z.object({
title: z.string().min(1).max(256),
sessionMode: z.enum(["practice", "pk"]).default("practice"),
startedAt: z.number(),
endedAt: z.number(),
durationMs: z.number().min(0),
dominantAction: z.string().optional(),
overallScore: z.number().optional(),
postureScore: z.number().optional(),
balanceScore: z.number().optional(),
techniqueScore: z.number().optional(),
footworkScore: z.number().optional(),
consistencyScore: z.number().optional(),
totalActionCount: z.number().default(0),
effectiveSegments: z.number().default(0),
totalSegments: z.number().default(0),
unknownSegments: z.number().default(0),
feedback: z.array(z.string()).default([]),
metrics: z.any().optional(),
segments: z.array(z.object({
actionType: z.string(),
isUnknown: z.boolean().default(false),
startMs: z.number(),
endMs: z.number(),
durationMs: z.number(),
confidenceAvg: z.number().optional(),
score: z.number().optional(),
peakScore: z.number().optional(),
frameCount: z.number().default(0),
issueSummary: z.array(z.string()).optional(),
keyFrames: z.array(z.number()).optional(),
clipLabel: z.string().optional(),
})).default([]),
videoId: z.number().optional(),
videoUrl: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const sessionId = await db.createLiveAnalysisSession({
userId: ctx.user.id,
title: input.title,
sessionMode: input.sessionMode,
status: "completed",
startedAt: new Date(input.startedAt),
endedAt: new Date(input.endedAt),
durationMs: input.durationMs,
dominantAction: input.dominantAction ?? "unknown",
overallScore: input.overallScore ?? null,
postureScore: input.postureScore ?? null,
balanceScore: input.balanceScore ?? null,
techniqueScore: input.techniqueScore ?? null,
footworkScore: input.footworkScore ?? null,
consistencyScore: input.consistencyScore ?? null,
unknownActionRatio: input.totalSegments > 0 ? input.unknownSegments / input.totalSegments : 0,
totalSegments: input.totalSegments,
effectiveSegments: input.effectiveSegments,
totalActionCount: input.totalActionCount,
videoId: input.videoId ?? null,
videoUrl: input.videoUrl ?? null,
summary: `${ACTION_LABELS[input.dominantAction ?? "unknown"] ?? input.dominantAction ?? "未知动作"} · ${input.effectiveSegments} 个有效片段`,
feedback: input.feedback,
metrics: input.metrics ?? null,
});
await db.createLiveActionSegments(input.segments.map((segment) => ({
sessionId,
actionType: segment.actionType,
isUnknown: segment.isUnknown ? 1 : 0,
startMs: segment.startMs,
endMs: segment.endMs,
durationMs: segment.durationMs,
confidenceAvg: segment.confidenceAvg ?? null,
score: segment.score ?? null,
peakScore: segment.peakScore ?? null,
frameCount: segment.frameCount,
issueSummary: segment.issueSummary ?? null,
keyFrames: segment.keyFrames ?? null,
clipLabel: segment.clipLabel ?? null,
})));
const syncResult = await syncLiveTrainingData({
userId: ctx.user.id,
sessionId,
title: input.title,
sessionMode: input.sessionMode,
dominantAction: input.dominantAction ?? "unknown",
durationMs: input.durationMs,
overallScore: input.overallScore ?? null,
effectiveSegments: input.effectiveSegments,
totalSegments: input.totalSegments,
unknownSegments: input.unknownSegments,
videoId: input.videoId ?? null,
});
return { sessionId, trainingRecordId: syncResult.recordId };
}),
liveSessionList: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }).optional())
.query(async ({ ctx, input }) => {
return db.listLiveAnalysisSessions(ctx.user.id, input?.limit ?? 20);
}),
liveSessionGet: protectedProcedure
.input(z.object({ sessionId: z.number() }))
.query(async ({ ctx, input }) => {
const session = await db.getLiveAnalysisSessionById(input.sessionId);
if (!session || session.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "实时分析记录不存在" });
}
const segments = await db.getLiveActionSegmentsBySessionId(input.sessionId);
return { session, segments };
}),
runtimeGet: protectedProcedure.query(async ({ ctx }) => {
const sessionSid = getRuntimeOwnerSid(ctx);
return resolveLiveRuntimeRole({
userId: ctx.user.id,
sessionSid,
});
}),
runtimeAcquire: protectedProcedure
.input(z.object({
title: z.string().min(1).max(256),
sessionMode: z.enum(["practice", "pk"]).default("practice"),
}))
.mutation(async ({ ctx, input }) => {
const sessionSid = getRuntimeOwnerSid(ctx);
const current = await resolveLiveRuntimeRole({
userId: ctx.user.id,
sessionSid,
});
if (current.role === "viewer" && current.runtimeSession?.status === "active") {
return current;
}
const runtime = current.runtimeSession?.status === "active" && current.role === "owner"
? await db.updateUserLiveAnalysisRuntime(ctx.user.id, {
ownerSid: sessionSid,
status: "active",
title: input.title,
sessionMode: input.sessionMode,
startedAt: current.runtimeSession.startedAt ?? new Date(),
endedAt: null,
lastHeartbeatAt: new Date(),
})
: await db.upsertUserLiveAnalysisRuntime(ctx.user.id, {
ownerSid: sessionSid,
status: "active",
title: input.title,
sessionMode: input.sessionMode,
mediaSessionId: null,
startedAt: new Date(),
endedAt: null,
lastHeartbeatAt: new Date(),
snapshot: {
phase: "idle",
startedAt: Date.now(),
durationMs: 0,
currentAction: "unknown",
rawAction: "unknown",
feedback: [],
visibleSegments: 0,
unknownSegments: 0,
archivedVideoCount: 0,
recentSegments: [],
},
});
return {
role: "owner" as const,
runtimeSession: runtime ?? null,
};
}),
runtimeHeartbeat: protectedProcedure
.input(z.object({
runtimeId: z.number(),
mediaSessionId: z.string().max(96).nullable().optional(),
snapshot: liveRuntimeSnapshotSchema.optional(),
}))
.mutation(async ({ ctx, input }) => {
const sessionSid = getRuntimeOwnerSid(ctx);
const runtime = await db.updateLiveAnalysisRuntimeHeartbeat({
userId: ctx.user.id,
ownerSid: sessionSid,
runtimeId: input.runtimeId,
mediaSessionId: input.mediaSessionId,
snapshot: input.snapshot,
});
if (!runtime) {
throw new TRPCError({ code: "FORBIDDEN", message: "当前设备不是实时分析持有端" });
}
return {
role: "owner" as const,
runtimeSession: runtime,
};
}),
runtimeRelease: protectedProcedure
.input(z.object({
runtimeId: z.number().optional(),
snapshot: liveRuntimeSnapshotSchema.optional(),
}).optional())
.mutation(async ({ ctx, input }) => {
const sessionSid = getRuntimeOwnerSid(ctx);
const runtime = await db.endUserLiveAnalysisRuntime({
userId: ctx.user.id,
ownerSid: sessionSid,
runtimeId: input?.runtimeId,
snapshot: input?.snapshot,
});
if (!runtime) {
const current = await db.getUserLiveAnalysisRuntime(ctx.user.id);
if (current?.status === "active" && current.ownerSid !== sessionSid) {
throw new TRPCError({ code: "FORBIDDEN", message: "当前设备不是实时分析持有端" });
}
}
return {
success: true,
runtimeSession: runtime ?? null,
};
}),
// Generate AI correction suggestions
getCorrections: protectedProcedure
.input(z.object({
poseMetrics: z.any(),
exerciseType: z.string(),
detectedIssues: z.any(),
imageUrls: z.array(z.string()).optional(),
imageDataUrls: z.array(z.string()).max(4).optional(),
}))
.mutation(async ({ ctx, input }) => {
const imageUrls = await prepareCorrectionImageUrls({
userId: ctx.user.id,
imageUrls: input.imageUrls,
imageDataUrls: input.imageDataUrls,
});
return enqueueTask({
userId: ctx.user.id,
type: imageUrls.length > 0 ? "pose_correction_multimodal" : "analysis_corrections",
title: `${input.exerciseType} 动作纠正`,
message: imageUrls.length > 0 ? "多模态动作纠正任务已加入后台队列" : "动作纠正任务已加入后台队列",
payload: {
poseMetrics: input.poseMetrics,
exerciseType: input.exerciseType,
detectedIssues: input.detectedIssues,
imageUrls,
},
});
}),
}),
vision: router({
library: protectedProcedure.query(async () => {
await db.seedVisionReferenceImages();
return db.listVisionReferenceImages();
}),
seedLibrary: adminProcedure.mutation(async () => {
await db.seedVisionReferenceImages();
const images = await db.listVisionReferenceImages();
return { count: images.length };
}),
runs: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(50) }).optional())
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 50;
return db.listVisionTestRuns(ctx.user.role === "admin" ? undefined : ctx.user.id, limit);
}),
runReference: protectedProcedure
.input(z.object({
referenceImageId: z.number(),
exerciseType: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const reference = await db.getVisionReferenceImageById(input.referenceImageId);
if (!reference || reference.isPublished !== 1) {
throw new Error("Reference image not found");
}
const task = await enqueueTask({
userId: ctx.user.id,
type: "pose_correction_multimodal",
title: `${reference.title} 视觉测试`,
message: "视觉标准图测试已加入后台队列",
payload: {
poseMetrics: {
referenceSource: "vision_reference_library",
expectedFocus: reference.expectedFocus,
sourcePageUrl: reference.sourcePageUrl,
},
exerciseType: input.exerciseType || reference.exerciseType,
detectedIssues: [],
imageUrls: [reference.imageUrl],
},
});
const runId = await db.createVisionTestRun({
taskId: task.taskId,
userId: ctx.user.id,
referenceImageId: reference.id,
title: `${reference.title} 视觉测试`,
exerciseType: input.exerciseType || reference.exerciseType,
imageUrl: reference.imageUrl,
status: "queued",
visionStatus: "pending",
configuredModel: ENV.llmVisionModel || null,
expectedFocus: reference.expectedFocus,
});
return { taskId: task.taskId, runId };
}),
runAll: protectedProcedure.mutation(async ({ ctx }) => {
const references = await db.listVisionReferenceImages();
const queued: Array<{ taskId: string; referenceImageId: number }> = [];
for (const reference of references) {
const task = await enqueueTask({
userId: ctx.user.id,
type: "pose_correction_multimodal",
title: `${reference.title} 视觉测试`,
message: "视觉标准图测试已加入后台队列",
payload: {
poseMetrics: {
referenceSource: "vision_reference_library",
expectedFocus: reference.expectedFocus,
sourcePageUrl: reference.sourcePageUrl,
},
exerciseType: reference.exerciseType,
detectedIssues: [],
imageUrls: [reference.imageUrl],
},
});
await db.createVisionTestRun({
taskId: task.taskId,
userId: ctx.user.id,
referenceImageId: reference.id,
title: `${reference.title} 视觉测试`,
exerciseType: reference.exerciseType,
imageUrl: reference.imageUrl,
status: "queued",
visionStatus: "pending",
configuredModel: ENV.llmVisionModel || null,
expectedFocus: reference.expectedFocus,
});
queued.push({ taskId: task.taskId, referenceImageId: reference.id });
}
return { count: queued.length, queued };
}),
retryRun: protectedProcedure
.input(z.object({ runId: z.number() }))
.mutation(async ({ ctx, input }) => {
const run = await db.getVisionTestRunById(input.runId);
if (!run) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vision run not found" });
}
if (ctx.user.role !== "admin" && run.userId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "No permission to retry this vision run" });
}
await db.resetVisionTestRun(run.taskId);
await db.retryBackgroundTask(run.userId, run.taskId);
if (ctx.user.role === "admin" && run.userId !== ctx.user.id) {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "vision_retry_run",
entityType: "vision_test_run",
entityId: String(run.id),
targetUserId: run.userId,
payload: { taskId: run.taskId, title: run.title },
});
}
return { taskId: run.taskId, runId: run.id };
}),
retryFallbacks: adminProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(20) }).optional())
.mutation(async ({ ctx, input }) => {
const runs = await db.listRepairableVisionTestRuns(input?.limit ?? 20);
for (const run of runs) {
await db.resetVisionTestRun(run.taskId);
await db.retryBackgroundTask(run.userId, run.taskId);
}
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "vision_retry_fallbacks",
entityType: "vision_test_run",
payload: { count: runs.length, runIds: runs.map((item) => item.id) },
});
return { count: runs.length, runIds: runs.map((item) => item.id) };
}),
}),
task: router({
list: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(20) }).optional())
.query(async ({ ctx, input }) => {
return db.listUserBackgroundTasks(ctx.user.id, input?.limit ?? 20);
}),
get: protectedProcedure
.input(z.object({ taskId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
return db.getUserBackgroundTaskById(ctx.user.id, input.taskId);
}),
retry: protectedProcedure
.input(z.object({ taskId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const task = await db.retryBackgroundTask(ctx.user.id, input.taskId);
return { task };
}),
createMediaFinalize: protectedProcedure
.input(z.object({
sessionId: z.string().min(1),
title: z.string().min(1).max(256),
exerciseType: z.string().optional(),
sessionMode: z.enum(["practice", "pk"]).default("practice"),
durationMinutes: z.number().min(1).max(720).optional(),
actionCount: z.number().min(0).max(100000).optional(),
actionSummary: z.record(z.string(), z.number()).optional(),
dominantAction: z.string().optional(),
validityStatus: z.enum(["pending", "valid", "valid_manual", "invalid_auto", "invalid_manual"]).optional(),
invalidReason: z.string().max(512).optional(),
}))
.mutation(async ({ ctx, input }) => {
return enqueueTask({
userId: ctx.user.id,
type: "media_finalize",
title: `${input.title} 归档`,
message: "录制文件归档任务已加入后台队列",
payload: input,
});
}),
}),
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
.input(z.object({
planId: z.number().optional(),
exerciseName: z.string(),
durationMinutes: z.number().optional(),
notes: z.string().optional(),
poseScore: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
const recordId = await db.createTrainingRecord({
userId: ctx.user.id,
...input,
completed: 0,
});
return { recordId };
}),
complete: protectedProcedure
.input(z.object({
recordId: z.number(),
poseScore: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
await db.markRecordCompleted(input.recordId, input.poseScore);
// Update user stats
const records = await db.getUserTrainingRecords(ctx.user.id, 1000);
const completed = records.filter(r => r.completed === 1);
const totalMinutes = records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0);
await db.updateUserProfile(ctx.user.id, {
totalSessions: completed.length,
totalMinutes,
});
return { success: true };
}),
list: protectedProcedure
.input(z.object({ limit: z.number().default(50) }).optional())
.query(async ({ ctx, input }) => {
return db.getUserTrainingRecords(ctx.user.id, input?.limit || 50);
}),
}),
// Rating system
rating: router({
history: protectedProcedure.query(async ({ ctx }) => {
return db.listNtrpSnapshots(ctx.user.id);
}),
current: protectedProcedure.query(async ({ ctx }) => {
const user = await db.getUserByOpenId(ctx.user.openId);
const latestSnapshot = await db.getLatestNtrpSnapshot(ctx.user.id);
return { rating: latestSnapshot?.rating || user?.ntrpRating || 1.5, latestSnapshot };
}),
refreshMine: protectedProcedure.mutation(async ({ ctx }) => {
return enqueueTask({
userId: ctx.user.id,
type: "ntrp_refresh_user",
title: "我的 NTRP 刷新",
message: "NTRP 刷新任务已加入后台队列",
payload: { targetUserId: ctx.user.id },
});
}),
}),
// Daily check-in system
checkin: router({
today: protectedProcedure.query(async ({ ctx }) => {
return db.getTodayCheckin(ctx.user.id);
}),
do: protectedProcedure
.input(z.object({
notes: z.string().optional(),
minutesTrained: z.number().optional(),
}).optional())
.mutation(async ({ ctx, input }) => {
const result = await db.checkinToday(ctx.user.id, input?.notes, input?.minutesTrained);
// Check for new badges after check-in
const newBadges = await db.checkAndAwardBadges(ctx.user.id);
return { ...result, newBadges };
}),
history: protectedProcedure
.input(z.object({ limit: z.number().default(60) }).optional())
.query(async ({ ctx, input }) => {
return db.getUserCheckins(ctx.user.id, input?.limit || 60);
}),
}),
achievement: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.listUserAchievements(ctx.user.id);
}),
definitions: publicProcedure.query(async () => {
return db.listAchievementDefinitions();
}),
}),
// Badge system
badge: router({
list: protectedProcedure.query(async ({ ctx }) => {
const earned = await db.getUserBadges(ctx.user.id);
const allBadges = Object.entries(db.BADGE_DEFINITIONS).map(([key, def]) => {
const earnedBadge = earned.find(b => b.badgeKey === key);
return {
key,
...def,
earned: !!earnedBadge,
earnedAt: earnedBadge?.earnedAt || null,
};
});
return allBadges;
}),
check: protectedProcedure.mutation(async ({ ctx }) => {
const newBadges = await db.checkAndAwardBadges(ctx.user.id);
return { newBadges: newBadges.map(key => ({ key, ...db.BADGE_DEFINITIONS[key] })) };
}),
definitions: publicProcedure.query(() => {
return Object.entries(db.BADGE_DEFINITIONS).map(([key, def]) => ({ key, ...def }));
}),
}),
admin: router({
users: adminProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }).optional())
.query(async ({ input }) => db.listUsersForAdmin(input?.limit ?? 100)),
tasks: adminProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }).optional())
.query(async ({ input }) => db.listAllBackgroundTasks(input?.limit ?? 100)),
liveSessions: adminProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(50) }).optional())
.query(async ({ input }) => db.listAdminLiveAnalysisSessions(input?.limit ?? 50)),
settings: adminProcedure.query(async () => db.listAppSettings()),
updateSetting: adminProcedure
.input(z.object({
settingKey: z.string().min(1),
value: z.any(),
}))
.mutation(async ({ ctx, input }) => {
await db.updateAppSetting(input.settingKey, input.value);
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "update_setting",
entityType: "app_setting",
entityId: input.settingKey,
payload: { value: input.value },
});
return { success: true };
}),
auditLogs: adminProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }).optional())
.query(async ({ input }) => db.listAdminAuditLogs(input?.limit ?? 100)),
refreshUserNtrp: adminProcedure
.input(z.object({ userId: z.number() }))
.mutation(async ({ ctx, input }) => {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "refresh_user_ntrp",
entityType: "user",
entityId: String(input.userId),
targetUserId: input.userId,
});
return enqueueTask({
userId: ctx.user.id,
type: "ntrp_refresh_user",
title: `用户 ${input.userId} NTRP 刷新`,
message: "用户 NTRP 刷新任务已加入后台队列",
payload: { targetUserId: input.userId },
});
}),
refreshAllNtrp: adminProcedure.mutation(async ({ ctx }) => {
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "refresh_all_ntrp",
entityType: "rating",
});
return enqueueTask({
userId: ctx.user.id,
type: "ntrp_refresh_all",
title: "全量 NTRP 刷新",
message: "全量 NTRP 刷新任务已加入后台队列",
payload: { source: "admin" },
});
}),
refreshUserNtrpNow: adminProcedure
.input(z.object({ userId: z.number() }))
.mutation(async ({ ctx, input }) => {
const snapshot = await refreshUserNtrp(input.userId, { triggerType: "manual" });
await auditAdminAction({
adminUserId: ctx.user.id,
actionType: "refresh_user_ntrp_now",
entityType: "user",
entityId: String(input.userId),
targetUserId: input.userId,
payload: snapshot,
});
return { snapshot };
}),
}),
// Leaderboard
leaderboard: router({
get: protectedProcedure
.input(z.object({
scope: leaderboardScopeSchema.default("training"),
sortBy: leaderboardSortSchema.default("ntrpRating"),
limit: z.number().default(50),
}).optional())
.query(async ({ input }) => {
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);
}),
}),
// Tutorial video library
tutorial: router({
list: publicProcedure
.input(z.object({
category: z.string().optional(),
skillLevel: z.string().optional(),
topicArea: z.string().optional(),
}).optional())
.query(async ({ input }) => {
await db.seedTutorials();
return db.getTutorials(input?.category, input?.skillLevel, input?.topicArea);
}),
get: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
await db.seedTutorials();
return db.getTutorialById(input.id);
}),
progress: protectedProcedure.query(async ({ ctx }) => {
return db.getUserTutorialProgress(ctx.user.id);
}),
updateProgress: protectedProcedure
.input(z.object({
tutorialId: z.number(),
watched: z.number().optional(),
completed: z.number().optional(),
selfScore: z.number().optional(),
notes: z.string().optional(),
comparisonVideoId: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { tutorialId, ...data } = input;
await db.updateTutorialProgress(ctx.user.id, tutorialId, data);
const unlockedKeys = await db.refreshAchievementsForUser(ctx.user.id);
return { success: true, unlockedAchievementKeys: unlockedKeys };
}),
}),
// Training reminders
reminder: router({
list: protectedProcedure.query(async ({ ctx }) => {
return db.getUserReminders(ctx.user.id);
}),
create: protectedProcedure
.input(z.object({
reminderType: z.string(),
title: z.string(),
message: z.string().optional(),
timeOfDay: z.string(),
daysOfWeek: z.array(z.number()),
}))
.mutation(async ({ ctx, input }) => {
const reminderId = await db.createReminder({
userId: ctx.user.id,
...input,
});
return { reminderId };
}),
update: protectedProcedure
.input(z.object({
reminderId: z.number(),
title: z.string().optional(),
message: z.string().optional(),
timeOfDay: z.string().optional(),
daysOfWeek: z.array(z.number()).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { reminderId, ...data } = input;
await db.updateReminder(reminderId, ctx.user.id, data);
return { success: true };
}),
delete: protectedProcedure
.input(z.object({ reminderId: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.deleteReminder(input.reminderId, ctx.user.id);
return { success: true };
}),
toggle: protectedProcedure
.input(z.object({ reminderId: z.number(), isActive: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.toggleReminder(input.reminderId, ctx.user.id, input.isActive);
return { success: true };
}),
}),
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
.input(z.object({ limit: z.number().default(50) }).optional())
.query(async ({ ctx, input }) => {
return db.getUserNotifications(ctx.user.id, input?.limit || 50);
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
return db.getUnreadNotificationCount(ctx.user.id);
}),
markRead: protectedProcedure
.input(z.object({ notificationId: z.number() }))
.mutation(async ({ ctx, input }) => {
await db.markNotificationRead(input.notificationId, ctx.user.id);
return { success: true };
}),
markAllRead: protectedProcedure.mutation(async ({ ctx }) => {
await db.markAllNotificationsRead(ctx.user.id);
return { success: true };
}),
}),
});
// NTRP Rating calculation function
async function recalculateNTRPRating(userId: number, latestAnalysisId: number) {
const analyses = await db.getUserAnalyses(userId);
if (analyses.length === 0) return;
// Weight recent analyses more heavily
const weightedScores = analyses.slice(0, 20).map((a, i) => {
const weight = Math.max(0.3, 1 - i * 0.05); // Recent = higher weight
return {
overallScore: (a.overallScore || 0) * weight,
strokeConsistency: (a.strokeConsistency || 0) * weight,
footworkScore: (a.footworkScore || 0) * weight,
fluidityScore: (a.fluidityScore || 0) * weight,
shotCount: (a.shotCount || 0) * weight,
avgSwingSpeed: (a.avgSwingSpeed || 0) * weight,
weight,
};
});
const totalWeight = weightedScores.reduce((sum, s) => sum + s.weight, 0);
const dimensions = {
poseAccuracy: weightedScores.reduce((sum, s) => sum + s.overallScore, 0) / totalWeight,
strokeConsistency: weightedScores.reduce((sum, s) => sum + s.strokeConsistency, 0) / totalWeight,
footwork: weightedScores.reduce((sum, s) => sum + s.footworkScore, 0) / totalWeight,
fluidity: weightedScores.reduce((sum, s) => sum + s.fluidityScore, 0) / totalWeight,
power: Math.min(100, weightedScores.reduce((sum, s) => sum + s.avgSwingSpeed, 0) / totalWeight * 5),
};
// Convert 0-100 scores to NTRP 1.0-5.0
// NTRP mapping: 0-20 → 1.0-1.5, 20-40 → 1.5-2.5, 40-60 → 2.5-3.5, 60-80 → 3.5-4.5, 80-100 → 4.5-5.0
const avgDimension = (
dimensions.poseAccuracy * 0.30 +
dimensions.strokeConsistency * 0.25 +
dimensions.footwork * 0.20 +
dimensions.fluidity * 0.15 +
dimensions.power * 0.10
);
let ntrpRating: number;
if (avgDimension <= 20) ntrpRating = 1.0 + (avgDimension / 20) * 0.5;
else if (avgDimension <= 40) ntrpRating = 1.5 + ((avgDimension - 20) / 20) * 1.0;
else if (avgDimension <= 60) ntrpRating = 2.5 + ((avgDimension - 40) / 20) * 1.0;
else if (avgDimension <= 80) ntrpRating = 3.5 + ((avgDimension - 60) / 20) * 1.0;
else ntrpRating = 4.5 + ((avgDimension - 80) / 20) * 0.5;
ntrpRating = Math.round(ntrpRating * 10) / 10;
ntrpRating = Math.max(1.0, Math.min(5.0, ntrpRating));
// Save rating history
await db.createRatingEntry({
userId,
rating: ntrpRating,
reason: `基于${analyses.length}次视频分析自动评分`,
dimensionScores: dimensions,
analysisId: latestAnalysisId,
});
// Update user's current rating
await db.updateUserProfile(userId, { ntrpRating });
}
export type AppRouter = typeof appRouter;