Implement live analysis achievements and admin console
这个提交包含在:
304
server/trainingAutomation.ts
普通文件
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 };
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户