import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { z } from "zod"; import { sdk } from "./_core/sdk"; import { storagePut } from "./storage"; import * as db from "./db"; import { nanoid } from "nanoid"; import { getRemoteMediaSession } from "./mediaService"; import { prepareCorrectionImageUrls } from "./taskWorker"; import { toPublicUrl } from "./publicUrl"; async function enqueueTask(params: { userId: number; type: "media_finalize" | "training_plan_generate" | "training_plan_adjust" | "analysis_corrections" | "pose_correction_multimodal"; 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 }; } 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().min(1).max(64) })) .mutation(async ({ ctx, input }) => { const { user, isNew } = await db.createUsernameAccount(input.username); const sessionToken = await sdk.createSessionToken(user.openId, { name: user.name || input.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(z.object({ skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(), trainingGoals: z.string().optional(), })) .mutation(async ({ ctx, input }) => { await db.updateUserProfile(ctx.user.id, input); 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 }) => { 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(), 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, exerciseType: input.exerciseType || null, analysisStatus: "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 ({ input }) => { return db.getVideoById(input.videoId); }), 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 }; }), }), // 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"); // Auto-update NTRP rating after analysis await recalculateNTRPRating(ctx.user.id, analysisId); return { analysisId }; }), 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); }), // 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, }, }); }), }), 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(), })) .mutation(async ({ ctx, input }) => { const session = await getRemoteMediaSession(input.sessionId); if (session.userId !== String(ctx.user.id)) { throw new Error("Media session not found"); } 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.getUserRatingHistory(ctx.user.id); }), current: protectedProcedure.query(async ({ ctx }) => { const user = await db.getUserByOpenId(ctx.user.openId); return { rating: user?.ntrpRating || 1.5 }; }), }), // 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); }), }), // 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 })); }), }), // 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(), }).optional()) .query(async ({ input }) => { // Auto-seed tutorials on first request await db.seedTutorials(); return db.getTutorials(input?.category, input?.skillLevel); }), get: publicProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { 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(), 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); return { success: true }; }), }), // 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;