新增普拉提训练系统的数据库结构和数据导入功能
- 创建普拉提分类和动作数据的SQL导入脚本,支持垫上普拉提和器械普拉提的分类管理 - 实现数据库结构迁移脚本,添加新字段以支持普拉提类型和器械名称 - 更新数据库升级总结文档,详细说明数据库结构变更和数据导入步骤 - 创建训练会话相关表,支持每日训练实例功能 - 引入训练会话管理模块,整合训练计划与实际训练会话的关系
This commit is contained in:
@@ -6,6 +6,8 @@ export enum ActivityEntityType {
|
||||
USER_PROFILE = 'USER_PROFILE',
|
||||
CHECKIN = 'CHECKIN',
|
||||
TRAINING_PLAN = 'TRAINING_PLAN',
|
||||
WORKOUT = 'WORKOUT',
|
||||
|
||||
}
|
||||
|
||||
export enum ActivityActionType {
|
||||
|
||||
@@ -24,31 +24,56 @@ export class AiCoachController {
|
||||
): Promise<StreamableFile | AiChatResponseDto | void> {
|
||||
const userId = user.sub;
|
||||
const stream = body.stream !== false; // 默认流式
|
||||
const userContent = body.messages?.[body.messages.length - 1]?.content || '';
|
||||
|
||||
// 创建或沿用会话ID,并保存用户消息
|
||||
const { conversationId } = await this.aiCoachService.createOrAppendMessages({
|
||||
userId,
|
||||
conversationId: body.conversationId,
|
||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||
userContent,
|
||||
});
|
||||
|
||||
// 智能体重识别:若疑似“记体重”且传入图片,则优先识别并更新体重
|
||||
let weightInfo: { weightKg?: number } = {};
|
||||
let weightInfo: { weightKg?: number; systemNotice?: string } = {};
|
||||
|
||||
// 体重识别逻辑优化:
|
||||
// 1. 如果有图片URL,使用原有的图片识别逻辑
|
||||
// 2. 如果没有图片URL,但文本中包含体重信息,使用新的文本识别逻辑
|
||||
try {
|
||||
weightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
|
||||
userId,
|
||||
body.imageUrl,
|
||||
body.messages?.[body.messages.length - 1]?.content,
|
||||
);
|
||||
} catch { }
|
||||
if (body.imageUrl) {
|
||||
// 原有逻辑:从图片识别体重
|
||||
const imageWeightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
|
||||
userId,
|
||||
body.imageUrl,
|
||||
userContent,
|
||||
);
|
||||
if (imageWeightInfo.weightKg) {
|
||||
weightInfo = {
|
||||
weightKg: imageWeightInfo.weightKg,
|
||||
systemNotice: `系统提示:已从图片识别体重为${imageWeightInfo.weightKg}kg,并已为你更新到个人资料。`
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 新逻辑:从文本识别体重,并获取历史对比信息
|
||||
const textWeightInfo = await this.aiCoachService.processWeightFromText(userId, userContent);
|
||||
if (textWeightInfo.weightKg && textWeightInfo.systemNotice) {
|
||||
weightInfo = {
|
||||
weightKg: textWeightInfo.weightKg,
|
||||
systemNotice: textWeightInfo.systemNotice
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 体重识别失败不影响正常对话
|
||||
console.error('体重识别失败:', error);
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
// 非流式:聚合后一次性返回文本
|
||||
const readable = await this.aiCoachService.streamChat({
|
||||
userId,
|
||||
conversationId,
|
||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined,
|
||||
userContent,
|
||||
systemNotice: weightInfo.systemNotice,
|
||||
});
|
||||
let text = '';
|
||||
for await (const chunk of readable) {
|
||||
@@ -67,13 +92,11 @@ export class AiCoachController {
|
||||
const readable = await this.aiCoachService.streamChat({
|
||||
userId,
|
||||
conversationId,
|
||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined,
|
||||
userContent,
|
||||
systemNotice: weightInfo.systemNotice,
|
||||
});
|
||||
|
||||
readable.on('data', (chunk) => {
|
||||
// 流水首段可提示体重已更新
|
||||
// 简化处理:服务端不额外注入推送段,直接靠 systemNotice
|
||||
res.write(chunk);
|
||||
});
|
||||
readable.on('end', () => {
|
||||
|
||||
@@ -306,6 +306,168 @@ export class AiCoachService {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户文本中识别体重信息
|
||||
* 支持多种格式:65kg、65公斤、65.5kg、体重65等
|
||||
*/
|
||||
private extractWeightFromText(text: string | undefined): number | null {
|
||||
if (!text) return null;
|
||||
|
||||
const t = text.toLowerCase();
|
||||
|
||||
// 匹配各种体重格式的正则表达式
|
||||
const patterns = [
|
||||
/(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
|
||||
/(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i,
|
||||
/(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)/i,
|
||||
/我(?:现在|今天)?(?:体重|重量|称重)?(?:是|为|有)?(\d+(?:\.\d+)?)/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = t.match(pattern);
|
||||
if (match) {
|
||||
const weight = parseFloat(match[1]);
|
||||
// 合理的体重范围检查 (20-400kg)
|
||||
if (weight >= 20 && weight <= 400) {
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户体重历史记录
|
||||
*/
|
||||
async getUserWeightHistory(userId: string, limit: number = 10): Promise<{
|
||||
currentWeight?: number;
|
||||
history: Array<{ weight: number; source: string; createdAt: Date }>;
|
||||
}> {
|
||||
try {
|
||||
// 获取当前体重
|
||||
const profile = await UserProfile.findOne({ where: { userId } });
|
||||
const currentWeight = profile?.weight;
|
||||
|
||||
// 获取体重历史
|
||||
const history = await this.usersService.getWeightHistory(userId, { limit });
|
||||
|
||||
return {
|
||||
currentWeight: currentWeight || undefined,
|
||||
history
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取用户体重历史失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return { history: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建体重更新的系统提示信息
|
||||
*/
|
||||
private buildWeightUpdateSystemNotice(
|
||||
newWeight: number,
|
||||
currentWeight?: number,
|
||||
history: Array<{ weight: number; source: string; createdAt: Date }> = []
|
||||
): string {
|
||||
let notice = `系统提示:已为你更新体重为${newWeight}kg。`;
|
||||
|
||||
if (currentWeight && currentWeight !== newWeight) {
|
||||
const diff = newWeight - currentWeight;
|
||||
const diffText = diff > 0 ? `增加了${diff.toFixed(1)}kg` : `减少了${Math.abs(diff).toFixed(1)}kg`;
|
||||
notice += `相比之前的${currentWeight}kg,你${diffText}。`;
|
||||
}
|
||||
|
||||
// 添加历史对比信息
|
||||
if (history.length > 0) {
|
||||
const recentWeights = history.slice(0, 3);
|
||||
if (recentWeights.length > 1) {
|
||||
const trend = this.analyzeWeightTrend(recentWeights, newWeight);
|
||||
notice += trend;
|
||||
}
|
||||
}
|
||||
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析体重趋势
|
||||
*/
|
||||
private analyzeWeightTrend(
|
||||
recentWeights: Array<{ weight: number; createdAt: Date }>,
|
||||
newWeight: number
|
||||
): string {
|
||||
if (recentWeights.length < 2) return '';
|
||||
|
||||
const weights = [newWeight, ...recentWeights.map(w => w.weight)];
|
||||
let trend = '';
|
||||
|
||||
// 计算最近几次的平均变化
|
||||
let totalChange = 0;
|
||||
for (let i = 0; i < weights.length - 1; i++) {
|
||||
totalChange += weights[i] - weights[i + 1];
|
||||
}
|
||||
const avgChange = totalChange / (weights.length - 1);
|
||||
|
||||
if (Math.abs(avgChange) < 0.5) {
|
||||
trend = '你的体重保持相对稳定,继续保持良好的生活习惯!';
|
||||
} else if (avgChange > 0) {
|
||||
trend = `最近体重呈上升趋势,建议加强运动和注意饮食控制。`;
|
||||
} else {
|
||||
trend = `最近体重呈下降趋势,很棒的进步!继续坚持健康的生活方式。`;
|
||||
}
|
||||
|
||||
return trend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理体重记录和更新(无图片版本)
|
||||
* 从用户文本中识别体重,更新记录,并返回相关信息
|
||||
*/
|
||||
async processWeightFromText(userId: string, userText?: string): Promise<{
|
||||
weightKg?: number;
|
||||
systemNotice?: string;
|
||||
shouldSkipChat?: boolean;
|
||||
}> {
|
||||
if (!userText) return {};
|
||||
|
||||
// 检查是否是体重记录意图
|
||||
if (!this.isLikelyWeightLogIntent(userText)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// 从文本中提取体重
|
||||
const extractedWeight = this.extractWeightFromText(userText);
|
||||
if (!extractedWeight) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 获取用户体重历史
|
||||
const { currentWeight, history } = await this.getUserWeightHistory(userId);
|
||||
|
||||
// 更新体重到数据库
|
||||
await this.usersService.addWeightByVision(userId, extractedWeight);
|
||||
|
||||
// 构建系统提示
|
||||
const systemNotice = this.buildWeightUpdateSystemNotice(
|
||||
extractedWeight,
|
||||
currentWeight || undefined,
|
||||
history
|
||||
);
|
||||
|
||||
return {
|
||||
weightKg: extractedWeight,
|
||||
systemNotice,
|
||||
shouldSkipChat: false // 仍然需要与AI聊天,让AI给出激励回复
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`处理文本体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ArticlesModule } from './articles/articles.module';
|
||||
import { RecommendationsModule } from './recommendations/recommendations.module';
|
||||
import { ActivityLogsModule } from './activity-logs/activity-logs.module';
|
||||
import { ExercisesModule } from './exercises/exercises.module';
|
||||
import { WorkoutsModule } from './workouts/workouts.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -29,6 +30,7 @@ import { ExercisesModule } from './exercises/exercises.module';
|
||||
RecommendationsModule,
|
||||
ActivityLogsModule,
|
||||
ExercisesModule,
|
||||
WorkoutsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
export interface ExerciseLibraryItem {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
category: string; // 中文分类名
|
||||
targetMuscleGroups: string;
|
||||
equipmentName?: string;
|
||||
beginnerReps?: number;
|
||||
beginnerSets?: number;
|
||||
breathingCycles?: number;
|
||||
holdDuration?: number;
|
||||
specialInstructions?: string;
|
||||
}
|
||||
|
||||
export interface ExerciseCategoryDto {
|
||||
key: string; // 英文 key
|
||||
name: string; // 中文名
|
||||
type: 'mat_pilates' | 'equipment_pilates';
|
||||
equipmentName?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ExerciseDto {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
categoryKey: string;
|
||||
categoryName: string;
|
||||
targetMuscleGroups: string;
|
||||
equipmentName?: string;
|
||||
beginnerReps?: number;
|
||||
beginnerSets?: number;
|
||||
breathingCycles?: number;
|
||||
holdDuration?: number;
|
||||
specialInstructions?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { ExerciseCategory } from './models/exercise-category.model';
|
||||
import { Exercise } from './models/exercise.model';
|
||||
import { ExerciseConfigResponse, ExerciseLibraryItem } from './dto/exercise.dto';
|
||||
import { ExerciseConfigResponse } from './dto/exercise.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ExercisesService {
|
||||
constructor(
|
||||
@InjectModel(ExerciseCategory) private readonly categoryModel: typeof ExerciseCategory,
|
||||
@InjectModel(Exercise) private readonly exerciseModel: typeof Exercise,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
async getConfig(): Promise<ExerciseConfigResponse> {
|
||||
const [categories, exercises] = await Promise.all([
|
||||
@@ -18,65 +18,11 @@ export class ExercisesService {
|
||||
]);
|
||||
|
||||
return {
|
||||
categories: categories.map((c) => ({ key: c.key, name: c.name, sortOrder: c.sortOrder })),
|
||||
exercises: exercises.map((e) => ({
|
||||
key: e.key,
|
||||
name: e.name,
|
||||
description: e.description,
|
||||
categoryKey: e.categoryKey,
|
||||
categoryName: e.categoryName,
|
||||
sortOrder: e.sortOrder,
|
||||
})),
|
||||
categories: categories,
|
||||
exercises,
|
||||
};
|
||||
}
|
||||
|
||||
async seedFromLibrary(library: ExerciseLibraryItem[]): Promise<void> {
|
||||
const categoryNameToKey: Record<string, string> = {};
|
||||
const uniqueCategoryNames = Array.from(new Set(library.map((i) => i.category)));
|
||||
|
||||
uniqueCategoryNames.forEach((name) => {
|
||||
const key = this.slugifyCategory(name);
|
||||
categoryNameToKey[name] = key;
|
||||
});
|
||||
|
||||
await this.categoryModel.bulkCreate(
|
||||
uniqueCategoryNames.map((name, index) => ({
|
||||
key: categoryNameToKey[name],
|
||||
name,
|
||||
sortOrder: index,
|
||||
})),
|
||||
{ ignoreDuplicates: true },
|
||||
);
|
||||
|
||||
await this.exerciseModel.bulkCreate(
|
||||
library.map((item, index) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
categoryKey: categoryNameToKey[item.category],
|
||||
categoryName: item.category,
|
||||
sortOrder: index,
|
||||
})),
|
||||
{ ignoreDuplicates: true },
|
||||
);
|
||||
}
|
||||
|
||||
private slugifyCategory(name: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'核心与腹部': 'core',
|
||||
'脊柱与后链': 'spine_posterior_chain',
|
||||
'侧链与髋': 'lateral_hip',
|
||||
'平衡与支撑': 'balance_support',
|
||||
'进阶控制': 'advanced_control',
|
||||
'柔韧与拉伸': 'mobility_stretch',
|
||||
};
|
||||
return mapping[name] || name
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,20 @@ export class ExerciseCategory extends Model {
|
||||
})
|
||||
declare name: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('mat_pilates', 'equipment_pilates'),
|
||||
allowNull: false,
|
||||
comment: '普拉提类型:垫上普拉提或器械普拉提',
|
||||
})
|
||||
declare type: 'mat_pilates' | 'equipment_pilates';
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true,
|
||||
comment: '器械名称(仅器械普拉提需要)',
|
||||
})
|
||||
declare equipmentName: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
|
||||
@@ -13,15 +13,36 @@ export class Exercise extends Model {
|
||||
})
|
||||
declare key: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: false, comment: '名称(含中英文)' })
|
||||
@Column({ type: DataType.STRING, allowNull: false, comment: '动作名称' })
|
||||
declare name: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: false, comment: '中文分类名(冗余,便于展示)' })
|
||||
declare categoryName: string;
|
||||
|
||||
@Column({ type: DataType.TEXT, allowNull: false, comment: '描述' })
|
||||
@Column({ type: DataType.TEXT, allowNull: true, comment: '动作描述' })
|
||||
declare description: string;
|
||||
|
||||
@Column({ type: DataType.TEXT, allowNull: false, comment: '主要锻炼肌肉群' })
|
||||
declare targetMuscleGroups: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true, comment: '器械名称(器械普拉提专用)' })
|
||||
declare equipmentName: string;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: true, comment: '入门级别建议练习次数' })
|
||||
declare beginnerReps: number;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: true, comment: '入门级别建议组数' })
|
||||
declare beginnerSets: number;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: true, comment: '呼吸循环次数(替代普通次数)' })
|
||||
declare breathingCycles: number;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: true, comment: '保持时间(秒)' })
|
||||
declare holdDuration: number;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true, comment: '特殊说明(如每侧、前后各等)' })
|
||||
declare specialInstructions: string;
|
||||
|
||||
@ForeignKey(() => ExerciseCategory)
|
||||
@Column({ type: DataType.STRING, allowNull: false, comment: '分类键' })
|
||||
declare categoryKey: string;
|
||||
|
||||
@@ -17,7 +17,7 @@ export class ScheduleExercise extends Model {
|
||||
declare id: string;
|
||||
|
||||
@ForeignKey(() => TrainingPlan)
|
||||
@Column({ type: DataType.STRING, allowNull: false })
|
||||
@Column({ type: DataType.UUID, allowNull: false })
|
||||
declare trainingPlanId: string;
|
||||
|
||||
@BelongsTo(() => TrainingPlan)
|
||||
|
||||
@@ -6,8 +6,7 @@ import { Exercise } from '../exercises/models/exercise.model';
|
||||
import {
|
||||
CreateScheduleExerciseDto,
|
||||
UpdateScheduleExerciseDto,
|
||||
UpdateScheduleExerciseOrderDto,
|
||||
CompleteScheduleExerciseDto
|
||||
UpdateScheduleExerciseOrderDto
|
||||
} from './dto/schedule-exercise.dto';
|
||||
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||||
@@ -299,60 +298,7 @@ export class ScheduleExerciseService {
|
||||
}
|
||||
}
|
||||
|
||||
// 标记完成状态
|
||||
async markComplete(userId: string, trainingPlanId: string, exerciseId: string, dto: CompleteScheduleExerciseDto) {
|
||||
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||
|
||||
const exercise = await this.scheduleExerciseModel.findOne({
|
||||
where: { id: exerciseId, trainingPlanId, userId, deleted: false }
|
||||
});
|
||||
|
||||
if (!exercise) {
|
||||
throw new NotFoundException('训练项目不存在');
|
||||
}
|
||||
|
||||
const before = exercise.completed;
|
||||
exercise.completed = dto.completed;
|
||||
await exercise.save();
|
||||
|
||||
await this.activityLogsService.record({
|
||||
userId,
|
||||
entityType: ActivityEntityType.TRAINING_PLAN,
|
||||
action: ActivityActionType.UPDATE,
|
||||
entityId: exerciseId,
|
||||
changes: {
|
||||
completed: { before, after: dto.completed }
|
||||
},
|
||||
});
|
||||
|
||||
this.winstonLogger.info(`标记训练项目完成状态 ${exerciseId}: ${dto.completed}`, {
|
||||
context: 'ScheduleExerciseService',
|
||||
userId,
|
||||
trainingPlanId,
|
||||
exerciseId,
|
||||
completed: dto.completed,
|
||||
});
|
||||
|
||||
return exercise.toJSON();
|
||||
}
|
||||
|
||||
// 获取训练计划的完成统计
|
||||
async getCompletionStats(userId: string, trainingPlanId: string) {
|
||||
await this.validateTrainingPlan(userId, trainingPlanId);
|
||||
|
||||
const [total, completed] = await Promise.all([
|
||||
this.scheduleExerciseModel.count({
|
||||
where: { trainingPlanId, userId, deleted: false, itemType: 'exercise' }
|
||||
}),
|
||||
this.scheduleExerciseModel.count({
|
||||
where: { trainingPlanId, userId, deleted: false, itemType: 'exercise', completed: true }
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
percentage: total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
};
|
||||
}
|
||||
// 注意:训练计划是模板,不应该有完成状态
|
||||
// 训练完成状态应该在 WorkoutSession 和 WorkoutExercise 中管理
|
||||
// 如需标记完成状态,请使用 WorkoutsService
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CreateScheduleExerciseDto,
|
||||
UpdateScheduleExerciseDto,
|
||||
UpdateScheduleExerciseOrderDto,
|
||||
CompleteScheduleExerciseDto,
|
||||
ScheduleExerciseResponseDto
|
||||
} from './dto/schedule-exercise.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
@@ -153,29 +152,9 @@ export class TrainingPlansController {
|
||||
return this.scheduleExerciseService.updateOrder(user.sub, trainingPlanId, dto);
|
||||
}
|
||||
|
||||
@Put(':id/exercises/:exerciseId/complete')
|
||||
@ApiOperation({ summary: '标记训练项目完成状态' })
|
||||
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||
@ApiParam({ name: 'exerciseId', description: '训练项目ID' })
|
||||
@ApiBody({ type: CompleteScheduleExerciseDto })
|
||||
async markExerciseComplete(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Param('id') trainingPlanId: string,
|
||||
@Param('exerciseId') exerciseId: string,
|
||||
@Body() dto: CompleteScheduleExerciseDto,
|
||||
) {
|
||||
return this.scheduleExerciseService.markComplete(user.sub, trainingPlanId, exerciseId, dto);
|
||||
}
|
||||
|
||||
@Get(':id/exercises/stats/completion')
|
||||
@ApiOperation({ summary: '获取训练计划完成统计' })
|
||||
@ApiParam({ name: 'id', description: '训练计划ID' })
|
||||
async getExerciseCompletionStats(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Param('id') trainingPlanId: string,
|
||||
) {
|
||||
return this.scheduleExerciseService.getCompletionStats(user.sub, trainingPlanId);
|
||||
}
|
||||
// 注意:训练计划是模板,不应该有完成状态
|
||||
// 实际的训练完成状态应该在 WorkoutSession 中管理
|
||||
// 如需完成训练,请使用 /workouts/sessions 相关接口
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export class UserProfile extends Model {
|
||||
declare weight: number | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
type: DataType.FLOAT,
|
||||
allowNull: true,
|
||||
comment: '身高(厘米)',
|
||||
})
|
||||
|
||||
@@ -170,7 +170,7 @@ export class UsersService {
|
||||
if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; }
|
||||
if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; }
|
||||
if (weight !== undefined) {
|
||||
profile.weight = weight as any;
|
||||
profile.weight = weight;
|
||||
try {
|
||||
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Manual });
|
||||
} catch (e) {
|
||||
@@ -178,7 +178,10 @@ export class UsersService {
|
||||
}
|
||||
profileChanges.weight = weight;
|
||||
}
|
||||
if (height !== undefined) { profile.height = height as any; profileChanges.height = height; }
|
||||
if (height !== undefined) {
|
||||
profile.height = height;
|
||||
profileChanges.height = height;
|
||||
}
|
||||
await profile.save();
|
||||
}
|
||||
|
||||
@@ -216,7 +219,7 @@ export class UsersService {
|
||||
const t = await this.sequelize.transaction();
|
||||
try {
|
||||
const [profile] = await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId }, transaction: t });
|
||||
profile.weight = weight as any;
|
||||
profile.weight = weight;
|
||||
await profile.save({ transaction: t });
|
||||
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Vision }, { transaction: t });
|
||||
await t.commit();
|
||||
|
||||
170
src/workouts/dto/workout-exercise.dto.ts
Normal file
170
src/workouts/dto/workout-exercise.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
63
src/workouts/dto/workout-session.dto.ts
Normal file
63
src/workouts/dto/workout-session.dto.ts
Normal 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[];
|
||||
}
|
||||
115
src/workouts/models/workout-exercise.model.ts
Normal file
115
src/workouts/models/workout-exercise.model.ts
Normal 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;
|
||||
}
|
||||
79
src/workouts/models/workout-session.model.ts
Normal file
79
src/workouts/models/workout-session.model.ts
Normal 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;
|
||||
}
|
||||
192
src/workouts/workouts.controller.ts
Normal file
192
src/workouts/workouts.controller.ts
Normal 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}天`,
|
||||
};
|
||||
}
|
||||
}
|
||||
29
src/workouts/workouts.module.ts
Normal file
29
src/workouts/workouts.module.ts
Normal 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 { }
|
||||
591
src/workouts/workouts.service.ts
Normal file
591
src/workouts/workouts.service.ts
Normal 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
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user