From 4257449f76ba372277500a1518b5a40a9738624a Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 15 Aug 2025 16:12:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AE=AD=E7=BB=83=E4=BC=9A?= =?UTF-8?q?=E8=AF=9DAPI=E6=96=87=E6=A1=A3=E5=92=8C=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20=E4=BF=AE=E6=94=B9=E8=AE=AD=E7=BB=83?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=96=87=E6=A1=A3=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=B8=89=E7=A7=8D=E8=AE=AD=E7=BB=83=E5=88=9B=E5=BB=BA=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E7=9A=84=E8=AF=A6=E7=BB=86=E8=AF=B4=E6=98=8E=EF=BC=8C?= =?UTF-8?q?=E5=8C=85=E6=8B=AC=E8=87=AA=E5=8A=A8=E8=8E=B7=E5=8F=96=E3=80=81?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E8=AE=A1=E5=88=92=E6=89=8B=E5=8A=A8=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=92=8C=E5=AE=8C=E5=85=A8=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E3=80=82=20-=20=E5=9C=A8=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=99=A8=E4=B8=AD=E6=96=B0=E5=A2=9E=E5=88=9B=E5=BB=BA=E8=AE=AD?= =?UTF-8?q?=E7=BB=83=E4=BC=9A=E8=AF=9D=E5=92=8C=E5=90=91=E8=AE=AD=E7=BB=83?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=B7=BB=E5=8A=A0=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E7=9A=84API=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E8=AE=AD=E7=BB=83=E8=AE=A1=E5=88=92=E6=88=96?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E5=8A=A8=E4=BD=9C=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E8=AE=AD=E7=BB=83=E4=BC=9A=E8=AF=9D=E3=80=82=20-=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=9C=8D=E5=8A=A1=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=9B=E5=BB=BA=E8=87=AA=E5=AE=9A=E4=B9=89=E8=AE=AD?= =?UTF-8?q?=E7=BB=83=E4=BC=9A=E8=AF=9D=E5=B9=B6=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=8A=A8=E4=BD=9C=EF=BC=8C=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E8=AE=AD=E7=BB=83=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E7=9A=84?= =?UTF-8?q?=E7=81=B5=E6=B4=BB=E6=80=A7=E5=92=8C=E7=94=A8=E6=88=B7=E4=BD=93?= =?UTF-8?q?=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/workout-sessions-api-guide.md | 77 ++++++-- src/workouts/dto/workout-session.dto.ts | 32 ++- src/workouts/workouts.controller.ts | 20 +- src/workouts/workouts.service.ts | 252 ++++++++++++++++-------- 4 files changed, 283 insertions(+), 98 deletions(-) diff --git a/docs/workout-sessions-api-guide.md b/docs/workout-sessions-api-guide.md index 6858ef3..2466fa1 100644 --- a/docs/workout-sessions-api-guide.md +++ b/docs/workout-sessions-api-guide.md @@ -45,17 +45,69 @@ POST /training-plans/{planId}/exercises POST /training-plans/{planId}/activate ``` -### 第二步:开始每日训练 +### 第二步:开始训练(三种方式) +#### 方式一:自动获取今日训练(推荐) ```bash -# 1. 获取今日训练会话(如不存在则自动创建) +# 获取今日训练会话(如不存在则自动创建) GET /workouts/today # 系统会自动基于激活的训练计划创建今日训练会话 +``` -# 2. 开始训练会话(可选) -POST /workouts/sessions/{sessionId}/start +#### 方式二:基于训练计划手动创建 +```bash +POST /workouts/sessions { - "startedAt": "2024-01-15T09:00:00.000Z" + "trainingPlanId": "{planId}", + "name": "晚间训练", + "scheduledDate": "2024-01-15T19:00:00.000Z" +} +``` + +#### 方式三:创建完全自定义训练 +```bash +POST /workouts/sessions +{ + "name": "自定义核心训练", + "scheduledDate": "2024-01-15T15:00:00.000Z", + "customExercises": [ + { + "exerciseKey": "plank", + "name": "平板支撑", + "plannedDurationSec": 60, + "itemType": "exercise", + "sortOrder": 1 + }, + { + "name": "休息", + "plannedDurationSec": 30, + "itemType": "rest", + "sortOrder": 2 + }, + { + "name": "自定义深蹲变式", + "plannedSets": 3, + "plannedReps": 20, + "note": "脚距离肩膀更宽", + "itemType": "exercise", + "sortOrder": 3 + } + ] +} +``` + +### 第三步:执行训练 + +```bash +# 1. 开始训练会话(可选) +POST /workouts/sessions/{sessionId}/start + +# 2. 动态添加动作(如果需要) +POST /workouts/sessions/{sessionId}/exercises +{ + "name": "额外的拉伸", + "plannedDurationSec": 120, + "itemType": "exercise" } # 3. 开始特定动作 @@ -87,25 +139,27 @@ POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete - 每日训练是独立的实例 - 修改计划不影响历史训练记录 -### 2. 自动化管理 -- 客户端直接获取今日训练,系统自动创建 -- 所有动作完成后自动完成训练会话 -- 无需手动管理会话生命周期 +### 2. 灵活的创建方式 +- 自动创建:基于激活计划的今日训练 +- 计划创建:基于指定训练计划创建 +- 自定义创建:完全自定义的训练动作 ### 3. 进度追踪 - 每个训练会话都有完整的状态跟踪 - 支持详细的性能数据记录 - 可以分析训练趋势和进步情况 -### 4. 灵活性 -- 支持训练中的临时调整 +### 4. 动态调整能力 +- 支持训练中动态添加动作 - 支持跳过或修改特定动作 +- 自动完成会话管理 - 自动计算训练统计数据 ## API 端点总览 ### 训练会话管理 - `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐ +- `POST /workouts/sessions` - 手动创建训练会话(支持基于计划或自定义动作) - `GET /workouts/sessions` - 获取训练会话列表 - `GET /workouts/sessions/{id}` - 获取训练会话详情 - `POST /workouts/sessions/{id}/start` - 开始训练(可选) @@ -113,6 +167,7 @@ POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete - 注意:训练会话在所有动作完成后自动完成 ### 训练动作管理 +- `POST /workouts/sessions/{id}/exercises` - 向训练会话添加自定义动作 ⭐ - `GET /workouts/sessions/{id}/exercises` - 获取训练动作列表 - `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情 - `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作 diff --git a/src/workouts/dto/workout-session.dto.ts b/src/workouts/dto/workout-session.dto.ts index 47211cb..14ff81a 100644 --- a/src/workouts/dto/workout-session.dto.ts +++ b/src/workouts/dto/workout-session.dto.ts @@ -2,7 +2,37 @@ 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 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 }) diff --git a/src/workouts/workouts.controller.ts b/src/workouts/workouts.controller.ts index 48ebf64..dc0871d 100644 --- a/src/workouts/workouts.controller.ts +++ b/src/workouts/workouts.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, Put } fro import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { WorkoutsService } from './workouts.service'; import { + CreateWorkoutSessionDto, StartWorkoutDto, UpdateWorkoutSessionDto, WorkoutSessionResponseDto @@ -26,7 +27,12 @@ export class WorkoutsController { // ==================== 训练会话管理 ==================== - // 注意:不提供手动创建会话接口,客户端应使用 GET /workouts/today 自动获取/创建 + @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: '获取训练会话列表' }) @@ -69,6 +75,18 @@ export class WorkoutsController { // ==================== 训练动作管理 ==================== + @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' }) diff --git a/src/workouts/workouts.service.ts b/src/workouts/workouts.service.ts index ff82876..eab4e78 100644 --- a/src/workouts/workouts.service.ts +++ b/src/workouts/workouts.service.ts @@ -6,21 +6,18 @@ 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, - 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'; +import { Op } from 'sequelize'; @Injectable() export class WorkoutsService { @@ -37,11 +34,30 @@ export class WorkoutsService { private scheduleExerciseModel: typeof ScheduleExercise, @InjectModel(Exercise) private exerciseModel: typeof Exercise, - private readonly activityLogsService: ActivityLogsService, ) { } // ==================== 训练会话管理 ==================== + /** + * 创建训练会话(支持基于训练计划或自定义动作) + */ + 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 if (dto.customExercises && dto.customExercises.length > 0) { + // 基于自定义动作创建 + return this.createCustomWorkoutSession(userId, dto); + } else { + throw new BadRequestException('必须提供训练计划ID或自定义动作列表'); + } + } + /** * 获取今日训练会话,如果不存在则自动创建 */ @@ -100,10 +116,69 @@ export class WorkoutsService { 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'); + + try { + // 1. 创建训练会话 + const workoutSession = await this.workoutSessionModel.create({ + userId, + trainingPlanId: null, // 自定义训练不关联训练计划 + name: dto.name, + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : new Date(), + status: 'planned', + }, { transaction }); + + // 2. 创建自定义动作 + 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 }); + } + + await transaction.commit(); + + this.winstonLogger.info(`创建自定义训练会话 ${workoutSession.id}`, { + context: 'WorkoutsService', + userId, + workoutSessionId: workoutSession.id, + exerciseCount: dto.customExercises!.length, + }); + + return this.getWorkoutSessionDetail(userId, workoutSession.id); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + /** * 从训练计划创建训练会话(内部方法) */ - private async createWorkoutSessionFromPlan(userId: string, trainingPlanId: string, scheduledDate: Date) { + private async createWorkoutSessionFromPlan(userId: string, trainingPlanId: string, scheduledDate: Date, name?: string) { const trainingPlan = await this.trainingPlanModel.findOne({ where: { id: trainingPlanId, userId, deleted: false } }); @@ -120,7 +195,7 @@ export class WorkoutsService { const workoutSession = await this.workoutSessionModel.create({ userId, trainingPlanId, - name: trainingPlan.name || '今日训练', + name: name || trainingPlan.name || '今日训练', scheduledDate, status: 'planned', }, { transaction }); @@ -158,14 +233,6 @@ export class WorkoutsService { 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(); @@ -177,38 +244,41 @@ export class WorkoutsService { * 开始训练会话 */ async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) { - const session = await this.workoutSessionModel.findOne({ - where: { id: sessionId, userId, deleted: false } - }); + try { - if (!session) { - throw new NotFoundException('训练会话不存在'); + 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, + }); + + return session.toJSON(); + } catch (error) { + this.winstonLogger.error(`开始训练会话失败 ${sessionId}`, { + context: 'WorkoutsService', + userId, + sessionId, + error, + }); + throw error; } - - 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(); } // 注意:训练会话现在自动完成,不需要手动完成方法 @@ -303,19 +373,61 @@ export class WorkoutsService { sessionId, }); - await this.activityLogsService.record({ - userId, - entityType: ActivityEntityType.WORKOUT, - action: ActivityActionType.DELETE, - entityId: sessionId, - changes: null, - }); - return { success: true }; } // ==================== 训练动作管理 ==================== + /** + * 向训练会话添加自定义动作 + */ + 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(); + } + /** * 开始训练动作 */ @@ -411,29 +523,10 @@ export class WorkoutsService { 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, @@ -441,7 +534,7 @@ export class WorkoutsService { exerciseId, }); - return after; + return exercise.toJSON(); } /** @@ -575,17 +668,6 @@ export class WorkoutsService { 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 - }, - }); } } }