diff --git a/sql-scripts/workout-sessions-table-create.sql b/sql-scripts/workout-sessions-table-create.sql deleted file mode 100644 index 9bf4bcd..0000000 --- a/sql-scripts/workout-sessions-table-create.sql +++ /dev/null @@ -1,31 +0,0 @@ --- 创建训练会话表 -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/sql-scripts/workout-tables-create.sql b/sql-scripts/workout-tables-create.sql deleted file mode 100644 index d5a3019..0000000 --- a/sql-scripts/workout-tables-create.sql +++ /dev/null @@ -1,85 +0,0 @@ --- 训练会话相关表创建脚本 --- 用于支持每日训练实例功能 - --- 禁用外键检查 -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/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts index 174e0c1..8edecae 100644 --- a/src/ai-coach/ai-coach.module.ts +++ b/src/ai-coach/ai-coach.module.ts @@ -12,7 +12,6 @@ import { AiReportHistory } from './models/ai-report-history.model'; import { UsersModule } from '../users/users.module'; import { DietRecordsModule } from '../diet-records/diet-records.module'; import { MedicationsModule } from '../medications/medications.module'; -import { WorkoutsModule } from '../workouts/workouts.module'; import { MoodCheckinsModule } from '../mood-checkins/mood-checkins.module'; import { WaterRecordsModule } from '../water-records/water-records.module'; import { ChallengesModule } from '../challenges/challenges.module'; @@ -24,7 +23,6 @@ import { CosService } from '../users/cos.service'; forwardRef(() => UsersModule), forwardRef(() => DietRecordsModule), forwardRef(() => MedicationsModule), - forwardRef(() => WorkoutsModule), forwardRef(() => MoodCheckinsModule), forwardRef(() => WaterRecordsModule), forwardRef(() => ChallengesModule), diff --git a/src/ai-coach/services/ai-report.service.ts b/src/ai-coach/services/ai-report.service.ts index 0be2b70..083e8d5 100644 --- a/src/ai-coach/services/ai-report.service.ts +++ b/src/ai-coach/services/ai-report.service.ts @@ -14,7 +14,6 @@ import { MedicationStatsService } from '../../medications/medication-stats.servi import { DietRecordsService } from '../../diet-records/diet-records.service'; import { WaterRecordsService } from '../../water-records/water-records.service'; import { MoodCheckinsService } from '../../mood-checkins/mood-checkins.service'; -import { WorkoutsService } from '../../workouts/workouts.service'; import { ChallengesService } from '../../challenges/challenges.service'; /** @@ -90,8 +89,6 @@ export class AiReportService { private readonly waterRecordsService: WaterRecordsService, @Inject(forwardRef(() => MoodCheckinsService)) private readonly moodCheckinsService: MoodCheckinsService, - @Inject(forwardRef(() => WorkoutsService)) - private readonly workoutsService: WorkoutsService, @Inject(forwardRef(() => ChallengesService)) private readonly challengesService: ChallengesService, private readonly cosService: CosService, diff --git a/src/app.module.ts b/src/app.module.ts index c140eb7..9a9ac6d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,7 +16,6 @@ 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'; import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module'; import { GoalsModule } from './goals/goals.module'; import { DietRecordsModule } from './diet-records/diet-records.module'; @@ -56,7 +55,6 @@ import { HealthProfilesModule } from './health-profiles/health-profiles.module'; RecommendationsModule, ActivityLogsModule, ExercisesModule, - WorkoutsModule, MoodCheckinsModule, GoalsModule, DietRecordsModule, diff --git a/src/training-plans/schedule-exercise.service.ts b/src/training-plans/schedule-exercise.service.ts index 86e0dcd..4dce7f5 100644 --- a/src/training-plans/schedule-exercise.service.ts +++ b/src/training-plans/schedule-exercise.service.ts @@ -297,6 +297,4 @@ export class ScheduleExerciseService { } // 注意:训练计划是模板,不应该有完成状态 - // 训练完成状态应该在 WorkoutSession 和 WorkoutExercise 中管理 - // 如需标记完成状态,请使用 WorkoutsService } diff --git a/src/training-plans/training-plans.controller.ts b/src/training-plans/training-plans.controller.ts index 6e79f86..cdc7cd4 100644 --- a/src/training-plans/training-plans.controller.ts +++ b/src/training-plans/training-plans.controller.ts @@ -153,8 +153,6 @@ export class TrainingPlansController { } // 注意:训练计划是模板,不应该有完成状态 - // 实际的训练完成状态应该在 WorkoutSession 中管理 - // 如需完成训练,请使用 /workouts/sessions 相关接口 } diff --git a/src/workouts/dto/workout-exercise.dto.ts b/src/workouts/dto/workout-exercise.dto.ts deleted file mode 100644 index 82a0610..0000000 --- a/src/workouts/dto/workout-exercise.dto.ts +++ /dev/null @@ -1,170 +0,0 @@ -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 deleted file mode 100644 index 14ff81a..0000000 --- a/src/workouts/dto/workout-session.dto.ts +++ /dev/null @@ -1,93 +0,0 @@ -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'; - -export class CreateWorkoutSessionDto { - @ApiProperty({ description: '训练计划ID(基于训练计划创建时必填)', required: false }) - @IsUUID() - @IsOptional() - trainingPlanId?: string; - - @ApiProperty({ description: '训练会话名称' }) - @IsString() - @IsNotEmpty() - name: string; - - @ApiProperty({ description: '计划训练日期', required: false }) - @IsDateString() - @IsOptional() - scheduledDate?: string; - - @ApiProperty({ description: '自定义训练动作列表(自定义训练时使用)', required: false, type: 'array' }) - @IsArray() - @IsOptional() - customExercises?: Array<{ - exerciseKey?: string; - name: string; - plannedSets?: number; - plannedReps?: number; - plannedDurationSec?: number; - restSec?: number; - note?: string; - itemType?: 'exercise' | 'rest' | 'note'; - sortOrder: number; - }>; -} - -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 deleted file mode 100644 index e3de873..0000000 --- a/src/workouts/models/workout-exercise.model.ts +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index da45e48..0000000 --- a/src/workouts/models/workout-session.model.ts +++ /dev/null @@ -1,79 +0,0 @@ -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: true, 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 deleted file mode 100644 index dc0871d..0000000 --- a/src/workouts/workouts.controller.ts +++ /dev/null @@ -1,210 +0,0 @@ -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 { - CreateWorkoutSessionDto, - 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) { } - - // ==================== 训练会话管理 ==================== - - @Post('sessions') - @ApiOperation({ summary: '创建训练会话(支持基于训练计划或自定义动作)' }) - @ApiBody({ type: CreateWorkoutSessionDto }) - async createSession(@CurrentUser() user: AccessTokenPayload, @Body() dto: CreateWorkoutSessionDto) { - return this.workoutsService.createWorkoutSession(user.sub, dto); - } - - @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); - } - - // ==================== 训练动作管理 ==================== - - @Post('sessions/:id/exercises') - @ApiOperation({ summary: '向训练会话添加自定义动作' }) - @ApiParam({ name: 'id', description: '训练会话ID' }) - @ApiBody({ type: CreateWorkoutExerciseDto }) - async addExerciseToSession( - @CurrentUser() user: AccessTokenPayload, - @Param('id') sessionId: string, - @Body() dto: CreateWorkoutExerciseDto, - ) { - return this.workoutsService.addExerciseToSession(user.sub, sessionId, dto); - } - - @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 deleted file mode 100644 index a83945b..0000000 --- a/src/workouts/workouts.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Module, forwardRef } 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, - ]), - forwardRef(() => ActivityLogsModule), - forwardRef(() => UsersModule), - ], - controllers: [WorkoutsController], - providers: [WorkoutsService], - exports: [WorkoutsService], -}) -export class WorkoutsModule { } diff --git a/src/workouts/workouts.service.ts b/src/workouts/workouts.service.ts deleted file mode 100644 index e99be24..0000000 --- a/src/workouts/workouts.service.ts +++ /dev/null @@ -1,712 +0,0 @@ -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 { - CreateWorkoutSessionDto, - StartWorkoutDto, -} from './dto/workout-session.dto'; -import { - CreateWorkoutExerciseDto, - UpdateWorkoutExerciseDto, - StartWorkoutExerciseDto, - CompleteWorkoutExerciseDto, -} from './dto/workout-exercise.dto'; -import { Logger as WinstonLogger } from 'winston'; -import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; -import { Op } 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, - ) { } - - // ==================== 训练会话管理 ==================== - - /** - * 创建训练会话(支持基于训练计划或自定义动作) - */ - async createWorkoutSession(userId: string, dto: CreateWorkoutSessionDto) { - if (dto.trainingPlanId) { - // 基于训练计划创建 - return this.createWorkoutSessionFromPlan( - userId, - dto.trainingPlanId, - dto.scheduledDate ? new Date(dto.scheduledDate) : new Date(), - dto.name - ); - } else { - // 基于自定义动作创建 - return this.createCustomWorkoutSession(userId, dto); - } - } - - /** - * 获取今日训练会话,如果不存在则自动创建 - */ - 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) { - this.winstonLogger.info(`今日没有激活的训练计划`, { - context: 'WorkoutsService', - userId, - }); - return null; - } - - // 创建今日训练会话 - return this.createWorkoutSessionFromPlan(userId, activeTrainingPlan.id, today); - } - - /** - * 创建自定义训练会话 - */ - private async createCustomWorkoutSession(userId: string, dto: CreateWorkoutSessionDto) { - const transaction = await this.workoutSessionModel.sequelize?.transaction(); - if (!transaction) throw new Error('Failed to start transaction'); - - this.winstonLogger.info(`创建自定义训练会话`, { - context: 'WorkoutsService', - userId, - dto, - }); - - try { - // 1. 创建训练会话 - const workoutSession = await this.workoutSessionModel.create({ - userId, - - name: dto.name, - scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : new Date(), - status: 'planned', - }, { transaction }); - - // 2. 创建自定义动作 - if (dto.customExercises && dto.customExercises.length > 0) { - for (const customExercise of dto.customExercises!) { - // 如果有exerciseKey,验证动作是否存在 - if (customExercise.exerciseKey) { - const exercise = await this.exerciseModel.findByPk(customExercise.exerciseKey); - if (!exercise) { - throw new NotFoundException(`动作 "${customExercise.exerciseKey}" 不存在`); - } - } - - await this.workoutExerciseModel.create({ - workoutSessionId: workoutSession.id, - userId, - exerciseKey: customExercise.exerciseKey, - name: customExercise.name, - plannedSets: customExercise.plannedSets, - plannedReps: customExercise.plannedReps, - plannedDurationSec: customExercise.plannedDurationSec, - restSec: customExercise.restSec, - note: customExercise.note || '', - itemType: customExercise.itemType || 'exercise', - status: 'pending', - sortOrder: customExercise.sortOrder, - }, { transaction }); - } - } - - this.winstonLogger.info(`创建自定义训练会话 ${workoutSession.id}`, { - context: 'WorkoutsService', - userId, - workoutSessionId: workoutSession.id, - exerciseCount: dto.customExercises?.length, - }); - - await transaction.commit(); - - return workoutSession.toJSON(); - } catch (error) { - this.winstonLogger.error(`创建自定义训练会话失败`, { - context: 'WorkoutsService', - userId, - dto, - error, - }); - await transaction.rollback(); - throw error; - } - } - - /** - * 从训练计划创建训练会话(内部方法) - */ - private async createWorkoutSessionFromPlan(userId: string, trainingPlanId: string, scheduledDate: Date, name?: string) { - 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: 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, - }); - - return this.getWorkoutSessionDetail(userId, workoutSession.id); - } catch (error) { - await transaction.rollback(); - throw error; - } - } - - /** - * 开始训练会话 - */ - async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) { - try { - - 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 exercises = await this.workoutExerciseModel.findAll({ - where: { workoutSessionId: sessionId, deleted: false } - }); - if (exercises.length === 0) { - 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, - }); - - return session.toJSON(); - } catch (error) { - this.winstonLogger.error(`开始训练会话失败 ${sessionId}`, { - context: 'WorkoutsService', - userId, - sessionId, - error, - }); - throw error; - } - } - - // 注意:训练会话现在自动完成,不需要手动完成方法 - - /** - * 获取训练会话列表 - */ - 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, - attributes: ['id', 'name', 'goal'] - } - ], - order: [['createdAt', 'DESC']], - limit, - offset, - }); - - return { - sessions, - 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 transaction = await this.workoutSessionModel.sequelize?.transaction(); - if (!transaction) throw new Error('Failed to start transaction'); - - try { - const [count] = await this.workoutSessionModel.update( - { deleted: true }, - { where: { id: sessionId, userId, deleted: false }, transaction } - ); - - if (count === 0) { - throw new NotFoundException('训练会话不存在'); - } - - // 同时删除关联的训练动作 - await this.workoutExerciseModel.update( - { deleted: true }, - { where: { workoutSessionId: sessionId, userId, deleted: false }, transaction } - ); - - await transaction.commit(); - - this.winstonLogger.info(`删除训练会话 ${sessionId}`, { - context: 'WorkoutsService', - userId, - sessionId, - }); - - return { success: true }; - } catch (error) { - await transaction.rollback(); - this.winstonLogger.error(`删除训练会话失败 ${sessionId}`, { - context: 'WorkoutsService', - userId, - sessionId, - error, - }); - throw error; - } - } - - // ==================== 训练动作管理 ==================== - - /** - * 向训练会话添加自定义动作 - */ - async addExerciseToSession(userId: string, sessionId: string, dto: CreateWorkoutExerciseDto) { - const session = await this.validateWorkoutSession(userId, sessionId); - - if (session.status === 'completed') { - throw new BadRequestException('已完成的训练会话无法添加动作'); - } - - // 如果有exerciseKey,验证动作是否存在 - if (dto.exerciseKey) { - const exercise = await this.exerciseModel.findByPk(dto.exerciseKey); - if (!exercise) { - throw new NotFoundException(`动作 "${dto.exerciseKey}" 不存在`); - } - } - - // 获取下一个排序顺序 - const lastExercise = await this.workoutExerciseModel.findOne({ - where: { workoutSessionId: sessionId, deleted: false }, - order: [['sortOrder', 'DESC']], - }); - const sortOrder = lastExercise ? lastExercise.sortOrder + 1 : 1; - - const exercise = await this.workoutExerciseModel.create({ - workoutSessionId: sessionId, - userId, - exerciseKey: dto.exerciseKey, - name: dto.name, - plannedSets: dto.plannedSets, - plannedReps: dto.plannedReps, - plannedDurationSec: dto.plannedDurationSec, - restSec: dto.restSec, - note: dto.note || '', - itemType: dto.itemType || 'exercise', - status: 'pending', - sortOrder, - }); - - this.winstonLogger.info(`向训练会话添加动作 ${exercise.id}`, { - context: 'WorkoutsService', - userId, - sessionId, - exerciseId: exercise.id, - }); - - return exercise.toJSON(); - } - - /** - * 开始训练动作 - */ - 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); - - // 更新字段 - Object.assign(exercise, dto); - await exercise.save(); - - this.winstonLogger.info(`更新训练动作 ${exerciseId}`, { - context: 'WorkoutsService', - userId, - sessionId, - exerciseId, - }); - - return exercise.toJSON(); - } - - /** - * 获取训练会话的所有动作 - */ - 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, - }); - } - } -}