diff --git a/docs/exercises-seed.sql b/docs/exercises-seed.sql index d29d049..ee5b10c 100644 --- a/docs/exercises-seed.sql +++ b/docs/exercises-seed.sql @@ -2,6 +2,8 @@ -- 说明:表由 Sequelize 同步创建(t_exercise_categories / t_exercises) -- 若要幂等导入,使用 INSERT ... ON DUPLICATE KEY UPDATE +USE `db-plates`; + START TRANSACTION; -- 分类 diff --git a/src/training-plans/dto/training-plan.dto.ts b/src/training-plans/dto/training-plan.dto.ts index 8c0f2be..4830570 100644 --- a/src/training-plans/dto/training-plan.dto.ts +++ b/src/training-plans/dto/training-plan.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, PartialType } from '@nestjs/swagger'; import { IsArray, IsDateString, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from 'class-validator'; import { PlanGoal, PlanMode } from '../models/training-plan.model'; @@ -48,4 +48,6 @@ export class TrainingPlanSummaryDto { @ApiProperty() goal: string; } +export class UpdateTrainingPlanDto extends PartialType(CreateTrainingPlanDto) {} + diff --git a/src/training-plans/models/training-plan.model.ts b/src/training-plans/models/training-plan.model.ts index fc16907..ea5898a 100644 --- a/src/training-plans/models/training-plan.model.ts +++ b/src/training-plans/models/training-plan.model.ts @@ -15,6 +15,10 @@ export class TrainingPlan extends Model { }) declare id: string; + + @Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否激活' }) + declare isActive: boolean; + @Column({ type: DataType.STRING, allowNull: false }) declare userId: string; diff --git a/src/training-plans/training-plans.controller.ts b/src/training-plans/training-plans.controller.ts index c3f0216..6b5939c 100644 --- a/src/training-plans/training-plans.controller.ts +++ b/src/training-plans/training-plans.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { TrainingPlansService } from './training-plans.service'; -import { CreateTrainingPlanDto } from './dto/training-plan.dto'; +import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-plan.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'; @@ -19,6 +19,18 @@ export class TrainingPlansController { return this.service.create(user.sub, dto); } + @Post(':id/update') + @ApiOperation({ summary: '更新训练计划' }) + @ApiParam({ name: 'id' }) + @ApiBody({ type: UpdateTrainingPlanDto }) + async update( + @CurrentUser() user: AccessTokenPayload, + @Param('id') id: string, + @Body() dto: UpdateTrainingPlanDto, + ) { + return this.service.update(user.sub, id, dto); + } + @Delete(':id') @ApiOperation({ summary: '删除训练计划' }) @ApiParam({ name: 'id' }) @@ -42,6 +54,13 @@ export class TrainingPlansController { async detail(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) { return this.service.detail(user.sub, id); } + + @Post(':id/activate') + @ApiOperation({ summary: '激活训练计划' }) + @ApiParam({ name: 'id' }) + async activate(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) { + return this.service.activate(user.sub, id); + } } diff --git a/src/training-plans/training-plans.service.ts b/src/training-plans/training-plans.service.ts index 8047418..090fe31 100644 --- a/src/training-plans/training-plans.service.ts +++ b/src/training-plans/training-plans.service.ts @@ -1,12 +1,15 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; import { TrainingPlan } from './models/training-plan.model'; -import { CreateTrainingPlanDto } from './dto/training-plan.dto'; +import { CreateTrainingPlanDto, UpdateTrainingPlanDto } from './dto/training-plan.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'; @Injectable() export class TrainingPlansService { + @Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger; constructor( @InjectModel(TrainingPlan) private trainingPlanModel: typeof TrainingPlan, @@ -16,6 +19,10 @@ export class TrainingPlansService { async create(userId: string, dto: CreateTrainingPlanDto) { const id = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const createdAt = new Date(); + + // 检查用户是否有其他激活的训练计划 + const activePlans = await this.trainingPlanModel.findAll({ where: { userId, isActive: true, deleted: false } }); + const plan = await this.trainingPlanModel.create({ id, userId, @@ -28,6 +35,12 @@ export class TrainingPlansService { goal: dto.goal, startWeightKg: dto.startWeightKg ?? null, preferredTimeOfDay: dto.preferredTimeOfDay ?? '', + isActive: activePlans.length === 0, + }); + this.winstonLogger.info(`create plan ${id} for user ${userId} success`, { + context: 'TrainingPlansService', + userId, + id, }); await this.activityLogsService.record({ userId, @@ -36,6 +49,11 @@ export class TrainingPlansService { entityId: plan.id, changes: plan.toJSON(), }); + this.winstonLogger.info(`create plan ${id} for user ${userId} success`, { + context: 'TrainingPlansService', + userId, + id, + }); return plan.toJSON(); } @@ -44,6 +62,11 @@ export class TrainingPlansService { { deleted: true }, { where: { id, userId, deleted: false } } ); + this.winstonLogger.info(`remove plan ${id} for user ${userId}`, { + context: 'TrainingPlansService', + userId, + id, + }); if (count > 0) { await this.activityLogsService.record({ userId, @@ -52,6 +75,11 @@ export class TrainingPlansService { entityId: id, changes: null, }); + this.winstonLogger.info(`remove plan ${id} for user ${userId} success`, { + context: 'TrainingPlansService', + userId, + id, + }); } return { success: count > 0 }; } @@ -81,10 +109,96 @@ export class TrainingPlansService { } async detail(userId: string, id: string) { - const plan = await this.trainingPlanModel.findOne({ where: { id, userId } }); + const plan = await this.trainingPlanModel.findOne({ where: { id, userId, deleted: false } }); if (!plan) throw new NotFoundException('训练计划不存在'); return plan.toJSON(); } + + async update(userId: string, id: string, dto: UpdateTrainingPlanDto) { + const plan = await this.trainingPlanModel.findOne({ where: { id, userId, deleted: false } }); + if (!plan) throw new NotFoundException('训练计划不存在'); + + this.winstonLogger.info(`update plan ${id} for user ${userId}`, { + context: 'TrainingPlansService', + userId, + id, + }); + + const before = plan.toJSON(); + + if (typeof dto.name !== 'undefined') plan.name = dto.name ?? ''; + if (typeof dto.startDate !== 'undefined') plan.startDate = new Date(dto.startDate); + if (typeof dto.mode !== 'undefined') plan.mode = dto.mode; + if (typeof dto.daysOfWeek !== 'undefined') plan.daysOfWeek = dto.daysOfWeek as number[]; + if (typeof dto.sessionsPerWeek !== 'undefined') plan.sessionsPerWeek = dto.sessionsPerWeek as number; + if (typeof dto.goal !== 'undefined') plan.goal = dto.goal; + if (typeof dto.startWeightKg !== 'undefined') plan.startWeightKg = dto.startWeightKg ?? null; + if (typeof dto.preferredTimeOfDay !== 'undefined') plan.preferredTimeOfDay = dto.preferredTimeOfDay ?? ''; + + await plan.save(); + + const after = plan.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] }; + } + + await this.activityLogsService.record({ + userId, + entityType: ActivityEntityType.TRAINING_PLAN, + action: ActivityActionType.UPDATE, + entityId: id, + changes, + }); + + this.winstonLogger.info(`update plan ${id} for user ${userId} success`, { + context: 'TrainingPlansService', + userId, + id, + }); + + return after; + } + + async activate(userId: string, id: string) { + const plan = await this.trainingPlanModel.findOne({ where: { id, userId, deleted: false } }); + if (!plan) throw new NotFoundException('训练计划不存在'); + + this.winstonLogger.info(`activate plan ${id} for user ${userId}`, { + context: 'TrainingPlansService', + userId, + id, + }); + + const transaction = await this.trainingPlanModel.sequelize?.transaction(); + if (!transaction) throw new Error('Failed to start transaction'); + try { + // 用户是否有其他激活的训练计划 + const activePlan = await this.trainingPlanModel.findAll({ where: { userId, isActive: true, deleted: false }, transaction }); + if (activePlan.length > 0) { + for (const p of activePlan) { + p.isActive = false; + await p.save({ transaction }); + } + } + + plan.isActive = true; + await plan.save({ transaction }); + await transaction.commit(); + + this.winstonLogger.info(`activate plan ${id} for user ${userId} success`, { + context: 'TrainingPlansService', + userId, + id, + }); + + return plan.toJSON(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } }