feat: 新增体重记录接口及枚举,优化AI教练选择项处理
This commit is contained in:
@@ -4,6 +4,7 @@ import { User } from '../../users/models/user.model';
|
||||
export enum ActivityEntityType {
|
||||
USER = 'USER',
|
||||
USER_PROFILE = 'USER_PROFILE',
|
||||
USER_WEIGHT_HISTORY = 'USER_WEIGHT_HISTORY',
|
||||
CHECKIN = 'CHECKIN',
|
||||
TRAINING_PLAN = 'TRAINING_PLAN',
|
||||
WORKOUT = 'WORKOUT',
|
||||
@@ -37,7 +38,7 @@ export class ActivityLog extends Model {
|
||||
declare user?: User;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('USER', 'USER_PROFILE', 'CHECKIN', 'TRAINING_PLAN'),
|
||||
type: DataType.ENUM('USER', 'USER_PROFILE', 'USER_WEIGHT_HISTORY', 'CHECKIN', 'TRAINING_PLAN', 'WORKOUT'),
|
||||
allowNull: false,
|
||||
comment: '实体类型',
|
||||
})
|
||||
|
||||
@@ -9,6 +9,11 @@ import { UserProfile } from '../users/models/user-profile.model';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { DietAnalysisService, DietAnalysisResult, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service';
|
||||
|
||||
enum SelectChoiceId {
|
||||
Diet = 'diet_confirmation',
|
||||
TrendAnalysis = 'trend_analysis'
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师(Nutrition Analyst)和健身教练,我拥有丰富的专业知识,包括但不限于:
|
||||
|
||||
运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。
|
||||
@@ -158,13 +163,13 @@ export class AiCoachService {
|
||||
userContent: string;
|
||||
systemNotice?: string;
|
||||
imageUrls?: string[];
|
||||
selectedChoiceId?: string;
|
||||
selectedChoiceId?: SelectChoiceId;
|
||||
confirmationData?: any;
|
||||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
try {
|
||||
|
||||
// 1. 优先处理用户选择(选择逻辑)
|
||||
if (params.selectedChoiceId) {
|
||||
if (params.selectedChoiceId && [SelectChoiceId.Diet, SelectChoiceId.TrendAnalysis].includes(params.selectedChoiceId)) {
|
||||
return await this.handleUserChoice({
|
||||
userId: params.userId,
|
||||
conversationId: params.conversationId,
|
||||
@@ -203,7 +208,7 @@ export class AiCoachService {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
userContent: string;
|
||||
selectedChoiceId: string;
|
||||
selectedChoiceId: SelectChoiceId;
|
||||
confirmationData?: any;
|
||||
}): Promise<Readable | { type: 'structured'; data: any }> {
|
||||
|
||||
@@ -217,7 +222,7 @@ export class AiCoachService {
|
||||
}
|
||||
|
||||
// 处理饮食确认选择
|
||||
if (params.selectedChoiceId && params.confirmationData) {
|
||||
if (params.selectedChoiceId === 'diet_confirmation' && params.confirmationData) {
|
||||
return await this.handleDietConfirmation({
|
||||
userId: params.userId,
|
||||
conversationId: params.conversationId,
|
||||
@@ -535,8 +540,8 @@ export class AiCoachService {
|
||||
model: this.model,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
max_tokens: 500,
|
||||
temperature: 1,
|
||||
max_completion_tokens: 500,
|
||||
});
|
||||
|
||||
const readable = new Readable({ read() { } });
|
||||
|
||||
@@ -37,7 +37,7 @@ export class AiChatRequestDto {
|
||||
@ApiProperty({ required: false, description: '用户选择的选项ID(用于确认流程)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selectedChoiceId?: string;
|
||||
selectedChoiceId?: any;
|
||||
|
||||
@ApiProperty({ required: false, description: '用户确认的数据(用于确认流程)' })
|
||||
@IsOptional()
|
||||
|
||||
72
src/users/dto/weight-record.dto.ts
Normal file
72
src/users/dto/weight-record.dto.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsEnum, Min, Max } from 'class-validator';
|
||||
import { ResponseCode } from 'src/base.dto';
|
||||
import { WeightUpdateSource } from '../models/user-weight-history.model';
|
||||
|
||||
/**
|
||||
* 更新体重记录请求DTO
|
||||
*/
|
||||
export class UpdateWeightRecordDto {
|
||||
@ApiProperty({
|
||||
description: '体重(kg)',
|
||||
example: 65.5,
|
||||
minimum: 20,
|
||||
maximum: 400,
|
||||
})
|
||||
@IsNumber({}, { message: '体重必须是数字' })
|
||||
@Min(20, { message: '体重不能小于20kg' })
|
||||
@Max(400, { message: '体重不能大于400kg' })
|
||||
weight: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '更新来源',
|
||||
enum: WeightUpdateSource,
|
||||
example: WeightUpdateSource.Manual,
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(WeightUpdateSource, { message: '更新来源必须是有效值' })
|
||||
source?: WeightUpdateSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 体重记录响应DTO
|
||||
*/
|
||||
export class WeightRecordResponseDto {
|
||||
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
|
||||
code: ResponseCode;
|
||||
|
||||
@ApiProperty({ description: '响应消息', example: 'success' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '体重记录数据',
|
||||
example: {
|
||||
id: 1,
|
||||
userId: 'user123',
|
||||
weight: 65.5,
|
||||
source: 'manual',
|
||||
createdAt: '2023-12-01T10:00:00.000Z',
|
||||
updatedAt: '2023-12-01T10:00:00.000Z',
|
||||
},
|
||||
})
|
||||
data: {
|
||||
id: number;
|
||||
userId: string;
|
||||
weight: number;
|
||||
source: WeightUpdateSource;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除体重记录响应DTO
|
||||
*/
|
||||
export class DeleteWeightRecordResponseDto {
|
||||
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
|
||||
code: ResponseCode;
|
||||
|
||||
@ApiProperty({ description: '响应消息', example: 'success' })
|
||||
message: string;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGues
|
||||
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
|
||||
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
|
||||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from './services/apple-auth.service';
|
||||
@@ -72,6 +73,40 @@ export class UsersController {
|
||||
return { code: ResponseCode.SUCCESS, message: 'success', data };
|
||||
}
|
||||
|
||||
// 更新体重记录
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('/weight-records/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新体重记录' })
|
||||
@ApiBody({ type: UpdateWeightRecordDto })
|
||||
@ApiResponse({ status: 200, description: '成功更新体重记录', type: WeightRecordResponseDto })
|
||||
async updateWeightRecord(
|
||||
@Param('id') recordId: string,
|
||||
@Body() updateDto: UpdateWeightRecordDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<WeightRecordResponseDto> {
|
||||
this.logger.log(`更新体重记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
|
||||
return this.usersService.updateWeightRecord(user.sub, parseInt(recordId), updateDto);
|
||||
}
|
||||
|
||||
// 删除体重记录
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('/weight-records/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '删除体重记录' })
|
||||
@ApiResponse({ status: 200, description: '成功删除体重记录', type: DeleteWeightRecordResponseDto })
|
||||
async deleteWeightRecord(
|
||||
@Param('id') recordId: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<DeleteWeightRecordResponseDto> {
|
||||
this.logger.log(`删除体重记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
|
||||
const success = await this.usersService.deleteWeightRecord(user.sub, parseInt(recordId));
|
||||
if (!success) {
|
||||
throw new NotFoundException('体重记录不存在');
|
||||
}
|
||||
return { code: ResponseCode.SUCCESS, message: 'success' };
|
||||
}
|
||||
|
||||
|
||||
// 更新用户昵称、头像
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@@ -216,12 +216,13 @@ export class UsersService {
|
||||
|
||||
await user.save();
|
||||
|
||||
// 更新或创建扩展信息
|
||||
if (dailyStepsGoal !== undefined || dailyCaloriesGoal !== undefined || pilatesPurposes !== undefined || weight !== undefined || initialWeight !== undefined || targetWeight !== undefined || height !== undefined || activityLevel !== undefined) {
|
||||
const [profile] = await this.userProfileModel.findOrCreate({
|
||||
where: { userId },
|
||||
defaults: { userId },
|
||||
});
|
||||
|
||||
// 更新或创建扩展信息
|
||||
if (dailyStepsGoal !== undefined || dailyCaloriesGoal !== undefined || pilatesPurposes !== undefined || weight !== undefined || initialWeight !== undefined || targetWeight !== undefined || height !== undefined || activityLevel !== undefined) {
|
||||
if (dailyStepsGoal !== undefined) { profile.dailyStepsGoal = dailyStepsGoal as any; profileChanges.dailyStepsGoal = dailyStepsGoal; }
|
||||
if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; }
|
||||
if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; }
|
||||
@@ -278,6 +279,7 @@ export class UsersService {
|
||||
message: 'success',
|
||||
data: {
|
||||
...user.toJSON(),
|
||||
...profile.toJSON(),
|
||||
isNew: false,
|
||||
} as any,
|
||||
};
|
||||
@@ -318,7 +320,156 @@ export class UsersService {
|
||||
order: [['created_at', 'DESC']],
|
||||
limit,
|
||||
});
|
||||
return rows.map(r => ({ weight: r.weight, source: r.source, createdAt: r.createdAt }));
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
weight: r.weight,
|
||||
source: r.source,
|
||||
createdAt: r.createdAt
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新体重记录
|
||||
*/
|
||||
async updateWeightRecord(userId: string, recordId: number, updateData: { weight: number; source?: WeightUpdateSource }) {
|
||||
const t = await this.sequelize.transaction();
|
||||
try {
|
||||
// 查找并验证体重记录是否存在且属于当前用户
|
||||
const weightRecord = await this.userWeightHistoryModel.findOne({
|
||||
where: { id: recordId, userId },
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (!weightRecord) {
|
||||
throw new NotFoundException('体重记录不存在');
|
||||
}
|
||||
|
||||
const oldWeight = weightRecord.weight;
|
||||
const oldSource = weightRecord.source;
|
||||
|
||||
// 更新体重记录
|
||||
await weightRecord.update({
|
||||
weight: updateData.weight,
|
||||
source: updateData.source || weightRecord.source,
|
||||
}, { transaction: t });
|
||||
|
||||
// 如果这是最新的体重记录,同时更新用户档案中的体重
|
||||
const latestRecord = await this.userWeightHistoryModel.findOne({
|
||||
where: { userId },
|
||||
order: [['created_at', 'DESC']],
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (latestRecord && latestRecord.id === recordId) {
|
||||
const profile = await this.userProfileModel.findOne({
|
||||
where: { userId },
|
||||
transaction: t,
|
||||
});
|
||||
if (profile) {
|
||||
profile.weight = updateData.weight;
|
||||
await profile.save({ transaction: t });
|
||||
}
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
// 记录活动日志
|
||||
await this.activityLogsService.record({
|
||||
userId,
|
||||
entityType: ActivityEntityType.USER_PROFILE,
|
||||
action: ActivityActionType.UPDATE,
|
||||
entityId: recordId.toString(),
|
||||
changes: {
|
||||
weight: { from: oldWeight, to: updateData.weight },
|
||||
...(updateData.source && updateData.source !== oldSource ? { source: { from: oldSource, to: updateData.source } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: 'success',
|
||||
data: {
|
||||
id: weightRecord.id,
|
||||
userId: weightRecord.userId,
|
||||
weight: weightRecord.weight,
|
||||
source: weightRecord.source,
|
||||
createdAt: weightRecord.createdAt,
|
||||
updatedAt: weightRecord.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
this.logger.error(`更新体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除体重记录
|
||||
*/
|
||||
async deleteWeightRecord(userId: string, recordId: number): Promise<boolean> {
|
||||
const t = await this.sequelize.transaction();
|
||||
try {
|
||||
// 查找并验证体重记录是否存在且属于当前用户
|
||||
const weightRecord = await this.userWeightHistoryModel.findOne({
|
||||
where: { id: recordId, userId },
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (!weightRecord) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recordData = {
|
||||
id: weightRecord.id,
|
||||
weight: weightRecord.weight,
|
||||
source: weightRecord.source,
|
||||
createdAt: weightRecord.createdAt,
|
||||
};
|
||||
|
||||
// 删除体重记录
|
||||
await weightRecord.destroy({ transaction: t });
|
||||
|
||||
// 如果删除的是最新记录,需要更新用户档案中的体重为倒数第二新的记录
|
||||
const latestRecord = await this.userWeightHistoryModel.findOne({
|
||||
where: { userId },
|
||||
order: [['created_at', 'DESC']],
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
const profile = await this.userProfileModel.findOne({
|
||||
where: { userId },
|
||||
transaction: t,
|
||||
});
|
||||
|
||||
if (profile) {
|
||||
if (latestRecord) {
|
||||
// 有其他体重记录,更新为最新的体重
|
||||
profile.weight = latestRecord.weight;
|
||||
} else {
|
||||
// 没有其他体重记录,清空体重字段
|
||||
profile.weight = null;
|
||||
}
|
||||
await profile.save({ transaction: t });
|
||||
}
|
||||
|
||||
await t.commit();
|
||||
|
||||
// 记录活动日志
|
||||
await this.activityLogsService.record({
|
||||
userId,
|
||||
entityType: ActivityEntityType.USER_PROFILE,
|
||||
action: ActivityActionType.DELETE,
|
||||
entityId: recordId.toString(),
|
||||
changes: recordData,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
this.logger.error(`删除体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user