feat(training-plans): 添加训练计划更新和激活功能
- 在控制器中新增更新和激活训练计划的API - 在服务中实现相应的更新和激活逻辑,支持检查用户的激活计划 - 在模型中添加isActive字段以标识训练计划的激活状态 - 更新DTO以支持训练计划的更新操作
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
-- 说明:表由 Sequelize 同步创建(t_exercise_categories / t_exercises)
|
||||
-- 若要幂等导入,使用 INSERT ... ON DUPLICATE KEY UPDATE
|
||||
|
||||
USE `db-plates`;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 分类
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user