更新训练会话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

@@ -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 })

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 { 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' })

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 { 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<string, any> = {};
for (const key of changedKeys) {
changes[key] = { before: (before as any)[key], after: (after as any)[key] };
}
if (Object.keys(changes).length > 0) {
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: exerciseId,
changes,
});
}
this.winstonLogger.info(`更新训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
@@ -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
},
});
}
}
}