305 行
11 KiB
TypeScript
305 行
11 KiB
TypeScript
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 };
|
|
}
|