Compare commits

...

2 Commits

Author SHA1 Message Date
richarjiang
0edcfdcae9 新增普拉提训练系统的数据库结构和数据导入功能
- 创建普拉提分类和动作数据的SQL导入脚本,支持垫上普拉提和器械普拉提的分类管理
- 实现数据库结构迁移脚本,添加新字段以支持普拉提类型和器械名称
- 更新数据库升级总结文档,详细说明数据库结构变更和数据导入步骤
- 创建训练会话相关表,支持每日训练实例功能
- 引入训练会话管理模块,整合训练计划与实际训练会话的关系
2025-08-15 15:34:11 +08:00
richarjiang
bea71af5d3 优化训练计划项目管理功能
- 更新训练项目文档,增加与动作库的智能关联和简化接口操作的说明
- 移除批量操作接口,专注于单项操作,提升用户体验
- 增强数据模型,确保训练项目与动作库的关联性,提升数据一致性和查询性能
- 更新服务逻辑,支持动作存在性验证,确保数据的准确性和完整性
2025-08-15 11:37:11 +08:00
33 changed files with 3093 additions and 612 deletions

View File

@@ -0,0 +1,121 @@
-- 普拉提分类和动作数据导入 SQL
-- 执行前请确保数据库表结构已更新
-- 清空现有数据(可选,根据实际情况决定是否执行)
-- DELETE FROM t_exercises;
-- DELETE FROM t_exercise_categories;
-- 插入普拉提分类数据
INSERT INTO t_exercise_categories (key, name, pilates_type, equipment_name, sort_order, created_at, updated_at) VALUES
-- 垫上普拉提
('mat_pilates', '垫上普拉提', 'mat_pilates', NULL, 1, NOW(), NOW()),
-- 器械普拉提
('reformer', '核心床', 'equipment_pilates', '核心床', 2, NOW(), NOW()),
('cadillac', '凯迪拉克', 'equipment_pilates', '凯迪拉克', 3, NOW(), NOW()),
('chair', '普拉提椅', 'equipment_pilates', '普拉提椅', 4, NOW(), NOW()),
('barrel', '普拉提桶', 'equipment_pilates', '普拉提桶', 5, NOW(), NOW());
-- 插入垫上普拉提动作数据
INSERT INTO t_exercises (
key, name, category_name, description, target_muscle_groups, equipment_name,
beginner_reps, beginner_sets, breathing_cycles, hold_duration, special_instructions,
category_key, sort_order, created_at, updated_at
) VALUES
('mat_hundred', '百次拍打', '垫上普拉提', '经典的普拉提动作,通过有节奏的手臂拍打配合呼吸来激活核心', '核心肌群、呼吸肌群、肩部稳定性', NULL, NULL, NULL, 10, NULL, '10个呼吸循环', 'mat_pilates', 1, NOW(), NOW()),
('mat_roll_up', '卷起', '垫上普拉提', '从仰卧位缓慢卷起至坐姿,锻炼脊柱逐节控制能力', '核心肌群、脊柱灵活性、腘绳肌柔韧性', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 2, NOW(), NOW()),
('mat_single_leg_circle', '单腿画圈', '垫上普拉提', '单腿做圆周运动,保持骨盆稳定', '核心稳定性、髋关节灵活性、股四头肌、腘绳肌、臀肌', NULL, 5, NULL, NULL, NULL, '每条腿每个方向5次', 'mat_pilates', 3, NOW(), NOW()),
('mat_rolling_like_ball', '滚球', '垫上普拉提', '保持紧凑姿势前后滚动,挑战平衡和控制', '核心肌群、脊柱柔韧性、平衡感', NULL, 8, NULL, NULL, NULL, '6-8次', 'mat_pilates', 4, NOW(), NOW()),
('mat_single_leg_stretch', '单腿伸展', '垫上普拉提', '交替单腿伸展,保持上身抬起', '核心肌群、髋屈肌、股四头肌', NULL, 10, NULL, NULL, NULL, '每侧8-10次', 'mat_pilates', 5, NOW(), NOW()),
('mat_double_leg_stretch', '双腿伸展', '垫上普拉提', '双腿和手臂同时伸展,回到起始位置', '核心肌群、髋屈肌、股四头肌、肩部稳定性', NULL, 10, NULL, NULL, NULL, '6-10次', 'mat_pilates', 6, NOW(), NOW()),
('mat_spine_stretch', '脊柱伸展', '垫上普拉提', '坐姿前屈,逐节伸展脊柱', '脊柱柔韧性、腘绳肌伸展、核心控制', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 7, NOW(), NOW()),
('mat_saw', '锯式', '垫上普拉提', '坐姿扭转配合前屈,增强脊柱旋转能力', '腹斜肌、背部伸肌、脊柱旋转灵活性', NULL, 6, NULL, NULL, NULL, '每侧6次', 'mat_pilates', 8, NOW(), NOW()),
('mat_swan', '天鹅式', '垫上普拉提', '俯卧位脊柱后伸,强化背部肌群', '背部伸肌、臀肌、腘绳肌', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 9, NOW(), NOW()),
('mat_shoulder_bridge', '肩桥', '垫上普拉提', '仰卧抬臀,激活后链肌群', '臀肌、腘绳肌、下背部、脊柱灵活性', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 10, NOW(), NOW());
-- 插入核心床(Reformer)动作数据
INSERT INTO t_exercises (
key, name, category_name, description, target_muscle_groups, equipment_name,
beginner_reps, beginner_sets, breathing_cycles, hold_duration, special_instructions,
category_key, sort_order, created_at, updated_at
) VALUES
('reformer_footwork', '脚部练习系列', '核心床', '在核心床上进行各种脚部位置的推蹬练习', '腿部、足部、核心稳定性', '核心床', 10, NULL, NULL, NULL, NULL, 'reformer', 1, NOW(), NOW()),
('reformer_hundred', '百次拍打', '核心床', '在核心床上进行百次拍打,增加阻力挑战', '核心肌群、呼吸肌群、上身耐力', '核心床', NULL, NULL, 10, NULL, '10个呼吸循环', 'reformer', 2, NOW(), NOW()),
('reformer_bridge', '桥式', '核心床', '在核心床上进行桥式动作,利用弹簧阻力', '臀肌、腘绳肌、脊柱关节', '核心床', 10, NULL, NULL, NULL, '6-10次', 'reformer', 3, NOW(), NOW()),
('reformer_straps', '脚套带练习', '核心床', '使用脚套带进行各种腿部和髋部练习', '髋部灵活性、骨盆稳定性、身体控制、内收肌', '核心床', 10, NULL, NULL, NULL, '8-10次', 'reformer', 4, NOW(), NOW()),
('reformer_elephant', '象式', '核心床', '四点跪撑位置进行推拉练习', '核心肌群、腘绳肌、背部伸肌、肩部稳定性', '核心床', 10, NULL, NULL, NULL, '8-10次', 'reformer', 5, NOW(), NOW()),
('reformer_knee_stretch', '跪姿伸展系列', '核心床', '跪姿位置进行多种伸展动作', '核心肌群、髋部灵活性、肩部控制', '核心床', 10, NULL, NULL, NULL, NULL, 'reformer', 6, NOW(), NOW());
-- 插入凯迪拉克(Cadillac)动作数据
INSERT INTO t_exercises (
key, name, category_name, description, target_muscle_groups, equipment_name,
beginner_reps, beginner_sets, breathing_cycles, hold_duration, special_instructions,
category_key, sort_order, created_at, updated_at
) VALUES
('cadillac_leg_springs', '腿部弹簧系列', '凯迪拉克', '使用腿部弹簧进行各种腿部强化练习', '腘绳肌、股四头肌、臀肌、髋屈肌、内收肌', '凯迪拉克', 20, NULL, NULL, NULL, '10-20次', 'cadillac', 1, NOW(), NOW()),
('cadillac_arm_springs', '手臂弹簧系列', '凯迪拉克', '使用手臂弹簧进行上身力量训练', '胸部、肩部、上背部、手臂', '凯迪拉克', 15, NULL, NULL, NULL, '10-15次', 'cadillac', 2, NOW(), NOW()),
('cadillac_roll_down_bar', '滚背杆卷下', '凯迪拉克', '使用滚背杆进行脊柱逐节控制练习', '腹部肌肉、脊柱灵活性', '凯迪拉克', 12, NULL, NULL, NULL, '8-12次', 'cadillac', 3, NOW(), NOW()),
('cadillac_push_through_bar', '推杆', '凯迪拉克', '推拉横杆进行全身协调练习', '肩部、核心、背部', '凯迪拉克', 12, NULL, NULL, NULL, '8-12次', 'cadillac', 4, NOW(), NOW());
-- 插入普拉提椅(Chair)动作数据
INSERT INTO t_exercises (
key, name, category_name, description, target_muscle_groups, equipment_name,
beginner_reps, beginner_sets, breathing_cycles, hold_duration, special_instructions,
category_key, sort_order, created_at, updated_at
) VALUES
('chair_seated_march', '坐姿行进', '普拉提椅', '坐在椅子上进行交替抬腿练习', '核心肌群、髋屈肌', '普拉提椅', 12, NULL, NULL, NULL, '每侧10-12次', 'chair', 1, NOW(), NOW()),
('chair_seated_leg_lift', '坐姿抬腿', '普拉提椅', '坐姿单腿伸展练习', '股四头肌、核心肌群', '普拉提椅', 12, NULL, NULL, NULL, '每侧10-12次', 'chair', 2, NOW(), NOW()),
('chair_seated_arm_circles', '坐姿手臂画圈', '普拉提椅', '坐姿手臂做圆周运动', '肩部、上背部、手臂', '普拉提椅', 12, NULL, NULL, NULL, '前后各10-12次', 'chair', 3, NOW(), NOW()),
('chair_spine_twist', '脊柱扭转', '普拉提椅', '坐姿脊柱旋转练习', '腹斜肌、脊柱旋转肌', '普拉提椅', 10, NULL, NULL, NULL, '每侧8-10次', 'chair', 4, NOW(), NOW()),
('chair_squat', '椅子深蹲', '普拉提椅', '使用椅子进行深蹲练习', '臀肌、股四头肌、腘绳肌、核心', '普拉提椅', 12, NULL, NULL, NULL, '10-12次', 'chair', 5, NOW(), NOW());
-- 插入普拉提桶(Barrel)动作数据
INSERT INTO t_exercises (
key, name, category_name, description, target_muscle_groups, equipment_name,
beginner_reps, beginner_sets, breathing_cycles, hold_duration, special_instructions,
category_key, sort_order, created_at, updated_at
) VALUES
('barrel_spine_stretch', '脊柱伸展', '普拉提桶', '在桶上进行脊柱后伸练习', '核心肌群、脊柱伸肌、背部肌肉', '普拉提桶', 12, 3, NULL, NULL, '10-12次2-3组', 'barrel', 1, NOW(), NOW()),
('barrel_mermaid', '美人鱼伸展', '普拉提桶', '侧坐在桶上进行侧向伸展', '核心肌群、腹斜肌、侧向柔韧性', '普拉提桶', 10, 3, NULL, NULL, '每侧8-10次2-3组', 'barrel', 2, NOW(), NOW()),
('barrel_side_bend', '侧弯', '普拉提桶', '侧卧在桶上进行侧向弯曲练习', '腹斜肌、腰部塑形、平衡感、核心稳定性', '普拉提桶', 10, 3, NULL, NULL, '每侧8-10次2-3组', 'barrel', 3, NOW(), NOW()),
('barrel_teaser_prep', '预备式Teaser', '普拉提桶', '在桶上进行Teaser动作的预备练习', '核心肌群、平衡感、本体感受', '普拉提桶', 5, 2, NULL, 3, '5次2组保持3秒', 'barrel', 4, NOW(), NOW()),
('barrel_swan_dive_prep', '预备式Swan Dive', '普拉提桶', '在桶上进行Swan Dive的预备练习', '背部伸肌、脊柱伸展、平衡感', '普拉提桶', 5, 2, NULL, 3, '5次2组保持3秒', 'barrel', 5, NOW(), NOW());
-- 提交事务
COMMIT;
-- 验证数据插入
SELECT
ec.name as category_name,
ec.pilates_type,
ec.equipment_name,
COUNT(e.key) as exercise_count
FROM t_exercise_categories ec
LEFT JOIN t_exercises e ON ec.key = e.category_key
GROUP BY ec.key, ec.name, ec.pilates_type, ec.equipment_name
ORDER BY ec.sort_order;

View File

@@ -0,0 +1,64 @@
-- 普拉提数据库结构迁移脚本
-- 此脚本用于将现有的数据库结构升级以支持新的普拉提分类和动作系统
-- 开始事务
BEGIN;
-- 1. 为分类表添加新字段
ALTER TABLE t_exercise_categories
ADD COLUMN pilates_type ENUM('mat_pilates', 'equipment_pilates') NOT NULL DEFAULT 'mat_pilates' COMMENT '普拉提类型:垫上普拉提或器械普拉提';
ALTER TABLE t_exercise_categories
ADD COLUMN equipment_name VARCHAR(255) NULL COMMENT '器械名称(仅器械普拉提需要)';
-- 2. 为动作表添加新字段
ALTER TABLE t_exercises
ADD COLUMN target_muscle_groups TEXT NOT NULL DEFAULT '' COMMENT '主要锻炼肌肉群';
ALTER TABLE t_exercises
ADD COLUMN equipment_name VARCHAR(255) NULL COMMENT '器械名称(器械普拉提专用)';
ALTER TABLE t_exercises
ADD COLUMN beginner_reps INTEGER NULL COMMENT '入门级别建议练习次数';
ALTER TABLE t_exercises
ADD COLUMN beginner_sets INTEGER NULL COMMENT '入门级别建议组数';
ALTER TABLE t_exercises
ADD COLUMN breathing_cycles INTEGER NULL COMMENT '呼吸循环次数(替代普通次数)';
ALTER TABLE t_exercises
ADD COLUMN hold_duration INTEGER NULL COMMENT '保持时间(秒)';
ALTER TABLE t_exercises
ADD COLUMN special_instructions VARCHAR(255) NULL COMMENT '特殊说明(如每侧、前后各等)';
-- 3. 修改现有字段属性
ALTER TABLE t_exercises
MODIFY COLUMN description TEXT NULL COMMENT '动作描述';
-- 提交事务
COMMIT;
-- 验证表结构
SELECT
COLUMN_NAME,
COLUMN_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 't_exercise_categories'
AND TABLE_SCHEMA = DATABASE()
ORDER BY ORDINAL_POSITION;
SELECT
COLUMN_NAME,
COLUMN_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT,
COLUMN_COMMENT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 't_exercises'
AND TABLE_SCHEMA = DATABASE()
ORDER BY ORDINAL_POSITION;

View File

@@ -0,0 +1,135 @@
# 普拉提数据库设计升级总结
## 概述
根据完整的普拉提训练资料,对现有的训练分类和动作数据库设计进行了全面升级,以更好地支持垫上普拉提和器械普拉提的管理。
## 数据库结构变更
### 1. 分类表 (t_exercise_categories) 新增字段
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `pilates_type` | ENUM('mat_pilates', 'equipment_pilates') | 普拉提类型:垫上普拉提或器械普拉提 |
| `equipment_name` | VARCHAR(255) | 器械名称(仅器械普拉提需要) |
### 2. 动作表 (t_exercises) 新增字段
| 字段名 | 类型 | 说明 |
|--------|------|------|
| `target_muscle_groups` | TEXT | 主要锻炼肌肉群 |
| `equipment_name` | VARCHAR(255) | 器械名称(器械普拉提专用) |
| `beginner_reps` | INTEGER | 入门级别建议练习次数 |
| `beginner_sets` | INTEGER | 入门级别建议组数 |
| `breathing_cycles` | INTEGER | 呼吸循环次数(替代普通次数) |
| `hold_duration` | INTEGER | 保持时间(秒) |
| `special_instructions` | VARCHAR(255) | 特殊说明(如每侧、前后各等) |
## 数据分类架构
### 垫上普拉提 (Mat Pilates)
- **分类**: 垫上普拉提
- **动作数量**: 10个经典动作
- **特点**: 无需器械,专注基础动作和呼吸控制
### 器械普拉提 (Equipment Pilates)
#### 1. 核心床 (Reformer)
- **动作数量**: 6个
- **主要功能**: 脚部练习、核心强化、全身协调
#### 2. 凯迪拉克 (Cadillac)
- **动作数量**: 4个
- **主要功能**: 弹簧阻力训练、上下肢力量、脊柱控制
#### 3. 普拉提椅 (Chair)
- **动作数量**: 5个
- **主要功能**: 坐姿训练、平衡挑战、功能性动作
#### 4. 普拉提桶 (Barrel)
- **动作数量**: 5个
- **主要功能**: 脊柱伸展、侧向柔韧性、平衡训练
## 数据导入内容
### 完整动作列表 (共30个动作)
#### 垫上普拉提动作
1. 百次拍打 - 核心肌群、呼吸肌群、肩部稳定性
2. 卷起 - 核心肌群、脊柱灵活性、腘绳肌柔韧性
3. 单腿画圈 - 核心稳定性、髋关节灵活性
4. 滚球 - 核心肌群、脊柱柔韧性、平衡感
5. 单腿伸展 - 核心肌群、髋屈肌、股四头肌
6. 双腿伸展 - 核心肌群、髋屈肌、股四头肌、肩部稳定性
7. 脊柱伸展 - 脊柱柔韧性、腘绳肌伸展、核心控制
8. 锯式 - 腹斜肌、背部伸肌、脊柱旋转灵活性
9. 天鹅式 - 背部伸肌、臀肌、腘绳肌
10. 肩桥 - 臀肌、腘绳肌、下背部、脊柱灵活性
#### 器械普拉提动作 (20个)
涵盖核心床、凯迪拉克、普拉提椅、普拉提桶四种器械的专业动作
## 数据特点
### 精准的训练参数
- **次数建议**: 针对入门级别的具体练习次数
- **组数设置**: 部分动作包含多组训练
- **呼吸循环**: 特殊动作使用呼吸循环替代次数
- **保持时间**: 静态动作的保持时长
- **特殊说明**: 详细的练习要求和注意事项
### 肌肉群定位
每个动作都明确标注主要锻炼的肌肉群,便于:
- 训练计划制定
- 肌肉群平衡分析
- 个性化推荐算法
## 文件清单
1. **pilates-database-migration.sql** - 数据库结构迁移脚本
2. **pilates-data-import.sql** - 完整数据导入脚本
3. **pilates-database-upgrade-summary.md** - 本总结文档
## 使用说明
### 1. 数据库升级步骤
```sql
-- 1. 执行结构迁移
source docs/pilates-database-migration.sql;
-- 2. 导入数据
source docs/pilates-data-import.sql;
```
### 2. 验证导入结果
```sql
-- 查看分类统计
SELECT
ec.name as category_name,
ec.pilates_type,
ec.equipment_name,
COUNT(e.key) as exercise_count
FROM t_exercise_categories ec
LEFT JOIN t_exercises e ON ec.key = e.category_key
GROUP BY ec.key, ec.name, ec.pilates_type, ec.equipment_name
ORDER BY ec.sort_order;
```
## 技术改进
### 1. 模型层更新
- 更新了 `ExerciseCategory``Exercise` 模型
- 添加新字段的 TypeScript 类型定义
- 保持向后兼容性
### 2. 服务层增强
- 更新 `ExercisesService` 以支持新字段
- 添加普拉提类型和器械名称的辅助方法
- 完善数据导入功能
### 3. DTO接口扩展
- 扩展 `ExerciseDto``ExerciseCategoryDto` 接口
- 添加训练参数相关字段
- 支持完整的普拉提训练信息
这次升级使数据库能够完整支持专业的普拉提训练管理,为后续的训练计划制定、进度跟踪和个性化推荐提供了坚实的数据基础。

View File

@@ -0,0 +1,247 @@
-- 普拉提训练系统完整表结构创建脚本
-- 执行前请确保删除旧表(注意删除顺序,避免外键约束错误)
-- 禁用外键检查(创建时)
SET FOREIGN_KEY_CHECKS = 0;
-- 删除现有表(如果存在)
DROP TABLE IF EXISTS `t_schedule_exercises`;
DROP TABLE IF EXISTS `t_training_plans`;
DROP TABLE IF EXISTS `t_exercises`;
DROP TABLE IF EXISTS `t_exercise_categories`;
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 创建分类表
CREATE TABLE `t_exercise_categories` (
`key` varchar(255) NOT NULL COMMENT '分类唯一键(英文/下划线)',
`name` varchar(255) NOT NULL COMMENT '分类中文名称',
`type` enum('mat_pilates','equipment_pilates') NOT NULL DEFAULT 'mat_pilates' COMMENT '普拉提类型:垫上普拉提或器械普拉提',
`equipment_name` varchar(255) DEFAULT NULL COMMENT '器械名称(仅器械普拉提需要)',
`sort_order` int NOT NULL DEFAULT '0' COMMENT '排序(升序)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`key`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='普拉提训练分类表';
-- 创建动作表
CREATE TABLE `t_exercises` (
`key` varchar(255) NOT NULL COMMENT '动作唯一键(英文/下划线)',
`name` varchar(255) NOT NULL COMMENT '动作名称',
`category_name` varchar(255) NOT NULL COMMENT '中文分类名(冗余,便于展示)',
`description` text DEFAULT NULL COMMENT '动作描述',
`target_muscle_groups` text NOT NULL COMMENT '主要锻炼肌肉群',
`equipment_name` varchar(255) DEFAULT NULL COMMENT '器械名称(器械普拉提专用)',
`beginner_reps` int DEFAULT NULL COMMENT '入门级别建议练习次数',
`beginner_sets` int DEFAULT NULL COMMENT '入门级别建议组数',
`breathing_cycles` int DEFAULT NULL COMMENT '呼吸循环次数(替代普通次数)',
`hold_duration` int DEFAULT NULL COMMENT '保持时间(秒)',
`special_instructions` varchar(255) DEFAULT NULL COMMENT '特殊说明(如每侧、前后各等)',
`category_key` varchar(255) NOT NULL COMMENT '分类键',
`sort_order` int NOT NULL DEFAULT '0' COMMENT '排序(分类内)',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`key`),
KEY `idx_category_key` (`category_key`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_category_sort` (`category_key`, `sort_order`),
KEY `idx_equipment_name` (`equipment_name`),
CONSTRAINT `fk_exercises_category` FOREIGN KEY (`category_key`) REFERENCES `t_exercise_categories` (`key`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='普拉提训练动作表';
-- 创建训练计划表
CREATE TABLE `t_training_plans` (
`id` char(36) NOT NULL COMMENT '训练计划唯一ID',
`is_active` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否激活',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`name` varchar(255) DEFAULT NULL COMMENT '计划名称',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`start_date` datetime NOT NULL COMMENT '开始日期',
`mode` enum('daysOfWeek','sessionsPerWeek') NOT NULL COMMENT '计划模式',
`days_of_week` json NOT NULL COMMENT '周几训练0-6',
`sessions_per_week` int NOT NULL COMMENT '每周训练次数',
`goal` enum('postpartum_recovery','fat_loss','posture_correction','core_strength','flexibility','rehab','stress_relief','') NOT NULL COMMENT '训练目标',
`start_weight_kg` float DEFAULT NULL COMMENT '起始体重(公斤)',
`preferred_time_of_day` enum('morning','noon','evening','') NOT NULL DEFAULT '' COMMENT '偏好训练时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_is_active` (`is_active`),
KEY `idx_deleted` (`deleted`),
KEY `idx_user_active` (`user_id`, `is_active`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练计划表';
-- 创建训练计划动作表
CREATE TABLE `t_schedule_exercises` (
`id` char(36) NOT NULL COMMENT '计划动作唯一ID',
`training_plan_id` char(36) NOT NULL COMMENT '训练计划ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`exercise_key` varchar(255) DEFAULT NULL COMMENT '关联的动作key仅exercise类型',
`name` varchar(255) NOT NULL COMMENT '项目名称',
`sets` int DEFAULT NULL COMMENT '组数',
`reps` int DEFAULT NULL COMMENT '重复次数',
`duration_sec` int DEFAULT NULL COMMENT '持续时长(秒)',
`rest_sec` int DEFAULT NULL COMMENT '休息时长(秒)',
`note` text DEFAULT NULL COMMENT '备注',
`item_type` enum('exercise','rest','note') NOT NULL DEFAULT 'exercise' COMMENT '项目类型',
`completed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已完成',
`sort_order` int NOT NULL COMMENT '排序顺序',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_training_plan_id` (`training_plan_id`),
KEY `idx_exercise_key` (`exercise_key`),
KEY `idx_user_id` (`user_id`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_item_type` (`item_type`),
KEY `idx_plan_sort` (`training_plan_id`, `sort_order`),
CONSTRAINT `fk_schedule_exercises_training_plan` FOREIGN KEY (`training_plan_id`) REFERENCES `t_training_plans` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_schedule_exercises_exercise` FOREIGN KEY (`exercise_key`) REFERENCES `t_exercises` (`key`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练计划动作表';
-- 创建索引以优化查询性能
-- 分类表索引已在建表时创建
-- 动作表额外索引
CREATE INDEX `idx_target_muscle_groups` ON `t_exercises` (`target_muscle_groups`(100));
-- 插入初始数据
-- 插入普拉提分类数据
INSERT INTO `t_exercise_categories` (`key`, `name`, `type`, `equipment_name`, `sort_order`, `created_at`, `updated_at`) VALUES
-- 垫上普拉提
('mat_pilates', '垫上普拉提', 'mat_pilates', NULL, 1, NOW(), NOW()),
-- 器械普拉提
('reformer', '核心床', 'equipment_pilates', '核心床', 2, NOW(), NOW()),
('cadillac', '凯迪拉克', 'equipment_pilates', '凯迪拉克', 3, NOW(), NOW()),
('chair', '普拉提椅', 'equipment_pilates', '普拉提椅', 4, NOW(), NOW()),
('barrel', '普拉提桶', 'equipment_pilates', '普拉提桶', 5, NOW(), NOW());
-- 插入垫上普拉提动作数据
INSERT INTO `t_exercises` (
`key`, `name`, `category_name`, `description`, `target_muscle_groups`, `equipment_name`,
`beginner_reps`, `beginner_sets`, `breathing_cycles`, `hold_duration`, `special_instructions`,
`category_key`, `sort_order`, `created_at`, `updated_at`
) VALUES
('mat_hundred', '百次拍打', '垫上普拉提', '经典的普拉提动作,通过有节奏的手臂拍打配合呼吸来激活核心', '核心肌群、呼吸肌群、肩部稳定性', NULL, NULL, NULL, 10, NULL, '10个呼吸循环', 'mat_pilates', 1, NOW(), NOW()),
('mat_roll_up', '卷起', '垫上普拉提', '从仰卧位缓慢卷起至坐姿,锻炼脊柱逐节控制能力', '核心肌群、脊柱灵活性、腘绳肌柔韧性', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 2, NOW(), NOW()),
('mat_single_leg_circle', '单腿画圈', '垫上普拉提', '单腿做圆周运动,保持骨盆稳定', '核心稳定性、髋关节灵活性、股四头肌、腘绳肌、臀肌', NULL, 5, NULL, NULL, NULL, '每条腿每个方向5次', 'mat_pilates', 3, NOW(), NOW()),
('mat_rolling_like_ball', '滚球', '垫上普拉提', '保持紧凑姿势前后滚动,挑战平衡和控制', '核心肌群、脊柱柔韧性、平衡感', NULL, 8, NULL, NULL, NULL, '6-8次', 'mat_pilates', 4, NOW(), NOW()),
('mat_single_leg_stretch', '单腿伸展', '垫上普拉提', '交替单腿伸展,保持上身抬起', '核心肌群、髋屈肌、股四头肌', NULL, 10, NULL, NULL, NULL, '每侧8-10次', 'mat_pilates', 5, NOW(), NOW()),
('mat_double_leg_stretch', '双腿伸展', '垫上普拉提', '双腿和手臂同时伸展,回到起始位置', '核心肌群、髋屈肌、股四头肌、肩部稳定性', NULL, 10, NULL, NULL, NULL, '6-10次', 'mat_pilates', 6, NOW(), NOW()),
('mat_spine_stretch', '脊柱伸展', '垫上普拉提', '坐姿前屈,逐节伸展脊柱', '脊柱柔韧性、腘绳肌伸展、核心控制', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 7, NOW(), NOW()),
('mat_saw', '锯式', '垫上普拉提', '坐姿扭转配合前屈,增强脊柱旋转能力', '腹斜肌、背部伸肌、脊柱旋转灵活性', NULL, 6, NULL, NULL, NULL, '每侧6次', 'mat_pilates', 8, NOW(), NOW()),
('mat_swan', '天鹅式', '垫上普拉提', '俯卧位脊柱后伸,强化背部肌群', '背部伸肌、臀肌、腘绳肌', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 9, NOW(), NOW()),
('mat_shoulder_bridge', '肩桥', '垫上普拉提', '仰卧抬臀,激活后链肌群', '臀肌、腘绳肌、下背部、脊柱灵活性', NULL, 6, NULL, NULL, NULL, NULL, 'mat_pilates', 10, NOW(), NOW());
-- 插入核心床(Reformer)动作数据
INSERT INTO `t_exercises` (
`key`, `name`, `category_name`, `description`, `target_muscle_groups`, `equipment_name`,
`beginner_reps`, `beginner_sets`, `breathing_cycles`, `hold_duration`, `special_instructions`,
`category_key`, `sort_order`, `created_at`, `updated_at`
) VALUES
('reformer_footwork', '脚部练习系列', '核心床', '在核心床上进行各种脚部位置的推蹬练习', '腿部、足部、核心稳定性', '核心床', 10, NULL, NULL, NULL, NULL, 'reformer', 1, NOW(), NOW()),
('reformer_hundred', '百次拍打', '核心床', '在核心床上进行百次拍打,增加阻力挑战', '核心肌群、呼吸肌群、上身耐力', '核心床', NULL, NULL, 10, NULL, '10个呼吸循环', 'reformer', 2, NOW(), NOW()),
('reformer_bridge', '桥式', '核心床', '在核心床上进行桥式动作,利用弹簧阻力', '臀肌、腘绳肌、脊柱关节', '核心床', 10, NULL, NULL, NULL, '6-10次', 'reformer', 3, NOW(), NOW()),
('reformer_straps', '脚套带练习', '核心床', '使用脚套带进行各种腿部和髋部练习', '髋部灵活性、骨盆稳定性、身体控制、内收肌', '核心床', 10, NULL, NULL, NULL, '8-10次', 'reformer', 4, NOW(), NOW()),
('reformer_elephant', '象式', '核心床', '四点跪撑位置进行推拉练习', '核心肌群、腘绳肌、背部伸肌、肩部稳定性', '核心床', 10, NULL, NULL, NULL, '8-10次', 'reformer', 5, NOW(), NOW()),
('reformer_knee_stretch', '跪姿伸展系列', '核心床', '跪姿位置进行多种伸展动作', '核心肌群、髋部灵活性、肩部控制', '核心床', 10, NULL, NULL, NULL, NULL, 'reformer', 6, NOW(), NOW());
-- 插入凯迪拉克(Cadillac)动作数据
INSERT INTO `t_exercises` (
`key`, `name`, `category_name`, `description`, `target_muscle_groups`, `equipment_name`,
`beginner_reps`, `beginner_sets`, `breathing_cycles`, `hold_duration`, `special_instructions`,
`category_key`, `sort_order`, `created_at`, `updated_at`
) VALUES
('cadillac_leg_springs', '腿部弹簧系列', '凯迪拉克', '使用腿部弹簧进行各种腿部强化练习', '腘绳肌、股四头肌、臀肌、髋屈肌、内收肌', '凯迪拉克', 20, NULL, NULL, NULL, '10-20次', 'cadillac', 1, NOW(), NOW()),
('cadillac_arm_springs', '手臂弹簧系列', '凯迪拉克', '使用手臂弹簧进行上身力量训练', '胸部、肩部、上背部、手臂', '凯迪拉克', 15, NULL, NULL, NULL, '10-15次', 'cadillac', 2, NOW(), NOW()),
('cadillac_roll_down_bar', '滚背杆卷下', '凯迪拉克', '使用滚背杆进行脊柱逐节控制练习', '腹部肌肉、脊柱灵活性', '凯迪拉克', 12, NULL, NULL, NULL, '8-12次', 'cadillac', 3, NOW(), NOW()),
('cadillac_push_through_bar', '推杆', '凯迪拉克', '推拉横杆进行全身协调练习', '肩部、核心、背部', '凯迪拉克', 12, NULL, NULL, NULL, '8-12次', 'cadillac', 4, NOW(), NOW());
-- 插入普拉提椅(Chair)动作数据
INSERT INTO `t_exercises` (
`key`, `name`, `category_name`, `description`, `target_muscle_groups`, `equipment_name`,
`beginner_reps`, `beginner_sets`, `breathing_cycles`, `hold_duration`, `special_instructions`,
`category_key`, `sort_order`, `created_at`, `updated_at`
) VALUES
('chair_seated_march', '坐姿行进', '普拉提椅', '坐在椅子上进行交替抬腿练习', '核心肌群、髋屈肌', '普拉提椅', 12, NULL, NULL, NULL, '每侧10-12次', 'chair', 1, NOW(), NOW()),
('chair_seated_leg_lift', '坐姿抬腿', '普拉提椅', '坐姿单腿伸展练习', '股四头肌、核心肌群', '普拉提椅', 12, NULL, NULL, NULL, '每侧10-12次', 'chair', 2, NOW(), NOW()),
('chair_seated_arm_circles', '坐姿手臂画圈', '普拉提椅', '坐姿手臂做圆周运动', '肩部、上背部、手臂', '普拉提椅', 12, NULL, NULL, NULL, '前后各10-12次', 'chair', 3, NOW(), NOW()),
('chair_spine_twist', '脊柱扭转', '普拉提椅', '坐姿脊柱旋转练习', '腹斜肌、脊柱旋转肌', '普拉提椅', 10, NULL, NULL, NULL, '每侧8-10次', 'chair', 4, NOW(), NOW()),
('chair_squat', '椅子深蹲', '普拉提椅', '使用椅子进行深蹲练习', '臀肌、股四头肌、腘绳肌、核心', '普拉提椅', 12, NULL, NULL, NULL, '10-12次', 'chair', 5, NOW(), NOW());
-- 插入普拉提桶(Barrel)动作数据
INSERT INTO `t_exercises` (
`key`, `name`, `category_name`, `description`, `target_muscle_groups`, `equipment_name`,
`beginner_reps`, `beginner_sets`, `breathing_cycles`, `hold_duration`, `special_instructions`,
`category_key`, `sort_order`, `created_at`, `updated_at`
) VALUES
('barrel_spine_stretch', '脊柱伸展', '普拉提桶', '在桶上进行脊柱后伸练习', '核心肌群、脊柱伸肌、背部肌肉', '普拉提桶', 12, 3, NULL, NULL, '10-12次2-3组', 'barrel', 1, NOW(), NOW()),
('barrel_mermaid', '美人鱼伸展', '普拉提桶', '侧坐在桶上进行侧向伸展', '核心肌群、腹斜肌、侧向柔韧性', '普拉提桶', 10, 3, NULL, NULL, '每侧8-10次2-3组', 'barrel', 2, NOW(), NOW()),
('barrel_side_bend', '侧弯', '普拉提桶', '侧卧在桶上进行侧向弯曲练习', '腹斜肌、腰部塑形、平衡感、核心稳定性', '普拉提桶', 10, 3, NULL, NULL, '每侧8-10次2-3组', 'barrel', 3, NOW(), NOW()),
('barrel_teaser_prep', '预备式Teaser', '普拉提桶', '在桶上进行Teaser动作的预备练习', '核心肌群、平衡感、本体感受', '普拉提桶', 5, 2, NULL, 3, '5次2组保持3秒', 'barrel', 4, NOW(), NOW()),
('barrel_swan_dive_prep', '预备式Swan Dive', '普拉提桶', '在桶上进行Swan Dive的预备练习', '背部伸肌、脊柱伸展、平衡感', '普拉提桶', 5, 2, NULL, 3, '5次2组保持3秒', 'barrel', 5, NOW(), NOW());
-- 验证数据插入结果
SELECT
ec.name as category_name,
ec.type,
ec.equipment_name,
COUNT(e.key) as exercise_count
FROM t_exercise_categories ec
LEFT JOIN t_exercises e ON ec.key = e.category_key
GROUP BY ec.key, ec.name, ec.type, ec.equipment_name
ORDER BY ec.sort_order;
-- 验证表结构
SELECT
TABLE_NAME,
TABLE_COMMENT,
TABLE_ROWS
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_exercise_categories', 't_exercises', 't_training_plans', 't_schedule_exercises')
ORDER BY TABLE_NAME;
-- 验证外键约束
SELECT
CONSTRAINT_NAME,
TABLE_NAME,
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_NAME IN ('t_exercises', 't_schedule_exercises')
ORDER BY TABLE_NAME, CONSTRAINT_NAME;

View File

@@ -1,4 +1,6 @@
# 训练计划项目管理 API 使用示例
# 训练计划项目管理 API 使用示例(优化版)
这个版本展示了优化后的 API 设计,重点是与动作库的智能关联和简化的接口操作。
## 创建一个完整的训练计划项目
@@ -35,139 +37,169 @@ Content-Type: application/json
}
```
### 2. 批量添加训练项目
### 2. 逐个添加训练项目(关联动作库)
#### 添加热身项目
```bash
POST /training-plans/plan_1705123456789_abc123/exercises/batch
POST /training-plans/plan_1705123456789_abc123/exercises
Authorization: Bearer {token}
Content-Type: application/json
{
"exercises": [
{
"key": "warmup_dynamic",
"name": "动态热身",
"category": "热身",
"durationSec": 300,
"itemType": "exercise",
"note": "轻松活动关节,准备训练"
},
{
"key": "note_safety",
"name": "安全提醒",
"note": "如感到不适请立即停止,保持正确呼吸",
"itemType": "note"
},
{
"key": "squat_exercise",
"name": "深蹲训练",
"category": "下肢力量",
"sets": 3,
"reps": 15,
"restSec": 60,
"itemType": "exercise",
"note": "下蹲时膝盖不超过脚尖"
},
{
"key": "rest_squat",
"name": "组间休息",
"durationSec": 90,
"itemType": "rest"
},
{
"key": "pushup_exercise",
"name": "俯卧撑",
"category": "上肢力量",
"sets": 3,
"reps": 12,
"restSec": 60,
"itemType": "exercise",
"note": "保持身体一条直线"
},
{
"key": "rest_pushup",
"name": "组间休息",
"durationSec": 90,
"itemType": "rest"
},
{
"key": "plank_exercise",
"name": "平板支撑",
"category": "核心力量",
"sets": 3,
"durationSec": 60,
"restSec": 45,
"itemType": "exercise",
"note": "腹部收紧,不要塌腰"
},
{
"key": "cooldown_stretch",
"name": "拉伸放松",
"category": "拉伸",
"durationSec": 600,
"itemType": "exercise",
"note": "充分拉伸训练过的肌群"
}
]
"exerciseKey": "dynamic_warmup", // 关联动作库中的动态热身
"name": "动态热身",
"durationSec": 300,
"itemType": "exercise",
"note": "轻松活动关节,准备训练"
}
```
响应:
```json
[
{
"id": "ex_1705123456790_def456",
"trainingPlanId": "plan_1705123456789_abc123",
"key": "warmup_dynamic",
"name": "动态热身",
"category": "热身",
"sets": 0,
"durationSec": 300,
"note": "轻松活动关节,准备训练",
"itemType": "exercise",
"completed": false,
"sortOrder": 1,
"createdAt": "2024-01-10T10:35:00.000Z",
"updatedAt": "2024-01-10T10:35:00.000Z"
},
// ... 其他项目
]
#### 添加安全提醒
```bash
POST /training-plans/plan_1705123456789_abc123/exercises
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "安全提醒",
"note": "如感到不适请立即停止,保持正确呼吸",
"itemType": "note"
}
```
### 3. 获取训练项目列表
#### 添加深蹲训练
```bash
POST /training-plans/plan_1705123456789_abc123/exercises
Authorization: Bearer {token}
Content-Type: application/json
{
"exerciseKey": "squat", // 关联动作库中的深蹲
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"restSec": 60,
"itemType": "exercise",
"note": "下蹲时膝盖不超过脚尖"
}
```
#### 添加休息时间
```bash
POST /training-plans/plan_1705123456789_abc123/exercises
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "组间休息",
"durationSec": 90,
"itemType": "rest"
}
```
#### 添加俯卧撑训练
```bash
POST /training-plans/plan_1705123456789_abc123/exercises
Authorization: Bearer {token}
Content-Type: application/json
{
"exerciseKey": "pushup", // 关联动作库中的俯卧撑
"name": "俯卧撑训练",
"sets": 3,
"reps": 12,
"restSec": 60,
"itemType": "exercise",
"note": "保持身体一条直线"
}
```
```
每个添加请求的响应示例:
```json
{
"id": "ex_1705123456790_def456",
"trainingPlanId": "plan_1705123456789_abc123",
"exerciseKey": "squat",
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"restSec": 60,
"note": "下蹲时膝盖不超过脚尖",
"itemType": "exercise",
"completed": false,
"sortOrder": 3,
"createdAt": "2024-01-10T10:35:00.000Z",
"updatedAt": "2024-01-10T10:35:00.000Z"
}
```
### 3. 获取训练项目列表(包含动作库信息)
```bash
GET /training-plans/plan_1705123456789_abc123/exercises
Authorization: Bearer {token}
```
响应按sortOrder排序的完整项目列表
响应按sortOrder排序的完整项目列表,包含关联的动作信息
```json
[
{
"id": "ex_1705123456790_def456",
"trainingPlanId": "plan_1705123456789_abc123",
"key": "warmup_dynamic",
"exerciseKey": "dynamic_warmup",
"name": "动态热身",
"category": "热身",
"sets": 0,
"durationSec": 300,
"note": "轻松活动关节,准备训练",
"itemType": "exercise",
"completed": false,
"sortOrder": 1
"sortOrder": 1,
"exercise": {
"key": "dynamic_warmup",
"name": "动态热身",
"description": "通过各种动态动作激活身体,为训练做准备",
"categoryKey": "warmup",
"categoryName": "热身"
}
},
{
"id": "ex_1705123456791_ghi789",
"trainingPlanId": "plan_1705123456789_abc123",
"key": "note_safety",
"name": "安全提醒",
"note": "如感到不适请立即停止,保持正确呼吸",
"itemType": "note",
"completed": false,
"sortOrder": 2
},
{
"id": "ex_1705123456792_jkl012",
"trainingPlanId": "plan_1705123456789_abc123",
"exerciseKey": "squat",
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"restSec": 60,
"note": "下蹲时膝盖不超过脚尖",
"itemType": "exercise",
"completed": false,
"sortOrder": 3,
"exercise": {
"key": "squat",
"name": "深蹲",
"description": "下肢力量训练的基础动作,主要锻炼大腿和臀部肌肉",
"categoryKey": "strength",
"categoryName": "力量训练"
}
},
{
"id": "ex_1705123456793_mno345",
"trainingPlanId": "plan_1705123456789_abc123",
"name": "组间休息",
"durationSec": 90,
"itemType": "rest",
"completed": false,
"sortOrder": 4
}
// ... 更多项目
]
```

View File

@@ -0,0 +1,200 @@
# 训练计划项目管理系统优化总结
## 优化背景
原始设计存在以下问题:
1. 训练项目与系统动作库缺乏关联
2. 接口复杂,包含过多批量操作
3. 数据结构冗余,缺乏标准化
## 核心优化
### 1. 数据库模型优化
#### 之前的设计
```typescript
// 独立的项目标识,与动作库无关联
interface ScheduleExercise {
key: string; // 自定义项目key
name: string; // 自定义名称
category?: string; // 自定义分类
// ...
}
```
#### 优化后的设计
```typescript
// 智能关联动作库的设计
interface ScheduleExercise {
exerciseKey?: string; // 关联动作库(外键)
name: string; // 可自定义的名称
// ...
exercise?: { // 关联的动作信息
key: string;
name: string;
description: string;
categoryKey: string;
categoryName: string;
};
}
```
#### 数据库关系
- `ScheduleExercise.exerciseKey``Exercise.key` (外键关联)
- 支持左连接查询,获取完整动作信息
- exercise类型项目必须关联动作库rest/note类型可选
### 2. 接口设计简化
#### 去除的批量操作接口
-`POST /exercises/batch` - 批量创建
-`PUT /exercises/batch` - 批量更新
-`DELETE /exercises` - 批量删除
#### 保留的核心接口
-`POST /exercises` - 单个创建
-`GET /exercises` - 列表查询
-`GET /exercises/:id` - 详情查询
-`PUT /exercises/:id` - 单个更新
-`DELETE /exercises/:id` - 单个删除
-`PUT /exercises/order` - 排序调整
-`PUT /exercises/:id/complete` - 完成状态
-`GET /exercises/stats/completion` - 统计信息
### 3. 业务逻辑优化
#### 智能验证机制
```typescript
// 动作存在性验证
private async validateExercise(exerciseKey: string) {
const exercise = await this.exerciseModel.findByPk(exerciseKey);
if (!exercise) {
throw new NotFoundException(`动作 "${exerciseKey}" 不存在`);
}
return exercise;
}
// 创建时的验证逻辑
if (dto.itemType === 'exercise' && dto.exerciseKey) {
await this.validateExercise(dto.exerciseKey);
}
```
#### 关联数据获取
```typescript
// 查询时自动包含动作信息
const exercises = await this.scheduleExerciseModel.findAll({
include: [{ model: Exercise, required: false }],
order: [['sortOrder', 'ASC']],
});
// 格式化返回数据
return exercises.map(exercise => ({
...exercise.toJSON(),
exercise: exercise.exercise ? {
key: exercise.exercise.key,
name: exercise.exercise.name,
description: exercise.exercise.description,
categoryKey: exercise.exercise.categoryKey,
categoryName: exercise.exercise.categoryName,
} : undefined
}));
```
## 技术收益
### 1. 数据一致性提升
- ✅ 动作信息标准化,避免重复维护
- ✅ 外键约束确保数据完整性
- ✅ 动作库统一管理,便于更新
### 2. 接口复杂度降低
- ✅ 去除批量操作,专注单项操作
- ✅ 接口数量从11个减少到8个
- ✅ 参数验证逻辑简化
### 3. 查询性能优化
- ✅ 左连接一次性获取完整信息
- ✅ 减少前端多次API调用
- ✅ 数据库索引优化
### 4. 扩展性增强
- ✅ 新增动作自动可用于训练计划
- ✅ 动作分类变更自动同步
- ✅ 支持动作版本管理
## 业务价值
### 1. 用户体验改善
- **专业性提升**: 基于标准动作库,确保训练的专业性
- **操作简化**: 单项操作更直观,降低学习成本
- **信息完整**: 自动获取动作描述、分类等详细信息
### 2. 内容管理优化
- **集中管理**: 动作库统一维护,避免内容重复
- **质量控制**: 标准化的动作描述和分类
- **版本控制**: 支持动作信息的统一更新
### 3. 开发效率提升
- **接口简化**: 减少API数量降低维护成本
- **类型安全**: 强类型验证,减少运行时错误
- **测试简化**: 单项操作更容易编写测试用例
## 前端适配建议
### 1. 动作选择组件
```typescript
// 获取可用动作列表
const exercises = await api.get('/exercises/config');
// 创建训练项目时选择动作
const createExercise = {
exerciseKey: selectedExercise.key, // 从动作库选择
name: customName || selectedExercise.name, // 可自定义名称
sets: 3,
reps: 15,
itemType: 'exercise'
};
```
### 2. 数据显示组件
```typescript
// 显示训练项目时同时展示动作详情
const ExerciseItem = ({ item }) => (
<div>
<h3>{item.name}</h3>
{item.exercise && (
<div>
<p>分类: {item.exercise.categoryName}</p>
<p>说明: {item.exercise.description}</p>
</div>
)}
<p>组数: {item.sets} | 次数: {item.reps}</p>
</div>
);
```
### 3. 操作流程优化
```typescript
// 简化的操作流程
const addExercise = async (exerciseKey, customParams) => {
const exercise = await api.post(`/training-plans/${planId}/exercises`, {
exerciseKey,
...customParams
});
// 无需额外查询,响应已包含完整信息
updateExerciseList(exercise);
};
```
## 总结
这次优化重点解决了数据关联和接口复杂度两大核心问题:
1. **智能关联**: 通过外键关联确保训练项目与动作库的数据一致性
2. **接口简化**: 去除批量操作,专注于单项操作的用户体验
3. **性能优化**: 通过关联查询减少API调用次数
4. **扩展性**: 为未来的功能扩展提供了良好的架构基础
这个优化后的设计既保持了功能的完整性,又显著提升了系统的可维护性和用户体验。

View File

@@ -0,0 +1,138 @@
-- 训练计划相关表单独创建脚本
-- 此脚本仅创建训练计划相关的表,前提是动作表已经存在
-- 禁用外键检查
SET FOREIGN_KEY_CHECKS = 0;
-- 删除训练计划相关表(如果存在)
DROP TABLE IF EXISTS `t_schedule_exercises`;
DROP TABLE IF EXISTS `t_training_plans`;
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 创建训练计划表
CREATE TABLE `t_training_plans` (
`id` char(36) NOT NULL COMMENT '训练计划唯一ID',
`is_active` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否激活',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`name` varchar(255) DEFAULT NULL COMMENT '计划名称',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`start_date` datetime NOT NULL COMMENT '开始日期',
`mode` enum('daysOfWeek','sessionsPerWeek') NOT NULL COMMENT '计划模式',
`days_of_week` json NOT NULL COMMENT '周几训练0-6',
`sessions_per_week` int NOT NULL COMMENT '每周训练次数',
`goal` enum('postpartum_recovery','fat_loss','posture_correction','core_strength','flexibility','rehab','stress_relief','') NOT NULL COMMENT '训练目标',
`start_weight_kg` float DEFAULT NULL COMMENT '起始体重(公斤)',
`preferred_time_of_day` enum('morning','noon','evening','') NOT NULL DEFAULT '' COMMENT '偏好训练时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_is_active` (`is_active`),
KEY `idx_deleted` (`deleted`),
KEY `idx_user_active` (`user_id`, `is_active`, `deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练计划表';
-- 创建训练计划动作表
CREATE TABLE `t_schedule_exercises` (
`id` char(36) NOT NULL COMMENT '计划动作唯一ID',
`training_plan_id` char(36) NOT NULL COMMENT '训练计划ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`exercise_key` varchar(255) DEFAULT NULL COMMENT '关联的动作key仅exercise类型',
`name` varchar(255) NOT NULL COMMENT '项目名称',
`sets` int DEFAULT NULL COMMENT '组数',
`reps` int DEFAULT NULL COMMENT '重复次数',
`duration_sec` int DEFAULT NULL COMMENT '持续时长(秒)',
`rest_sec` int DEFAULT NULL COMMENT '休息时长(秒)',
`note` text DEFAULT NULL COMMENT '备注',
`item_type` enum('exercise','rest','note') NOT NULL DEFAULT 'exercise' COMMENT '项目类型',
`completed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已完成',
`sort_order` int NOT NULL COMMENT '排序顺序',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_training_plan_id` (`training_plan_id`),
KEY `idx_exercise_key` (`exercise_key`),
KEY `idx_user_id` (`user_id`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_item_type` (`item_type`),
KEY `idx_plan_sort` (`training_plan_id`, `sort_order`),
CONSTRAINT `fk_schedule_exercises_training_plan` FOREIGN KEY (`training_plan_id`) REFERENCES `t_training_plans` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_schedule_exercises_exercise` FOREIGN KEY (`exercise_key`) REFERENCES `t_exercises` (`key`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练计划动作表';
-- 验证表创建
SELECT
TABLE_NAME,
TABLE_COMMENT,
ENGINE
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_training_plans', 't_schedule_exercises')
ORDER BY TABLE_NAME;
-- 验证外键约束
SELECT
CONSTRAINT_NAME,
TABLE_NAME,
COLUMN_NAME,
REFERENCED_TABLE_NAME,
REFERENCED_COLUMN_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME IS NOT NULL
AND TABLE_NAME = 't_schedule_exercises'
ORDER BY CONSTRAINT_NAME;
-- 测试外键约束(插入测试数据)
-- 注意:这需要先有有效的 exercise_key 数据
/*
示例测试(请根据实际数据调整):
-- 插入测试训练计划
INSERT INTO t_training_plans (
id, user_id, name, start_date, mode, days_of_week, sessions_per_week, goal
) VALUES (
'550e8400-e29b-41d4-a716-446655440000',
'test_user_123',
'测试训练计划',
NOW(),
'daysOfWeek',
'[1,3,5]',
3,
'core_strength'
);
-- 插入测试动作
INSERT INTO t_schedule_exercises (
id, training_plan_id, user_id, exercise_key, name, sets, reps, item_type, sort_order
) VALUES (
'550e8400-e29b-41d4-a716-446655440001',
'550e8400-e29b-41d4-a716-446655440000',
'test_user_123',
'mat_hundred',
'百次拍打',
3,
10,
'exercise',
1
);
-- 验证数据
SELECT
tp.name as plan_name,
se.name as exercise_name,
se.exercise_key,
e.target_muscle_groups
FROM t_training_plans tp
JOIN t_schedule_exercises se ON tp.id = se.training_plan_id
LEFT JOIN t_exercises e ON se.exercise_key = e.key
WHERE tp.user_id = 'test_user_123';
-- 清理测试数据
DELETE FROM t_schedule_exercises WHERE user_id = 'test_user_123';
DELETE FROM t_training_plans WHERE user_id = 'test_user_123';
*/

View File

@@ -0,0 +1,190 @@
# 训练会话 API 使用指南
## 架构说明
新的训练系统采用了分离的架构设计,符合健身应用的最佳实践:
### 1. 训练计划模板 (Training Plans)
- **用途**: 用户创建和管理的训练计划模板
- **表**: `t_training_plans` + `t_schedule_exercises`
- **特点**: 静态配置,不包含完成状态
- **API**: `/training-plans`
### 2. 训练会话实例 (Workout Sessions)
- **用途**: 每日实际训练,从训练计划模板复制而来
- **表**: `t_workout_sessions` + `t_workout_exercises`
- **特点**: 动态数据,包含完成状态、进度追踪
- **API**: `/workouts`
## API 使用流程
### 第一步:创建训练计划模板
```bash
# 1. 创建训练计划
POST /training-plans
{
"name": "全身力量训练",
"startDate": "2024-01-15T00:00:00.000Z",
"mode": "daysOfWeek",
"daysOfWeek": [1, 3, 5],
"goal": "core_strength"
}
# 2. 添加训练动作到计划
POST /training-plans/{planId}/exercises
{
"exerciseKey": "squat",
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"itemType": "exercise"
}
# 3. 激活训练计划
POST /training-plans/{planId}/activate
```
### 第二步:开始每日训练
```bash
# 1. 获取今日训练会话(如不存在则自动创建)
GET /workouts/today
# 系统会自动基于激活的训练计划创建今日训练会话
# 2. 开始训练会话(可选)
POST /workouts/sessions/{sessionId}/start
{
"startedAt": "2024-01-15T09:00:00.000Z"
}
# 3. 开始特定动作
POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/start
# 4. 完成动作
POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete
{
"completedSets": 3,
"completedReps": 15,
"actualDurationSec": 180,
"performanceData": {
"sets": [
{ "reps": 15, "difficulty": 7 },
{ "reps": 12, "difficulty": 8 },
{ "reps": 10, "difficulty": 9 }
],
"perceivedExertion": 8
}
}
# 注意:当所有动作完成后,训练会话会自动标记为完成
```
## 主要优势
### 1. 数据分离
- 训练计划是可重用的模板
- 每日训练是独立的实例
- 修改计划不影响历史训练记录
### 2. 自动化管理
- 客户端直接获取今日训练,系统自动创建
- 所有动作完成后自动完成训练会话
- 无需手动管理会话生命周期
### 3. 进度追踪
- 每个训练会话都有完整的状态跟踪
- 支持详细的性能数据记录
- 可以分析训练趋势和进步情况
### 4. 灵活性
- 支持训练中的临时调整
- 支持跳过或修改特定动作
- 自动计算训练统计数据
## API 端点总览
### 训练会话管理
- `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐
- `GET /workouts/sessions` - 获取训练会话列表
- `GET /workouts/sessions/{id}` - 获取训练会话详情
- `POST /workouts/sessions/{id}/start` - 开始训练(可选)
- `DELETE /workouts/sessions/{id}` - 删除训练会话
- 注意:训练会话在所有动作完成后自动完成
### 训练动作管理
- `GET /workouts/sessions/{id}/exercises` - 获取训练动作列表
- `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/complete` - 完成动作
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/skip` - 跳过动作
- `PUT /workouts/sessions/{id}/exercises/{exerciseId}` - 更新动作信息
### 统计和快捷功能
- `GET /workouts/sessions/{id}/stats` - 获取训练统计
- `GET /workouts/recent` - 获取最近训练
## 数据模型
### WorkoutSession (训练会话)
```typescript
{
id: string;
userId: string;
trainingPlanId: string; // 关联的训练计划模板
name: string;
scheduledDate: Date;
startedAt?: Date;
completedAt?: Date;
status: 'planned' | 'in_progress' | 'completed' | 'skipped';
totalDurationSec?: number;
summary?: string;
caloriesBurned?: number;
stats?: {
totalExercises: number;
completedExercises: number;
totalSets: number;
completedSets: number;
// ...
};
}
```
### WorkoutExercise (训练动作实例)
```typescript
{
id: string;
workoutSessionId: string;
exerciseKey?: string; // 关联动作库
name: string;
plannedSets?: number; // 计划数值
completedSets?: number; // 实际完成数值
plannedReps?: number;
completedReps?: number;
plannedDurationSec?: number;
actualDurationSec?: number;
status: 'pending' | 'in_progress' | 'completed' | 'skipped';
startedAt?: Date;
completedAt?: Date;
performanceData?: { // 详细性能数据
sets: Array<{
reps?: number;
weight?: number;
difficulty?: number;
notes?: string;
}>;
heartRate?: { avg: number; max: number };
perceivedExertion?: number; // RPE 1-10
};
}
```
## 迁移说明
如果您之前使用了 `training-plans` 的完成状态功能,现在需要:
1. 使用 `/workouts/sessions` 来创建每日训练
2. 使用新的完成状态 API 来跟踪进度
3. 原有的训练计划数据保持不变,作为模板使用
这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。

View File

@@ -0,0 +1,31 @@
-- 创建训练会话表
CREATE TABLE t_workout_sessions (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
user_id VARCHAR(255) NOT NULL COMMENT '用户ID',
training_plan_id VARCHAR(36) NOT NULL COMMENT '关联的训练计划模板',
name VARCHAR(255) NOT NULL COMMENT '训练会话名称',
scheduled_date DATETIME NOT NULL COMMENT '计划训练日期',
started_at DATETIME NULL COMMENT '实际开始时间',
completed_at DATETIME NULL COMMENT '实际结束时间',
status ENUM('planned', 'in_progress', 'completed', 'skipped') NOT NULL DEFAULT 'planned' COMMENT '训练状态',
total_duration_sec INT NULL COMMENT '总时长(秒)',
summary TEXT NULL COMMENT '训练总结/备注',
calories_burned INT NULL COMMENT '消耗卡路里(估算)',
stats JSON NULL COMMENT '训练统计数据',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE COMMENT '是否已删除',
-- 外键约束
FOREIGN KEY (training_plan_id) REFERENCES t_training_plans(id),
-- 索引
INDEX idx_user_id (user_id),
INDEX idx_training_plan_id (training_plan_id),
INDEX idx_scheduled_date (scheduled_date),
INDEX idx_status (status),
INDEX idx_deleted (deleted)
);
-- 添加表注释
ALTER TABLE t_workout_sessions COMMENT = '训练会话表';

View File

@@ -0,0 +1,85 @@
-- 训练会话相关表创建脚本
-- 用于支持每日训练实例功能
-- 禁用外键检查
SET FOREIGN_KEY_CHECKS = 0;
-- 删除训练会话相关表(如果存在)
DROP TABLE IF EXISTS `t_workout_exercises`;
DROP TABLE IF EXISTS `t_workout_sessions`;
-- 重新启用外键检查
SET FOREIGN_KEY_CHECKS = 1;
-- 创建训练会话表
CREATE TABLE `t_workout_sessions` (
`id` char(36) NOT NULL COMMENT '训练会话唯一ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`training_plan_id` char(36) NOT NULL COMMENT '关联的训练计划模板',
`name` varchar(255) NOT NULL COMMENT '训练会话名称',
`scheduled_date` datetime NOT NULL COMMENT '计划训练日期',
`started_at` datetime DEFAULT NULL COMMENT '实际开始时间',
`completed_at` datetime DEFAULT NULL COMMENT '实际结束时间',
`status` enum('planned','in_progress','completed','skipped') NOT NULL DEFAULT 'planned' COMMENT '训练状态',
`total_duration_sec` int DEFAULT NULL COMMENT '总时长(秒)',
`summary` text COMMENT '训练总结/备注',
`calories_burned` int DEFAULT NULL COMMENT '消耗卡路里(估算)',
`stats` json DEFAULT NULL COMMENT '训练统计数据',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_training_plan_id` (`training_plan_id`),
KEY `idx_scheduled_date` (`scheduled_date`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`deleted`),
KEY `idx_user_date` (`user_id`, `scheduled_date`, `deleted`),
CONSTRAINT `fk_workout_sessions_training_plan` FOREIGN KEY (`training_plan_id`) REFERENCES `t_training_plans` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话表(每日训练实例)';
-- 创建训练会话动作表
CREATE TABLE `t_workout_exercises` (
`id` char(36) NOT NULL COMMENT '训练动作唯一ID',
`workout_session_id` char(36) NOT NULL COMMENT '所属训练会话ID',
`user_id` varchar(255) NOT NULL COMMENT '用户ID',
`exercise_key` varchar(255) DEFAULT NULL COMMENT '关联的动作key仅exercise类型',
`name` varchar(255) NOT NULL COMMENT '项目名称',
`planned_sets` int DEFAULT NULL COMMENT '计划组数',
`completed_sets` int DEFAULT NULL COMMENT '实际完成组数',
`planned_reps` int DEFAULT NULL COMMENT '计划重复次数',
`completed_reps` int DEFAULT NULL COMMENT '实际完成重复次数',
`planned_duration_sec` int DEFAULT NULL COMMENT '计划持续时长(秒)',
`actual_duration_sec` int DEFAULT NULL COMMENT '实际持续时长(秒)',
`rest_sec` int DEFAULT NULL COMMENT '休息时长(秒)',
`note` text COMMENT '备注',
`item_type` enum('exercise','rest','note') NOT NULL DEFAULT 'exercise' COMMENT '项目类型',
`status` enum('pending','in_progress','completed','skipped') NOT NULL DEFAULT 'pending' COMMENT '动作状态',
`sort_order` int NOT NULL COMMENT '排序顺序',
`started_at` datetime DEFAULT NULL COMMENT '开始时间',
`completed_at` datetime DEFAULT NULL COMMENT '完成时间',
`performance_data` json DEFAULT NULL COMMENT '详细执行数据',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已删除',
PRIMARY KEY (`id`),
KEY `idx_workout_session_id` (`workout_session_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_exercise_key` (`exercise_key`),
KEY `idx_sort_order` (`sort_order`),
KEY `idx_status` (`status`),
KEY `idx_deleted` (`deleted`),
KEY `idx_session_order` (`workout_session_id`, `sort_order`, `deleted`),
CONSTRAINT `fk_workout_exercises_session` FOREIGN KEY (`workout_session_id`) REFERENCES `t_workout_sessions` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_workout_exercises_exercise` FOREIGN KEY (`exercise_key`) REFERENCES `t_exercises` (`key`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='训练会话动作表(每日训练实例动作)';
-- 为 t_schedule_exercises 表添加注释,澄清其用途
ALTER TABLE `t_schedule_exercises` COMMENT = '训练计划动作表(训练计划模板的动作配置)';
-- 创建一些有用的索引
CREATE INDEX `idx_workout_sessions_user_status` ON `t_workout_sessions` (`user_id`, `status`, `deleted`);
CREATE INDEX `idx_workout_exercises_session_type` ON `t_workout_exercises` (`workout_session_id`, `item_type`, `deleted`);
-- 插入一些示例数据来测试
-- 注意实际使用时应该通过API来创建数据

View File

@@ -6,6 +6,8 @@ export enum ActivityEntityType {
USER_PROFILE = 'USER_PROFILE',
CHECKIN = 'CHECKIN',
TRAINING_PLAN = 'TRAINING_PLAN',
WORKOUT = 'WORKOUT',
}
export enum ActivityActionType {

View File

@@ -24,31 +24,56 @@ export class AiCoachController {
): Promise<StreamableFile | AiChatResponseDto | void> {
const userId = user.sub;
const stream = body.stream !== false; // 默认流式
const userContent = body.messages?.[body.messages.length - 1]?.content || '';
// 创建或沿用会话ID并保存用户消息
const { conversationId } = await this.aiCoachService.createOrAppendMessages({
userId,
conversationId: body.conversationId,
userContent: body.messages?.[body.messages.length - 1]?.content || '',
userContent,
});
// 智能体重识别:若疑似“记体重”且传入图片,则优先识别并更新体重
let weightInfo: { weightKg?: number } = {};
let weightInfo: { weightKg?: number; systemNotice?: string } = {};
// 体重识别逻辑优化:
// 1. 如果有图片URL使用原有的图片识别逻辑
// 2. 如果没有图片URL但文本中包含体重信息使用新的文本识别逻辑
try {
weightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
userId,
body.imageUrl,
body.messages?.[body.messages.length - 1]?.content,
);
} catch { }
if (body.imageUrl) {
// 原有逻辑:从图片识别体重
const imageWeightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
userId,
body.imageUrl,
userContent,
);
if (imageWeightInfo.weightKg) {
weightInfo = {
weightKg: imageWeightInfo.weightKg,
systemNotice: `系统提示:已从图片识别体重为${imageWeightInfo.weightKg}kg并已为你更新到个人资料。`
};
}
} else {
// 新逻辑:从文本识别体重,并获取历史对比信息
const textWeightInfo = await this.aiCoachService.processWeightFromText(userId, userContent);
if (textWeightInfo.weightKg && textWeightInfo.systemNotice) {
weightInfo = {
weightKg: textWeightInfo.weightKg,
systemNotice: textWeightInfo.systemNotice
};
}
}
} catch (error) {
// 体重识别失败不影响正常对话
console.error('体重识别失败:', error);
}
if (!stream) {
// 非流式:聚合后一次性返回文本
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent: body.messages?.[body.messages.length - 1]?.content || '',
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg并已为你更新到个人资料。` : undefined,
userContent,
systemNotice: weightInfo.systemNotice,
});
let text = '';
for await (const chunk of readable) {
@@ -67,13 +92,11 @@ export class AiCoachController {
const readable = await this.aiCoachService.streamChat({
userId,
conversationId,
userContent: body.messages?.[body.messages.length - 1]?.content || '',
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg并已为你更新到个人资料。` : undefined,
userContent,
systemNotice: weightInfo.systemNotice,
});
readable.on('data', (chunk) => {
// 流水首段可提示体重已更新
// 简化处理:服务端不额外注入推送段,直接靠 systemNotice
res.write(chunk);
});
readable.on('end', () => {

View File

@@ -306,6 +306,168 @@ export class AiCoachService {
return {};
}
}
/**
* 从用户文本中识别体重信息
* 支持多种格式65kg、65公斤、65.5kg、体重65等
*/
private extractWeightFromText(text: string | undefined): number | null {
if (!text) return null;
const t = text.toLowerCase();
// 匹配各种体重格式的正则表达式
const patterns = [
/(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
/(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
/(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)/i,
/我(?:现在|今天)?(?:体重|重量|称重)?(?:是|为|有)?(\d+(?:\.\d+)?)/i,
];
for (const pattern of patterns) {
const match = t.match(pattern);
if (match) {
const weight = parseFloat(match[1]);
// 合理的体重范围检查 (20-400kg)
if (weight >= 20 && weight <= 400) {
return weight;
}
}
}
return null;
}
/**
* 获取用户体重历史记录
*/
async getUserWeightHistory(userId: string, limit: number = 10): Promise<{
currentWeight?: number;
history: Array<{ weight: number; source: string; createdAt: Date }>;
}> {
try {
// 获取当前体重
const profile = await UserProfile.findOne({ where: { userId } });
const currentWeight = profile?.weight;
// 获取体重历史
const history = await this.usersService.getWeightHistory(userId, { limit });
return {
currentWeight: currentWeight || undefined,
history
};
} catch (error) {
this.logger.error(`获取用户体重历史失败: ${error instanceof Error ? error.message : String(error)}`);
return { history: [] };
}
}
/**
* 构建体重更新的系统提示信息
*/
private buildWeightUpdateSystemNotice(
newWeight: number,
currentWeight?: number,
history: Array<{ weight: number; source: string; createdAt: Date }> = []
): string {
let notice = `系统提示:已为你更新体重为${newWeight}kg。`;
if (currentWeight && currentWeight !== newWeight) {
const diff = newWeight - currentWeight;
const diffText = diff > 0 ? `增加了${diff.toFixed(1)}kg` : `减少了${Math.abs(diff).toFixed(1)}kg`;
notice += `相比之前的${currentWeight}kg${diffText}`;
}
// 添加历史对比信息
if (history.length > 0) {
const recentWeights = history.slice(0, 3);
if (recentWeights.length > 1) {
const trend = this.analyzeWeightTrend(recentWeights, newWeight);
notice += trend;
}
}
return notice;
}
/**
* 分析体重趋势
*/
private analyzeWeightTrend(
recentWeights: Array<{ weight: number; createdAt: Date }>,
newWeight: number
): string {
if (recentWeights.length < 2) return '';
const weights = [newWeight, ...recentWeights.map(w => w.weight)];
let trend = '';
// 计算最近几次的平均变化
let totalChange = 0;
for (let i = 0; i < weights.length - 1; i++) {
totalChange += weights[i] - weights[i + 1];
}
const avgChange = totalChange / (weights.length - 1);
if (Math.abs(avgChange) < 0.5) {
trend = '你的体重保持相对稳定,继续保持良好的生活习惯!';
} else if (avgChange > 0) {
trend = `最近体重呈上升趋势,建议加强运动和注意饮食控制。`;
} else {
trend = `最近体重呈下降趋势,很棒的进步!继续坚持健康的生活方式。`;
}
return trend;
}
/**
* 处理体重记录和更新(无图片版本)
* 从用户文本中识别体重,更新记录,并返回相关信息
*/
async processWeightFromText(userId: string, userText?: string): Promise<{
weightKg?: number;
systemNotice?: string;
shouldSkipChat?: boolean;
}> {
if (!userText) return {};
// 检查是否是体重记录意图
if (!this.isLikelyWeightLogIntent(userText)) {
return {};
}
try {
// 从文本中提取体重
const extractedWeight = this.extractWeightFromText(userText);
if (!extractedWeight) {
return {};
}
// 获取用户体重历史
const { currentWeight, history } = await this.getUserWeightHistory(userId);
// 更新体重到数据库
await this.usersService.addWeightByVision(userId, extractedWeight);
// 构建系统提示
const systemNotice = this.buildWeightUpdateSystemNotice(
extractedWeight,
currentWeight || undefined,
history
);
return {
weightKg: extractedWeight,
systemNotice,
shouldSkipChat: false // 仍然需要与AI聊天让AI给出激励回复
};
} catch (error) {
this.logger.error(`处理文本体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
}

View File

@@ -12,6 +12,7 @@ import { ArticlesModule } from './articles/articles.module';
import { RecommendationsModule } from './recommendations/recommendations.module';
import { ActivityLogsModule } from './activity-logs/activity-logs.module';
import { ExercisesModule } from './exercises/exercises.module';
import { WorkoutsModule } from './workouts/workouts.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { ExercisesModule } from './exercises/exercises.module';
RecommendationsModule,
ActivityLogsModule,
ExercisesModule,
WorkoutsModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -1,22 +1,38 @@
export interface ExerciseLibraryItem {
key: string;
name: string;
description: string;
description?: string;
category: string; // 中文分类名
targetMuscleGroups: string;
equipmentName?: string;
beginnerReps?: number;
beginnerSets?: number;
breathingCycles?: number;
holdDuration?: number;
specialInstructions?: string;
}
export interface ExerciseCategoryDto {
key: string; // 英文 key
name: string; // 中文名
type: 'mat_pilates' | 'equipment_pilates';
equipmentName?: string;
sortOrder?: number;
}
export interface ExerciseDto {
key: string;
name: string;
description: string;
description?: string;
categoryKey: string;
categoryName: string;
targetMuscleGroups: string;
equipmentName?: string;
beginnerReps?: number;
beginnerSets?: number;
breathingCycles?: number;
holdDuration?: number;
specialInstructions?: string;
sortOrder?: number;
}

View File

@@ -2,14 +2,14 @@ import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { ExerciseCategory } from './models/exercise-category.model';
import { Exercise } from './models/exercise.model';
import { ExerciseConfigResponse, ExerciseLibraryItem } from './dto/exercise.dto';
import { ExerciseConfigResponse } from './dto/exercise.dto';
@Injectable()
export class ExercisesService {
constructor(
@InjectModel(ExerciseCategory) private readonly categoryModel: typeof ExerciseCategory,
@InjectModel(Exercise) private readonly exerciseModel: typeof Exercise,
) {}
) { }
async getConfig(): Promise<ExerciseConfigResponse> {
const [categories, exercises] = await Promise.all([
@@ -18,65 +18,11 @@ export class ExercisesService {
]);
return {
categories: categories.map((c) => ({ key: c.key, name: c.name, sortOrder: c.sortOrder })),
exercises: exercises.map((e) => ({
key: e.key,
name: e.name,
description: e.description,
categoryKey: e.categoryKey,
categoryName: e.categoryName,
sortOrder: e.sortOrder,
})),
categories: categories,
exercises,
};
}
async seedFromLibrary(library: ExerciseLibraryItem[]): Promise<void> {
const categoryNameToKey: Record<string, string> = {};
const uniqueCategoryNames = Array.from(new Set(library.map((i) => i.category)));
uniqueCategoryNames.forEach((name) => {
const key = this.slugifyCategory(name);
categoryNameToKey[name] = key;
});
await this.categoryModel.bulkCreate(
uniqueCategoryNames.map((name, index) => ({
key: categoryNameToKey[name],
name,
sortOrder: index,
})),
{ ignoreDuplicates: true },
);
await this.exerciseModel.bulkCreate(
library.map((item, index) => ({
key: item.key,
name: item.name,
description: item.description,
categoryKey: categoryNameToKey[item.category],
categoryName: item.category,
sortOrder: index,
})),
{ ignoreDuplicates: true },
);
}
private slugifyCategory(name: string): string {
const mapping: Record<string, string> = {
'核心与腹部': 'core',
'脊柱与后链': 'spine_posterior_chain',
'侧链与髋': 'lateral_hip',
'平衡与支撑': 'balance_support',
'进阶控制': 'advanced_control',
'柔韧与拉伸': 'mobility_stretch',
};
return mapping[name] || name
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.toLowerCase();
}
}

View File

@@ -20,6 +20,20 @@ export class ExerciseCategory extends Model {
})
declare name: string;
@Column({
type: DataType.ENUM('mat_pilates', 'equipment_pilates'),
allowNull: false,
comment: '普拉提类型:垫上普拉提或器械普拉提',
})
declare type: 'mat_pilates' | 'equipment_pilates';
@Column({
type: DataType.STRING,
allowNull: true,
comment: '器械名称(仅器械普拉提需要)',
})
declare equipmentName: string;
@Column({
type: DataType.INTEGER,
allowNull: false,

View File

@@ -13,15 +13,36 @@ export class Exercise extends Model {
})
declare key: string;
@Column({ type: DataType.STRING, allowNull: false, comment: '名称(含中英文)' })
@Column({ type: DataType.STRING, allowNull: false, comment: '动作名称' })
declare name: string;
@Column({ type: DataType.STRING, allowNull: false, comment: '中文分类名(冗余,便于展示)' })
declare categoryName: string;
@Column({ type: DataType.TEXT, allowNull: false, comment: '描述' })
@Column({ type: DataType.TEXT, allowNull: true, comment: '动作描述' })
declare description: string;
@Column({ type: DataType.TEXT, allowNull: false, comment: '主要锻炼肌肉群' })
declare targetMuscleGroups: string;
@Column({ type: DataType.STRING, allowNull: true, comment: '器械名称(器械普拉提专用)' })
declare equipmentName: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '入门级别建议练习次数' })
declare beginnerReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '入门级别建议组数' })
declare beginnerSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '呼吸循环次数(替代普通次数)' })
declare breathingCycles: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '保持时间(秒)' })
declare holdDuration: number;
@Column({ type: DataType.STRING, allowNull: true, comment: '特殊说明(如每侧、前后各等)' })
declare specialInstructions: string;
@ForeignKey(() => ExerciseCategory)
@Column({ type: DataType.STRING, allowNull: false, comment: '分类键' })
declare categoryKey: string;

View File

@@ -1,6 +1,6 @@
# 训练计划项目管理 API 文档
这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序、批量操作和完成状态跟踪。
这个功能实现了对训练计划下具体训练项目的完整管理,支持增删改查、排序和完成状态跟踪。项目与系统动作库智能关联,提供了训练、休息、提醒三种类型的灵活支持。
## 数据模型
@@ -11,9 +11,8 @@ interface ScheduleExercise {
id: string; // 项目ID
trainingPlanId: string; // 所属训练计划ID
userId: string; // 用户ID
key: string; // 项目标识key (唯一)
exerciseKey?: string; // 关联的动作key仅exercise类型
name: string; // 项目名称
category?: string; // 项目分类
sets?: number; // 组数
reps?: number; // 重复次数
durationSec?: number; // 持续时长(秒)
@@ -25,6 +24,15 @@ interface ScheduleExercise {
createdAt: Date; // 创建时间
updatedAt: Date; // 更新时间
deleted: boolean; // 是否已删除
// 关联的动作信息仅exercise类型时存在
exercise?: {
key: string; // 动作key
name: string; // 动作名称
description: string; // 动作描述
categoryKey: string; // 分类key
categoryName: string; // 分类名称
};
}
```
@@ -36,102 +44,95 @@ interface ScheduleExercise {
```json
{
"key": "warm_up_1",
"name": "热身运动",
"category": "热身",
"sets": 1,
"durationSec": 300,
"itemType": "exercise"
"exerciseKey": "squat", // 关联到动作库中的深蹲
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"restSec": 60,
"itemType": "exercise",
"note": "注意膝盖不要超过脚尖"
}
```
### 2. 批量添加训练项目
**POST** `/training-plans/:id/exercises/batch`
或者添加休息项目
```json
{
"exercises": [
{
"key": "exercise_1",
"name": "深蹲",
"category": "力量训练",
"sets": 3,
"reps": 15,
"itemType": "exercise"
},
{
"key": "rest_1",
"name": "休息",
"itemType": "rest",
"durationSec": 60
},
{
"key": "note_1",
"name": "注意事项",
"note": "保持呼吸平稳",
"itemType": "note"
}
]
"name": "组间休息",
"durationSec": 90,
"itemType": "rest"
}
```
### 3. 获取训练计划的所有项目
或者添加提醒项目
```json
{
"name": "安全提醒",
"note": "如感到不适请立即停止",
"itemType": "note"
}
```
### 2. 获取训练计划的所有项目
**GET** `/training-plans/:id/exercises`
返回按排序顺序排列的所有训练项目
返回按排序顺序排列的所有训练项目,包含关联的动作信息:
### 4. 获取训练项目详情
```json
[
{
"id": "ex_123",
"trainingPlanId": "plan_456",
"exerciseKey": "squat",
"name": "深蹲训练",
"sets": 3,
"reps": 15,
"restSec": 60,
"itemType": "exercise",
"completed": false,
"sortOrder": 1,
"exercise": {
"key": "squat",
"name": "深蹲",
"description": "下肢力量训练的基础动作",
"categoryKey": "strength",
"categoryName": "力量训练"
}
},
{
"id": "ex_124",
"name": "组间休息",
"durationSec": 90,
"itemType": "rest",
"completed": false,
"sortOrder": 2
}
]
```
### 3. 获取训练项目详情
**GET** `/training-plans/:id/exercises/:exerciseId`
### 5. 更新训练项目
### 4. 更新训练项目
**PUT** `/training-plans/:id/exercises/:exerciseId`
```json
{
"name": "修改后的名称",
"exerciseKey": "pushup", // 更换为俯卧撑
"name": "俯卧撑训练",
"sets": 4,
"reps": 12,
"completed": true
}
```
### 6. 批量更新训练项目
**PUT** `/training-plans/:id/exercises/batch`
```json
{
"exercises": [
{
"id": "exercise_id_1",
"sets": 4,
"completed": true
},
{
"id": "exercise_id_2",
"reps": 20
}
]
}
```
### 7. 删除训练项目
### 5. 删除训练项目
**DELETE** `/training-plans/:id/exercises/:exerciseId`
### 8. 批量删除训练项目
**DELETE** `/training-plans/:id/exercises`
```json
["exercise_id_1", "exercise_id_2", "exercise_id_3"]
```
### 9. 更新训练项目排序
### 6. 更新训练项目排序
**PUT** `/training-plans/:id/exercises/order`
@@ -143,7 +144,7 @@ interface ScheduleExercise {
重新排列项目顺序,数组中的顺序即为新的排序。
### 10. 标记训练项目完成状态
### 7. 标记训练项目完成状态
**PUT** `/training-plans/:id/exercises/:exerciseId/complete`
@@ -153,51 +154,52 @@ interface ScheduleExercise {
}
```
### 11. 获取训练计划完成统计
### 8. 获取训练计划完成统计
**GET** `/training-plans/:id/exercises/stats/completion`
```json
{
"total": 10,
"completed": 6,
"percentage": 60
"total": 5, // 总共5个运动项目(不包括休息和提醒)
"completed": 3, // 已完成3个
"percentage": 60 // 完成率60%
}
```
## 功能特性
### 1. 智能排序
### 1. 动作库集成
- **智能关联**: exercise类型自动关联系统动作库
- **标准化**: 确保动作的准确性和专业性
- **分类管理**: 通过动作分类快速筛选和组织
### 2. 灵活的项目类型
- **exercise**: 运动项目 (关联动作库,支持组数、次数、时长等)
- **rest**: 休息项目 (设置休息时长)
- **note**: 提醒项目 (添加注意事项和指导)
### 3. 智能排序和管理
- 新增项目自动添加到列表末尾
- 支持拖拽重新排序
- 批量操作时保持排序逻辑
- 简洁的单项操作设计
### 2. 项目类型支持
- **exercise**: 运动项目 (支持组数、次数、时长等)
- **rest**: 休息项目 (主要设置休息时长)
- **note**: 提示项目 (主要用于注意事项)
### 3. 灵活的参数配置
- `sets`: 组数
### 4. 参数配置丰富
- `exerciseKey`: 关联系统动作库
- `sets`: 训练组数
- `reps`: 每组重复次数
- `durationSec`: 持续时长(秒),适用于有氧运动或休息
- `restSec`: 组间休息时长
- `note`: 备注信息
- `note`: 个性化备注信息
### 4. 完成状态跟踪
- 每个项目都有完成状态
- 支持统计整体完成进度
- 只有运动类型项目计入统计
### 5. 完成状态跟踪
- 实时跟踪每个项目完成状态
- 智能统计整体完成进度
- 只有运动类型项目计入完成率统计
### 5. 批量操作
- 批量创建:一次性添加多个项目
- 批量更新:同时修改多个项目
- 批量删除:一次性删除多个项目
### 6. 数据安全
- 所有操作都验证用户权限
- 项目key在同一训练计划内唯一
- 支持软删除,数据可恢复
### 6. 数据安全与验证
- 用户权限验证,确保数据安全
- 动作存在性验证,防止无效关联
- 软删除机制,数据可恢复
## 使用示例
@@ -207,34 +209,73 @@ interface ScheduleExercise {
// 1. 先创建训练计划
const plan = await createTrainingPlan({...});
// 2. 批量添加训练项目
await batchCreateExercises(plan.id, {
exercises: [
// 热身阶段
{ key: "warmup", name: "热身", category: "热身", durationSec: 300, itemType: "exercise" },
// 2. 逐个添加训练项目(关联动作库)
// 热身阶段
await createExercise(plan.id, {
exerciseKey: "dynamic_warmup",
name: "动态热身",
durationSec: 300,
itemType: "exercise"
});
// 主要训练
{ key: "squat", name: "深蹲", category: "力量", sets: 3, reps: 15, itemType: "exercise" },
{ key: "rest_1", name: "休息", itemType: "rest", durationSec: 60 },
// 主要训练 - 深蹲
await createExercise(plan.id, {
exerciseKey: "squat",
name: "深蹲训练",
sets: 3,
reps: 15,
restSec: 60,
itemType: "exercise",
note: "保持膝盖不超过脚尖"
});
{ key: "pushup", name: "俯卧撑", category: "力量", sets: 3, reps: 12, itemType: "exercise" },
{ key: "rest_2", name: "休息", itemType: "rest", durationSec: 60 },
// 休息
await createExercise(plan.id, {
name: "组间休息",
durationSec: 90,
itemType: "rest"
});
// 注意事项
{ key: "note_form", name: "注意动作标准", note: "保持核心紧张,动作缓慢控制", itemType: "note" },
// 俯卧撑
await createExercise(plan.id, {
exerciseKey: "pushup",
name: "俯卧撑训练",
sets: 3,
reps: 12,
restSec: 60,
itemType: "exercise"
});
// 放松阶段
{ key: "cooldown", name: "拉伸放松", category: "拉伸", durationSec: 300, itemType: "exercise" }
]
// 安全提醒
await createExercise(plan.id, {
name: "注意动作标准",
note: "保持核心紧张,动作缓慢控制",
itemType: "note"
});
// 3. 用户完成训练项目
await markExerciseComplete(plan.id, "squat", { completed: true });
await markExerciseComplete(plan.id, "pushup", { completed: true });
await markExerciseComplete(plan.id, squatId, { completed: true });
await markExerciseComplete(plan.id, pushupId, { completed: true });
// 4. 查看完成进度
const stats = await getCompletionStats(plan.id);
// { total: 4, completed: 2, percentage: 50 }
// { total: 3, completed: 2, percentage: 67 }
// 5. 获取包含动作详情的训练列表
const exercises = await listExercises(plan.id);
// 返回的数据包含关联的动作库信息
```
这个实现提供了与前端 `ScheduleExercise` 接口完全匹配的后端支持,用户可以灵活地管理训练计划的具体内容。
## 关键优化
### 数据库设计优化
- **外键关联**: ScheduleExercise.exerciseKey → Exercise.key
- **数据一致性**: 动作存在性验证
- **查询优化**: 左连接获取动作详情
### 接口设计简化
- **去除批量操作**: 专注单项操作,降低复杂度
- **智能关联**: 自动验证和获取动作信息
- **类型安全**: 强类型验证确保数据准确性
这个优化后的实现确保了训练计划项目与系统动作库的正确关联,提供了更加专业和标准化的训练管理体验。

View File

@@ -3,21 +3,16 @@ import { IsArray, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Mi
import { ScheduleItemType } from '../models/schedule-exercise.model';
export class CreateScheduleExerciseDto {
@ApiProperty({ description: '项目标识key' })
@ApiProperty({ description: '动作key仅exercise类型需要', required: false })
@IsString()
@IsNotEmpty()
key: string;
@IsOptional()
exerciseKey?: string;
@ApiProperty({ description: '项目名称' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: '项目分类', required: false })
@IsString()
@IsOptional()
category?: string;
@ApiProperty({ description: '组数', required: false })
@IsInt()
@Min(0)
@@ -65,18 +60,6 @@ export class CreateScheduleExerciseDto {
export class UpdateScheduleExerciseDto extends PartialType(CreateScheduleExerciseDto) { }
export class BatchCreateScheduleExerciseDto {
@ApiProperty({ type: [CreateScheduleExerciseDto], description: '训练项目列表' })
@IsArray()
exercises: CreateScheduleExerciseDto[];
}
export class BatchUpdateScheduleExerciseDto {
@ApiProperty({ type: [UpdateScheduleExerciseDto], description: '要更新的训练项目列表' })
@IsArray()
exercises: (UpdateScheduleExerciseDto & { id: string })[];
}
export class UpdateScheduleExerciseOrderDto {
@ApiProperty({ description: '项目ID列表按新的顺序排列' })
@IsArray()
@@ -93,9 +76,8 @@ export class CompleteScheduleExerciseDto {
export class ScheduleExerciseResponseDto {
@ApiProperty() id: string;
@ApiProperty() trainingPlanId: string;
@ApiProperty() key: string;
@ApiProperty() exerciseKey?: string;
@ApiProperty() name: string;
@ApiProperty() category?: string;
@ApiProperty() sets?: number;
@ApiProperty() reps?: number;
@ApiProperty() durationSec?: number;
@@ -106,4 +88,14 @@ export class ScheduleExerciseResponseDto {
@ApiProperty() sortOrder: number;
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的动作信息仅exercise类型时存在
@ApiProperty({ required: false })
exercise?: {
key: string;
name: string;
description: string;
categoryKey: string;
categoryName: string;
};
}

View File

@@ -1,5 +1,6 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
import { TrainingPlan } from './training-plan.model';
import { Exercise } from '../../exercises/models/exercise.model';
export type ScheduleItemType = 'exercise' | 'rest' | 'note';
@@ -16,7 +17,7 @@ export class ScheduleExercise extends Model {
declare id: string;
@ForeignKey(() => TrainingPlan)
@Column({ type: DataType.STRING, allowNull: false })
@Column({ type: DataType.UUID, allowNull: false })
declare trainingPlanId: string;
@BelongsTo(() => TrainingPlan)
@@ -25,15 +26,17 @@ export class ScheduleExercise extends Model {
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
@Column({ type: DataType.STRING, allowNull: false, comment: '项目标识key' })
declare key: string;
// 关联到动作库仅exercise类型需要
@ForeignKey(() => Exercise)
@Column({ type: DataType.STRING, allowNull: true, comment: '关联的动作key仅exercise类型' })
declare exerciseKey: string;
@BelongsTo(() => Exercise, { foreignKey: 'exerciseKey', targetKey: 'key' })
declare exercise: Exercise;
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
declare name: string;
@Column({ type: DataType.STRING, allowNull: true, comment: '项目分类' })
declare category: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '组数' })
declare sets: number;

View File

@@ -2,13 +2,11 @@ import { Inject, Injectable, NotFoundException, BadRequestException } from '@nes
import { InjectModel } from '@nestjs/sequelize';
import { ScheduleExercise } from './models/schedule-exercise.model';
import { TrainingPlan } from './models/training-plan.model';
import { Exercise } from '../exercises/models/exercise.model';
import {
CreateScheduleExerciseDto,
UpdateScheduleExerciseDto,
BatchCreateScheduleExerciseDto,
BatchUpdateScheduleExerciseDto,
UpdateScheduleExerciseOrderDto,
CompleteScheduleExerciseDto
UpdateScheduleExerciseOrderDto
} from './dto/schedule-exercise.dto';
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
@@ -25,6 +23,8 @@ export class ScheduleExerciseService {
private scheduleExerciseModel: typeof ScheduleExercise,
@InjectModel(TrainingPlan)
private trainingPlanModel: typeof TrainingPlan,
@InjectModel(Exercise)
private exerciseModel: typeof Exercise,
private readonly activityLogsService: ActivityLogsService,
) { }
@@ -48,16 +48,22 @@ export class ScheduleExerciseService {
return lastExercise ? lastExercise.sortOrder + 1 : 1;
}
// 验证动作是否存在
private async validateExercise(exerciseKey: string): Promise<Exercise> {
const exercise = await this.exerciseModel.findByPk(exerciseKey);
if (!exercise) {
throw new NotFoundException(`动作 "${exerciseKey}" 不存在`);
}
return exercise;
}
// 创建单个训练项目
async create(userId: string, trainingPlanId: string, dto: CreateScheduleExerciseDto) {
await this.validateTrainingPlan(userId, trainingPlanId);
// 检查key是否存在
const existingExercise = await this.scheduleExerciseModel.findOne({
where: { trainingPlanId, key: dto.key, deleted: false }
});
if (existingExercise) {
throw new BadRequestException(`项目key "${dto.key}" 已存在`);
// 如果是exercise类型验证动作是否存在
if (dto.itemType === 'exercise' && dto.exerciseKey) {
await this.validateExercise(dto.exerciseKey);
}
const sortOrder = await this.getNextSortOrder(trainingPlanId);
@@ -65,9 +71,8 @@ export class ScheduleExerciseService {
const exercise = await this.scheduleExerciseModel.create({
trainingPlanId,
userId,
key: dto.key,
exerciseKey: dto.exerciseKey,
name: dto.name,
category: dto.category || '',
sets: dto.sets || 0,
reps: dto.reps,
durationSec: dto.durationSec,
@@ -96,61 +101,7 @@ export class ScheduleExerciseService {
return exercise.toJSON();
}
// 批量创建训练项目
async batchCreate(userId: string, trainingPlanId: string, dto: BatchCreateScheduleExerciseDto) {
await this.validateTrainingPlan(userId, trainingPlanId);
const transaction = await this.scheduleExerciseModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
try {
const exercises: ScheduleExercise[] = [];
let sortOrder = await this.getNextSortOrder(trainingPlanId);
for (const exerciseDto of dto.exercises) {
// 检查key是否已存在
const existingExercise = await this.scheduleExerciseModel.findOne({
where: { trainingPlanId, key: exerciseDto.key, deleted: false },
transaction
});
if (existingExercise) {
throw new BadRequestException(`项目key "${exerciseDto.key}" 已存在`);
}
const exercise = await this.scheduleExerciseModel.create({
trainingPlanId,
userId,
key: exerciseDto.key,
name: exerciseDto.name,
category: exerciseDto.category || '',
sets: exerciseDto.sets || 0,
reps: exerciseDto.reps,
durationSec: exerciseDto.durationSec,
restSec: exerciseDto.restSec,
note: exerciseDto.note || '',
itemType: exerciseDto.itemType || 'exercise',
completed: exerciseDto.completed || false,
sortOrder: sortOrder++,
}, { transaction });
exercises.push(exercise);
}
await transaction.commit();
this.winstonLogger.info(`批量创建训练项目 ${exercises.length}`, {
context: 'ScheduleExerciseService',
userId,
trainingPlanId,
count: exercises.length,
});
return exercises.map(exercise => exercise.toJSON());
} catch (error) {
await transaction.rollback();
throw error;
}
}
// 获取训练计划的所有项目
async list(userId: string, trainingPlanId: string) {
@@ -158,10 +109,23 @@ export class ScheduleExerciseService {
const exercises = await this.scheduleExerciseModel.findAll({
where: { trainingPlanId, userId, deleted: false },
include: [{ model: Exercise, required: false }],
order: [['sortOrder', 'ASC']],
});
return exercises.map(exercise => exercise.toJSON());
return exercises.map(exercise => {
const data = exercise.toJSON();
return {
...data,
exercise: exercise.exercise ? {
key: exercise.exercise.key,
name: exercise.exercise.name,
description: exercise.exercise.description,
categoryKey: exercise.exercise.categoryKey,
categoryName: exercise.exercise.categoryName,
} : undefined
};
});
}
// 获取单个训练项目详情
@@ -169,14 +133,25 @@ export class ScheduleExerciseService {
await this.validateTrainingPlan(userId, trainingPlanId);
const exercise = await this.scheduleExerciseModel.findOne({
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
where: { id: exerciseId, trainingPlanId, userId, deleted: false },
include: [{ model: Exercise, required: false }]
});
if (!exercise) {
throw new NotFoundException('训练项目不存在');
}
return exercise.toJSON();
const data = exercise.toJSON();
return {
...data,
exercise: exercise.exercise ? {
key: exercise.exercise.key,
name: exercise.exercise.name,
description: exercise.exercise.description,
categoryKey: exercise.exercise.categoryKey,
categoryName: exercise.exercise.categoryName,
} : undefined
};
}
// 更新训练项目
@@ -191,22 +166,16 @@ export class ScheduleExerciseService {
throw new NotFoundException('训练项目不存在');
}
// 如果更新key检查是否冲突
if (dto.key && dto.key !== exercise.key) {
const existingExercise = await this.scheduleExerciseModel.findOne({
where: { trainingPlanId, key: dto.key, deleted: false, id: { [Op.ne]: exerciseId } }
});
if (existingExercise) {
throw new BadRequestException(`项目key "${dto.key}" 已存在`);
}
// 如果更新exerciseKey验证动作是否存在
if (dto.exerciseKey && dto.exerciseKey !== exercise.exerciseKey) {
await this.validateExercise(dto.exerciseKey);
}
const before = exercise.toJSON();
// 更新字段
if (dto.key !== undefined) exercise.key = dto.key;
if (dto.exerciseKey !== undefined) exercise.exerciseKey = dto.exerciseKey;
if (dto.name !== undefined) exercise.name = dto.name;
if (dto.category !== undefined) exercise.category = dto.category || '';
if (dto.sets !== undefined) exercise.sets = dto.sets || 0;
if (dto.reps !== undefined) exercise.reps = dto.reps;
if (dto.durationSec !== undefined) exercise.durationSec = dto.durationSec;
@@ -244,73 +213,7 @@ export class ScheduleExerciseService {
return after;
}
// 批量更新训练项目
async batchUpdate(userId: string, trainingPlanId: string, dto: BatchUpdateScheduleExerciseDto) {
await this.validateTrainingPlan(userId, trainingPlanId);
const transaction = await this.scheduleExerciseModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
try {
const updatedExercises: ScheduleExercise[] = [];
for (const exerciseDto of dto.exercises) {
const exercise = await this.scheduleExerciseModel.findOne({
where: { id: exerciseDto.id, trainingPlanId, userId, deleted: false },
transaction
});
if (!exercise) {
throw new NotFoundException(`训练项目 ${exerciseDto.id} 不存在`);
}
// 如果更新key检查是否冲突
if (exerciseDto.key && exerciseDto.key !== exercise.key) {
const existingExercise = await this.scheduleExerciseModel.findOne({
where: {
trainingPlanId,
key: exerciseDto.key,
deleted: false,
id: { [Op.ne]: exerciseDto.id }
},
transaction
});
if (existingExercise) {
throw new BadRequestException(`项目key "${exerciseDto.key}" 已存在`);
}
}
// 更新字段
if (exerciseDto.key !== undefined) exercise.key = exerciseDto.key;
if (exerciseDto.name !== undefined) exercise.name = exerciseDto.name;
if (exerciseDto.category !== undefined) exercise.category = exerciseDto.category || '';
if (exerciseDto.sets !== undefined) exercise.sets = exerciseDto.sets || 0;
if (exerciseDto.reps !== undefined) exercise.reps = exerciseDto.reps;
if (exerciseDto.durationSec !== undefined) exercise.durationSec = exerciseDto.durationSec;
if (exerciseDto.restSec !== undefined) exercise.restSec = exerciseDto.restSec;
if (exerciseDto.note !== undefined) exercise.note = exerciseDto.note || '';
if (exerciseDto.itemType !== undefined) exercise.itemType = exerciseDto.itemType;
if (exerciseDto.completed !== undefined) exercise.completed = exerciseDto.completed;
await exercise.save({ transaction });
updatedExercises.push(exercise);
}
await transaction.commit();
this.winstonLogger.info(`批量更新训练项目 ${updatedExercises.length}`, {
context: 'ScheduleExerciseService',
userId,
trainingPlanId,
count: updatedExercises.length,
});
return updatedExercises.map(exercise => exercise.toJSON());
} catch (error) {
await transaction.rollback();
throw error;
}
}
// 删除训练项目
async remove(userId: string, trainingPlanId: string, exerciseId: string) {
@@ -343,31 +246,7 @@ export class ScheduleExerciseService {
return { success: true };
}
// 批量删除训练项目
async batchRemove(userId: string, trainingPlanId: string, exerciseIds: string[]) {
await this.validateTrainingPlan(userId, trainingPlanId);
const [count] = await this.scheduleExerciseModel.update(
{ deleted: true },
{
where: {
id: { [Op.in]: exerciseIds },
trainingPlanId,
userId,
deleted: false
}
}
);
this.winstonLogger.info(`批量删除训练项目 ${count}`, {
context: 'ScheduleExerciseService',
userId,
trainingPlanId,
count,
});
return { success: true, deletedCount: count };
}
// 更新训练项目排序
async updateOrder(userId: string, trainingPlanId: string, dto: UpdateScheduleExerciseOrderDto) {
@@ -419,60 +298,7 @@ export class ScheduleExerciseService {
}
}
// 标记完成状态
async markComplete(userId: string, trainingPlanId: string, exerciseId: string, dto: CompleteScheduleExerciseDto) {
await this.validateTrainingPlan(userId, trainingPlanId);
const exercise = await this.scheduleExerciseModel.findOne({
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
});
if (!exercise) {
throw new NotFoundException('训练项目不存在');
}
const before = exercise.completed;
exercise.completed = dto.completed;
await exercise.save();
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.TRAINING_PLAN,
action: ActivityActionType.UPDATE,
entityId: exerciseId,
changes: {
completed: { before, after: dto.completed }
},
});
this.winstonLogger.info(`标记训练项目完成状态 ${exerciseId}: ${dto.completed}`, {
context: 'ScheduleExerciseService',
userId,
trainingPlanId,
exerciseId,
completed: dto.completed,
});
return exercise.toJSON();
}
// 获取训练计划的完成统计
async getCompletionStats(userId: string, trainingPlanId: string) {
await this.validateTrainingPlan(userId, trainingPlanId);
const [total, completed] = await Promise.all([
this.scheduleExerciseModel.count({
where: { trainingPlanId, userId, deleted: false, itemType: 'exercise' }
}),
this.scheduleExerciseModel.count({
where: { trainingPlanId, userId, deleted: false, itemType: 'exercise', completed: true }
})
]);
return {
total,
completed,
percentage: total > 0 ? Math.round((completed / total) * 100) : 0
};
}
// 注意:训练计划是模板,不应该有完成状态
// 训练完成状态应该在 WorkoutSession 和 WorkoutExercise 中管理
// 如需标记完成状态,请使用 WorkoutsService
}

View File

@@ -6,10 +6,7 @@ import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-pla
import {
CreateScheduleExerciseDto,
UpdateScheduleExerciseDto,
BatchCreateScheduleExerciseDto,
BatchUpdateScheduleExerciseDto,
UpdateScheduleExerciseOrderDto,
CompleteScheduleExerciseDto,
ScheduleExerciseResponseDto
} from './dto/schedule-exercise.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@@ -89,17 +86,7 @@ export class TrainingPlansController {
return this.scheduleExerciseService.create(user.sub, trainingPlanId, dto);
}
@Post(':id/exercises/batch')
@ApiOperation({ summary: '批量添加训练项目' })
@ApiParam({ name: 'id', description: '训练计划ID' })
@ApiBody({ type: BatchCreateScheduleExerciseDto })
async batchCreateExercises(
@CurrentUser() user: AccessTokenPayload,
@Param('id') trainingPlanId: string,
@Body() dto: BatchCreateScheduleExerciseDto,
) {
return this.scheduleExerciseService.batchCreate(user.sub, trainingPlanId, dto);
}
@Get(':id/exercises')
@ApiOperation({ summary: '获取训练计划的所有项目' })
@@ -137,17 +124,7 @@ export class TrainingPlansController {
return this.scheduleExerciseService.update(user.sub, trainingPlanId, exerciseId, dto);
}
@Put(':id/exercises/batch')
@ApiOperation({ summary: '批量更新训练项目' })
@ApiParam({ name: 'id', description: '训练计划ID' })
@ApiBody({ type: BatchUpdateScheduleExerciseDto })
async batchUpdateExercises(
@CurrentUser() user: AccessTokenPayload,
@Param('id') trainingPlanId: string,
@Body() dto: BatchUpdateScheduleExerciseDto,
) {
return this.scheduleExerciseService.batchUpdate(user.sub, trainingPlanId, dto);
}
@Delete(':id/exercises/:exerciseId')
@ApiOperation({ summary: '删除训练项目' })
@@ -161,17 +138,7 @@ export class TrainingPlansController {
return this.scheduleExerciseService.remove(user.sub, trainingPlanId, exerciseId);
}
@Delete(':id/exercises')
@ApiOperation({ summary: '批量删除训练项目' })
@ApiParam({ name: 'id', description: '训练计划ID' })
@ApiBody({ type: [String], description: '训练项目ID列表' })
async batchRemoveExercises(
@CurrentUser() user: AccessTokenPayload,
@Param('id') trainingPlanId: string,
@Body() exerciseIds: string[],
) {
return this.scheduleExerciseService.batchRemove(user.sub, trainingPlanId, exerciseIds);
}
@Put(':id/exercises/order')
@ApiOperation({ summary: '更新训练项目排序' })
@@ -185,29 +152,9 @@ export class TrainingPlansController {
return this.scheduleExerciseService.updateOrder(user.sub, trainingPlanId, dto);
}
@Put(':id/exercises/:exerciseId/complete')
@ApiOperation({ summary: '标记训练项目完成状态' })
@ApiParam({ name: 'id', description: '训练计划ID' })
@ApiParam({ name: 'exerciseId', description: '训练项目ID' })
@ApiBody({ type: CompleteScheduleExerciseDto })
async markExerciseComplete(
@CurrentUser() user: AccessTokenPayload,
@Param('id') trainingPlanId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: CompleteScheduleExerciseDto,
) {
return this.scheduleExerciseService.markComplete(user.sub, trainingPlanId, exerciseId, dto);
}
@Get(':id/exercises/stats/completion')
@ApiOperation({ summary: '获取训练计划完成统计' })
@ApiParam({ name: 'id', description: '训练计划ID' })
async getExerciseCompletionStats(
@CurrentUser() user: AccessTokenPayload,
@Param('id') trainingPlanId: string,
) {
return this.scheduleExerciseService.getCompletionStats(user.sub, trainingPlanId);
}
// 注意:训练计划是模板,不应该有完成状态
// 实际的训练完成状态应该在 WorkoutSession 中管理
// 如需完成训练,请使用 /workouts/sessions 相关接口
}

View File

@@ -5,6 +5,7 @@ import { ScheduleExerciseService } from './schedule-exercise.service';
import { TrainingPlansController } from './training-plans.controller';
import { TrainingPlan } from './models/training-plan.model';
import { ScheduleExercise } from './models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import { UsersModule } from '../users/users.module';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
@@ -12,7 +13,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
imports: [
UsersModule,
ActivityLogsModule,
SequelizeModule.forFeature([TrainingPlan, ScheduleExercise]),
SequelizeModule.forFeature([TrainingPlan, ScheduleExercise, Exercise]),
],
controllers: [TrainingPlansController],
providers: [TrainingPlansService, ScheduleExerciseService],

View File

@@ -47,7 +47,7 @@ export class UserProfile extends Model {
declare weight: number | null;
@Column({
type: DataType.INTEGER,
type: DataType.FLOAT,
allowNull: true,
comment: '身高(厘米)',
})

View File

@@ -170,7 +170,7 @@ export class UsersService {
if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; }
if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; }
if (weight !== undefined) {
profile.weight = weight as any;
profile.weight = weight;
try {
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Manual });
} catch (e) {
@@ -178,7 +178,10 @@ export class UsersService {
}
profileChanges.weight = weight;
}
if (height !== undefined) { profile.height = height as any; profileChanges.height = height; }
if (height !== undefined) {
profile.height = height;
profileChanges.height = height;
}
await profile.save();
}
@@ -216,7 +219,7 @@ export class UsersService {
const t = await this.sequelize.transaction();
try {
const [profile] = await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId }, transaction: t });
profile.weight = weight as any;
profile.weight = weight;
await profile.save({ transaction: t });
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Vision }, { transaction: t });
await t.commit();

View File

@@ -0,0 +1,170 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min, Max } from 'class-validator';
import { WorkoutItemType, WorkoutExerciseStatus } from '../models/workout-exercise.model';
export class CreateWorkoutExerciseDto {
@ApiProperty({ description: '关联的动作key仅exercise类型需要', required: false })
@IsString()
@IsOptional()
exerciseKey?: string;
@ApiProperty({ description: '项目名称' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: '计划组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedSets?: number;
@ApiProperty({ description: '计划重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedReps?: number;
@ApiProperty({ description: '计划持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedDurationSec?: number;
@ApiProperty({ description: '休息时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
restSec?: number;
@ApiProperty({ description: '备注', required: false })
@IsString()
@IsOptional()
note?: string;
@ApiProperty({
enum: ['exercise', 'rest', 'note'],
description: '项目类型',
default: 'exercise',
required: false
})
@IsEnum(['exercise', 'rest', 'note'])
@IsOptional()
itemType?: WorkoutItemType;
}
export class UpdateWorkoutExerciseDto extends PartialType(CreateWorkoutExerciseDto) {
@ApiProperty({ description: '实际完成组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedSets?: number;
@ApiProperty({ description: '实际完成重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedReps?: number;
@ApiProperty({ description: '实际持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
actualDurationSec?: number;
@ApiProperty({ enum: ['pending', 'in_progress', 'completed', 'skipped'], required: false })
@IsEnum(['pending', 'in_progress', 'completed', 'skipped'])
@IsOptional()
status?: WorkoutExerciseStatus;
}
export class StartWorkoutExerciseDto {
@ApiProperty({ description: '开始时间', required: false })
@IsDateString()
@IsOptional()
startedAt?: string;
}
export class CompleteWorkoutExerciseDto {
@ApiProperty({ description: '实际完成组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedSets?: number;
@ApiProperty({ description: '实际完成重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedReps?: number;
@ApiProperty({ description: '实际持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
actualDurationSec?: number;
@ApiProperty({ description: '完成时间', required: false })
@IsDateString()
@IsOptional()
completedAt?: string;
@ApiProperty({ description: '详细执行数据', required: false })
@IsOptional()
performanceData?: {
sets?: Array<{
reps?: number;
weight?: number;
duration?: number;
restTime?: number;
difficulty?: number;
notes?: string;
}>;
heartRate?: {
avg?: number;
max?: number;
};
perceivedExertion?: number;
};
}
export class UpdateWorkoutExerciseOrderDto {
@ApiProperty({ description: '动作ID列表按新的顺序排列' })
@IsArray()
@IsString({ each: true })
exerciseIds: string[];
}
export class WorkoutExerciseResponseDto {
@ApiProperty() id: string;
@ApiProperty() workoutSessionId: string;
@ApiProperty() userId: string;
@ApiProperty({ required: false }) exerciseKey?: string;
@ApiProperty() name: string;
@ApiProperty({ required: false }) plannedSets?: number;
@ApiProperty({ required: false }) completedSets?: number;
@ApiProperty({ required: false }) plannedReps?: number;
@ApiProperty({ required: false }) completedReps?: number;
@ApiProperty({ required: false }) plannedDurationSec?: number;
@ApiProperty({ required: false }) actualDurationSec?: number;
@ApiProperty({ required: false }) restSec?: number;
@ApiProperty({ required: false }) note?: string;
@ApiProperty({ enum: ['exercise', 'rest', 'note'] }) itemType: WorkoutItemType;
@ApiProperty({ enum: ['pending', 'in_progress', 'completed', 'skipped'] }) status: WorkoutExerciseStatus;
@ApiProperty() sortOrder: number;
@ApiProperty({ required: false }) startedAt?: Date;
@ApiProperty({ required: false }) completedAt?: Date;
@ApiProperty({ required: false }) performanceData?: any;
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的动作信息仅exercise类型时存在
@ApiProperty({ required: false })
exercise?: {
key: string;
name: string;
description: string;
categoryKey: string;
categoryName: string;
};
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { WorkoutStatus } from '../models/workout-session.model';
// 注意训练会话由系统自动创建不需要手动创建DTO
export class StartWorkoutDto {
@ApiProperty({ description: '实际开始时间', required: false })
@IsDateString()
@IsOptional()
startedAt?: string;
}
// 注意训练会话自动完成不需要手动完成DTO
export class UpdateWorkoutSessionDto {
@ApiProperty({ description: '训练总结', required: false })
@IsString()
@IsOptional()
summary?: string;
@ApiProperty({ description: '消耗卡路里', required: false })
@IsInt()
@Min(0)
@IsOptional()
caloriesBurned?: number;
}
export class WorkoutSessionResponseDto {
@ApiProperty() id: string;
@ApiProperty() userId: string;
@ApiProperty() trainingPlanId: string;
@ApiProperty() name: string;
@ApiProperty() scheduledDate: Date;
@ApiProperty({ required: false }) startedAt?: Date;
@ApiProperty({ required: false }) completedAt?: Date;
@ApiProperty({ enum: ['planned', 'in_progress', 'completed', 'skipped'] }) status: WorkoutStatus;
@ApiProperty({ required: false }) totalDurationSec?: number;
@ApiProperty({ required: false }) summary?: string;
@ApiProperty({ required: false }) caloriesBurned?: number;
@ApiProperty({ required: false }) stats?: {
totalExercises?: number;
completedExercises?: number;
totalSets?: number;
completedSets?: number;
totalReps?: number;
completedReps?: number;
};
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的训练计划信息
@ApiProperty({ required: false })
trainingPlan?: {
id: string;
name: string;
goal: string;
};
// 训练动作列表
@ApiProperty({ required: false, type: 'array' })
exercises?: any[];
}

View File

@@ -0,0 +1,115 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
import { WorkoutSession } from './workout-session.model';
import { Exercise } from '../../exercises/models/exercise.model';
export type WorkoutItemType = 'exercise' | 'rest' | 'note';
export type WorkoutExerciseStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
@Table({
tableName: 't_workout_exercises',
underscored: true,
})
export class WorkoutExercise extends Model {
@PrimaryKey
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
declare id: string;
@ForeignKey(() => WorkoutSession)
@Column({ type: DataType.UUID, allowNull: false })
declare workoutSessionId: string;
@BelongsTo(() => WorkoutSession)
declare workoutSession: WorkoutSession;
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
// 关联到动作库仅exercise类型需要
@ForeignKey(() => Exercise)
@Column({ type: DataType.STRING, allowNull: true, comment: '关联的动作key仅exercise类型' })
declare exerciseKey: string;
@BelongsTo(() => Exercise, { foreignKey: 'exerciseKey', targetKey: 'key' })
declare exercise: Exercise;
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
declare name: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划组数' })
declare plannedSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际完成组数' })
declare completedSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划重复次数' })
declare plannedReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际完成重复次数' })
declare completedReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划持续时长(秒)' })
declare plannedDurationSec: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际持续时长(秒)' })
declare actualDurationSec: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '休息时长(秒)' })
declare restSec: number;
@Column({ type: DataType.TEXT, allowNull: true, comment: '备注' })
declare note: string;
@Column({
type: DataType.ENUM('exercise', 'rest', 'note'),
allowNull: false,
defaultValue: 'exercise',
comment: '项目类型'
})
declare itemType: WorkoutItemType;
@Column({
type: DataType.ENUM('pending', 'in_progress', 'completed', 'skipped'),
allowNull: false,
defaultValue: 'pending',
comment: '动作状态'
})
declare status: WorkoutExerciseStatus;
@Column({ type: DataType.INTEGER, allowNull: false, comment: '排序顺序' })
declare sortOrder: number;
@Column({ type: DataType.DATE, allowNull: true, comment: '开始时间' })
declare startedAt: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '完成时间' })
declare completedAt: Date;
@Column({ type: DataType.JSON, allowNull: true, comment: '详细执行数据' })
declare performanceData: {
sets?: Array<{
reps?: number;
weight?: number;
duration?: number;
restTime?: number;
difficulty?: number; // 1-10
notes?: string;
}>;
heartRate?: {
avg?: number;
max?: number;
};
perceivedExertion?: number; // 1-10 RPE scale
};
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
declare deleted: boolean;
}

View File

@@ -0,0 +1,79 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo, HasMany } from 'sequelize-typescript';
import { TrainingPlan } from '../../training-plans/models/training-plan.model';
import { WorkoutExercise } from './workout-exercise.model';
export type WorkoutStatus = 'planned' | 'in_progress' | 'completed' | 'skipped';
@Table({
tableName: 't_workout_sessions',
underscored: true,
})
export class WorkoutSession extends Model {
@PrimaryKey
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
declare id: string;
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
@ForeignKey(() => TrainingPlan)
@Column({ type: DataType.UUID, allowNull: false, comment: '关联的训练计划模板' })
declare trainingPlanId: string;
@BelongsTo(() => TrainingPlan)
declare trainingPlan: TrainingPlan;
@HasMany(() => WorkoutExercise)
declare exercises: WorkoutExercise[];
@Column({ type: DataType.STRING, allowNull: false, comment: '训练会话名称' })
declare name: string;
@Column({ type: DataType.DATE, allowNull: false, comment: '计划训练日期' })
declare scheduledDate: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '实际开始时间' })
declare startedAt: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '实际结束时间' })
declare completedAt: Date;
@Column({
type: DataType.ENUM('planned', 'in_progress', 'completed', 'skipped'),
allowNull: false,
defaultValue: 'planned',
comment: '训练状态'
})
declare status: WorkoutStatus;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '总时长(秒)' })
declare totalDurationSec: number;
@Column({ type: DataType.TEXT, allowNull: true, comment: '训练总结/备注' })
declare summary: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '消耗卡路里(估算)' })
declare caloriesBurned: number;
@Column({ type: DataType.JSON, allowNull: true, comment: '训练统计数据' })
declare stats: {
totalExercises?: number;
completedExercises?: number;
totalSets?: number;
completedSets?: number;
totalReps?: number;
completedReps?: number;
};
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
declare deleted: boolean;
}

View File

@@ -0,0 +1,192 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, Put } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { WorkoutsService } from './workouts.service';
import {
StartWorkoutDto,
UpdateWorkoutSessionDto,
WorkoutSessionResponseDto
} from './dto/workout-session.dto';
import {
CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto,
UpdateWorkoutExerciseOrderDto,
WorkoutExerciseResponseDto
} from './dto/workout-exercise.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
@ApiTags('workouts')
@Controller('workouts')
@UseGuards(JwtAuthGuard)
export class WorkoutsController {
constructor(private readonly workoutsService: WorkoutsService) { }
// ==================== 训练会话管理 ====================
// 注意:不提供手动创建会话接口,客户端应使用 GET /workouts/today 自动获取/创建
@Get('sessions')
@ApiOperation({ summary: '获取训练会话列表' })
async getSessions(
@CurrentUser() user: AccessTokenPayload,
@Query('page') page: number = 1,
@Query('limit') limit: number = 10,
) {
return this.workoutsService.getWorkoutSessions(user.sub, page, limit);
}
@Get('sessions/:id')
@ApiOperation({ summary: '获取训练会话详情' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionDetail(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.getWorkoutSessionDetail(user.sub, sessionId);
}
@Post('sessions/:id/start')
@ApiOperation({ summary: '开始训练会话' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiBody({ type: StartWorkoutDto, required: false })
async startSession(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Body() dto: StartWorkoutDto = {},
) {
return this.workoutsService.startWorkoutSession(user.sub, sessionId, dto);
}
// 注意:训练会话自动完成,无需手动标记
// 当所有动作完成时,会话自动标记为完成
@Delete('sessions/:id')
@ApiOperation({ summary: '删除训练会话' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async deleteSession(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.deleteWorkoutSession(user.sub, sessionId);
}
// ==================== 训练动作管理 ====================
@Get('sessions/:id/exercises')
@ApiOperation({ summary: '获取训练会话的所有动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionExercises(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.getWorkoutExercises(user.sub, sessionId);
}
@Get('sessions/:id/exercises/:exerciseId')
@ApiOperation({ summary: '获取训练动作详情' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
async getExerciseDetail(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
) {
return this.workoutsService.getWorkoutExerciseDetail(user.sub, sessionId, exerciseId);
}
@Post('sessions/:id/exercises/:exerciseId/start')
@ApiOperation({ summary: '开始训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: StartWorkoutExerciseDto, required: false })
async startExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: StartWorkoutExerciseDto = {},
) {
return this.workoutsService.startWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
@Post('sessions/:id/exercises/:exerciseId/complete')
@ApiOperation({ summary: '完成训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: CompleteWorkoutExerciseDto })
async completeExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: CompleteWorkoutExerciseDto,
) {
return this.workoutsService.completeWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
@Post('sessions/:id/exercises/:exerciseId/skip')
@ApiOperation({ summary: '跳过训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
async skipExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
) {
return this.workoutsService.skipWorkoutExercise(user.sub, sessionId, exerciseId);
}
@Put('sessions/:id/exercises/:exerciseId')
@ApiOperation({ summary: '更新训练动作信息' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: UpdateWorkoutExerciseDto })
async updateExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: UpdateWorkoutExerciseDto,
) {
return this.workoutsService.updateWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
// ==================== 统计和分析 ====================
@Get('sessions/:id/stats')
@ApiOperation({ summary: '获取训练会话统计数据' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionStats(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
const session = await this.workoutsService.getWorkoutSessionDetail(user.sub, sessionId);
return {
status: session.status,
duration: session.totalDurationSec,
calories: session.caloriesBurned,
stats: session.stats,
exerciseCount: session.exercises?.length || 0,
completedExercises: session.exercises?.filter((e: any) => e.status === 'completed').length || 0,
};
}
// ==================== 快捷操作 ====================
@Get('today')
@ApiOperation({ summary: '获取/创建今日训练会话(基于激活的训练计划)' })
async getTodayWorkout(@CurrentUser() user: AccessTokenPayload) {
return this.workoutsService.getTodayWorkoutSession(user.sub);
}
@Get('recent')
@ApiOperation({ summary: '获取最近的训练会话' })
async getRecentWorkouts(
@CurrentUser() user: AccessTokenPayload,
@Query('days') days: number = 7,
@Query('limit') limit: number = 10,
) {
const sessions = await this.workoutsService.getWorkoutSessions(user.sub, 1, limit);
// 简化版本,实际应该在数据库层面过滤
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const recentSessions = sessions.sessions.filter(session =>
new Date(session.scheduledDate) >= cutoffDate
);
return {
sessions: recentSessions,
period: `最近${days}`,
};
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { WorkoutsController } from './workouts.controller';
import { WorkoutsService } from './workouts.service';
import { WorkoutSession } from './models/workout-session.model';
import { WorkoutExercise } from './models/workout-exercise.model';
import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([
WorkoutSession,
WorkoutExercise,
TrainingPlan,
ScheduleExercise,
Exercise,
]),
ActivityLogsModule,
UsersModule,
],
controllers: [WorkoutsController],
providers: [WorkoutsService],
exports: [WorkoutsService],
})
export class WorkoutsModule { }

View File

@@ -0,0 +1,591 @@
import { Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { WorkoutSession } from './models/workout-session.model';
import { WorkoutExercise } from './models/workout-exercise.model';
import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import {
StartWorkoutDto,
UpdateWorkoutSessionDto,
} from './dto/workout-session.dto';
import {
CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto,
UpdateWorkoutExerciseOrderDto,
} from './dto/workout-exercise.dto';
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { Logger as WinstonLogger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Op, Transaction } from 'sequelize';
@Injectable()
export class WorkoutsService {
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger;
constructor(
@InjectModel(WorkoutSession)
private workoutSessionModel: typeof WorkoutSession,
@InjectModel(WorkoutExercise)
private workoutExerciseModel: typeof WorkoutExercise,
@InjectModel(TrainingPlan)
private trainingPlanModel: typeof TrainingPlan,
@InjectModel(ScheduleExercise)
private scheduleExerciseModel: typeof ScheduleExercise,
@InjectModel(Exercise)
private exerciseModel: typeof Exercise,
private readonly activityLogsService: ActivityLogsService,
) { }
// ==================== 训练会话管理 ====================
/**
* 获取今日训练会话,如果不存在则自动创建
*/
async getTodayWorkoutSession(userId: string) {
const today = new Date();
today.setHours(0, 0, 0, 0); // 设置为今日0点
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// 查找今日是否已有训练会话
let session = await this.workoutSessionModel.findOne({
where: {
userId,
deleted: false,
scheduledDate: {
[Op.gte]: today,
[Op.lt]: tomorrow
}
},
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
},
{
model: WorkoutExercise,
required: false,
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']]
}
],
});
if (session) {
return session.toJSON();
}
// 如果没有训练会话,查找激活的训练计划
const activeTrainingPlan = await this.trainingPlanModel.findOne({
where: { userId, isActive: true, deleted: false }
});
if (!activeTrainingPlan) {
throw new NotFoundException('请先激活一个训练计划');
}
// 创建今日训练会话
return this.createWorkoutSessionFromPlan(userId, activeTrainingPlan.id, today);
}
/**
* 从训练计划创建训练会话(内部方法)
*/
private async createWorkoutSessionFromPlan(userId: string, trainingPlanId: string, scheduledDate: Date) {
const trainingPlan = await this.trainingPlanModel.findOne({
where: { id: trainingPlanId, userId, deleted: false }
});
if (!trainingPlan) {
throw new NotFoundException('训练计划不存在或不属于当前用户');
}
const transaction = await this.workoutSessionModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
try {
// 1. 创建训练会话
const workoutSession = await this.workoutSessionModel.create({
userId,
trainingPlanId,
name: trainingPlan.name || '今日训练',
scheduledDate,
status: 'planned',
}, { transaction });
// 2. 复制训练计划中的动作到训练会话
const scheduleExercises = await this.scheduleExerciseModel.findAll({
where: { trainingPlanId, userId, deleted: false },
order: [['sortOrder', 'ASC']],
transaction
});
for (const scheduleExercise of scheduleExercises) {
await this.workoutExerciseModel.create({
workoutSessionId: workoutSession.id,
userId,
exerciseKey: scheduleExercise.exerciseKey,
name: scheduleExercise.name,
plannedSets: scheduleExercise.sets,
plannedReps: scheduleExercise.reps,
plannedDurationSec: scheduleExercise.durationSec,
restSec: scheduleExercise.restSec,
note: scheduleExercise.note,
itemType: scheduleExercise.itemType,
status: 'pending',
sortOrder: scheduleExercise.sortOrder,
}, { transaction });
}
await transaction.commit();
this.winstonLogger.info(`自动创建训练会话 ${workoutSession.id}`, {
context: 'WorkoutsService',
userId,
trainingPlanId,
workoutSessionId: workoutSession.id,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.CREATE,
entityId: workoutSession.id,
changes: workoutSession.toJSON(),
});
return this.getWorkoutSessionDetail(userId, workoutSession.id);
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* 开始训练会话
*/
async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
if (session.status !== 'planned') {
throw new BadRequestException('只能开始计划中的训练会话');
}
const startTime = dto.startedAt ? new Date(dto.startedAt) : new Date();
session.startedAt = startTime;
session.status = 'in_progress';
await session.save();
this.winstonLogger.info(`开始训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: sessionId,
changes: { status: { before: 'planned', after: 'in_progress' } },
});
return session.toJSON();
}
// 注意:训练会话现在自动完成,不需要手动完成方法
/**
* 获取训练会话列表
*/
async getWorkoutSessions(userId: string, page: number = 1, limit: number = 10) {
const offset = (page - 1) * limit;
const { rows: sessions, count } = await this.workoutSessionModel.findAndCountAll({
where: { userId, deleted: false },
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
}
],
order: [['scheduledDate', 'DESC']],
limit,
offset,
});
return {
sessions: sessions.map(s => s.toJSON()),
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit),
}
};
}
/**
* 获取训练会话详情
*/
async getWorkoutSessionDetail(userId: string, sessionId: string) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false },
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
},
{
model: WorkoutExercise,
required: false,
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']]
}
],
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
return session.toJSON();
}
/**
* 删除训练会话
*/
async deleteWorkoutSession(userId: string, sessionId: string) {
const [count] = await this.workoutSessionModel.update(
{ deleted: true },
{ where: { id: sessionId, userId, deleted: false } }
);
if (count === 0) {
throw new NotFoundException('训练会话不存在');
}
// 同时删除关联的训练动作
await this.workoutExerciseModel.update(
{ deleted: true },
{ where: { workoutSessionId: sessionId, userId, deleted: false } }
);
this.winstonLogger.info(`删除训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.DELETE,
entityId: sessionId,
changes: null,
});
return { success: true };
}
// ==================== 训练动作管理 ====================
/**
* 开始训练动作
*/
async startWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: StartWorkoutExerciseDto = {}) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status !== 'pending') {
throw new BadRequestException('只能开始待执行的训练动作');
}
const startTime = dto.startedAt ? new Date(dto.startedAt) : new Date();
exercise.startedAt = startTime;
exercise.status = 'in_progress';
await exercise.save();
this.winstonLogger.info(`开始训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
return exercise.toJSON();
}
/**
* 完成训练动作
*/
async completeWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: CompleteWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status === 'completed') {
throw new BadRequestException('训练动作已经完成');
}
const completedTime = dto.completedAt ? new Date(dto.completedAt) : new Date();
exercise.completedAt = completedTime;
exercise.status = 'completed';
exercise.completedSets = dto.completedSets || exercise.completedSets;
exercise.completedReps = dto.completedReps || exercise.completedReps;
exercise.actualDurationSec = dto.actualDurationSec || exercise.actualDurationSec;
exercise.performanceData = dto.performanceData || exercise.performanceData;
// 计算实际时长(如果没有传入)
if (!dto.actualDurationSec && exercise.startedAt) {
exercise.actualDurationSec = Math.floor((completedTime.getTime() - exercise.startedAt.getTime()) / 1000);
}
await exercise.save();
this.winstonLogger.info(`完成训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
// 检查是否所有动作都完成,如果是则自动完成训练会话
await this.checkAndAutoCompleteSession(userId, sessionId);
return exercise.toJSON();
}
/**
* 跳过训练动作
*/
async skipWorkoutExercise(userId: string, sessionId: string, exerciseId: string) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status === 'completed') {
throw new BadRequestException('已完成的训练动作不能跳过');
}
exercise.status = 'skipped';
await exercise.save();
this.winstonLogger.info(`跳过训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
// 检查是否所有动作都完成,如果是则自动完成训练会话
await this.checkAndAutoCompleteSession(userId, sessionId);
return exercise.toJSON();
}
/**
* 更新训练动作
*/
async updateWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: UpdateWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
const before = exercise.toJSON();
// 更新字段
Object.assign(exercise, dto);
await exercise.save();
const after = exercise.toJSON();
const changedKeys = Object.keys(after).filter((key) => (before as any)[key] !== (after as any)[key]);
const changes: Record<string, any> = {};
for (const key of changedKeys) {
changes[key] = { before: (before as any)[key], after: (after as any)[key] };
}
if (Object.keys(changes).length > 0) {
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: exerciseId,
changes,
});
}
this.winstonLogger.info(`更新训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
return after;
}
/**
* 获取训练会话的所有动作
*/
async getWorkoutExercises(userId: string, sessionId: string) {
await this.validateWorkoutSession(userId, sessionId);
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, userId, deleted: false },
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']],
});
return exercises.map(e => e.toJSON());
}
/**
* 获取训练动作详情
*/
async getWorkoutExerciseDetail(userId: string, sessionId: string, exerciseId: string) {
const exercise = await this.workoutExerciseModel.findOne({
where: { id: exerciseId, workoutSessionId: sessionId, userId, deleted: false },
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
});
if (!exercise) {
throw new NotFoundException('训练动作不存在');
}
return exercise.toJSON();
}
// ==================== 工具方法 ====================
private async validateWorkoutSession(userId: string, sessionId: string): Promise<WorkoutSession> {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
return session;
}
private async validateWorkoutExercise(userId: string, sessionId: string, exerciseId: string): Promise<WorkoutExercise> {
const exercise = await this.workoutExerciseModel.findOne({
where: { id: exerciseId, workoutSessionId: sessionId, userId, deleted: false }
});
if (!exercise) {
throw new NotFoundException('训练动作不存在');
}
return exercise;
}
private async calculateWorkoutStats(sessionId: string) {
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, deleted: false, itemType: 'exercise' }
});
const stats = {
totalExercises: exercises.length,
completedExercises: exercises.filter(e => e.status === 'completed').length,
totalSets: exercises.reduce((sum, e) => sum + (e.plannedSets || 0), 0),
completedSets: exercises.reduce((sum, e) => sum + (e.completedSets || 0), 0),
totalReps: exercises.reduce((sum, e) => sum + (e.plannedReps || 0), 0),
completedReps: exercises.reduce((sum, e) => sum + (e.completedReps || 0), 0),
};
return stats;
}
/**
* 检查并自动完成训练会话
*/
private async checkAndAutoCompleteSession(userId: string, sessionId: string) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session || session.status === 'completed') {
return;
}
// 检查所有exercise类型的动作是否都完成
const exerciseActions = await this.workoutExerciseModel.findAll({
where: {
workoutSessionId: sessionId,
userId,
deleted: false,
itemType: 'exercise'
}
});
const allCompleted = exerciseActions.every(exercise =>
exercise.status === 'completed' || exercise.status === 'skipped'
);
if (allCompleted && exerciseActions.length > 0) {
// 自动完成训练会话
const completedTime = new Date();
session.completedAt = completedTime;
session.status = 'completed';
// 计算总时长
if (session.startedAt) {
session.totalDurationSec = Math.floor((completedTime.getTime() - session.startedAt.getTime()) / 1000);
}
// 计算统计数据
const stats = await this.calculateWorkoutStats(sessionId);
session.stats = stats;
await session.save();
this.winstonLogger.info(`自动完成训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
duration: session.totalDurationSec,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: sessionId,
changes: {
status: { before: 'in_progress', after: 'completed' },
autoCompleted: true
},
});
}
}
}