新增普拉提训练系统的数据库结构和数据导入功能

- 创建普拉提分类和动作数据的SQL导入脚本,支持垫上普拉提和器械普拉提的分类管理
- 实现数据库结构迁移脚本,添加新字段以支持普拉提类型和器械名称
- 更新数据库升级总结文档,详细说明数据库结构变更和数据导入步骤
- 创建训练会话相关表,支持每日训练实例功能
- 引入训练会话管理模块,整合训练计划与实际训练会话的关系
This commit is contained in:
richarjiang
2025-08-15 15:34:11 +08:00
parent bea71af5d3
commit 0edcfdcae9
28 changed files with 2528 additions and 164 deletions

View File

@@ -0,0 +1,170 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min, Max } from 'class-validator';
import { WorkoutItemType, WorkoutExerciseStatus } from '../models/workout-exercise.model';
export class CreateWorkoutExerciseDto {
@ApiProperty({ description: '关联的动作key仅exercise类型需要', required: false })
@IsString()
@IsOptional()
exerciseKey?: string;
@ApiProperty({ description: '项目名称' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ description: '计划组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedSets?: number;
@ApiProperty({ description: '计划重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedReps?: number;
@ApiProperty({ description: '计划持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
plannedDurationSec?: number;
@ApiProperty({ description: '休息时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
restSec?: number;
@ApiProperty({ description: '备注', required: false })
@IsString()
@IsOptional()
note?: string;
@ApiProperty({
enum: ['exercise', 'rest', 'note'],
description: '项目类型',
default: 'exercise',
required: false
})
@IsEnum(['exercise', 'rest', 'note'])
@IsOptional()
itemType?: WorkoutItemType;
}
export class UpdateWorkoutExerciseDto extends PartialType(CreateWorkoutExerciseDto) {
@ApiProperty({ description: '实际完成组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedSets?: number;
@ApiProperty({ description: '实际完成重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedReps?: number;
@ApiProperty({ description: '实际持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
actualDurationSec?: number;
@ApiProperty({ enum: ['pending', 'in_progress', 'completed', 'skipped'], required: false })
@IsEnum(['pending', 'in_progress', 'completed', 'skipped'])
@IsOptional()
status?: WorkoutExerciseStatus;
}
export class StartWorkoutExerciseDto {
@ApiProperty({ description: '开始时间', required: false })
@IsDateString()
@IsOptional()
startedAt?: string;
}
export class CompleteWorkoutExerciseDto {
@ApiProperty({ description: '实际完成组数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedSets?: number;
@ApiProperty({ description: '实际完成重复次数', required: false })
@IsInt()
@Min(0)
@IsOptional()
completedReps?: number;
@ApiProperty({ description: '实际持续时长(秒)', required: false })
@IsInt()
@Min(0)
@IsOptional()
actualDurationSec?: number;
@ApiProperty({ description: '完成时间', required: false })
@IsDateString()
@IsOptional()
completedAt?: string;
@ApiProperty({ description: '详细执行数据', required: false })
@IsOptional()
performanceData?: {
sets?: Array<{
reps?: number;
weight?: number;
duration?: number;
restTime?: number;
difficulty?: number;
notes?: string;
}>;
heartRate?: {
avg?: number;
max?: number;
};
perceivedExertion?: number;
};
}
export class UpdateWorkoutExerciseOrderDto {
@ApiProperty({ description: '动作ID列表按新的顺序排列' })
@IsArray()
@IsString({ each: true })
exerciseIds: string[];
}
export class WorkoutExerciseResponseDto {
@ApiProperty() id: string;
@ApiProperty() workoutSessionId: string;
@ApiProperty() userId: string;
@ApiProperty({ required: false }) exerciseKey?: string;
@ApiProperty() name: string;
@ApiProperty({ required: false }) plannedSets?: number;
@ApiProperty({ required: false }) completedSets?: number;
@ApiProperty({ required: false }) plannedReps?: number;
@ApiProperty({ required: false }) completedReps?: number;
@ApiProperty({ required: false }) plannedDurationSec?: number;
@ApiProperty({ required: false }) actualDurationSec?: number;
@ApiProperty({ required: false }) restSec?: number;
@ApiProperty({ required: false }) note?: string;
@ApiProperty({ enum: ['exercise', 'rest', 'note'] }) itemType: WorkoutItemType;
@ApiProperty({ enum: ['pending', 'in_progress', 'completed', 'skipped'] }) status: WorkoutExerciseStatus;
@ApiProperty() sortOrder: number;
@ApiProperty({ required: false }) startedAt?: Date;
@ApiProperty({ required: false }) completedAt?: Date;
@ApiProperty({ required: false }) performanceData?: any;
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的动作信息仅exercise类型时存在
@ApiProperty({ required: false })
exercise?: {
key: string;
name: string;
description: string;
categoryKey: string;
categoryName: string;
};
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsDateString, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { WorkoutStatus } from '../models/workout-session.model';
// 注意训练会话由系统自动创建不需要手动创建DTO
export class StartWorkoutDto {
@ApiProperty({ description: '实际开始时间', required: false })
@IsDateString()
@IsOptional()
startedAt?: string;
}
// 注意训练会话自动完成不需要手动完成DTO
export class UpdateWorkoutSessionDto {
@ApiProperty({ description: '训练总结', required: false })
@IsString()
@IsOptional()
summary?: string;
@ApiProperty({ description: '消耗卡路里', required: false })
@IsInt()
@Min(0)
@IsOptional()
caloriesBurned?: number;
}
export class WorkoutSessionResponseDto {
@ApiProperty() id: string;
@ApiProperty() userId: string;
@ApiProperty() trainingPlanId: string;
@ApiProperty() name: string;
@ApiProperty() scheduledDate: Date;
@ApiProperty({ required: false }) startedAt?: Date;
@ApiProperty({ required: false }) completedAt?: Date;
@ApiProperty({ enum: ['planned', 'in_progress', 'completed', 'skipped'] }) status: WorkoutStatus;
@ApiProperty({ required: false }) totalDurationSec?: number;
@ApiProperty({ required: false }) summary?: string;
@ApiProperty({ required: false }) caloriesBurned?: number;
@ApiProperty({ required: false }) stats?: {
totalExercises?: number;
completedExercises?: number;
totalSets?: number;
completedSets?: number;
totalReps?: number;
completedReps?: number;
};
@ApiProperty() createdAt: Date;
@ApiProperty() updatedAt: Date;
// 关联的训练计划信息
@ApiProperty({ required: false })
trainingPlan?: {
id: string;
name: string;
goal: string;
};
// 训练动作列表
@ApiProperty({ required: false, type: 'array' })
exercises?: any[];
}

View File

@@ -0,0 +1,115 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo } from 'sequelize-typescript';
import { WorkoutSession } from './workout-session.model';
import { Exercise } from '../../exercises/models/exercise.model';
export type WorkoutItemType = 'exercise' | 'rest' | 'note';
export type WorkoutExerciseStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
@Table({
tableName: 't_workout_exercises',
underscored: true,
})
export class WorkoutExercise extends Model {
@PrimaryKey
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
declare id: string;
@ForeignKey(() => WorkoutSession)
@Column({ type: DataType.UUID, allowNull: false })
declare workoutSessionId: string;
@BelongsTo(() => WorkoutSession)
declare workoutSession: WorkoutSession;
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
// 关联到动作库仅exercise类型需要
@ForeignKey(() => Exercise)
@Column({ type: DataType.STRING, allowNull: true, comment: '关联的动作key仅exercise类型' })
declare exerciseKey: string;
@BelongsTo(() => Exercise, { foreignKey: 'exerciseKey', targetKey: 'key' })
declare exercise: Exercise;
@Column({ type: DataType.STRING, allowNull: false, comment: '项目名称' })
declare name: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划组数' })
declare plannedSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际完成组数' })
declare completedSets: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划重复次数' })
declare plannedReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际完成重复次数' })
declare completedReps: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '计划持续时长(秒)' })
declare plannedDurationSec: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '实际持续时长(秒)' })
declare actualDurationSec: number;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '休息时长(秒)' })
declare restSec: number;
@Column({ type: DataType.TEXT, allowNull: true, comment: '备注' })
declare note: string;
@Column({
type: DataType.ENUM('exercise', 'rest', 'note'),
allowNull: false,
defaultValue: 'exercise',
comment: '项目类型'
})
declare itemType: WorkoutItemType;
@Column({
type: DataType.ENUM('pending', 'in_progress', 'completed', 'skipped'),
allowNull: false,
defaultValue: 'pending',
comment: '动作状态'
})
declare status: WorkoutExerciseStatus;
@Column({ type: DataType.INTEGER, allowNull: false, comment: '排序顺序' })
declare sortOrder: number;
@Column({ type: DataType.DATE, allowNull: true, comment: '开始时间' })
declare startedAt: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '完成时间' })
declare completedAt: Date;
@Column({ type: DataType.JSON, allowNull: true, comment: '详细执行数据' })
declare performanceData: {
sets?: Array<{
reps?: number;
weight?: number;
duration?: number;
restTime?: number;
difficulty?: number; // 1-10
notes?: string;
}>;
heartRate?: {
avg?: number;
max?: number;
};
perceivedExertion?: number; // 1-10 RPE scale
};
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
declare deleted: boolean;
}

View File

@@ -0,0 +1,79 @@
import { Column, DataType, ForeignKey, Model, PrimaryKey, Table, BelongsTo, HasMany } from 'sequelize-typescript';
import { TrainingPlan } from '../../training-plans/models/training-plan.model';
import { WorkoutExercise } from './workout-exercise.model';
export type WorkoutStatus = 'planned' | 'in_progress' | 'completed' | 'skipped';
@Table({
tableName: 't_workout_sessions',
underscored: true,
})
export class WorkoutSession extends Model {
@PrimaryKey
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4,
})
declare id: string;
@Column({ type: DataType.STRING, allowNull: false })
declare userId: string;
@ForeignKey(() => TrainingPlan)
@Column({ type: DataType.UUID, allowNull: false, comment: '关联的训练计划模板' })
declare trainingPlanId: string;
@BelongsTo(() => TrainingPlan)
declare trainingPlan: TrainingPlan;
@HasMany(() => WorkoutExercise)
declare exercises: WorkoutExercise[];
@Column({ type: DataType.STRING, allowNull: false, comment: '训练会话名称' })
declare name: string;
@Column({ type: DataType.DATE, allowNull: false, comment: '计划训练日期' })
declare scheduledDate: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '实际开始时间' })
declare startedAt: Date;
@Column({ type: DataType.DATE, allowNull: true, comment: '实际结束时间' })
declare completedAt: Date;
@Column({
type: DataType.ENUM('planned', 'in_progress', 'completed', 'skipped'),
allowNull: false,
defaultValue: 'planned',
comment: '训练状态'
})
declare status: WorkoutStatus;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '总时长(秒)' })
declare totalDurationSec: number;
@Column({ type: DataType.TEXT, allowNull: true, comment: '训练总结/备注' })
declare summary: string;
@Column({ type: DataType.INTEGER, allowNull: true, comment: '消耗卡路里(估算)' })
declare caloriesBurned: number;
@Column({ type: DataType.JSON, allowNull: true, comment: '训练统计数据' })
declare stats: {
totalExercises?: number;
completedExercises?: number;
totalSets?: number;
completedSets?: number;
totalReps?: number;
completedReps?: number;
};
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否已删除' })
declare deleted: boolean;
}

View File

@@ -0,0 +1,192 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards, Put } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { WorkoutsService } from './workouts.service';
import {
StartWorkoutDto,
UpdateWorkoutSessionDto,
WorkoutSessionResponseDto
} from './dto/workout-session.dto';
import {
CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto,
UpdateWorkoutExerciseOrderDto,
WorkoutExerciseResponseDto
} from './dto/workout-exercise.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service';
@ApiTags('workouts')
@Controller('workouts')
@UseGuards(JwtAuthGuard)
export class WorkoutsController {
constructor(private readonly workoutsService: WorkoutsService) { }
// ==================== 训练会话管理 ====================
// 注意:不提供手动创建会话接口,客户端应使用 GET /workouts/today 自动获取/创建
@Get('sessions')
@ApiOperation({ summary: '获取训练会话列表' })
async getSessions(
@CurrentUser() user: AccessTokenPayload,
@Query('page') page: number = 1,
@Query('limit') limit: number = 10,
) {
return this.workoutsService.getWorkoutSessions(user.sub, page, limit);
}
@Get('sessions/:id')
@ApiOperation({ summary: '获取训练会话详情' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionDetail(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.getWorkoutSessionDetail(user.sub, sessionId);
}
@Post('sessions/:id/start')
@ApiOperation({ summary: '开始训练会话' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiBody({ type: StartWorkoutDto, required: false })
async startSession(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Body() dto: StartWorkoutDto = {},
) {
return this.workoutsService.startWorkoutSession(user.sub, sessionId, dto);
}
// 注意:训练会话自动完成,无需手动标记
// 当所有动作完成时,会话自动标记为完成
@Delete('sessions/:id')
@ApiOperation({ summary: '删除训练会话' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async deleteSession(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.deleteWorkoutSession(user.sub, sessionId);
}
// ==================== 训练动作管理 ====================
@Get('sessions/:id/exercises')
@ApiOperation({ summary: '获取训练会话的所有动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionExercises(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
return this.workoutsService.getWorkoutExercises(user.sub, sessionId);
}
@Get('sessions/:id/exercises/:exerciseId')
@ApiOperation({ summary: '获取训练动作详情' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
async getExerciseDetail(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
) {
return this.workoutsService.getWorkoutExerciseDetail(user.sub, sessionId, exerciseId);
}
@Post('sessions/:id/exercises/:exerciseId/start')
@ApiOperation({ summary: '开始训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: StartWorkoutExerciseDto, required: false })
async startExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: StartWorkoutExerciseDto = {},
) {
return this.workoutsService.startWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
@Post('sessions/:id/exercises/:exerciseId/complete')
@ApiOperation({ summary: '完成训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: CompleteWorkoutExerciseDto })
async completeExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: CompleteWorkoutExerciseDto,
) {
return this.workoutsService.completeWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
@Post('sessions/:id/exercises/:exerciseId/skip')
@ApiOperation({ summary: '跳过训练动作' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
async skipExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
) {
return this.workoutsService.skipWorkoutExercise(user.sub, sessionId, exerciseId);
}
@Put('sessions/:id/exercises/:exerciseId')
@ApiOperation({ summary: '更新训练动作信息' })
@ApiParam({ name: 'id', description: '训练会话ID' })
@ApiParam({ name: 'exerciseId', description: '训练动作ID' })
@ApiBody({ type: UpdateWorkoutExerciseDto })
async updateExercise(
@CurrentUser() user: AccessTokenPayload,
@Param('id') sessionId: string,
@Param('exerciseId') exerciseId: string,
@Body() dto: UpdateWorkoutExerciseDto,
) {
return this.workoutsService.updateWorkoutExercise(user.sub, sessionId, exerciseId, dto);
}
// ==================== 统计和分析 ====================
@Get('sessions/:id/stats')
@ApiOperation({ summary: '获取训练会话统计数据' })
@ApiParam({ name: 'id', description: '训练会话ID' })
async getSessionStats(@CurrentUser() user: AccessTokenPayload, @Param('id') sessionId: string) {
const session = await this.workoutsService.getWorkoutSessionDetail(user.sub, sessionId);
return {
status: session.status,
duration: session.totalDurationSec,
calories: session.caloriesBurned,
stats: session.stats,
exerciseCount: session.exercises?.length || 0,
completedExercises: session.exercises?.filter((e: any) => e.status === 'completed').length || 0,
};
}
// ==================== 快捷操作 ====================
@Get('today')
@ApiOperation({ summary: '获取/创建今日训练会话(基于激活的训练计划)' })
async getTodayWorkout(@CurrentUser() user: AccessTokenPayload) {
return this.workoutsService.getTodayWorkoutSession(user.sub);
}
@Get('recent')
@ApiOperation({ summary: '获取最近的训练会话' })
async getRecentWorkouts(
@CurrentUser() user: AccessTokenPayload,
@Query('days') days: number = 7,
@Query('limit') limit: number = 10,
) {
const sessions = await this.workoutsService.getWorkoutSessions(user.sub, 1, limit);
// 简化版本,实际应该在数据库层面过滤
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);
const recentSessions = sessions.sessions.filter(session =>
new Date(session.scheduledDate) >= cutoffDate
);
return {
sessions: recentSessions,
period: `最近${days}`,
};
}
}

View File

@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { WorkoutsController } from './workouts.controller';
import { WorkoutsService } from './workouts.service';
import { WorkoutSession } from './models/workout-session.model';
import { WorkoutExercise } from './models/workout-exercise.model';
import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
SequelizeModule.forFeature([
WorkoutSession,
WorkoutExercise,
TrainingPlan,
ScheduleExercise,
Exercise,
]),
ActivityLogsModule,
UsersModule,
],
controllers: [WorkoutsController],
providers: [WorkoutsService],
exports: [WorkoutsService],
})
export class WorkoutsModule { }

View File

@@ -0,0 +1,591 @@
import { Inject, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize';
import { WorkoutSession } from './models/workout-session.model';
import { WorkoutExercise } from './models/workout-exercise.model';
import { TrainingPlan } from '../training-plans/models/training-plan.model';
import { ScheduleExercise } from '../training-plans/models/schedule-exercise.model';
import { Exercise } from '../exercises/models/exercise.model';
import {
StartWorkoutDto,
UpdateWorkoutSessionDto,
} from './dto/workout-session.dto';
import {
CreateWorkoutExerciseDto,
UpdateWorkoutExerciseDto,
StartWorkoutExerciseDto,
CompleteWorkoutExerciseDto,
UpdateWorkoutExerciseOrderDto,
} from './dto/workout-exercise.dto';
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { Logger as WinstonLogger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Op, Transaction } from 'sequelize';
@Injectable()
export class WorkoutsService {
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger;
constructor(
@InjectModel(WorkoutSession)
private workoutSessionModel: typeof WorkoutSession,
@InjectModel(WorkoutExercise)
private workoutExerciseModel: typeof WorkoutExercise,
@InjectModel(TrainingPlan)
private trainingPlanModel: typeof TrainingPlan,
@InjectModel(ScheduleExercise)
private scheduleExerciseModel: typeof ScheduleExercise,
@InjectModel(Exercise)
private exerciseModel: typeof Exercise,
private readonly activityLogsService: ActivityLogsService,
) { }
// ==================== 训练会话管理 ====================
/**
* 获取今日训练会话,如果不存在则自动创建
*/
async getTodayWorkoutSession(userId: string) {
const today = new Date();
today.setHours(0, 0, 0, 0); // 设置为今日0点
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// 查找今日是否已有训练会话
let session = await this.workoutSessionModel.findOne({
where: {
userId,
deleted: false,
scheduledDate: {
[Op.gte]: today,
[Op.lt]: tomorrow
}
},
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
},
{
model: WorkoutExercise,
required: false,
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']]
}
],
});
if (session) {
return session.toJSON();
}
// 如果没有训练会话,查找激活的训练计划
const activeTrainingPlan = await this.trainingPlanModel.findOne({
where: { userId, isActive: true, deleted: false }
});
if (!activeTrainingPlan) {
throw new NotFoundException('请先激活一个训练计划');
}
// 创建今日训练会话
return this.createWorkoutSessionFromPlan(userId, activeTrainingPlan.id, today);
}
/**
* 从训练计划创建训练会话(内部方法)
*/
private async createWorkoutSessionFromPlan(userId: string, trainingPlanId: string, scheduledDate: Date) {
const trainingPlan = await this.trainingPlanModel.findOne({
where: { id: trainingPlanId, userId, deleted: false }
});
if (!trainingPlan) {
throw new NotFoundException('训练计划不存在或不属于当前用户');
}
const transaction = await this.workoutSessionModel.sequelize?.transaction();
if (!transaction) throw new Error('Failed to start transaction');
try {
// 1. 创建训练会话
const workoutSession = await this.workoutSessionModel.create({
userId,
trainingPlanId,
name: trainingPlan.name || '今日训练',
scheduledDate,
status: 'planned',
}, { transaction });
// 2. 复制训练计划中的动作到训练会话
const scheduleExercises = await this.scheduleExerciseModel.findAll({
where: { trainingPlanId, userId, deleted: false },
order: [['sortOrder', 'ASC']],
transaction
});
for (const scheduleExercise of scheduleExercises) {
await this.workoutExerciseModel.create({
workoutSessionId: workoutSession.id,
userId,
exerciseKey: scheduleExercise.exerciseKey,
name: scheduleExercise.name,
plannedSets: scheduleExercise.sets,
plannedReps: scheduleExercise.reps,
plannedDurationSec: scheduleExercise.durationSec,
restSec: scheduleExercise.restSec,
note: scheduleExercise.note,
itemType: scheduleExercise.itemType,
status: 'pending',
sortOrder: scheduleExercise.sortOrder,
}, { transaction });
}
await transaction.commit();
this.winstonLogger.info(`自动创建训练会话 ${workoutSession.id}`, {
context: 'WorkoutsService',
userId,
trainingPlanId,
workoutSessionId: workoutSession.id,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.CREATE,
entityId: workoutSession.id,
changes: workoutSession.toJSON(),
});
return this.getWorkoutSessionDetail(userId, workoutSession.id);
} catch (error) {
await transaction.rollback();
throw error;
}
}
/**
* 开始训练会话
*/
async startWorkoutSession(userId: string, sessionId: string, dto: StartWorkoutDto = {}) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
if (session.status !== 'planned') {
throw new BadRequestException('只能开始计划中的训练会话');
}
const startTime = dto.startedAt ? new Date(dto.startedAt) : new Date();
session.startedAt = startTime;
session.status = 'in_progress';
await session.save();
this.winstonLogger.info(`开始训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: sessionId,
changes: { status: { before: 'planned', after: 'in_progress' } },
});
return session.toJSON();
}
// 注意:训练会话现在自动完成,不需要手动完成方法
/**
* 获取训练会话列表
*/
async getWorkoutSessions(userId: string, page: number = 1, limit: number = 10) {
const offset = (page - 1) * limit;
const { rows: sessions, count } = await this.workoutSessionModel.findAndCountAll({
where: { userId, deleted: false },
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
}
],
order: [['scheduledDate', 'DESC']],
limit,
offset,
});
return {
sessions: sessions.map(s => s.toJSON()),
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit),
}
};
}
/**
* 获取训练会话详情
*/
async getWorkoutSessionDetail(userId: string, sessionId: string) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false },
include: [
{
model: TrainingPlan,
required: false,
attributes: ['id', 'name', 'goal']
},
{
model: WorkoutExercise,
required: false,
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']]
}
],
});
if (!session) {
throw new NotFoundException('训练会话不存在');
}
return session.toJSON();
}
/**
* 删除训练会话
*/
async deleteWorkoutSession(userId: string, sessionId: string) {
const [count] = await this.workoutSessionModel.update(
{ deleted: true },
{ where: { id: sessionId, userId, deleted: false } }
);
if (count === 0) {
throw new NotFoundException('训练会话不存在');
}
// 同时删除关联的训练动作
await this.workoutExerciseModel.update(
{ deleted: true },
{ where: { workoutSessionId: sessionId, userId, deleted: false } }
);
this.winstonLogger.info(`删除训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.DELETE,
entityId: sessionId,
changes: null,
});
return { success: true };
}
// ==================== 训练动作管理 ====================
/**
* 开始训练动作
*/
async startWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: StartWorkoutExerciseDto = {}) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status !== 'pending') {
throw new BadRequestException('只能开始待执行的训练动作');
}
const startTime = dto.startedAt ? new Date(dto.startedAt) : new Date();
exercise.startedAt = startTime;
exercise.status = 'in_progress';
await exercise.save();
this.winstonLogger.info(`开始训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
return exercise.toJSON();
}
/**
* 完成训练动作
*/
async completeWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: CompleteWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status === 'completed') {
throw new BadRequestException('训练动作已经完成');
}
const completedTime = dto.completedAt ? new Date(dto.completedAt) : new Date();
exercise.completedAt = completedTime;
exercise.status = 'completed';
exercise.completedSets = dto.completedSets || exercise.completedSets;
exercise.completedReps = dto.completedReps || exercise.completedReps;
exercise.actualDurationSec = dto.actualDurationSec || exercise.actualDurationSec;
exercise.performanceData = dto.performanceData || exercise.performanceData;
// 计算实际时长(如果没有传入)
if (!dto.actualDurationSec && exercise.startedAt) {
exercise.actualDurationSec = Math.floor((completedTime.getTime() - exercise.startedAt.getTime()) / 1000);
}
await exercise.save();
this.winstonLogger.info(`完成训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
// 检查是否所有动作都完成,如果是则自动完成训练会话
await this.checkAndAutoCompleteSession(userId, sessionId);
return exercise.toJSON();
}
/**
* 跳过训练动作
*/
async skipWorkoutExercise(userId: string, sessionId: string, exerciseId: string) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
if (exercise.status === 'completed') {
throw new BadRequestException('已完成的训练动作不能跳过');
}
exercise.status = 'skipped';
await exercise.save();
this.winstonLogger.info(`跳过训练动作 ${exerciseId}`, {
context: 'WorkoutsService',
userId,
sessionId,
exerciseId,
});
// 检查是否所有动作都完成,如果是则自动完成训练会话
await this.checkAndAutoCompleteSession(userId, sessionId);
return exercise.toJSON();
}
/**
* 更新训练动作
*/
async updateWorkoutExercise(userId: string, sessionId: string, exerciseId: string, dto: UpdateWorkoutExerciseDto) {
const exercise = await this.validateWorkoutExercise(userId, sessionId, exerciseId);
const before = exercise.toJSON();
// 更新字段
Object.assign(exercise, dto);
await exercise.save();
const after = exercise.toJSON();
const changedKeys = Object.keys(after).filter((key) => (before as any)[key] !== (after as any)[key]);
const changes: Record<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,
sessionId,
exerciseId,
});
return after;
}
/**
* 获取训练会话的所有动作
*/
async getWorkoutExercises(userId: string, sessionId: string) {
await this.validateWorkoutSession(userId, sessionId);
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, userId, deleted: false },
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
order: [['sortOrder', 'ASC']],
});
return exercises.map(e => e.toJSON());
}
/**
* 获取训练动作详情
*/
async getWorkoutExerciseDetail(userId: string, sessionId: string, exerciseId: string) {
const exercise = await this.workoutExerciseModel.findOne({
where: { id: exerciseId, workoutSessionId: sessionId, userId, deleted: false },
include: [
{
model: Exercise,
required: false,
attributes: ['key', 'name', 'description', 'categoryKey', 'categoryName']
}
],
});
if (!exercise) {
throw new NotFoundException('训练动作不存在');
}
return exercise.toJSON();
}
// ==================== 工具方法 ====================
private async validateWorkoutSession(userId: string, sessionId: string): Promise<WorkoutSession> {
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<WorkoutExercise> {
const exercise = await this.workoutExerciseModel.findOne({
where: { id: exerciseId, workoutSessionId: sessionId, userId, deleted: false }
});
if (!exercise) {
throw new NotFoundException('训练动作不存在');
}
return exercise;
}
private async calculateWorkoutStats(sessionId: string) {
const exercises = await this.workoutExerciseModel.findAll({
where: { workoutSessionId: sessionId, deleted: false, itemType: 'exercise' }
});
const stats = {
totalExercises: exercises.length,
completedExercises: exercises.filter(e => e.status === 'completed').length,
totalSets: exercises.reduce((sum, e) => sum + (e.plannedSets || 0), 0),
completedSets: exercises.reduce((sum, e) => sum + (e.completedSets || 0), 0),
totalReps: exercises.reduce((sum, e) => sum + (e.plannedReps || 0), 0),
completedReps: exercises.reduce((sum, e) => sum + (e.completedReps || 0), 0),
};
return stats;
}
/**
* 检查并自动完成训练会话
*/
private async checkAndAutoCompleteSession(userId: string, sessionId: string) {
const session = await this.workoutSessionModel.findOne({
where: { id: sessionId, userId, deleted: false }
});
if (!session || session.status === 'completed') {
return;
}
// 检查所有exercise类型的动作是否都完成
const exerciseActions = await this.workoutExerciseModel.findAll({
where: {
workoutSessionId: sessionId,
userId,
deleted: false,
itemType: 'exercise'
}
});
const allCompleted = exerciseActions.every(exercise =>
exercise.status === 'completed' || exercise.status === 'skipped'
);
if (allCompleted && exerciseActions.length > 0) {
// 自动完成训练会话
const completedTime = new Date();
session.completedAt = completedTime;
session.status = 'completed';
// 计算总时长
if (session.startedAt) {
session.totalDurationSec = Math.floor((completedTime.getTime() - session.startedAt.getTime()) / 1000);
}
// 计算统计数据
const stats = await this.calculateWorkoutStats(sessionId);
session.stats = stats;
await session.save();
this.winstonLogger.info(`自动完成训练会话 ${sessionId}`, {
context: 'WorkoutsService',
userId,
sessionId,
duration: session.totalDurationSec,
});
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.WORKOUT,
action: ActivityActionType.UPDATE,
entityId: sessionId,
changes: {
status: { before: 'in_progress', after: 'completed' },
autoCompleted: true
},
});
}
}
}