setTitle(event.target.value)}
placeholder="本次训练录制标题"
className="h-12 rounded-2xl border-border/60"
/>
+
{renderPrimaryActions()}
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 7fb63d7..f977431 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -10,14 +10,18 @@
### 用户与训练
- 用户名登录:无需注册,输入用户名即可进入训练工作台
+- 新用户邀请:首次创建用户名账号需要邀请码 `CA2026`
- 训练计划:按技能等级和训练周期生成训练计划,改为后台异步生成
- 训练进度:展示训练次数、时长、评分趋势、最近分析结果
-- 每日打卡与提醒:支持训练打卡、提醒、通知记录
+- 成就系统与提醒:训练日聚合、成就进度、连练统计、提醒、通知记录
### 视频与分析
- 视频上传分析:上传 `webm/mp4` 视频进入视频库并触发分析流程
-- 实时摄像头分析:浏览器端调用 MediaPipe,进行姿势识别和反馈展示
+- 实时摄像头分析:浏览器端调用 MediaPipe,自动识别 `forehand/backhand/serve/volley/overhead/slice/lob/unknown`
+- 连续动作片段:自动聚合连续同类动作区间,单段不超过 10 秒,并保存得分、置信度与反馈摘要
+- 实时分析录制:分析阶段可同步保留浏览器端本地录制视频,停止分析后自动登记到系统
+- 训练数据回写:实时分析与录制数据自动写入训练记录、日训练聚合、成就系统和 NTRP 评分
- 动作纠正:支持文本纠正和多模态纠正两条链路,统一通过后台任务执行
- 多模态图片输入:上传关键帧后会转换为公网可访问的绝对 URL,再提交给视觉模型
- 视觉标准图库:内置网球公网参考图,可直接发起视觉识别测试并保存结果
@@ -36,6 +40,15 @@
- 视频库登记:归档完成后由 app worker 自动写回现有视频库
- 上传稳定性:媒体分段上传遇到 `502/503/504` 会自动重试
+### 评分、成就与管理
+
+- 每日异步 NTRP:系统会在每日零点后自动排队全量 NTRP 刷新任务
+- 用户手动刷新:普通用户可刷新自己的 NTRP;管理员可刷新任意用户或全量用户
+- NTRP 快照:每次刷新都会生成可追踪的快照,保存维度评分和数据来源摘要
+- 成就定义表:成就系统已独立于旧徽章表,支持大规模扩展、分层、隐藏成就与分类
+- 管理系统:`/admin` 提供用户管理、任务列表、实时分析会话列表、应用设置和审计日志
+- H1 管理能力:当 `H1` 被配置为 admin 后,可查看全部视觉测试数据与后台管理数据
+
## 前端能力
### 移动端
@@ -46,6 +59,7 @@
- 横屏视频优先布局
- 录制页和分析页防下拉刷新干扰
- 录制时按设备场景自动调整码率和控件密度
+- 实时分析页支持竖屏最大化预览,主要操作按钮放在侧边
### 桌面端
@@ -76,6 +90,7 @@
- 当前 PC 剪辑仍处于基础媒体域准备阶段,未交付完整多轨编辑器
- 当前存储策略为本地卷优先,未接入对象存储归档
- 当前 `.env` 配置的视觉网关若忽略 `LLM_VISION_MODEL`,系统会回退到文本纠正;代码已支持独立视觉模型配置,但上游网关能力仍需单独确认
+- 当前实时动作识别仍基于姿态启发式分类,不是专门训练的动作识别模型
## 后续增强方向
diff --git a/docs/developer-workflow.md b/docs/developer-workflow.md
index a894eb3..c1e1a14 100644
--- a/docs/developer-workflow.md
+++ b/docs/developer-workflow.md
@@ -11,6 +11,7 @@
## Recommended loop
```bash
+set -a && source .env && set +a && pnpm exec drizzle-kit migrate
pnpm check
pnpm test
pnpm test:go
@@ -31,11 +32,12 @@ git commit -m "..."
如果业务开发中被打断,恢复时按以下顺序:
1. `git status` 查看当前工作树
-2. 先跑 `pnpm check`
-3. 再跑 `pnpm test`
-4. 若涉及媒体链路,再跑 `pnpm test:go`
-5. 最后跑 `pnpm test:e2e`
-6. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
+2. 若 schema 或环境变量改动过,先执行 `set -a && source .env && set +a && pnpm exec drizzle-kit migrate`
+3. 再跑 `pnpm check`
+4. 再跑 `pnpm test`
+5. 若涉及媒体链路,再跑 `pnpm test:go`
+6. 最后跑 `pnpm test:e2e`
+7. 若当前分支包含部署改动,再执行 `docker compose config` 与基础 smoke check
不要在一半状态下长时间保留“能编译但主流程不可用”的改动。
@@ -55,6 +57,7 @@ git commit -m "..."
- `client/src/lib/media.ts`
- `client/src/pages/Recorder.tsx`
+- `client/src/pages/LiveCamera.tsx`
- `media/main.go`
- `server/routers.ts`
- `server/_core/mediaProxy.ts`
@@ -65,6 +68,7 @@ git commit -m "..."
- marker 写入
- finalize
- 视频库登记
+- 实时分析停止后的会话保存和训练数据回写
## Documentation discipline
diff --git a/docs/testing.md b/docs/testing.md
index 5ffbd9b..054b849 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -19,6 +19,7 @@
当前覆盖:
- Node/tRPC 路由输入校验与权限检查
+- 实时分析会话保存、管理员权限与异步 NTRP 刷新入队
- LLM 模块请求配置与环境变量回退逻辑
- 视觉模型 per-request model override 能力
- 视觉标准图库路由与 admin/H1 全量可见性逻辑
@@ -41,11 +42,18 @@
使用 Playwright。为保证稳定性:
- 启动本地测试服务器 `pnpm dev:test`
+- 测试服务器启动前要求数据库已完成 Drizzle 迁移
- 通过路由拦截模拟 tRPC 和 `/media` 接口
- 注入假媒体设备、假 `MediaRecorder` 和假 `RTCPeerConnection`
-这样可以自动验证前端主流程,而不依赖数据库、真实摄像头权限和真实 WebRTC 网络环境。
-当前 E2E 已覆盖新的后台任务流和任务中心依赖的接口 mock。
+这样可以自动验证前端主流程,而不依赖真实摄像头权限和真实 WebRTC 网络环境。
+当前 E2E 已覆盖新的后台任务流、实时分析入口、录制焦点视图和任务中心依赖的接口 mock。
+
+首次在新库或新 schema 上执行前,先跑:
+
+```bash
+set -a && source .env && set +a && pnpm exec drizzle-kit migrate
+```
## Unified verification
@@ -109,7 +117,10 @@ pnpm test:llm
- 打开 `https://te.hao.work/`
- 打开 `https://te.hao.work/login`
+- 打开 `https://te.hao.work/checkin`
+- 打开 `https://te.hao.work/admin`(管理员)
- 打开 `https://te.hao.work/recorder`
+- 打开 `https://te.hao.work/live-camera`
- 确认没有 `pageerror` 或首屏 `console.error`
## Local browser prerequisites
@@ -125,6 +136,7 @@ pnpm exec playwright install chromium
- E2E 目前验证的是“模块主流程是否正常”,不是媒体编码质量本身
- 若需要真实录制验证,可额外用本地 Chrome 和真实摄像头做手工联调
- 若 `pnpm test:e2e` 失败,优先检查:
+ - 本地数据库是否已执行最新 Drizzle 迁移
- `PORT=3100` 是否被占用
- 浏览器依赖是否安装
- 前端路由或测试标识是否被改动
diff --git a/docs/verified-features.md b/docs/verified-features.md
index 7dd54d0..47e5f83 100644
--- a/docs/verified-features.md
+++ b/docs/verified-features.md
@@ -1,12 +1,13 @@
# Verified Features
-本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:52 CST。
+本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 01:39 CST。
## 最新完整验证记录
- 通过命令:`pnpm verify`
-- 验证时间:2026-03-15 00:51 CST
-- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(90/90),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6),`pnpm test:llm` 通过
+- 验证时间:2026-03-15 01:38 - 01:39 CST
+- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(95/95),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6)
+- 数据库状态:已执行 `set -a && source .env && set +a && pnpm exec drizzle-kit migrate`,`0007_grounded_live_ops` 已成功应用
## 生产部署联测
@@ -44,9 +45,12 @@
| plan | `pnpm test` | 通过 |
| video | `pnpm test` | 通过 |
| analysis | `pnpm test` | 通过 |
+| live analysis 会话保存 | `pnpm test` | 通过 |
| record | `pnpm test` | 通过 |
| rating | `pnpm test` | 通过 |
-| checkin | `pnpm test` | 通过 |
+| achievement | `pnpm test` | 通过 |
+| admin | `pnpm test` | 通过 |
+| checkin 兼容路由 | `pnpm test` | 通过 |
| badge | `pnpm test` | 通过 |
| leaderboard | `pnpm test` | 通过 |
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
@@ -95,6 +99,7 @@
- Playwright 运行依赖 mocked media/network,不等价于真机摄像头、真实弱网和真实 WebRTC 质量验收
- 当前上游视觉网关可能忽略 `LLM_VISION_MODEL` 并回退为文本模型;服务端已实现自动降级,任务不会因此直接失败
- 2026-03-15 的真实标准图测试中,正手 / 反手 / 发球三条记录均以 `fallback` 完成,说明当前上游视觉网关仍未稳定返回结构化视觉结果
+- 开发服务器启动阶段仍会打印 `OAUTH_SERVER_URL` 未配置提示;当前用户名登录、mock auth 和自动化测试不受影响
## 当前未纳入自动验证的内容
diff --git a/drizzle/0007_grounded_live_ops.sql b/drizzle/0007_grounded_live_ops.sql
new file mode 100644
index 0000000..ee66fad
--- /dev/null
+++ b/drizzle/0007_grounded_live_ops.sql
@@ -0,0 +1,159 @@
+ALTER TABLE `training_records`
+ ADD `exerciseType` varchar(64),
+ ADD `sourceType` varchar(32) DEFAULT 'manual',
+ ADD `sourceId` varchar(64),
+ ADD `videoId` int,
+ ADD `linkedPlanId` int,
+ ADD `matchConfidence` float,
+ ADD `actionCount` int DEFAULT 0,
+ ADD `metadata` json;
+--> statement-breakpoint
+CREATE TABLE `live_analysis_sessions` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `userId` int NOT NULL,
+ `title` varchar(256) NOT NULL,
+ `sessionMode` enum('practice','pk') NOT NULL DEFAULT 'practice',
+ `status` enum('active','completed','aborted') NOT NULL DEFAULT 'completed',
+ `startedAt` timestamp NOT NULL DEFAULT (now()),
+ `endedAt` timestamp,
+ `durationMs` int NOT NULL DEFAULT 0,
+ `dominantAction` varchar(64),
+ `overallScore` float,
+ `postureScore` float,
+ `balanceScore` float,
+ `techniqueScore` float,
+ `footworkScore` float,
+ `consistencyScore` float,
+ `unknownActionRatio` float,
+ `totalSegments` int NOT NULL DEFAULT 0,
+ `effectiveSegments` int NOT NULL DEFAULT 0,
+ `totalActionCount` int NOT NULL DEFAULT 0,
+ `videoId` int,
+ `videoUrl` text,
+ `summary` text,
+ `feedback` json,
+ `metrics` json,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `live_analysis_sessions_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `live_action_segments` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `sessionId` int NOT NULL,
+ `actionType` varchar(64) NOT NULL,
+ `isUnknown` int NOT NULL DEFAULT 0,
+ `startMs` int NOT NULL,
+ `endMs` int NOT NULL,
+ `durationMs` int NOT NULL,
+ `confidenceAvg` float,
+ `score` float,
+ `peakScore` float,
+ `frameCount` int NOT NULL DEFAULT 0,
+ `issueSummary` json,
+ `keyFrames` json,
+ `clipLabel` varchar(128),
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `live_action_segments_id` PRIMARY KEY(`id`),
+ CONSTRAINT `live_action_segments_session_start_idx` UNIQUE(`sessionId`,`startMs`)
+);
+--> statement-breakpoint
+CREATE TABLE `daily_training_aggregates` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `dayKey` varchar(32) NOT NULL,
+ `userId` int NOT NULL,
+ `trainingDate` varchar(10) NOT NULL,
+ `totalMinutes` int NOT NULL DEFAULT 0,
+ `sessionCount` int NOT NULL DEFAULT 0,
+ `analysisCount` int NOT NULL DEFAULT 0,
+ `liveAnalysisCount` int NOT NULL DEFAULT 0,
+ `recordingCount` int NOT NULL DEFAULT 0,
+ `pkCount` int NOT NULL DEFAULT 0,
+ `totalActions` int NOT NULL DEFAULT 0,
+ `effectiveActions` int NOT NULL DEFAULT 0,
+ `unknownActions` int NOT NULL DEFAULT 0,
+ `totalScore` float NOT NULL DEFAULT 0,
+ `averageScore` float NOT NULL DEFAULT 0,
+ `metadata` json,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `daily_training_aggregates_id` PRIMARY KEY(`id`),
+ CONSTRAINT `daily_training_aggregates_dayKey_unique` UNIQUE(`dayKey`)
+);
+--> statement-breakpoint
+CREATE TABLE `ntrp_snapshots` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `snapshotKey` varchar(64) NOT NULL,
+ `userId` int NOT NULL,
+ `snapshotDate` varchar(10) NOT NULL,
+ `rating` float NOT NULL,
+ `triggerType` enum('analysis','daily','manual') NOT NULL DEFAULT 'daily',
+ `taskId` varchar(64),
+ `dimensionScores` json,
+ `sourceSummary` json,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `ntrp_snapshots_id` PRIMARY KEY(`id`),
+ CONSTRAINT `ntrp_snapshots_snapshotKey_unique` UNIQUE(`snapshotKey`)
+);
+--> statement-breakpoint
+CREATE TABLE `achievement_definitions` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `key` varchar(64) NOT NULL,
+ `name` varchar(128) NOT NULL,
+ `description` text,
+ `category` varchar(32) NOT NULL,
+ `rarity` varchar(16) NOT NULL DEFAULT 'common',
+ `icon` varchar(16) NOT NULL DEFAULT '🎾',
+ `metricKey` varchar(64) NOT NULL,
+ `targetValue` float NOT NULL,
+ `tier` int NOT NULL DEFAULT 1,
+ `isHidden` int NOT NULL DEFAULT 0,
+ `isActive` int NOT NULL DEFAULT 1,
+ `sortOrder` int NOT NULL DEFAULT 0,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `achievement_definitions_id` PRIMARY KEY(`id`),
+ CONSTRAINT `achievement_definitions_key_unique` UNIQUE(`key`)
+);
+--> statement-breakpoint
+CREATE TABLE `user_achievements` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `progressKey` varchar(96) NOT NULL,
+ `userId` int NOT NULL,
+ `achievementKey` varchar(64) NOT NULL,
+ `currentValue` float NOT NULL DEFAULT 0,
+ `progressPct` float NOT NULL DEFAULT 0,
+ `unlockedAt` timestamp,
+ `lastEvaluatedAt` timestamp NOT NULL DEFAULT (now()),
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `user_achievements_id` PRIMARY KEY(`id`),
+ CONSTRAINT `user_achievements_progressKey_unique` UNIQUE(`progressKey`)
+);
+--> statement-breakpoint
+ALTER TABLE `background_tasks`
+ MODIFY COLUMN `type` enum('media_finalize','training_plan_generate','training_plan_adjust','analysis_corrections','pose_correction_multimodal','ntrp_refresh_user','ntrp_refresh_all') NOT NULL;
+--> statement-breakpoint
+CREATE TABLE `admin_audit_logs` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `adminUserId` int NOT NULL,
+ `actionType` varchar(64) NOT NULL,
+ `entityType` varchar(64) NOT NULL,
+ `entityId` varchar(96),
+ `targetUserId` int,
+ `payload` json,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ CONSTRAINT `admin_audit_logs_id` PRIMARY KEY(`id`)
+);
+--> statement-breakpoint
+CREATE TABLE `app_settings` (
+ `id` int AUTO_INCREMENT NOT NULL,
+ `settingKey` varchar(64) NOT NULL,
+ `label` varchar(128) NOT NULL,
+ `description` text,
+ `value` json,
+ `createdAt` timestamp NOT NULL DEFAULT (now()),
+ `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT `app_settings_id` PRIMARY KEY(`id`),
+ CONSTRAINT `app_settings_settingKey_unique` UNIQUE(`settingKey`)
+);
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 544eb7a..28729a8 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -50,6 +50,13 @@
"when": 1773510000000,
"tag": "0006_solid_vision_library",
"breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "5",
+ "when": 1773543600000,
+ "tag": "0007_grounded_live_ops",
+ "breakpoints": true
}
]
}
diff --git a/drizzle/schema.ts b/drizzle/schema.ts
index c39a637..05e1870 100644
--- a/drizzle/schema.ts
+++ b/drizzle/schema.ts
@@ -1,4 +1,4 @@
-import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float } from "drizzle-orm/mysql-core";
+import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float, uniqueIndex } from "drizzle-orm/mysql-core";
/**
* Core user table - supports both OAuth and simple username login
@@ -152,6 +152,18 @@ export const trainingRecords = mysqlTable("training_records", {
planId: int("planId"),
/** Exercise name/type */
exerciseName: varchar("exerciseName", { length: 128 }).notNull(),
+ exerciseType: varchar("exerciseType", { length: 64 }),
+ /** Source of the training fact */
+ sourceType: varchar("sourceType", { length: 32 }).default("manual"),
+ /** Reference id from source system */
+ sourceId: varchar("sourceId", { length: 64 }),
+ /** Optional linked video */
+ videoId: int("videoId"),
+ /** Optional linked plan match */
+ linkedPlanId: int("linkedPlanId"),
+ matchConfidence: float("matchConfidence"),
+ actionCount: int("actionCount").default(0),
+ metadata: json("metadata"),
/** Duration in minutes */
durationMinutes: int("durationMinutes"),
/** Completion status */
@@ -168,6 +180,94 @@ export const trainingRecords = mysqlTable("training_records", {
export type TrainingRecord = typeof trainingRecords.$inferSelect;
export type InsertTrainingRecord = typeof trainingRecords.$inferInsert;
+/**
+ * Live analysis sessions captured from the realtime camera workflow.
+ */
+export const liveAnalysisSessions = mysqlTable("live_analysis_sessions", {
+ id: int("id").autoincrement().primaryKey(),
+ userId: int("userId").notNull(),
+ title: varchar("title", { length: 256 }).notNull(),
+ sessionMode: mysqlEnum("sessionMode", ["practice", "pk"]).default("practice").notNull(),
+ status: mysqlEnum("status", ["active", "completed", "aborted"]).default("completed").notNull(),
+ startedAt: timestamp("startedAt").defaultNow().notNull(),
+ endedAt: timestamp("endedAt"),
+ durationMs: int("durationMs").default(0).notNull(),
+ dominantAction: varchar("dominantAction", { length: 64 }),
+ overallScore: float("overallScore"),
+ postureScore: float("postureScore"),
+ balanceScore: float("balanceScore"),
+ techniqueScore: float("techniqueScore"),
+ footworkScore: float("footworkScore"),
+ consistencyScore: float("consistencyScore"),
+ unknownActionRatio: float("unknownActionRatio"),
+ totalSegments: int("totalSegments").default(0).notNull(),
+ effectiveSegments: int("effectiveSegments").default(0).notNull(),
+ totalActionCount: int("totalActionCount").default(0).notNull(),
+ videoId: int("videoId"),
+ videoUrl: text("videoUrl"),
+ summary: text("summary"),
+ feedback: json("feedback"),
+ metrics: json("metrics"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type LiveAnalysisSession = typeof liveAnalysisSessions.$inferSelect;
+export type InsertLiveAnalysisSession = typeof liveAnalysisSessions.$inferInsert;
+
+/**
+ * Action segments extracted from a realtime analysis session.
+ */
+export const liveActionSegments = mysqlTable("live_action_segments", {
+ id: int("id").autoincrement().primaryKey(),
+ sessionId: int("sessionId").notNull(),
+ actionType: varchar("actionType", { length: 64 }).notNull(),
+ isUnknown: int("isUnknown").default(0).notNull(),
+ startMs: int("startMs").notNull(),
+ endMs: int("endMs").notNull(),
+ durationMs: int("durationMs").notNull(),
+ confidenceAvg: float("confidenceAvg"),
+ score: float("score"),
+ peakScore: float("peakScore"),
+ frameCount: int("frameCount").default(0).notNull(),
+ issueSummary: json("issueSummary"),
+ keyFrames: json("keyFrames"),
+ clipLabel: varchar("clipLabel", { length: 128 }),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+}, (table) => ({
+ sessionIndex: uniqueIndex("live_action_segments_session_start_idx").on(table.sessionId, table.startMs),
+}));
+
+export type LiveActionSegment = typeof liveActionSegments.$inferSelect;
+export type InsertLiveActionSegment = typeof liveActionSegments.$inferInsert;
+
+/**
+ * Daily training aggregate used for streaks, achievements and daily NTRP refresh.
+ */
+export const dailyTrainingAggregates = mysqlTable("daily_training_aggregates", {
+ id: int("id").autoincrement().primaryKey(),
+ dayKey: varchar("dayKey", { length: 32 }).notNull().unique(),
+ userId: int("userId").notNull(),
+ trainingDate: varchar("trainingDate", { length: 10 }).notNull(),
+ totalMinutes: int("totalMinutes").default(0).notNull(),
+ sessionCount: int("sessionCount").default(0).notNull(),
+ analysisCount: int("analysisCount").default(0).notNull(),
+ liveAnalysisCount: int("liveAnalysisCount").default(0).notNull(),
+ recordingCount: int("recordingCount").default(0).notNull(),
+ pkCount: int("pkCount").default(0).notNull(),
+ totalActions: int("totalActions").default(0).notNull(),
+ effectiveActions: int("effectiveActions").default(0).notNull(),
+ unknownActions: int("unknownActions").default(0).notNull(),
+ totalScore: float("totalScore").default(0).notNull(),
+ averageScore: float("averageScore").default(0).notNull(),
+ metadata: json("metadata"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type DailyTrainingAggregate = typeof dailyTrainingAggregates.$inferSelect;
+export type InsertDailyTrainingAggregate = typeof dailyTrainingAggregates.$inferInsert;
+
/**
* NTRP Rating history - tracks rating changes over time
*/
@@ -188,6 +288,25 @@ export const ratingHistory = mysqlTable("rating_history", {
export type RatingHistory = typeof ratingHistory.$inferSelect;
export type InsertRatingHistory = typeof ratingHistory.$inferInsert;
+/**
+ * Daily NTRP snapshots generated by async refresh jobs.
+ */
+export const ntrpSnapshots = mysqlTable("ntrp_snapshots", {
+ id: int("id").autoincrement().primaryKey(),
+ snapshotKey: varchar("snapshotKey", { length: 64 }).notNull().unique(),
+ userId: int("userId").notNull(),
+ snapshotDate: varchar("snapshotDate", { length: 10 }).notNull(),
+ rating: float("rating").notNull(),
+ triggerType: mysqlEnum("triggerType", ["analysis", "daily", "manual"]).default("daily").notNull(),
+ taskId: varchar("taskId", { length: 64 }),
+ dimensionScores: json("dimensionScores"),
+ sourceSummary: json("sourceSummary"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type NtrpSnapshot = typeof ntrpSnapshots.$inferSelect;
+export type InsertNtrpSnapshot = typeof ntrpSnapshots.$inferInsert;
+
/**
* Daily check-in records for streak tracking
*/
@@ -223,6 +342,49 @@ export const userBadges = mysqlTable("user_badges", {
export type UserBadge = typeof userBadges.$inferSelect;
export type InsertUserBadge = typeof userBadges.$inferInsert;
+/**
+ * Achievement definitions that can scale beyond the legacy badge system.
+ */
+export const achievementDefinitions = mysqlTable("achievement_definitions", {
+ id: int("id").autoincrement().primaryKey(),
+ key: varchar("key", { length: 64 }).notNull().unique(),
+ name: varchar("name", { length: 128 }).notNull(),
+ description: text("description"),
+ category: varchar("category", { length: 32 }).notNull(),
+ rarity: varchar("rarity", { length: 16 }).default("common").notNull(),
+ icon: varchar("icon", { length: 16 }).default("🎾").notNull(),
+ metricKey: varchar("metricKey", { length: 64 }).notNull(),
+ targetValue: float("targetValue").notNull(),
+ tier: int("tier").default(1).notNull(),
+ isHidden: int("isHidden").default(0).notNull(),
+ isActive: int("isActive").default(1).notNull(),
+ sortOrder: int("sortOrder").default(0).notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type AchievementDefinition = typeof achievementDefinitions.$inferSelect;
+export type InsertAchievementDefinition = typeof achievementDefinitions.$inferInsert;
+
+/**
+ * User achievement progress and unlock records.
+ */
+export const userAchievements = mysqlTable("user_achievements", {
+ id: int("id").autoincrement().primaryKey(),
+ progressKey: varchar("progressKey", { length: 96 }).notNull().unique(),
+ userId: int("userId").notNull(),
+ achievementKey: varchar("achievementKey", { length: 64 }).notNull(),
+ currentValue: float("currentValue").default(0).notNull(),
+ progressPct: float("progressPct").default(0).notNull(),
+ unlockedAt: timestamp("unlockedAt"),
+ lastEvaluatedAt: timestamp("lastEvaluatedAt").defaultNow().notNull(),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type UserAchievement = typeof userAchievements.$inferSelect;
+export type InsertUserAchievement = typeof userAchievements.$inferInsert;
+
/**
* Tutorial video library - professional coaching reference videos
*/
@@ -313,6 +475,8 @@ export const backgroundTasks = mysqlTable("background_tasks", {
"training_plan_adjust",
"analysis_corrections",
"pose_correction_multimodal",
+ "ntrp_refresh_user",
+ "ntrp_refresh_all",
]).notNull(),
status: mysqlEnum("status", ["queued", "running", "succeeded", "failed"]).notNull().default("queued"),
title: varchar("title", { length: 256 }).notNull(),
@@ -335,6 +499,39 @@ export const backgroundTasks = mysqlTable("background_tasks", {
export type BackgroundTask = typeof backgroundTasks.$inferSelect;
export type InsertBackgroundTask = typeof backgroundTasks.$inferInsert;
+/**
+ * Admin audit trail for privileged actions.
+ */
+export const adminAuditLogs = mysqlTable("admin_audit_logs", {
+ id: int("id").autoincrement().primaryKey(),
+ adminUserId: int("adminUserId").notNull(),
+ actionType: varchar("actionType", { length: 64 }).notNull(),
+ entityType: varchar("entityType", { length: 64 }).notNull(),
+ entityId: varchar("entityId", { length: 96 }),
+ targetUserId: int("targetUserId"),
+ payload: json("payload"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+});
+
+export type AdminAuditLog = typeof adminAuditLogs.$inferSelect;
+export type InsertAdminAuditLog = typeof adminAuditLogs.$inferInsert;
+
+/**
+ * App settings editable from the admin console.
+ */
+export const appSettings = mysqlTable("app_settings", {
+ id: int("id").autoincrement().primaryKey(),
+ settingKey: varchar("settingKey", { length: 64 }).notNull().unique(),
+ label: varchar("label", { length: 128 }).notNull(),
+ description: text("description"),
+ value: json("value"),
+ createdAt: timestamp("createdAt").defaultNow().notNull(),
+ updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
+});
+
+export type AppSetting = typeof appSettings.$inferSelect;
+export type InsertAppSetting = typeof appSettings.$inferInsert;
+
/**
* Vision reference library - canonical public tennis images used for multimodal evaluation
*/
diff --git a/server/_core/index.ts b/server/_core/index.ts
index e6024fd..9df3408 100644
--- a/server/_core/index.ts
+++ b/server/_core/index.ts
@@ -9,7 +9,39 @@ import { appRouter } from "../routers";
import { createContext } from "./context";
import { registerMediaProxy } from "./mediaProxy";
import { serveStatic } from "./static";
-import { seedTutorials, seedVisionReferenceImages } from "../db";
+import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
+import { nanoid } from "nanoid";
+
+async function scheduleDailyNtrpRefresh() {
+ const now = new Date();
+ if (now.getHours() !== 0 || now.getMinutes() > 5) {
+ return;
+ }
+
+ const midnight = new Date();
+ midnight.setHours(0, 0, 0, 0);
+ const exists = await hasRecentBackgroundTaskOfType("ntrp_refresh_all", midnight);
+ if (exists) {
+ return;
+ }
+
+ const adminUserId = await getAdminUserId();
+ if (!adminUserId) {
+ return;
+ }
+
+ const taskId = nanoid();
+ await createBackgroundTask({
+ id: taskId,
+ userId: adminUserId,
+ type: "ntrp_refresh_all",
+ title: "每日 NTRP 刷新",
+ message: "系统已自动创建每日 NTRP 刷新任务",
+ payload: { source: "scheduler", scheduledAt: now.toISOString() },
+ progress: 0,
+ maxAttempts: 3,
+ });
+}
function isPortAvailable(port: number): Promise
{
return new Promise(resolve => {
@@ -33,6 +65,8 @@ async function findAvailablePort(startPort: number = 3000): Promise {
async function startServer() {
await seedTutorials();
await seedVisionReferenceImages();
+ await seedAchievementDefinitions();
+ await seedAppSettings();
const app = express();
const server = createServer(app);
@@ -73,6 +107,12 @@ async function startServer() {
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
+
+ setInterval(() => {
+ void scheduleDailyNtrpRefresh().catch((error) => {
+ console.error("[scheduler] failed to schedule NTRP refresh", error);
+ });
+ }, 60_000);
}
startServer().catch(console.error);
diff --git a/server/db.ts b/server/db.ts
index e14a814..7440aa6 100644
--- a/server/db.ts
+++ b/server/db.ts
@@ -1,4 +1,4 @@
-import { eq, desc, and, asc, lte, sql } from "drizzle-orm";
+import { eq, desc, and, asc, lte, gte, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser, users,
@@ -7,14 +7,22 @@ import {
trainingVideos, InsertTrainingVideo,
poseAnalyses, InsertPoseAnalysis,
trainingRecords, InsertTrainingRecord,
+ liveAnalysisSessions, InsertLiveAnalysisSession,
+ liveActionSegments, InsertLiveActionSegment,
+ dailyTrainingAggregates, InsertDailyTrainingAggregate,
ratingHistory, InsertRatingHistory,
+ ntrpSnapshots, InsertNtrpSnapshot,
dailyCheckins, InsertDailyCheckin,
userBadges, InsertUserBadge,
+ achievementDefinitions, InsertAchievementDefinition,
+ userAchievements, InsertUserAchievement,
tutorialVideos, InsertTutorialVideo,
tutorialProgress, InsertTutorialProgress,
trainingReminders, InsertTrainingReminder,
notificationLog, InsertNotificationLog,
backgroundTasks, InsertBackgroundTask,
+ adminAuditLogs, InsertAdminAuditLog,
+ appSettings, InsertAppSetting,
visionReferenceImages, InsertVisionReferenceImage,
visionTestRuns, InsertVisionTestRun,
} from "../drizzle/schema";
@@ -22,6 +30,72 @@ import { ENV } from './_core/env';
let _db: ReturnType | null = null;
+const APP_TIMEZONE = process.env.TZ || "Asia/Shanghai";
+
+function getDateFormatter() {
+ return new Intl.DateTimeFormat("en-CA", {
+ timeZone: APP_TIMEZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ });
+}
+
+export function getDateKey(date = new Date()) {
+ return getDateFormatter().format(date);
+}
+
+function toDayKey(userId: number, trainingDate: string) {
+ return `${userId}:${trainingDate}`;
+}
+
+export const DEFAULT_APP_SETTINGS: Omit[] = [
+ {
+ settingKey: "action_unknown_confidence_threshold",
+ label: "未知动作阈值",
+ description: "当动作识别置信度低于此值时归类为未知动作。",
+ value: { value: 0.45, type: "number" },
+ },
+ {
+ settingKey: "action_merge_gap_ms",
+ label: "动作合并间隔",
+ description: "相邻同类动作小于该间隔时会合并为同一片段。",
+ value: { value: 500, type: "number" },
+ },
+ {
+ settingKey: "action_segment_max_ms",
+ label: "动作片段最长时长",
+ description: "单个动作片段最长持续时间。",
+ value: { value: 10000, type: "number" },
+ },
+ {
+ settingKey: "ntrp_daily_refresh_hour",
+ label: "NTRP 每日刷新小时",
+ description: "每天异步刷新 NTRP 的小时数。",
+ value: { value: 0, type: "number" },
+ },
+];
+
+export const ACHIEVEMENT_DEFINITION_SEED_DATA: Omit[] = [
+ { key: "training_day_1", name: "开练", description: "完成首个训练日", category: "consistency", rarity: "common", icon: "🎾", metricKey: "training_days", targetValue: 1, tier: 1, sortOrder: 1, isHidden: 0, isActive: 1 },
+ { key: "training_day_3", name: "三日连练", description: "连续训练 3 天", category: "consistency", rarity: "common", icon: "🔥", metricKey: "current_streak", targetValue: 3, tier: 2, sortOrder: 2, isHidden: 0, isActive: 1 },
+ { key: "training_day_7", name: "一周稳定", description: "连续训练 7 天", category: "consistency", rarity: "rare", icon: "⭐", metricKey: "current_streak", targetValue: 7, tier: 3, sortOrder: 3, isHidden: 0, isActive: 1 },
+ { key: "training_minutes_60", name: "首个小时", description: "累计训练 60 分钟", category: "volume", rarity: "common", icon: "⏱️", metricKey: "total_minutes", targetValue: 60, tier: 1, sortOrder: 10, isHidden: 0, isActive: 1 },
+ { key: "training_minutes_300", name: "五小时达标", description: "累计训练 300 分钟", category: "volume", rarity: "rare", icon: "🕐", metricKey: "total_minutes", targetValue: 300, tier: 2, sortOrder: 11, isHidden: 0, isActive: 1 },
+ { key: "training_minutes_1000", name: "千分钟训练者", description: "累计训练 1000 分钟", category: "volume", rarity: "epic", icon: "⏰", metricKey: "total_minutes", targetValue: 1000, tier: 3, sortOrder: 12, isHidden: 0, isActive: 1 },
+ { key: "effective_actions_50", name: "动作起步", description: "累计完成 50 个有效动作", category: "technique", rarity: "common", icon: "🏓", metricKey: "effective_actions", targetValue: 50, tier: 1, sortOrder: 20, isHidden: 0, isActive: 1 },
+ { key: "effective_actions_200", name: "动作累积", description: "累计完成 200 个有效动作", category: "technique", rarity: "rare", icon: "💥", metricKey: "effective_actions", targetValue: 200, tier: 2, sortOrder: 21, isHidden: 0, isActive: 1 },
+ { key: "recordings_1", name: "录像开启", description: "完成首个录制归档", category: "recording", rarity: "common", icon: "🎥", metricKey: "recording_count", targetValue: 1, tier: 1, sortOrder: 30, isHidden: 0, isActive: 1 },
+ { key: "analyses_1", name: "分析首秀", description: "完成首个分析会话", category: "analysis", rarity: "common", icon: "🧠", metricKey: "analysis_count", targetValue: 1, tier: 1, sortOrder: 31, isHidden: 0, isActive: 1 },
+ { key: "live_analysis_5", name: "实时观察者", description: "完成 5 次实时分析", category: "analysis", rarity: "rare", icon: "📹", metricKey: "live_analysis_count", targetValue: 5, tier: 2, sortOrder: 32, isHidden: 0, isActive: 1 },
+ { key: "score_80", name: "高分动作", description: "任意训练得分达到 80", category: "quality", rarity: "rare", icon: "🏅", metricKey: "best_score", targetValue: 80, tier: 1, sortOrder: 40, isHidden: 0, isActive: 1 },
+ { key: "score_90", name: "精确击球", description: "任意训练得分达到 90", category: "quality", rarity: "epic", icon: "🥇", metricKey: "best_score", targetValue: 90, tier: 2, sortOrder: 41, isHidden: 0, isActive: 1 },
+ { key: "ntrp_2_5", name: "NTRP 2.5", description: "综合评分达到 2.5", category: "rating", rarity: "rare", icon: "📈", metricKey: "ntrp_rating", targetValue: 2.5, tier: 1, sortOrder: 50, isHidden: 0, isActive: 1 },
+ { key: "ntrp_3_0", name: "NTRP 3.0", description: "综合评分达到 3.0", category: "rating", rarity: "epic", icon: "🚀", metricKey: "ntrp_rating", targetValue: 3.0, tier: 2, sortOrder: 51, isHidden: 0, isActive: 1 },
+ { key: "pk_session_1", name: "训练 PK", description: "完成首个 PK 会话", category: "pk", rarity: "rare", icon: "⚔️", metricKey: "pk_count", targetValue: 1, tier: 1, sortOrder: 60, isHidden: 0, isActive: 1 },
+ { key: "plan_link_5", name: "按计划训练", description: "累计 5 次训练匹配训练计划", category: "plan", rarity: "rare", icon: "🗂️", metricKey: "plan_matches", targetValue: 5, tier: 1, sortOrder: 70, isHidden: 0, isActive: 1 },
+];
+
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
@@ -34,6 +108,150 @@ export async function getDb() {
return _db;
}
+export async function seedAppSettings() {
+ const db = await getDb();
+ if (!db) return;
+
+ for (const setting of DEFAULT_APP_SETTINGS) {
+ const existing = await db.select().from(appSettings).where(eq(appSettings.settingKey, setting.settingKey)).limit(1);
+ if (existing.length === 0) {
+ await db.insert(appSettings).values(setting);
+ }
+ }
+}
+
+export async function listAppSettings() {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(appSettings).orderBy(asc(appSettings.settingKey));
+}
+
+export async function updateAppSetting(settingKey: string, value: unknown) {
+ const db = await getDb();
+ if (!db) return;
+ await db.update(appSettings).set({ value }).where(eq(appSettings.settingKey, settingKey));
+}
+
+export async function seedAchievementDefinitions() {
+ const db = await getDb();
+ if (!db) return;
+
+ for (const definition of ACHIEVEMENT_DEFINITION_SEED_DATA) {
+ const existing = await db.select().from(achievementDefinitions).where(eq(achievementDefinitions.key, definition.key)).limit(1);
+ if (existing.length === 0) {
+ await db.insert(achievementDefinitions).values(definition);
+ }
+ }
+}
+
+export async function listAchievementDefinitions() {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(achievementDefinitions)
+ .where(eq(achievementDefinitions.isActive, 1))
+ .orderBy(asc(achievementDefinitions.sortOrder), asc(achievementDefinitions.id));
+}
+
+export async function listAllAchievementDefinitions() {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(achievementDefinitions)
+ .orderBy(asc(achievementDefinitions.sortOrder), asc(achievementDefinitions.id));
+}
+
+export async function createAdminAuditLog(entry: InsertAdminAuditLog) {
+ const db = await getDb();
+ if (!db) return;
+ await db.insert(adminAuditLogs).values(entry);
+}
+
+export async function listAdminAuditLogs(limit = 100) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select({
+ id: adminAuditLogs.id,
+ adminUserId: adminAuditLogs.adminUserId,
+ adminName: users.name,
+ actionType: adminAuditLogs.actionType,
+ entityType: adminAuditLogs.entityType,
+ entityId: adminAuditLogs.entityId,
+ targetUserId: adminAuditLogs.targetUserId,
+ payload: adminAuditLogs.payload,
+ createdAt: adminAuditLogs.createdAt,
+ }).from(adminAuditLogs)
+ .leftJoin(users, eq(users.id, adminAuditLogs.adminUserId))
+ .orderBy(desc(adminAuditLogs.createdAt))
+ .limit(limit);
+}
+
+export async function listUsersForAdmin(limit = 100) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select({
+ id: users.id,
+ name: users.name,
+ role: users.role,
+ ntrpRating: users.ntrpRating,
+ totalSessions: users.totalSessions,
+ totalMinutes: users.totalMinutes,
+ totalShots: users.totalShots,
+ currentStreak: users.currentStreak,
+ longestStreak: users.longestStreak,
+ createdAt: users.createdAt,
+ lastSignedIn: users.lastSignedIn,
+ }).from(users).orderBy(desc(users.lastSignedIn)).limit(limit);
+}
+
+export async function getAdminUserId() {
+ const db = await getDb();
+ if (!db) return null;
+ const [admin] = await db.select().from(users).where(eq(users.role, "admin")).orderBy(desc(users.lastSignedIn)).limit(1);
+ return admin?.id ?? null;
+}
+
+export async function listAllBackgroundTasks(limit = 100) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select({
+ id: backgroundTasks.id,
+ userId: backgroundTasks.userId,
+ userName: users.name,
+ type: backgroundTasks.type,
+ status: backgroundTasks.status,
+ title: backgroundTasks.title,
+ message: backgroundTasks.message,
+ progress: backgroundTasks.progress,
+ payload: backgroundTasks.payload,
+ result: backgroundTasks.result,
+ error: backgroundTasks.error,
+ attempts: backgroundTasks.attempts,
+ maxAttempts: backgroundTasks.maxAttempts,
+ createdAt: backgroundTasks.createdAt,
+ updatedAt: backgroundTasks.updatedAt,
+ completedAt: backgroundTasks.completedAt,
+ }).from(backgroundTasks)
+ .leftJoin(users, eq(users.id, backgroundTasks.userId))
+ .orderBy(desc(backgroundTasks.createdAt))
+ .limit(limit);
+}
+
+export async function hasRecentBackgroundTaskOfType(
+ type: "ntrp_refresh_user" | "ntrp_refresh_all",
+ since: Date,
+) {
+ const db = await getDb();
+ if (!db) return false;
+ const result = await db.select({ count: sql`count(*)` }).from(backgroundTasks)
+ .where(and(eq(backgroundTasks.type, type), gte(backgroundTasks.createdAt, since)));
+ return (result[0]?.count || 0) > 0;
+}
+
+export async function listUserIds() {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select({ id: users.id }).from(users).orderBy(asc(users.id));
+}
+
// ===== USER OPERATIONS =====
export async function upsertUser(user: InsertUser): Promise {
@@ -175,6 +393,41 @@ export async function updateTrainingPlan(planId: number, data: Partial = {
+ forehand: ["正手", "forehand"],
+ backhand: ["反手", "backhand"],
+ serve: ["发球", "serve"],
+ volley: ["截击", "volley"],
+ overhead: ["高压", "overhead"],
+ slice: ["切削", "slice"],
+ lob: ["挑高", "lob"],
+ unknown: ["综合", "基础", "训练"],
+};
+
+export async function matchActivePlanForExercise(userId: number, exerciseType?: string | null) {
+ const activePlan = await getActivePlan(userId);
+ if (!activePlan || !exerciseType) {
+ return null;
+ }
+
+ const keywords = PLAN_KEYWORDS[exerciseType] ?? [exerciseType];
+ const exercises = Array.isArray(activePlan.exercises) ? activePlan.exercises as Array> : [];
+ const matched = exercises.find((exercise) => {
+ const haystack = JSON.stringify(exercise).toLowerCase();
+ return keywords.some(keyword => haystack.includes(keyword.toLowerCase()));
+ });
+
+ if (!matched) {
+ return null;
+ }
+
+ return {
+ planId: activePlan.id,
+ confidence: 0.72,
+ matchedExercise: matched,
+ };
+}
+
// ===== VIDEO OPERATIONS =====
export async function createVideo(video: InsertTrainingVideo) {
@@ -255,6 +508,173 @@ export async function markRecordCompleted(recordId: number, poseScore?: number)
await db.update(trainingRecords).set({ completed: 1, poseScore: poseScore ?? null }).where(eq(trainingRecords.id, recordId));
}
+export async function upsertTrainingRecordBySource(
+ record: InsertTrainingRecord & { sourceType: string; sourceId: string; userId: number }
+) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+
+ const existing = await db.select().from(trainingRecords)
+ .where(and(
+ eq(trainingRecords.userId, record.userId),
+ eq(trainingRecords.sourceType, record.sourceType),
+ eq(trainingRecords.sourceId, record.sourceId),
+ ))
+ .limit(1);
+
+ if (existing.length > 0) {
+ await db.update(trainingRecords).set(record).where(eq(trainingRecords.id, existing[0].id));
+ return { recordId: existing[0].id, isNew: false };
+ }
+
+ const result = await db.insert(trainingRecords).values(record);
+ return { recordId: result[0].insertId, isNew: true };
+}
+
+export async function upsertDailyTrainingAggregate(input: {
+ userId: number;
+ trainingDate: string;
+ deltaMinutes?: number;
+ deltaSessions?: number;
+ deltaAnalysisCount?: number;
+ deltaLiveAnalysisCount?: number;
+ deltaRecordingCount?: number;
+ deltaPkCount?: number;
+ deltaTotalActions?: number;
+ deltaEffectiveActions?: number;
+ deltaUnknownActions?: number;
+ score?: number | null;
+ metadata?: Record;
+}) {
+ const db = await getDb();
+ if (!db) return;
+
+ const dayKey = toDayKey(input.userId, input.trainingDate);
+ const [existing] = await db.select().from(dailyTrainingAggregates)
+ .where(eq(dailyTrainingAggregates.dayKey, dayKey))
+ .limit(1);
+
+ if (!existing) {
+ const totalScore = input.score ?? 0;
+ await db.insert(dailyTrainingAggregates).values({
+ dayKey,
+ userId: input.userId,
+ trainingDate: input.trainingDate,
+ totalMinutes: input.deltaMinutes ?? 0,
+ sessionCount: input.deltaSessions ?? 0,
+ analysisCount: input.deltaAnalysisCount ?? 0,
+ liveAnalysisCount: input.deltaLiveAnalysisCount ?? 0,
+ recordingCount: input.deltaRecordingCount ?? 0,
+ pkCount: input.deltaPkCount ?? 0,
+ totalActions: input.deltaTotalActions ?? 0,
+ effectiveActions: input.deltaEffectiveActions ?? 0,
+ unknownActions: input.deltaUnknownActions ?? 0,
+ totalScore,
+ averageScore: totalScore > 0 ? totalScore / Math.max(1, input.deltaSessions ?? 1) : 0,
+ metadata: input.metadata ?? null,
+ });
+ } else {
+ const nextSessionCount = (existing.sessionCount || 0) + (input.deltaSessions ?? 0);
+ const nextTotalScore = (existing.totalScore || 0) + (input.score ?? 0);
+ await db.update(dailyTrainingAggregates).set({
+ totalMinutes: (existing.totalMinutes || 0) + (input.deltaMinutes ?? 0),
+ sessionCount: nextSessionCount,
+ analysisCount: (existing.analysisCount || 0) + (input.deltaAnalysisCount ?? 0),
+ liveAnalysisCount: (existing.liveAnalysisCount || 0) + (input.deltaLiveAnalysisCount ?? 0),
+ recordingCount: (existing.recordingCount || 0) + (input.deltaRecordingCount ?? 0),
+ pkCount: (existing.pkCount || 0) + (input.deltaPkCount ?? 0),
+ totalActions: (existing.totalActions || 0) + (input.deltaTotalActions ?? 0),
+ effectiveActions: (existing.effectiveActions || 0) + (input.deltaEffectiveActions ?? 0),
+ unknownActions: (existing.unknownActions || 0) + (input.deltaUnknownActions ?? 0),
+ totalScore: nextTotalScore,
+ averageScore: nextSessionCount > 0 ? nextTotalScore / nextSessionCount : 0,
+ metadata: input.metadata ? { ...(existing.metadata as Record | null ?? {}), ...input.metadata } : existing.metadata,
+ }).where(eq(dailyTrainingAggregates.id, existing.id));
+ }
+
+ await refreshUserTrainingSummary(input.userId);
+}
+
+export async function listDailyTrainingAggregates(userId: number, limit = 30) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(dailyTrainingAggregates)
+ .where(eq(dailyTrainingAggregates.userId, userId))
+ .orderBy(desc(dailyTrainingAggregates.trainingDate))
+ .limit(limit);
+}
+
+export async function refreshUserTrainingSummary(userId: number) {
+ const db = await getDb();
+ if (!db) return;
+
+ const records = await db.select().from(trainingRecords)
+ .where(and(eq(trainingRecords.userId, userId), eq(trainingRecords.completed, 1)));
+ const aggregates = await db.select().from(dailyTrainingAggregates)
+ .where(eq(dailyTrainingAggregates.userId, userId))
+ .orderBy(desc(dailyTrainingAggregates.trainingDate));
+
+ const totalSessions = records.length;
+ const totalMinutes = records.reduce((sum, item) => sum + (item.durationMinutes || 0), 0);
+ const totalShots = aggregates.reduce((sum, item) => sum + (item.effectiveActions || 0), 0);
+
+ let currentStreak = 0;
+ const sortedDays = aggregates
+ .filter(item => (item.sessionCount || 0) > 0)
+ .map(item => item.trainingDate)
+ .sort((a, b) => a < b ? 1 : -1);
+ let cursor = new Date(`${getDateKey()}T00:00:00`);
+ for (const day of sortedDays) {
+ const normalized = new Date(`${day}T00:00:00`);
+ const diffDays = Math.round((cursor.getTime() - normalized.getTime()) / 86400000);
+ if (diffDays === 0 || diffDays === 1) {
+ currentStreak += 1;
+ cursor = normalized;
+ continue;
+ }
+ if (currentStreak > 0) {
+ break;
+ }
+ cursor = normalized;
+ currentStreak = 1;
+ }
+
+ const longestStreak = Math.max(currentStreak, records.length > 0 ? (await getLongestTrainingStreak(userId)) : 0);
+
+ await db.update(users).set({
+ totalSessions,
+ totalMinutes,
+ totalShots,
+ currentStreak,
+ longestStreak,
+ }).where(eq(users.id, userId));
+}
+
+async function getLongestTrainingStreak(userId: number) {
+ const db = await getDb();
+ if (!db) return 0;
+ const aggregates = await db.select().from(dailyTrainingAggregates)
+ .where(eq(dailyTrainingAggregates.userId, userId))
+ .orderBy(asc(dailyTrainingAggregates.trainingDate));
+
+ let longest = 0;
+ let current = 0;
+ let prev: Date | null = null;
+ for (const item of aggregates) {
+ if ((item.sessionCount || 0) <= 0) continue;
+ const currentDate = new Date(`${item.trainingDate}T00:00:00`);
+ if (!prev) {
+ current = 1;
+ } else {
+ const diff = Math.round((currentDate.getTime() - prev.getTime()) / 86400000);
+ current = diff === 1 ? current + 1 : 1;
+ }
+ longest = Math.max(longest, current);
+ prev = currentDate;
+ }
+ return longest;
+}
+
// ===== RATING HISTORY OPERATIONS =====
export async function createRatingEntry(entry: InsertRatingHistory) {
@@ -270,6 +690,109 @@ export async function getUserRatingHistory(userId: number, limit = 30) {
return db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(limit);
}
+export async function createNtrpSnapshot(snapshot: InsertNtrpSnapshot) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ const existing = await db.select().from(ntrpSnapshots)
+ .where(eq(ntrpSnapshots.snapshotKey, snapshot.snapshotKey))
+ .limit(1);
+ if (existing.length > 0) {
+ await db.update(ntrpSnapshots).set(snapshot).where(eq(ntrpSnapshots.id, existing[0].id));
+ return existing[0].id;
+ }
+ const result = await db.insert(ntrpSnapshots).values(snapshot);
+ return result[0].insertId;
+}
+
+export async function getLatestNtrpSnapshot(userId: number) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(ntrpSnapshots)
+ .where(eq(ntrpSnapshots.userId, userId))
+ .orderBy(desc(ntrpSnapshots.createdAt))
+ .limit(1);
+ return result[0];
+}
+
+export async function listNtrpSnapshots(userId: number, limit = 30) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(ntrpSnapshots)
+ .where(eq(ntrpSnapshots.userId, userId))
+ .orderBy(desc(ntrpSnapshots.createdAt))
+ .limit(limit);
+}
+
+export async function createLiveAnalysisSession(session: InsertLiveAnalysisSession) {
+ const db = await getDb();
+ if (!db) throw new Error("Database not available");
+ const result = await db.insert(liveAnalysisSessions).values(session);
+ return result[0].insertId;
+}
+
+export async function createLiveActionSegments(segments: InsertLiveActionSegment[]) {
+ const db = await getDb();
+ if (!db || segments.length === 0) return;
+ await db.insert(liveActionSegments).values(segments);
+}
+
+export async function listLiveAnalysisSessions(userId: number, limit = 20) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(liveAnalysisSessions)
+ .where(eq(liveAnalysisSessions.userId, userId))
+ .orderBy(desc(liveAnalysisSessions.createdAt))
+ .limit(limit);
+}
+
+export async function listAdminLiveAnalysisSessions(limit = 50) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select({
+ id: liveAnalysisSessions.id,
+ userId: liveAnalysisSessions.userId,
+ userName: users.name,
+ title: liveAnalysisSessions.title,
+ sessionMode: liveAnalysisSessions.sessionMode,
+ status: liveAnalysisSessions.status,
+ dominantAction: liveAnalysisSessions.dominantAction,
+ overallScore: liveAnalysisSessions.overallScore,
+ durationMs: liveAnalysisSessions.durationMs,
+ effectiveSegments: liveAnalysisSessions.effectiveSegments,
+ totalSegments: liveAnalysisSessions.totalSegments,
+ videoUrl: liveAnalysisSessions.videoUrl,
+ createdAt: liveAnalysisSessions.createdAt,
+ }).from(liveAnalysisSessions)
+ .leftJoin(users, eq(users.id, liveAnalysisSessions.userId))
+ .orderBy(desc(liveAnalysisSessions.createdAt))
+ .limit(limit);
+}
+
+export async function getLiveAnalysisSessionById(sessionId: number) {
+ const db = await getDb();
+ if (!db) return undefined;
+ const result = await db.select().from(liveAnalysisSessions)
+ .where(eq(liveAnalysisSessions.id, sessionId))
+ .limit(1);
+ return result[0];
+}
+
+export async function getLiveActionSegmentsBySessionId(sessionId: number) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(liveActionSegments)
+ .where(eq(liveActionSegments.sessionId, sessionId))
+ .orderBy(asc(liveActionSegments.startMs));
+}
+
+export async function getAchievementProgress(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+ return db.select().from(userAchievements)
+ .where(eq(userAchievements.userId, userId))
+ .orderBy(desc(userAchievements.unlockedAt), asc(userAchievements.achievementKey));
+}
+
// ===== DAILY CHECK-IN OPERATIONS =====
export async function checkinToday(userId: number, notes?: string, minutesTrained?: number) {
@@ -329,6 +852,118 @@ export async function getTodayCheckin(userId: number) {
return result.length > 0 ? result[0] : null;
}
+function metricValueFromContext(metricKey: string, context: {
+ trainingDays: number;
+ currentStreak: number;
+ totalMinutes: number;
+ effectiveActions: number;
+ recordingCount: number;
+ analysisCount: number;
+ liveAnalysisCount: number;
+ bestScore: number;
+ ntrpRating: number;
+ pkCount: number;
+ planMatches: number;
+}) {
+ const metricMap: Record = {
+ training_days: context.trainingDays,
+ current_streak: context.currentStreak,
+ total_minutes: context.totalMinutes,
+ effective_actions: context.effectiveActions,
+ recording_count: context.recordingCount,
+ analysis_count: context.analysisCount,
+ live_analysis_count: context.liveAnalysisCount,
+ best_score: context.bestScore,
+ ntrp_rating: context.ntrpRating,
+ pk_count: context.pkCount,
+ plan_matches: context.planMatches,
+ };
+ return metricMap[metricKey] ?? 0;
+}
+
+export async function refreshAchievementsForUser(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+
+ const definitions = await listAchievementDefinitions();
+ const progressRows = await getAchievementProgress(userId);
+ const records = await db.select().from(trainingRecords).where(and(eq(trainingRecords.userId, userId), eq(trainingRecords.completed, 1)));
+ const aggregates = await db.select().from(dailyTrainingAggregates).where(eq(dailyTrainingAggregates.userId, userId));
+ const liveSessions = await db.select().from(liveAnalysisSessions).where(eq(liveAnalysisSessions.userId, userId));
+ const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
+
+ const bestScore = Math.max(
+ 0,
+ ...records.map((record) => record.poseScore || 0),
+ ...liveSessions.map((session) => session.overallScore || 0),
+ );
+ const planMatches = records.filter((record) => record.linkedPlanId != null).length;
+ const context = {
+ trainingDays: aggregates.filter(item => (item.sessionCount || 0) > 0).length,
+ currentStreak: userRow?.currentStreak || 0,
+ totalMinutes: userRow?.totalMinutes || 0,
+ effectiveActions: userRow?.totalShots || 0,
+ recordingCount: records.filter(record => record.sourceType === "recording").length,
+ analysisCount: records.filter(record => record.sourceType === "analysis_upload").length,
+ liveAnalysisCount: records.filter(record => record.sourceType === "live_analysis").length,
+ bestScore,
+ ntrpRating: userRow?.ntrpRating || 1.5,
+ pkCount: records.filter(record => ((record.metadata as Record | null)?.sessionMode) === "pk").length,
+ planMatches,
+ };
+
+ const unlockedKeys: string[] = [];
+ for (const definition of definitions) {
+ const currentValue = metricValueFromContext(definition.metricKey, context);
+ const progressPct = definition.targetValue > 0 ? Math.min(100, (currentValue / definition.targetValue) * 100) : 0;
+ const progressKey = `${userId}:${definition.key}`;
+ const existing = progressRows.find((row) => row.achievementKey === definition.key);
+ const unlockedAt = currentValue >= definition.targetValue ? (existing?.unlockedAt ?? new Date()) : null;
+
+ if (!existing) {
+ await db.insert(userAchievements).values({
+ progressKey,
+ userId,
+ achievementKey: definition.key,
+ currentValue,
+ progressPct,
+ unlockedAt,
+ });
+ if (unlockedAt) unlockedKeys.push(definition.key);
+ } else {
+ await db.update(userAchievements).set({
+ currentValue,
+ progressPct,
+ unlockedAt: existing.unlockedAt ?? unlockedAt,
+ lastEvaluatedAt: new Date(),
+ }).where(eq(userAchievements.id, existing.id));
+ if (!existing.unlockedAt && unlockedAt) unlockedKeys.push(definition.key);
+ }
+ }
+
+ return unlockedKeys;
+}
+
+export async function listUserAchievements(userId: number) {
+ const db = await getDb();
+ if (!db) return [];
+
+ const definitions = await listAllAchievementDefinitions();
+ const progress = await getAchievementProgress(userId);
+ const progressMap = new Map(progress.map(item => [item.achievementKey, item]));
+
+ return definitions.map((definition) => {
+ const row = progressMap.get(definition.key);
+ return {
+ ...definition,
+ currentValue: row?.currentValue ?? 0,
+ progressPct: row?.progressPct ?? 0,
+ unlockedAt: row?.unlockedAt ?? null,
+ unlocked: Boolean(row?.unlockedAt),
+ };
+ });
+}
+
// ===== BADGE OPERATIONS =====
// Badge definitions
@@ -1073,13 +1708,21 @@ export async function getUserStats(userId: number) {
const records = await db.select().from(trainingRecords).where(eq(trainingRecords.userId, userId));
const videos = await db.select().from(trainingVideos).where(eq(trainingVideos.userId, userId));
const ratings = await db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(30);
+ const daily = await db.select().from(dailyTrainingAggregates).where(eq(dailyTrainingAggregates.userId, userId)).orderBy(desc(dailyTrainingAggregates.trainingDate)).limit(30);
+ const liveSessions = await db.select().from(liveAnalysisSessions).where(eq(liveAnalysisSessions.userId, userId)).orderBy(desc(liveAnalysisSessions.createdAt)).limit(10);
+ const latestSnapshot = await getLatestNtrpSnapshot(userId);
+ const achievements = await listUserAchievements(userId);
const completedRecords = records.filter(r => r.completed === 1);
- const totalShots = analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0);
+ const totalShots = Math.max(
+ analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0),
+ daily.reduce((sum, item) => sum + (item.effectiveActions || 0), 0),
+ userRow.totalShots || 0,
+ );
const avgScore = analyses.length > 0 ? analyses.reduce((sum, a) => sum + (a.overallScore || 0), 0) / analyses.length : 0;
return {
- ntrpRating: userRow.ntrpRating || 1.5,
+ ntrpRating: userRow.ntrpRating || latestSnapshot?.rating || 1.5,
totalSessions: completedRecords.length,
totalMinutes: records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0),
totalVideos: videos.length,
@@ -1088,5 +1731,9 @@ export async function getUserStats(userId: number) {
averageScore: Math.round(avgScore * 10) / 10,
ratingHistory: ratings.reverse(),
recentAnalyses: analyses.slice(0, 10),
+ recentLiveSessions: liveSessions,
+ dailyTraining: daily.reverse(),
+ achievements,
+ latestNtrpSnapshot: latestSnapshot ?? null,
};
}
diff --git a/server/features.test.ts b/server/features.test.ts
index 77004e3..89914e6 100644
--- a/server/features.test.ts
+++ b/server/features.test.ts
@@ -3,6 +3,7 @@ import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
+import * as trainingAutomation from "./trainingAutomation";
import { ENV } from "./_core/env";
import { sdk } from "./_core/sdk";
@@ -957,3 +958,173 @@ describe("vision.seedLibrary", () => {
await expect(caller.vision.seedLibrary()).rejects.toThrow();
});
});
+
+describe("achievement.list", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("returns achievement progress for authenticated users", async () => {
+ const user = createTestUser({ id: 12 });
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+ const listSpy = vi.spyOn(db, "listUserAchievements").mockResolvedValueOnce([
+ {
+ id: 1,
+ key: "training_day_1",
+ name: "开练",
+ description: "完成首个训练日",
+ category: "consistency",
+ rarity: "common",
+ icon: "🎾",
+ metricKey: "training_days",
+ targetValue: 1,
+ tier: 1,
+ isHidden: 0,
+ isActive: 1,
+ sortOrder: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ currentValue: 1,
+ progressPct: 100,
+ unlockedAt: new Date(),
+ unlocked: true,
+ },
+ ] as any);
+
+ const result = await caller.achievement.list();
+
+ expect(listSpy).toHaveBeenCalledWith(12);
+ expect(result).toHaveLength(1);
+ expect((result[0] as any).key).toBe("training_day_1");
+ });
+});
+
+describe("analysis.liveSessionSave", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("persists a live session and syncs training data", async () => {
+ const user = createTestUser({ id: 5 });
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ const createSessionSpy = vi.spyOn(db, "createLiveAnalysisSession").mockResolvedValueOnce(101);
+ const createSegmentsSpy = vi.spyOn(db, "createLiveActionSegments").mockResolvedValueOnce();
+ const syncSpy = vi.spyOn(trainingAutomation, "syncLiveTrainingData").mockResolvedValueOnce({
+ recordId: 88,
+ unlocked: ["training_day_1"],
+ });
+
+ const result = await caller.analysis.liveSessionSave({
+ title: "实时分析 正手",
+ sessionMode: "practice",
+ startedAt: Date.now() - 4_000,
+ endedAt: Date.now(),
+ durationMs: 4_000,
+ dominantAction: "forehand",
+ overallScore: 84,
+ postureScore: 82,
+ balanceScore: 78,
+ techniqueScore: 86,
+ footworkScore: 75,
+ consistencyScore: 80,
+ totalActionCount: 3,
+ effectiveSegments: 2,
+ totalSegments: 3,
+ unknownSegments: 1,
+ feedback: ["节奏稳定"],
+ metrics: { sampleCount: 12 },
+ segments: [
+ {
+ actionType: "forehand",
+ isUnknown: false,
+ startMs: 500,
+ endMs: 2_500,
+ durationMs: 2_000,
+ confidenceAvg: 0.82,
+ score: 84,
+ peakScore: 90,
+ frameCount: 24,
+ issueSummary: ["击球点前移"],
+ keyFrames: [500, 1500, 2500],
+ clipLabel: "正手挥拍 00:00 - 00:02",
+ },
+ ],
+ });
+
+ expect(createSessionSpy).toHaveBeenCalledTimes(1);
+ expect(createSegmentsSpy).toHaveBeenCalledTimes(1);
+ expect(syncSpy).toHaveBeenCalledWith(expect.objectContaining({
+ userId: 5,
+ sessionId: 101,
+ dominantAction: "forehand",
+ sessionMode: "practice",
+ }));
+ expect(result).toEqual({ sessionId: 101, trainingRecordId: 88 });
+ });
+});
+
+describe("rating.refreshMine", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("creates an async NTRP refresh task for the current user", async () => {
+ const user = createTestUser({ id: 22 });
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+ const createTaskSpy = vi.spyOn(db, "createBackgroundTask").mockResolvedValueOnce();
+
+ const result = await caller.rating.refreshMine();
+
+ expect(createTaskSpy).toHaveBeenCalledWith(expect.objectContaining({
+ userId: 22,
+ type: "ntrp_refresh_user",
+ payload: { targetUserId: 22 },
+ }));
+ expect(result.taskId).toBeTruthy();
+ });
+});
+
+describe("admin.users", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("rejects non-admin users", async () => {
+ const user = createTestUser({ role: "user" });
+ const { ctx } = createMockContext(user);
+ const caller = appRouter.createCaller(ctx);
+
+ await expect(caller.admin.users({ limit: 20 })).rejects.toThrow();
+ });
+
+ it("returns user list for admin users", async () => {
+ const admin = createTestUser({ id: 1, role: "admin", name: "H1" });
+ const { ctx } = createMockContext(admin);
+ const caller = appRouter.createCaller(ctx);
+ const usersSpy = vi.spyOn(db, "listUsersForAdmin").mockResolvedValueOnce([
+ {
+ id: 1,
+ name: "H1",
+ role: "admin",
+ ntrpRating: 3.4,
+ totalSessions: 10,
+ totalMinutes: 320,
+ totalShots: 240,
+ currentStreak: 6,
+ longestStreak: 12,
+ createdAt: new Date(),
+ lastSignedIn: new Date(),
+ },
+ ] as any);
+
+ const result = await caller.admin.users({ limit: 20 });
+
+ expect(usersSpy).toHaveBeenCalledWith(20);
+ expect(result).toHaveLength(1);
+ expect((result[0] as any).name).toBe("H1");
+ });
+});
diff --git a/server/routers.ts b/server/routers.ts
index e6d319c..e37d8ad 100644
--- a/server/routers.ts
+++ b/server/routers.ts
@@ -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;
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;
+}) {
+ 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
diff --git a/server/taskWorker.ts b/server/taskWorker.ts
index d3705e6..866020d 100644
--- a/server/taskWorker.ts
+++ b/server/taskWorker.ts
@@ -17,6 +17,7 @@ import {
normalizeAdjustedPlanResponse,
normalizeTrainingPlanResponse,
} from "./trainingPlan";
+import { refreshAllUsersNtrp, refreshUserNtrp, syncRecordingTrainingData } from "./trainingAutomation";
type TaskRow = Awaited>;
@@ -419,6 +420,8 @@ async function runMediaFinalizeTask(task: NonNullable) {
sessionId: string;
title: string;
exerciseType?: string;
+ sessionMode?: "practice" | "pk";
+ durationMinutes?: number;
};
const session = await getRemoteMediaSession(payload.sessionId);
@@ -489,6 +492,15 @@ async function runMediaFinalizeTask(task: NonNullable) {
analysisStatus: "completed",
});
+ await syncRecordingTrainingData({
+ userId: task.userId,
+ videoId,
+ exerciseType: payload.exerciseType || "unknown",
+ title: payload.title || session.title,
+ sessionMode: payload.sessionMode || "practice",
+ durationMinutes: payload.durationMinutes ?? 5,
+ });
+
return {
kind: "media_finalize" as const,
sessionId: session.id,
@@ -499,6 +511,26 @@ async function runMediaFinalizeTask(task: NonNullable) {
};
}
+async function runNtrpRefreshUserTask(task: NonNullable) {
+ const payload = task.payload as { targetUserId?: number };
+ const targetUserId = payload.targetUserId ?? task.userId;
+ const snapshot = await refreshUserNtrp(targetUserId, { triggerType: "manual", taskId: task.id });
+ return {
+ kind: "ntrp_refresh_user" as const,
+ targetUserId,
+ snapshot,
+ };
+}
+
+async function runNtrpRefreshAllTask(task: NonNullable) {
+ const results = await refreshAllUsersNtrp({ triggerType: "daily", taskId: task.id });
+ return {
+ kind: "ntrp_refresh_all" as const,
+ refreshedUsers: results.length,
+ results,
+ };
+}
+
export async function processBackgroundTask(task: NonNullable) {
switch (task.type) {
case "training_plan_generate":
@@ -511,6 +543,10 @@ export async function processBackgroundTask(task: NonNullable) {
return runMultimodalCorrectionTask(task);
case "media_finalize":
return runMediaFinalizeTask(task);
+ case "ntrp_refresh_user":
+ return runNtrpRefreshUserTask(task);
+ case "ntrp_refresh_all":
+ return runNtrpRefreshAllTask(task);
default:
throw new Error(`Unsupported task type: ${String(task.type)}`);
}
diff --git a/server/trainingAutomation.ts b/server/trainingAutomation.ts
new file mode 100644
index 0000000..5551bd7
--- /dev/null
+++ b/server/trainingAutomation.ts
@@ -0,0 +1,304 @@
+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 };
+}
diff --git a/tests/e2e/helpers/mockApp.ts b/tests/e2e/helpers/mockApp.ts
index 3b42525..6142776 100644
--- a/tests/e2e/helpers/mockApp.ts
+++ b/tests/e2e/helpers/mockApp.ts
@@ -134,6 +134,58 @@ function buildStats(user: MockUser) {
shotCount: 18,
},
],
+ recentLiveSessions: [],
+ dailyTraining: [
+ {
+ trainingDate: "2026-03-13",
+ totalMinutes: 48,
+ sessionCount: 2,
+ effectiveActions: 36,
+ averageScore: 80,
+ },
+ {
+ trainingDate: "2026-03-14",
+ totalMinutes: 32,
+ sessionCount: 1,
+ effectiveActions: 18,
+ averageScore: 84,
+ },
+ ],
+ achievements: [
+ {
+ key: "training_day_1",
+ name: "开练",
+ description: "完成首个训练日",
+ progressPct: 100,
+ unlocked: true,
+ },
+ {
+ key: "analyses_1",
+ name: "分析首秀",
+ description: "完成首个分析会话",
+ progressPct: 100,
+ unlocked: true,
+ },
+ {
+ key: "live_analysis_5",
+ name: "实时观察者",
+ description: "完成 5 次实时分析",
+ progressPct: 40,
+ unlocked: false,
+ },
+ ],
+ latestNtrpSnapshot: {
+ rating: user.ntrpRating,
+ createdAt: nowIso(),
+ dimensionScores: {
+ poseAccuracy: 82,
+ strokeConsistency: 78,
+ footwork: 74,
+ fluidity: 79,
+ timing: 77,
+ matchReadiness: 70,
+ },
+ },
};
}
@@ -272,6 +324,10 @@ async function handleTrpc(route: Route, state: MockAppState) {
return trpcResult(state.videos);
case "analysis.list":
return trpcResult(state.analyses);
+ case "analysis.liveSessionList":
+ return trpcResult([]);
+ case "analysis.liveSessionSave":
+ return trpcResult({ sessionId: 1, trainingRecordId: 1 });
case "task.list":
return trpcResult(state.tasks);
case "task.get": {
@@ -369,6 +425,39 @@ async function handleTrpc(route: Route, state: MockAppState) {
];
}
return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl });
+ case "achievement.list":
+ return trpcResult(buildStats(state.user).achievements);
+ case "rating.current":
+ return trpcResult({
+ rating: state.user.ntrpRating,
+ latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
+ });
+ case "rating.history":
+ return trpcResult([
+ {
+ id: 1,
+ rating: 2.4,
+ triggerType: "daily",
+ createdAt: nowIso(),
+ dimensionScores: {
+ poseAccuracy: 72,
+ strokeConsistency: 70,
+ footwork: 66,
+ fluidity: 69,
+ timing: 68,
+ matchReadiness: 60,
+ },
+ sourceSummary: { analyses: 1, liveSessions: 0, totalEffectiveActions: 12, totalPk: 0, activeDays: 1 },
+ },
+ {
+ id: 2,
+ rating: state.user.ntrpRating,
+ triggerType: "daily",
+ createdAt: nowIso(),
+ dimensionScores: buildStats(state.user).latestNtrpSnapshot.dimensionScores,
+ sourceSummary: { analyses: 2, liveSessions: 1, totalEffectiveActions: 36, totalPk: 0, activeDays: 2 },
+ },
+ ]);
default:
return trpcResult(null);
}