feat: 新增体重记录接口及枚举,优化AI教练选择项处理

This commit is contained in:
richarjiang
2025-08-28 09:46:03 +08:00
parent e3cd496f33
commit 17ee96638e
6 changed files with 277 additions and 13 deletions

View File

@@ -4,6 +4,7 @@ import { User } from '../../users/models/user.model';
export enum ActivityEntityType { export enum ActivityEntityType {
USER = 'USER', USER = 'USER',
USER_PROFILE = 'USER_PROFILE', USER_PROFILE = 'USER_PROFILE',
USER_WEIGHT_HISTORY = 'USER_WEIGHT_HISTORY',
CHECKIN = 'CHECKIN', CHECKIN = 'CHECKIN',
TRAINING_PLAN = 'TRAINING_PLAN', TRAINING_PLAN = 'TRAINING_PLAN',
WORKOUT = 'WORKOUT', WORKOUT = 'WORKOUT',
@@ -37,7 +38,7 @@ export class ActivityLog extends Model {
declare user?: User; declare user?: User;
@Column({ @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, allowNull: false,
comment: '实体类型', comment: '实体类型',
}) })

View File

@@ -9,6 +9,11 @@ import { UserProfile } from '../users/models/user-profile.model';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { DietAnalysisService, DietAnalysisResult, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service'; import { DietAnalysisService, DietAnalysisResult, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service';
enum SelectChoiceId {
Diet = 'diet_confirmation',
TrendAnalysis = 'trend_analysis'
}
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师Nutrition Analyst和健身教练我拥有丰富的专业知识包括但不限于 const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师Nutrition Analyst和健身教练我拥有丰富的专业知识包括但不限于
运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。 运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。
@@ -158,13 +163,13 @@ export class AiCoachService {
userContent: string; userContent: string;
systemNotice?: string; systemNotice?: string;
imageUrls?: string[]; imageUrls?: string[];
selectedChoiceId?: string; selectedChoiceId?: SelectChoiceId;
confirmationData?: any; confirmationData?: any;
}): Promise<Readable | { type: 'structured'; data: any }> { }): Promise<Readable | { type: 'structured'; data: any }> {
try { try {
// 1. 优先处理用户选择(选择逻辑) // 1. 优先处理用户选择(选择逻辑)
if (params.selectedChoiceId) { if (params.selectedChoiceId && [SelectChoiceId.Diet, SelectChoiceId.TrendAnalysis].includes(params.selectedChoiceId)) {
return await this.handleUserChoice({ return await this.handleUserChoice({
userId: params.userId, userId: params.userId,
conversationId: params.conversationId, conversationId: params.conversationId,
@@ -203,7 +208,7 @@ export class AiCoachService {
userId: string; userId: string;
conversationId: string; conversationId: string;
userContent: string; userContent: string;
selectedChoiceId: string; selectedChoiceId: SelectChoiceId;
confirmationData?: any; confirmationData?: any;
}): Promise<Readable | { type: 'structured'; data: 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({ return await this.handleDietConfirmation({
userId: params.userId, userId: params.userId,
conversationId: params.conversationId, conversationId: params.conversationId,
@@ -535,8 +540,8 @@ export class AiCoachService {
model: this.model, model: this.model,
messages, messages,
stream: true, stream: true,
temperature: 0.7, temperature: 1,
max_tokens: 500, max_completion_tokens: 500,
}); });
const readable = new Readable({ read() { } }); const readable = new Readable({ read() { } });

View File

@@ -37,7 +37,7 @@ export class AiChatRequestDto {
@ApiProperty({ required: false, description: '用户选择的选项ID用于确认流程' }) @ApiProperty({ required: false, description: '用户选择的选项ID用于确认流程' })
@IsOptional() @IsOptional()
@IsString() @IsString()
selectedChoiceId?: string; selectedChoiceId?: any;
@ApiProperty({ required: false, description: '用户确认的数据(用于确认流程)' }) @ApiProperty({ required: false, description: '用户确认的数据(用于确认流程)' })
@IsOptional() @IsOptional()

View 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;
}

View File

@@ -30,6 +30,7 @@ import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGues
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto'; import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto'; import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.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 { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AccessTokenPayload } from './services/apple-auth.service'; import { AccessTokenPayload } from './services/apple-auth.service';
@@ -72,6 +73,40 @@ export class UsersController {
return { code: ResponseCode.SUCCESS, message: 'success', data }; 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) @UseGuards(JwtAuthGuard)

View File

@@ -216,12 +216,13 @@ export class UsersService {
await user.save(); 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({ const [profile] = await this.userProfileModel.findOrCreate({
where: { userId }, where: { userId },
defaults: { 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 (dailyStepsGoal !== undefined) { profile.dailyStepsGoal = dailyStepsGoal as any; profileChanges.dailyStepsGoal = dailyStepsGoal; }
if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; } if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; }
if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; } if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; }
@@ -278,6 +279,7 @@ export class UsersService {
message: 'success', message: 'success',
data: { data: {
...user.toJSON(), ...user.toJSON(),
...profile.toJSON(),
isNew: false, isNew: false,
} as any, } as any,
}; };
@@ -318,7 +320,156 @@ export class UsersService {
order: [['created_at', 'DESC']], order: [['created_at', 'DESC']],
limit, 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;
}
} }
/** /**