Implement live analysis achievements and admin console

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

304
server/trainingAutomation.ts 普通文件
查看文件

@@ -0,0 +1,304 @@
import * as db from "./db";
export const ACTION_LABELS: Record<string, string> = {
forehand: "正手挥拍",
backhand: "反手挥拍",
serve: "发球",
volley: "截击",
overhead: "高压",
slice: "切削",
lob: "挑高球",
unknown: "未知动作",
};
function toMinutes(durationMs?: number | null) {
if (!durationMs || durationMs <= 0) return 1;
return Math.max(1, Math.round(durationMs / 60000));
}
function normalizeScore(value?: number | null) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.min(100, value));
}
type NtrpTrigger = "analysis" | "daily" | "manual";
export async function refreshUserNtrp(userId: number, options: { triggerType: NtrpTrigger; taskId?: string | null }) {
const analyses = await db.getUserAnalyses(userId);
const aggregates = await db.listDailyTrainingAggregates(userId, 90);
const liveSessions = await db.listLiveAnalysisSessions(userId, 30);
const records = await db.getUserTrainingRecords(userId, 500);
const avgAnalysisScore = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.overallScore || 0), 0) / analyses.length
: 0;
const avgLiveScore = liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.overallScore || 0), 0) / liveSessions.length
: 0;
const avgScore = avgAnalysisScore > 0 || avgLiveScore > 0
? ((avgAnalysisScore || 0) * 0.65 + (avgLiveScore || 0) * 0.35)
: 0;
const avgConsistency = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.strokeConsistency || 0), 0) / analyses.length
: liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.consistencyScore || 0), 0) / liveSessions.length
: 0;
const avgFootwork = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.footworkScore || 0), 0) / analyses.length
: liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.footworkScore || 0), 0) / liveSessions.length
: 0;
const avgFluidity = analyses.length > 0
? analyses.reduce((sum, item) => sum + (item.fluidityScore || 0), 0) / analyses.length
: liveSessions.length > 0
? liveSessions.reduce((sum, item) => sum + (item.techniqueScore || 0), 0) / liveSessions.length
: 0;
const totalMinutes = aggregates.reduce((sum, item) => sum + (item.totalMinutes || 0), 0);
const totalEffectiveActions = aggregates.reduce((sum, item) => sum + (item.effectiveActions || 0), 0);
const totalPk = aggregates.reduce((sum, item) => sum + (item.pkCount || 0), 0);
const activeDays = aggregates.filter(item => (item.sessionCount || 0) > 0).length;
const dimensions = {
poseAccuracy: normalizeScore(avgScore),
strokeConsistency: normalizeScore(avgConsistency),
footwork: normalizeScore(avgFootwork),
fluidity: normalizeScore(avgFluidity),
timing: normalizeScore(avgConsistency * 0.6 + avgScore * 0.4),
matchReadiness: normalizeScore(
Math.min(100, totalPk * 12) * 0.4 +
Math.min(100, activeDays * 3) * 0.3 +
Math.min(100, totalEffectiveActions / 5) * 0.3,
),
activityWeight: normalizeScore(Math.min(100, totalMinutes / 8 + activeDays * 2)),
};
const composite = (
dimensions.poseAccuracy * 0.22 +
dimensions.strokeConsistency * 0.18 +
dimensions.footwork * 0.16 +
dimensions.fluidity * 0.12 +
dimensions.timing * 0.12 +
dimensions.matchReadiness * 0.10 +
dimensions.activityWeight * 0.10
);
let ntrpRating: number;
if (composite <= 20) ntrpRating = 1.0 + (composite / 20) * 0.5;
else if (composite <= 40) ntrpRating = 1.5 + ((composite - 20) / 20) * 1.0;
else if (composite <= 60) ntrpRating = 2.5 + ((composite - 40) / 20) * 1.0;
else if (composite <= 80) ntrpRating = 3.5 + ((composite - 60) / 20) * 1.0;
else ntrpRating = 4.5 + ((composite - 80) / 20) * 0.5;
ntrpRating = Math.max(1.0, Math.min(5.0, Math.round(ntrpRating * 10) / 10));
const snapshotDate = db.getDateKey();
const snapshotKey = `${userId}:${snapshotDate}:${options.triggerType}`;
await db.createRatingEntry({
userId,
rating: ntrpRating,
reason: options.triggerType === "daily" ? "每日异步综合评分刷新" : "手动或分析触发综合评分刷新",
dimensionScores: dimensions,
analysisId: null,
});
await db.createNtrpSnapshot({
snapshotKey,
userId,
snapshotDate,
rating: ntrpRating,
triggerType: options.triggerType,
taskId: options.taskId ?? null,
dimensionScores: dimensions,
sourceSummary: {
analyses: analyses.length,
liveSessions: liveSessions.length,
records: records.length,
activeDays,
totalMinutes,
totalEffectiveActions,
totalPk,
},
});
await db.updateUserProfile(userId, { ntrpRating });
await db.refreshAchievementsForUser(userId);
return {
rating: ntrpRating,
dimensions,
snapshotDate,
};
}
export async function refreshAllUsersNtrp(options: { triggerType: NtrpTrigger; taskId?: string | null }) {
const userIds = await db.listUserIds();
const results = [];
for (const user of userIds) {
const snapshot = await refreshUserNtrp(user.id, options);
results.push({ userId: user.id, ...snapshot });
}
return results;
}
export async function syncAnalysisTrainingData(input: {
userId: number;
videoId: number;
exerciseType?: string | null;
overallScore?: number | null;
shotCount?: number | null;
framesAnalyzed?: number | null;
}) {
const trainingDate = db.getDateKey();
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || "视频分析";
const recordResult = await db.upsertTrainingRecordBySource({
userId: input.userId,
planId: planMatch?.planId ?? null,
linkedPlanId: planMatch?.planId ?? null,
matchConfidence: planMatch?.confidence ?? null,
exerciseName: exerciseLabel,
exerciseType: input.exerciseType || "unknown",
sourceType: "analysis_upload",
sourceId: `analysis:${input.videoId}`,
videoId: input.videoId,
actionCount: input.shotCount ?? 0,
durationMinutes: Math.max(1, Math.round((input.framesAnalyzed || 0) / 60)),
completed: 1,
poseScore: input.overallScore ?? null,
trainingDate: new Date(),
metadata: {
source: "analysis_upload",
shotCount: input.shotCount ?? 0,
},
notes: "自动写入:视频分析",
});
if (recordResult.isNew) {
await db.upsertDailyTrainingAggregate({
userId: input.userId,
trainingDate,
deltaMinutes: Math.max(1, Math.round((input.framesAnalyzed || 0) / 60)),
deltaSessions: 1,
deltaAnalysisCount: 1,
deltaTotalActions: input.shotCount ?? 0,
deltaEffectiveActions: input.shotCount ?? 0,
score: input.overallScore ?? null,
metadata: { latestAnalysisExerciseType: input.exerciseType || "unknown" },
});
}
const unlocked = await db.refreshAchievementsForUser(input.userId);
await refreshUserNtrp(input.userId, { triggerType: "analysis" });
return { recordId: recordResult.recordId, unlocked };
}
export async function syncRecordingTrainingData(input: {
userId: number;
videoId: number;
exerciseType?: string | null;
title: string;
sessionMode?: "practice" | "pk";
durationMinutes?: number | null;
}) {
const trainingDate = db.getDateKey();
const planMatch = await db.matchActivePlanForExercise(input.userId, input.exerciseType);
const exerciseLabel = ACTION_LABELS[input.exerciseType || "unknown"] || input.exerciseType || input.title;
const recordResult = await db.upsertTrainingRecordBySource({
userId: input.userId,
planId: planMatch?.planId ?? null,
linkedPlanId: planMatch?.planId ?? null,
matchConfidence: planMatch?.confidence ?? null,
exerciseName: exerciseLabel,
exerciseType: input.exerciseType || "unknown",
sourceType: "recording",
sourceId: `recording:${input.videoId}`,
videoId: input.videoId,
actionCount: 0,
durationMinutes: Math.max(1, input.durationMinutes ?? 5),
completed: 1,
poseScore: null,
trainingDate: new Date(),
metadata: {
source: "recording",
sessionMode: input.sessionMode || "practice",
title: input.title,
},
notes: "自动写入:录制归档",
});
if (recordResult.isNew) {
await db.upsertDailyTrainingAggregate({
userId: input.userId,
trainingDate,
deltaMinutes: Math.max(1, input.durationMinutes ?? 5),
deltaSessions: 1,
deltaRecordingCount: 1,
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
metadata: { latestRecordingExerciseType: input.exerciseType || "unknown" },
});
}
const unlocked = await db.refreshAchievementsForUser(input.userId);
return { recordId: recordResult.recordId, unlocked };
}
export async function syncLiveTrainingData(input: {
userId: number;
sessionId: number;
title: string;
sessionMode: "practice" | "pk";
dominantAction?: string | null;
durationMs: number;
overallScore?: number | null;
effectiveSegments: number;
totalSegments: number;
unknownSegments: number;
videoId?: number | null;
}) {
const trainingDate = db.getDateKey();
const planMatch = await db.matchActivePlanForExercise(input.userId, input.dominantAction);
const exerciseLabel = ACTION_LABELS[input.dominantAction || "unknown"] || input.title;
const recordResult = await db.upsertTrainingRecordBySource({
userId: input.userId,
planId: planMatch?.planId ?? null,
linkedPlanId: planMatch?.planId ?? null,
matchConfidence: planMatch?.confidence ?? null,
exerciseName: exerciseLabel,
exerciseType: input.dominantAction || "unknown",
sourceType: "live_analysis",
sourceId: `live:${input.sessionId}`,
videoId: input.videoId ?? null,
actionCount: input.effectiveSegments,
durationMinutes: toMinutes(input.durationMs),
completed: 1,
poseScore: input.overallScore ?? null,
trainingDate: new Date(),
metadata: {
source: "live_analysis",
sessionMode: input.sessionMode,
totalSegments: input.totalSegments,
unknownSegments: input.unknownSegments,
},
notes: "自动写入:实时分析",
});
if (recordResult.isNew) {
await db.upsertDailyTrainingAggregate({
userId: input.userId,
trainingDate,
deltaMinutes: toMinutes(input.durationMs),
deltaSessions: 1,
deltaLiveAnalysisCount: 1,
deltaPkCount: input.sessionMode === "pk" ? 1 : 0,
deltaTotalActions: input.totalSegments,
deltaEffectiveActions: input.effectiveSegments,
deltaUnknownActions: input.unknownSegments,
score: input.overallScore ?? null,
metadata: { latestLiveDominantAction: input.dominantAction || "unknown" },
});
}
const unlocked = await db.refreshAchievementsForUser(input.userId);
await refreshUserNtrp(input.userId, { triggerType: "analysis" });
return { recordId: recordResult.recordId, unlocked };
}