feat(training-plans): 添加训练计划更新和激活功能

- 在控制器中新增更新和激活训练计划的API
- 在服务中实现相应的更新和激活逻辑,支持检查用户的激活计划
- 在模型中添加isActive字段以标识训练计划的激活状态
- 更新DTO以支持训练计划的更新操作
This commit is contained in:
2025-08-14 22:00:44 +08:00
parent 4a77dc1b88
commit bef2c2d910
5 changed files with 146 additions and 5 deletions

View File

@@ -2,6 +2,8 @@
-- 说明:表由 Sequelize 同步创建t_exercise_categories / t_exercises
-- 若要幂等导入,使用 INSERT ... ON DUPLICATE KEY UPDATE
USE `db-plates`;
START TRANSACTION;
-- 分类

View File

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

View File

@@ -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;

View File

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

View File

@@ -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<string, any> = {};
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;
}
}
}