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 -- 说明:表由 Sequelize 同步创建t_exercise_categories / t_exercises
-- 若要幂等导入,使用 INSERT ... ON DUPLICATE KEY UPDATE -- 若要幂等导入,使用 INSERT ... ON DUPLICATE KEY UPDATE
USE `db-plates`;
START TRANSACTION; 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 { IsArray, IsDateString, IsEnum, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, ValidateIf } from 'class-validator';
import { PlanGoal, PlanMode } from '../models/training-plan.model'; import { PlanGoal, PlanMode } from '../models/training-plan.model';
@@ -48,4 +48,6 @@ export class TrainingPlanSummaryDto {
@ApiProperty() goal: string; @ApiProperty() goal: string;
} }
export class UpdateTrainingPlanDto extends PartialType(CreateTrainingPlanDto) {}

View File

@@ -15,6 +15,10 @@ export class TrainingPlan extends Model {
}) })
declare id: string; declare id: string;
@Column({ type: DataType.BOOLEAN, defaultValue: false, comment: '是否激活' })
declare isActive: boolean;
@Column({ type: DataType.STRING, allowNull: false }) @Column({ type: DataType.STRING, allowNull: false })
declare userId: string; declare userId: string;

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import { TrainingPlansService } from './training-plans.service'; 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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { AccessTokenPayload } from '../users/services/apple-auth.service';
@@ -19,6 +19,18 @@ export class TrainingPlansController {
return this.service.create(user.sub, dto); 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') @Delete(':id')
@ApiOperation({ summary: '删除训练计划' }) @ApiOperation({ summary: '删除训练计划' })
@ApiParam({ name: 'id' }) @ApiParam({ name: 'id' })
@@ -42,6 +54,13 @@ export class TrainingPlansController {
async detail(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) { async detail(@CurrentUser() user: AccessTokenPayload, @Param('id') id: string) {
return this.service.detail(user.sub, id); 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 { InjectModel } from '@nestjs/sequelize';
import { TrainingPlan } from './models/training-plan.model'; 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 { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model'; import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { Logger as WinstonLogger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
@Injectable() @Injectable()
export class TrainingPlansService { export class TrainingPlansService {
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger;
constructor( constructor(
@InjectModel(TrainingPlan) @InjectModel(TrainingPlan)
private trainingPlanModel: typeof TrainingPlan, private trainingPlanModel: typeof TrainingPlan,
@@ -16,6 +19,10 @@ export class TrainingPlansService {
async create(userId: string, dto: CreateTrainingPlanDto) { async create(userId: string, dto: CreateTrainingPlanDto) {
const id = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const id = `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const createdAt = new Date(); const createdAt = new Date();
// 检查用户是否有其他激活的训练计划
const activePlans = await this.trainingPlanModel.findAll({ where: { userId, isActive: true, deleted: false } });
const plan = await this.trainingPlanModel.create({ const plan = await this.trainingPlanModel.create({
id, id,
userId, userId,
@@ -28,6 +35,12 @@ export class TrainingPlansService {
goal: dto.goal, goal: dto.goal,
startWeightKg: dto.startWeightKg ?? null, startWeightKg: dto.startWeightKg ?? null,
preferredTimeOfDay: dto.preferredTimeOfDay ?? '', 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({ await this.activityLogsService.record({
userId, userId,
@@ -36,6 +49,11 @@ export class TrainingPlansService {
entityId: plan.id, entityId: plan.id,
changes: plan.toJSON(), changes: plan.toJSON(),
}); });
this.winstonLogger.info(`create plan ${id} for user ${userId} success`, {
context: 'TrainingPlansService',
userId,
id,
});
return plan.toJSON(); return plan.toJSON();
} }
@@ -44,6 +62,11 @@ export class TrainingPlansService {
{ deleted: true }, { deleted: true },
{ where: { id, userId, deleted: false } } { where: { id, userId, deleted: false } }
); );
this.winstonLogger.info(`remove plan ${id} for user ${userId}`, {
context: 'TrainingPlansService',
userId,
id,
});
if (count > 0) { if (count > 0) {
await this.activityLogsService.record({ await this.activityLogsService.record({
userId, userId,
@@ -52,6 +75,11 @@ export class TrainingPlansService {
entityId: id, entityId: id,
changes: null, changes: null,
}); });
this.winstonLogger.info(`remove plan ${id} for user ${userId} success`, {
context: 'TrainingPlansService',
userId,
id,
});
} }
return { success: count > 0 }; return { success: count > 0 };
} }
@@ -81,10 +109,96 @@ export class TrainingPlansService {
} }
async detail(userId: string, id: string) { 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('训练计划不存在'); if (!plan) throw new NotFoundException('训练计划不存在');
return plan.toJSON(); 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;
}
}
} }