Implement live analysis achievements and admin console

这个提交包含在:
cryptocommuniums-afk
2026-03-15 01:39:34 +08:00
父节点 d1b6603061
当前提交 edc66ea5bc
修改 23 个文件,包含 4033 行新增1022 行删除

查看文件

@@ -12,10 +12,11 @@ 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";
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;
@@ -36,6 +37,24 @@ async function enqueueTask(params: {
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,
@@ -234,11 +253,16 @@ export const appRouter = router({
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,
});
// Auto-update NTRP rating after analysis
await recalculateNTRPRating(ctx.user.id, analysisId);
return { analysisId };
return { analysisId, trainingRecordId: syncResult.recordId };
}),
getByVideo: protectedProcedure
@@ -251,6 +275,120 @@ export const appRouter = router({
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({
@@ -412,6 +550,8 @@ export const appRouter = router({
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);
@@ -476,11 +616,21 @@ export const appRouter = router({
// Rating system
rating: router({
history: protectedProcedure.query(async ({ ctx }) => {
return db.getUserRatingHistory(ctx.user.id);
return db.listNtrpSnapshots(ctx.user.id);
}),
current: protectedProcedure.query(async ({ ctx }) => {
const user = await db.getUserByOpenId(ctx.user.openId);
return { rating: user?.ntrpRating || 1.5 };
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 },
});
}),
}),
@@ -507,6 +657,15 @@ export const appRouter = router({
}),
}),
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 }) => {
@@ -531,6 +690,92 @@ export const appRouter = router({
}),
}),
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