更新训练会话API文档和服务逻辑

- 修改训练会话文档,增加三种训练创建方式的详细说明,包括自动获取、基于计划手动创建和完全自定义创建。
- 在控制器中新增创建训练会话和向训练会话添加自定义动作的API,支持基于训练计划或自定义动作创建训练会话。
- 更新服务逻辑,支持创建自定义训练会话并添加自定义动作,增强训练会话管理的灵活性和用户体验。
This commit is contained in:
richarjiang
2025-08-15 16:12:27 +08:00
parent 0edcfdcae9
commit 4257449f76
4 changed files with 283 additions and 98 deletions

View File

@@ -45,17 +45,69 @@ POST /training-plans/{planId}/exercises
POST /training-plans/{planId}/activate POST /training-plans/{planId}/activate
``` ```
### 第二步:开始每日训练 ### 第二步:开始训练(三种方式)
#### 方式一:自动获取今日训练(推荐)
```bash ```bash
# 1. 获取今日训练会话(如不存在则自动创建) # 获取今日训练会话(如不存在则自动创建)
GET /workouts/today 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. 开始特定动作 # 3. 开始特定动作
@@ -87,25 +139,27 @@ POST /workouts/sessions/{sessionId}/exercises/{exerciseId}/complete
- 每日训练是独立的实例 - 每日训练是独立的实例
- 修改计划不影响历史训练记录 - 修改计划不影响历史训练记录
### 2. 自动化管理 ### 2. 灵活的创建方式
- 客户端直接获取今日训练,系统自动创建 - 自动创建:基于激活计划的今日训练
- 所有动作完成后自动完成训练会话 - 计划创建:基于指定训练计划创建
- 无需手动管理会话生命周期 - 自定义创建:完全自定义的训练动作
### 3. 进度追踪 ### 3. 进度追踪
- 每个训练会话都有完整的状态跟踪 - 每个训练会话都有完整的状态跟踪
- 支持详细的性能数据记录 - 支持详细的性能数据记录
- 可以分析训练趋势和进步情况 - 可以分析训练趋势和进步情况
### 4. 灵活性 ### 4. 动态调整能力
- 支持训练中的临时调整 - 支持训练中动态添加动作
- 支持跳过或修改特定动作 - 支持跳过或修改特定动作
- 自动完成会话管理
- 自动计算训练统计数据 - 自动计算训练统计数据
## API 端点总览 ## API 端点总览
### 训练会话管理 ### 训练会话管理
- `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐ - `GET /workouts/today` - 获取/自动创建今日训练会话 ⭐
- `POST /workouts/sessions` - 手动创建训练会话(支持基于计划或自定义动作)
- `GET /workouts/sessions` - 获取训练会话列表 - `GET /workouts/sessions` - 获取训练会话列表
- `GET /workouts/sessions/{id}` - 获取训练会话详情 - `GET /workouts/sessions/{id}` - 获取训练会话详情
- `POST /workouts/sessions/{id}/start` - 开始训练(可选) - `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` - 获取训练动作列表
- `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情 - `GET /workouts/sessions/{id}/exercises/{exerciseId}` - 获取动作详情
- `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作 - `POST /workouts/sessions/{id}/exercises/{exerciseId}/start` - 开始动作

View File

@@ -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 { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { WorkoutStatus } from '../models/workout-session.model'; 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 { export class StartWorkoutDto {
@ApiProperty({ description: '实际开始时间', required: false }) @ApiProperty({ description: '实际开始时间', required: false })

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, Put } fro
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { WorkoutsService } from './workouts.service'; import { WorkoutsService } from './workouts.service';
import { import {
CreateWorkoutSessionDto,
StartWorkoutDto, StartWorkoutDto,
UpdateWorkoutSessionDto, UpdateWorkoutSessionDto,
WorkoutSessionResponseDto 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') @Get('sessions')
@ApiOperation({ summary: '获取训练会话列表' }) @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') @Get('sessions/:id/exercises')
@ApiOperation({ summary: '获取训练会话的所有动作' }) @ApiOperation({ summary: '获取训练会话的所有动作' })
@ApiParam({ name: 'id', description: '训练会话ID' }) @ApiParam({ name: 'id', description: '训练会话ID' })

View File

@@ -6,21 +6,18 @@ import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model'; import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model'; import { Exercise } from '../exercises/models/exercise.model';
import { import {
CreateWorkoutSessionDto,
StartWorkoutDto, StartWorkoutDto,
UpdateWorkoutSessionDto,
} from './dto/workout-session.dto'; } from './dto/workout-session.dto';
import { import {
CreateWorkoutExerciseDto, CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto, UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto, StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto, CompleteWorkoutExerciseDto,
UpdateWorkoutExerciseOrderDto,
} from './dto/workout-exercise.dto'; } 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 { Logger as WinstonLogger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Op, Transaction } from 'sequelize'; import { Op } from 'sequelize';
@Injectable() @Injectable()
export class WorkoutsService { export class WorkoutsService {
@@ -37,11 +34,30 @@ export class WorkoutsService {
private scheduleExerciseModel: typeof ScheduleExercise, private scheduleExerciseModel: typeof ScheduleExercise,
@InjectModel(Exercise) @InjectModel(Exercise)
private exerciseModel: typeof 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); 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({ const trainingPlan = await this.trainingPlanModel.findOne({
where: { id: trainingPlanId, userId, deleted: false } where: { id: trainingPlanId, userId, deleted: false }
}); });
@@ -120,7 +195,7 @@ export class WorkoutsService {
const workoutSession = await this.workoutSessionModel.create({ const workoutSession = await this.workoutSessionModel.create({
userId, userId,
trainingPlanId, trainingPlanId,
name: trainingPlan.name || '今日训练', name: name || trainingPlan.name || '今日训练',
scheduledDate, scheduledDate,
status: 'planned', status: 'planned',
}, { transaction }); }, { transaction });
@@ -158,14 +233,6 @@ export class WorkoutsService {
workoutSessionId: workoutSession.id, 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); return this.getWorkoutSessionDetail(userId, workoutSession.id);
} catch (error) { } catch (error) {
await transaction.rollback(); await transaction.rollback();
@@ -177,6 +244,8 @@ export class WorkoutsService {
* 开始训练会话 * 开始训练会话
*/ */
async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) { async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) {
try {
const session = await this.workoutSessionModel.findOne({ const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false } where: { id: sessionId, userId, deleted: false }
}); });
@@ -200,15 +269,16 @@ export class WorkoutsService {
sessionId, sessionId,
}); });
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: sessionId,
changes: { status: { before: 'planned', after: 'in_progress' } },
});
return session.toJSON(); return session.toJSON();
} catch (error) {
this.winstonLogger.error(`开始训练会话失败 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
error,
});
throw error;
}
} }
// 注意:训练会话现在自动完成,不需要手动完成方法 // 注意:训练会话现在自动完成,不需要手动完成方法
@@ -303,19 +373,61 @@ export class WorkoutsService {
sessionId, sessionId,
}); });
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.DELETE,
entityId: sessionId,
changes: null,
});
return { success: true }; 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) { async updateWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: UpdateWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId); const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
const before = exercise.toJSON();
// 更新字段 // 更新字段
Object.assign(exercise, dto); Object.assign(exercise, dto);
await exercise.save(); await exercise.save();
const after = exercise.toJSON();
const changedKeys = Object.keys(after).filter((key) => (before as any)[key] !== (after as any)[key]);
const changes: Record<string, any> = {};
for (const key of changedKeys) {
changes[key] = { before: (before as any)[key], after: (after as any)[key] };
}
if (Object.keys(changes).length > 0) {
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: exerciseId,
changes,
});
}
this.winstonLogger.info(`更新训练动作 ${exerciseId}`, { this.winstonLogger.info(`更新训练动作 ${exerciseId}`, {
context: 'WorkoutsService', context: 'WorkoutsService',
userId, userId,
@@ -441,7 +534,7 @@ export class WorkoutsService {
exerciseId, exerciseId,
}); });
return after; return exercise.toJSON();
} }
/** /**
@@ -575,17 +668,6 @@ export class WorkoutsService {
sessionId, sessionId,
duration: session.totalDurationSec, 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
},
});
} }
} }
} }