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 { invokeLLM } from "./_core/llm"; import { storagePut } from "./storage"; import * as db from "./db"; import { nanoid } from "nanoid"; 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 }) => { const user = ctx.user; // Get user's recent analyses for personalization const analyses = await db.getUserAnalyses(user.id); const recentScores = analyses.slice(0, 5).map(a => ({ score: a.overallScore, issues: a.detectedIssues, exerciseType: a.exerciseType, shotCount: a.shotCount, strokeConsistency: a.strokeConsistency, footworkScore: a.footworkScore, })); const prompt = `你是一位专业网球教练。请为一位${ input.skillLevel === "beginner" ? "初级" : input.skillLevel === "intermediate" ? "中级" : "高级" }水平的网球学员生成一个${input.durationDays}天的在家训练计划。 要求: - 只需要球拍,不需要球场和球网 - 包含影子挥拍、墙壁练习、脚步移动、体能训练等 - 每天训练30-60分钟 ${input.focusAreas?.length ? `- 重点关注: ${input.focusAreas.join(", ")}` : ""} ${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(recentScores)}` : ""} 请返回JSON格式,包含每天的训练内容。`; const response = await invokeLLM({ messages: [ { role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" }, { role: "user", content: prompt }, ], response_format: { type: "json_schema", json_schema: { name: "training_plan", strict: true, schema: { type: "object", properties: { title: { type: "string", description: "训练计划标题" }, exercises: { type: "array", items: { type: "object", properties: { day: { type: "number" }, name: { type: "string" }, category: { type: "string" }, duration: { type: "number", description: "分钟" }, description: { type: "string" }, tips: { type: "string" }, sets: { type: "number" }, reps: { type: "number" }, }, required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"], additionalProperties: false, }, }, }, required: ["title", "exercises"], additionalProperties: false, }, }, }, }); const content = response.choices[0]?.message?.content; const parsed = typeof content === "string" ? JSON.parse(content) : null; if (!parsed) throw new Error("Failed to generate training plan"); const planId = await db.createTrainingPlan({ userId: user.id, title: parsed.title, skillLevel: input.skillLevel, durationDays: input.durationDays, exercises: parsed.exercises, isActive: 1, version: 1, }); return { planId, plan: parsed }; }), 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 analyses = await db.getUserAnalyses(ctx.user.id); const recentAnalyses = analyses.slice(0, 5); const currentPlan = (await db.getUserTrainingPlans(ctx.user.id)).find(p => p.id === input.planId); if (!currentPlan) throw new Error("Plan not found"); const prompt = `基于以下用户的姿势分析结果,调整训练计划: 当前计划: ${JSON.stringify(currentPlan.exercises)} 最近分析结果: ${JSON.stringify(recentAnalyses.map(a => ({ score: a.overallScore, issues: a.detectedIssues, corrections: a.corrections, shotCount: a.shotCount, strokeConsistency: a.strokeConsistency, footworkScore: a.footworkScore, fluidityScore: a.fluidityScore, })))} 请根据分析结果调整训练计划,增加针对薄弱环节的训练,返回与原计划相同格式的JSON。`; const response = await invokeLLM({ messages: [ { role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" }, { role: "user", content: prompt }, ], response_format: { type: "json_schema", json_schema: { name: "adjusted_plan", strict: true, schema: { type: "object", properties: { title: { type: "string" }, adjustmentNotes: { type: "string", description: "调整说明" }, exercises: { type: "array", items: { type: "object", properties: { day: { type: "number" }, name: { type: "string" }, category: { type: "string" }, duration: { type: "number" }, description: { type: "string" }, tips: { type: "string" }, sets: { type: "number" }, reps: { type: "number" }, }, required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"], additionalProperties: false, }, }, }, required: ["title", "adjustmentNotes", "exercises"], additionalProperties: false, }, }, }, }); const content = response.choices[0]?.message?.content; const parsed = typeof content === "string" ? JSON.parse(content) : null; if (!parsed) throw new Error("Failed to adjust plan"); await db.updateTrainingPlan(input.planId, { exercises: parsed.exercises, adjustmentNotes: parsed.adjustmentNotes, version: (currentPlan.version || 1) + 1, }); return { success: true, adjustmentNotes: parsed.adjustmentNotes }; }), }), // 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 videoId = await db.createVideo({ userId: ctx.user.id, title: input.title, fileKey, url, format: input.format, fileSize: input.fileSize, exerciseType: input.exerciseType || null, analysisStatus: "pending", }); return { videoId, url }; }), 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 videoId = await db.createVideo({ userId: ctx.user.id, title: input.title, fileKey: input.fileKey, url: input.url, format: input.format, fileSize: input.fileSize ?? null, duration: input.duration ?? null, exerciseType: input.exerciseType || "recording", analysisStatus: "completed", }); return { videoId, url: input.url }; }), 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(), })) .mutation(async ({ input }) => { const response = await invokeLLM({ messages: [ { role: "system", content: "你是一位专业网球教练。根据MediaPipe姿势分析数据,给出具体的姿势矫正建议。用中文回答。", }, { role: "user", content: `分析以下网球动作数据并给出矫正建议: 动作类型: ${input.exerciseType} 姿势指标: ${JSON.stringify(input.poseMetrics)} 检测到的问题: ${JSON.stringify(input.detectedIssues)} 请给出: 1. 每个问题的具体矫正方法 2. 推荐的练习动作 3. 需要注意的关键点`, }, ], }); return { corrections: response.choices[0]?.message?.content || "暂无建议", }; }), }), // 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;