文件
tennis-training-hub/server/routers.ts
2026-03-15 01:39:34 +08:00

969 行
34 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 { getRemoteMediaSession } from "./mediaService";
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<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,
});
}
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(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");
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 };
}),
// 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 };
}),
}),
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(),
}))
.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.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(),
}).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;