feat: 新增体重记录接口及枚举,优化AI教练选择项处理
This commit is contained in:
@@ -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: '实体类型',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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() { } });
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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 { 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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user