Implement live analysis achievements and admin console
这个提交包含在:
@@ -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
|
||||
|
||||
在新工单中引用
屏蔽一个用户