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 { 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"; title: string; payload: Record; 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; }) { 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(); 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, }; } 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, }); }), }), // 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({ sortBy: z.enum(["ntrpRating", "totalMinutes", "totalSessions", "totalShots"]).default("ntrpRating"), limit: z.number().default(50), }).optional()) .query(async ({ input }) => { return db.getLeaderboard(input?.sortBy || "ntrpRating", input?.limit || 50); }), }), // 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 }; }), }), // 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;