import * as db from "./db"; export const ACTION_LABELS: Record = { 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 }; }