diff --git a/docs/pilates-data-import.sql b/docs/pilates-data-import.sql new file mode 100644 index 0000000..a108d40 --- /dev/null +++ b/docs/pilates-data-import.sql @@ -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; diff --git a/docs/pilates-database-migration.sql b/docs/pilates-database-migration.sql new file mode 100644 index 0000000..d9b64e5 --- /dev/null +++ b/docs/pilates-database-migration.sql @@ -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; diff --git a/docs/pilates-database-upgrade-summary.md b/docs/pilates-database-upgrade-summary.md new file mode 100644 index 0000000..88fcb8b --- /dev/null +++ b/docs/pilates-database-upgrade-summary.md @@ -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` 接口 +- 添加训练参数相关字段 +- 支持完整的普拉提训练信息 + +这次升级使数据库能够完整支持专业的普拉提训练管理,为后续的训练计划制定、进度跟踪和个性化推荐提供了坚实的数据基础。 diff --git a/docs/pilates-tables-create.sql b/docs/pilates-tables-create.sql new file mode 100644 index 0000000..e9648d7 --- /dev/null +++ b/docs/pilates-tables-create.sql @@ -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; diff --git a/docs/training-plans-tables-only.sql b/docs/training-plans-tables-only.sql new file mode 100644 index 0000000..945782b --- /dev/null +++ b/docs/training-plans-tables-only.sql @@ -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'; +*/ diff --git a/docs/workout-sessions-api-guide.md b/docs/workout-sessions-api-guide.md new file mode 100644 index 0000000..6858ef3 --- /dev/null +++ b/docs/workout-sessions-api-guide.md @@ -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. 原有的训练计划数据保持不变,作为模板使用 + +这样的架构分离使得系统更加清晰、可维护,也更符合健身应用的实际使用场景。 diff --git a/docs/workout-sessions-table-create.sql b/docs/workout-sessions-table-create.sql new file mode 100644 index 0000000..9bf4bcd --- /dev/null +++ b/docs/workout-sessions-table-create.sql @@ -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 = '训练会话表'; \ No newline at end of file diff --git a/docs/workout-tables-create.sql b/docs/workout-tables-create.sql new file mode 100644 index 0000000..d5a3019 --- /dev/null +++ b/docs/workout-tables-create.sql @@ -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来创建数据 diff --git a/src/activity-logs/models/activity-log.model.ts b/src/activity-logs/models/activity-log.model.ts index 92b0378..749065e 100644 --- a/src/activity-logs/models/activity-log.model.ts +++ b/src/activity-logs/models/activity-log.model.ts @@ -6,6 +6,8 @@ export enum ActivityEntityType { USER_PROFILE = 'USER_PROFILE', CHECKIN = 'CHECKIN', TRAINING_PLAN = 'TRAINING_PLAN', + WORKOUT = 'WORKOUT', + } export enum ActivityActionType { diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index bb51410..ca3ce5c 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -24,31 +24,56 @@ export class AiCoachController { ): Promise { 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', () => { diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index 1fa457a..bb62978 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -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 {}; + } + } } diff --git a/src/app.module.ts b/src/app.module.ts index 4f9edca..5ee4c5b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/exercises/dto/exercise.dto.ts b/src/exercises/dto/exercise.dto.ts index adade9f..d21cd53 100644 --- a/src/exercises/dto/exercise.dto.ts +++ b/src/exercises/dto/exercise.dto.ts @@ -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; } diff --git a/src/exercises/exercises.service.ts b/src/exercises/exercises.service.ts index 22aaef4..362d2c7 100644 --- a/src/exercises/exercises.service.ts +++ b/src/exercises/exercises.service.ts @@ -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 { 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 { - const categoryNameToKey: Record = {}; - 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 = { - '核心与腹部': '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(); - } } diff --git a/src/exercises/models/exercise-category.model.ts b/src/exercises/models/exercise-category.model.ts index ea2533a..a13af6a 100644 --- a/src/exercises/models/exercise-category.model.ts +++ b/src/exercises/models/exercise-category.model.ts @@ -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, diff --git a/src/exercises/models/exercise.model.ts b/src/exercises/models/exercise.model.ts index 37e62f6..c5837b0 100644 --- a/src/exercises/models/exercise.model.ts +++ b/src/exercises/models/exercise.model.ts @@ -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; diff --git a/src/training-plans/models/schedule-exercise.model.ts b/src/training-plans/models/schedule-exercise.model.ts index 7fba398..9563ff7 100644 --- a/src/training-plans/models/schedule-exercise.model.ts +++ b/src/training-plans/models/schedule-exercise.model.ts @@ -17,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) diff --git a/src/training-plans/schedule-exercise.service.ts b/src/training-plans/schedule-exercise.service.ts index 9081001..4cf3b00 100644 --- a/src/training-plans/schedule-exercise.service.ts +++ b/src/training-plans/schedule-exercise.service.ts @@ -6,8 +6,7 @@ import { Exercise } from '../exercises/models/exercise.model'; import { CreateScheduleExerciseDto, UpdateScheduleExerciseDto, - 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'; @@ -299,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 } diff --git a/src/training-plans/training-plans.controller.ts b/src/training-plans/training-plans.controller.ts index 85aaf0e..6e79f86 100644 --- a/src/training-plans/training-plans.controller.ts +++ b/src/training-plans/training-plans.controller.ts @@ -7,7 +7,6 @@ import { CreateScheduleExerciseDto, UpdateScheduleExerciseDto, UpdateScheduleExerciseOrderDto, - CompleteScheduleExerciseDto, ScheduleExerciseResponseDto } from './dto/schedule-exercise.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; @@ -153,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 相关接口 } diff --git a/src/users/models/user-profile.model.ts b/src/users/models/user-profile.model.ts index 352a11b..2d82017 100644 --- a/src/users/models/user-profile.model.ts +++ b/src/users/models/user-profile.model.ts @@ -47,7 +47,7 @@ export class UserProfile extends Model { declare weight: number | null; @Column({ - type: DataType.INTEGER, + type: DataType.FLOAT, allowNull: true, comment: '身高(厘米)', }) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 4732630..d55c940 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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(); diff --git a/src/workouts/dto/workout-exercise.dto.ts b/src/workouts/dto/workout-exercise.dto.ts new file mode 100644 index 0000000..82a0610 --- /dev/null +++ b/src/workouts/dto/workout-exercise.dto.ts @@ -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; + }; +} diff --git a/src/workouts/dto/workout-session.dto.ts b/src/workouts/dto/workout-session.dto.ts new file mode 100644 index 0000000..47211cb --- /dev/null +++ b/src/workouts/dto/workout-session.dto.ts @@ -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[]; +} diff --git a/src/workouts/models/workout-exercise.model.ts b/src/workouts/models/workout-exercise.model.ts new file mode 100644 index 0000000..e3de873 --- /dev/null +++ b/src/workouts/models/workout-exercise.model.ts @@ -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; +} diff --git a/src/workouts/models/workout-session.model.ts b/src/workouts/models/workout-session.model.ts new file mode 100644 index 0000000..cba3150 --- /dev/null +++ b/src/workouts/models/workout-session.model.ts @@ -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; +} diff --git a/src/workouts/workouts.controller.ts b/src/workouts/workouts.controller.ts new file mode 100644 index 0000000..48ebf64 --- /dev/null +++ b/src/workouts/workouts.controller.ts @@ -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}天`, + }; + } +} diff --git a/src/workouts/workouts.module.ts b/src/workouts/workouts.module.ts new file mode 100644 index 0000000..9e5179e --- /dev/null +++ b/src/workouts/workouts.module.ts @@ -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 { } diff --git a/src/workouts/workouts.service.ts b/src/workouts/workouts.service.ts new file mode 100644 index 0000000..ff82876 --- /dev/null +++ b/src/workouts/workouts.service.ts @@ -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 = {}; + 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 { + 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 { + 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 + }, + }); + } + } +}