feat: 新增饮食记录和分析功能

- 创建饮食记录相关的数据库模型、DTO和API接口,支持用户手动添加和AI视觉识别记录饮食。
- 实现饮食分析服务,提供营养分析和健康建议,优化AI教练服务以集成饮食分析功能。
- 更新用户控制器,添加饮食记录的增删查改接口,增强用户饮食管理体验。
- 提供详细的API使用指南和数据库创建脚本,确保功能的完整性和可用性。
This commit is contained in:
richarjiang
2025-08-18 16:27:01 +08:00
parent 3d36ee90f0
commit 485ba1f67c
19 changed files with 2031 additions and 52 deletions

View File

@@ -3,6 +3,7 @@ import { SequelizeModule } from '@nestjs/sequelize';
import { ConfigModule } from '@nestjs/config';
import { AiCoachController } from './ai-coach.controller';
import { AiCoachService } from './ai-coach.service';
import { DietAnalysisService } from './services/diet-analysis.service';
import { AiMessage } from './models/ai-message.model';
import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model';
@@ -15,7 +16,7 @@ import { UsersModule } from '../users/users.module';
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
],
controllers: [AiCoachController],
providers: [AiCoachService],
providers: [AiCoachService, DietAnalysisService],
})
export class AiCoachModule { }

View File

@@ -7,6 +7,7 @@ import { AiConversation } from './models/ai-conversation.model';
import { PostureAssessment } from './models/posture-assessment.model';
import { UserProfile } from '../users/models/user-profile.model';
import { UsersService } from '../users/users.service';
import { DietAnalysisService, DietAnalysisResult } from './services/diet-analysis.service';
const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师Nutrition Analyst和健身教练我拥有丰富的专业知识包括但不限于
@@ -86,6 +87,8 @@ interface CommandResult {
cleanText: string;
}
@Injectable()
export class AiCoachService {
private readonly logger = new Logger(AiCoachService.name);
@@ -93,7 +96,11 @@ export class AiCoachService {
private readonly model: string;
private readonly visionModel: string;
constructor(private readonly configService: ConfigService, private readonly usersService: UsersService) {
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
private readonly dietAnalysisService: DietAnalysisService,
) {
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
@@ -195,6 +202,8 @@ export class AiCoachService {
}
}
async streamChat(params: {
userId: string;
conversationId: string;
@@ -226,15 +235,33 @@ export class AiCoachService {
messages.unshift({ role: 'system', content: weightContext });
}
} else if (commandResult.command === 'diet') {
// 使用视觉模型分析饮食图片
// 使用饮食分析服务处理图片
if (params.imageUrls) {
const dietAnalysis = await this.analyzeDietImage(params.imageUrls);
const dietAnalysisResult = await this.dietAnalysisService.analyzeDietImageEnhanced(params.imageUrls);
// 如果AI确定应该记录饮食则自动添加到数据库
const createDto = await this.dietAnalysisService.processDietRecord(
params.userId,
dietAnalysisResult,
params.imageUrls[0]
);
if (createDto) {
// 构建用户的最近饮食上下文用于营养分析
const nutritionContext = await this.dietAnalysisService.buildUserNutritionContext(params.userId);
if (nutritionContext) {
messages.unshift({ role: 'system', content: nutritionContext });
}
params.systemNotice = `系统提示:已成功为您记录了${createDto.foodName}的饮食信息(${createDto.portionDescription || ''},约${createDto.estimatedCalories || 0}卡路里)。`;
}
messages.push({
role: 'user',
content: `用户通过拍照记录饮食,图片分析结果如下\n${dietAnalysis}`
content: `用户通过拍照记录饮食,AI分析结果:\n${dietAnalysisResult.analysisText}`
});
messages.unshift({ role: 'system', content: this.dietAnalysisService.buildEnhancedDietAnalysisPrompt() });
}
messages.unshift({ role: 'system', content: this.buildDietAnalysisPrompt() });
}
// else if (this.isLikelyNutritionTopic(params.userContent, messages)) {
@@ -386,8 +413,10 @@ export class AiCoachService {
};
}
/**
* 构建饮食分析提示
* 构建饮食分析提示(保留原有方法用于兼容)
* @returns 饮食分析提示文本
*/
private buildDietAnalysisPrompt(): string {
@@ -413,53 +442,8 @@ export class AiCoachService {
请以结构化、清晰的方式输出结果,使用亲切专业的语言风格。`;
}
/**
* 分析饮食图片
* @param imageUrl 图片URL
* @returns 饮食分析结果
*/
private async analyzeDietImage(imageUrls: string[]): Promise<string> {
try {
const prompt = `请分析这张食物图片,识别其中的食物种类、分量,并提供以下信息:
1. 食物识别:
- 主要食材名称
- 烹饪方式
- 食物类型(主食、蛋白质、蔬菜、水果等)
2. 分量估算:
- 每种食物的大致重量或体积
- 使用常见单位描述100g、1碗、2片等
3. 营养分析:
- 估算总热量kcal
- 三大营养素含量(蛋白质、碳水化合物、脂肪)
- 主要维生素和矿物质
请以结构化、清晰的方式输出结果。`;
const completion = await this.client.chat.completions.create({
model: this.visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...imageUrls.map((imageUrl) => ({ type: 'image_url', image_url: { url: imageUrl } as any })),
] as any,
},
],
temperature: 1,
});
this.logger.log(`diet image analysis result: ${completion.choices?.[0]?.message?.content}`);
return completion.choices?.[0]?.message?.content || '无法分析图片中的食物';
} catch (error) {
this.logger.error(`饮食图片分析失败: ${error instanceof Error ? error.message : String(error)}`);
return '饮食图片分析失败';
}
}
private deriveTitleIfEmpty(assistantReply: string): string | null {
if (!assistantReply) return null;

View File

@@ -0,0 +1,422 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
import { UsersService } from '../../users/users.service';
import { CreateDietRecordDto } from '../../users/dto/diet-record.dto';
import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model';
/**
* 饮食分析结果接口
*/
export interface DietAnalysisResult {
shouldRecord: boolean;
confidence: number;
extractedData?: {
foodName: string;
mealType: MealType;
portionDescription?: string;
estimatedCalories?: number;
proteinGrams?: number;
carbohydrateGrams?: number;
fatGrams?: number;
fiberGrams?: number;
nutritionDetails?: any;
};
analysisText: string;
}
/**
* 饮食分析服务
* 负责处理饮食相关的AI分析、营养评估和上下文构建
*/
@Injectable()
export class DietAnalysisService {
private readonly logger = new Logger(DietAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
this.client = new OpenAI({
apiKey: dashScopeApiKey,
baseURL,
});
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max';
}
/**
* 增强版饮食图片分析 - 返回结构化数据
* @param imageUrls 图片URL数组
* @returns 结构化的饮食分析结果
*/
async analyzeDietImageEnhanced(imageUrls: string[]): Promise<DietAnalysisResult> {
try {
const currentHour = new Date().getHours();
const suggestedMealType = this.getSuggestedMealType(currentHour);
const prompt = this.buildDietAnalysisPrompt(suggestedMealType);
const completion = await this.client.chat.completions.create({
model: this.visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...imageUrls.map((imageUrl) => ({ type: 'image_url', image_url: { url: imageUrl } as any })),
] as any,
},
],
temperature: 0.3,
response_format: { type: 'json_object' } as any,
});
const rawResult = completion.choices?.[0]?.message?.content || '{}';
this.logger.log(`Enhanced diet analysis result: ${rawResult}`);
return this.parseAndValidateResult(rawResult, suggestedMealType);
} catch (error) {
this.logger.error(`增强版饮食图片分析失败: ${error instanceof Error ? error.message : String(error)}`);
return {
shouldRecord: false,
confidence: 0,
analysisText: '饮食图片分析失败,请稍后重试'
};
}
}
/**
* 处理饮食记录并添加到数据库
* @param userId 用户ID
* @param analysisResult 分析结果
* @param imageUrl 图片URL
* @returns 饮食记录响应
*/
async processDietRecord(userId: string, analysisResult: DietAnalysisResult, imageUrl: string): Promise<CreateDietRecordDto | null> {
if (!analysisResult.shouldRecord || !analysisResult.extractedData) {
return null;
}
try {
const createDto: CreateDietRecordDto = {
mealType: analysisResult.extractedData.mealType,
foodName: analysisResult.extractedData.foodName,
portionDescription: analysisResult.extractedData.portionDescription,
estimatedCalories: analysisResult.extractedData.estimatedCalories,
proteinGrams: analysisResult.extractedData.proteinGrams,
carbohydrateGrams: analysisResult.extractedData.carbohydrateGrams,
fatGrams: analysisResult.extractedData.fatGrams,
fiberGrams: analysisResult.extractedData.fiberGrams,
source: DietRecordSource.Vision,
imageUrl: imageUrl,
aiAnalysisResult: analysisResult,
};
await this.usersService.addDietRecord(userId, createDto);
return createDto;
} catch (error) {
this.logger.error(`自动添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* 构建包含用户营养信息的系统提示
* @param userId 用户ID
* @returns 营养上下文字符串
*/
async buildUserNutritionContext(userId: string): Promise<string> {
try {
// 获取最近10顿饮食记录
const recentDietHistory = await this.usersService.getDietHistory(userId, { limit: 10 });
if (recentDietHistory.total === 0) {
return '\n\n=== 用户营养信息 ===\n这是用户的第一次饮食记录请给予鼓励并介绍饮食记录的价值。\n';
}
let context = '\n\n=== 用户最近饮食记录分析 ===\n';
// 获取营养汇总
const nutritionSummary = await this.usersService.getRecentNutritionSummary(userId, 10);
context += this.buildNutritionSummaryText(nutritionSummary);
context += this.buildMealDistributionText(recentDietHistory.records);
context += this.buildRecentMealsText(recentDietHistory.records);
context += this.buildNutritionTrendText(nutritionSummary);
context += `\n请基于用户的饮食记录历史提供个性化的营养分析和健康建议。`;
return context;
} catch (error) {
this.logger.error(`构建用户营养上下文失败: ${error instanceof Error ? error.message : String(error)}`);
return '';
}
}
/**
* 构建增强版饮食分析提示
* @returns 增强版饮食分析提示文本
*/
buildEnhancedDietAnalysisPrompt(): string {
return `增强版饮食分析专家模式:
你是一位资深的营养分析师专门负责处理用户的饮食记录和营养分析。用户已通过AI视觉识别记录了饮食信息你需要
1. 综合分析:
- 结合用户最近的饮食记录趋势
- 评估当前这餐在整体饮食结构中的作用
- 分析营养素搭配的合理性
2. 个性化建议:
- 基于用户的历史饮食记录给出针对性建议
- 考虑营养平衡、热量控制、健康目标等因素
- 提供具体可执行的改善方案
3. 健康指导:
- 如果发现营养不均衡,给出调整建议
- 推荐搭配食物或下一餐的建议
- 强调长期健康饮食习惯的重要性
请以温暖、专业、实用的语言风格回复,让用户感受到个性化的关怀和专业的指导。`;
}
/**
* 根据时间推断餐次类型
* @param currentHour 当前小时
* @returns 建议的餐次类型
*/
private getSuggestedMealType(currentHour: number): MealType {
if (currentHour >= 6 && currentHour < 10) {
return MealType.Breakfast;
} else if (currentHour >= 11 && currentHour < 15) {
return MealType.Lunch;
} else if (currentHour >= 17 && currentHour < 21) {
return MealType.Dinner;
} else {
return MealType.Snack;
}
}
/**
* 构建饮食分析提示
* @param suggestedMealType 建议的餐次类型
* @returns 提示文本
*/
private buildDietAnalysisPrompt(suggestedMealType: MealType): string {
return `作为专业营养分析师请分析这张食物图片并以严格JSON格式返回结果。
当前时间建议餐次:${suggestedMealType}
请返回以下格式的JSON不要包含其他文本
{
"shouldRecord": boolean, // 是否应该记录如果图片清晰且包含食物则为true
"confidence": number, // 识别置信度 0-100
"extractedData": {
"foodName": string, // 主要食物名称(简洁)
"mealType": "${suggestedMealType}", // 餐次类型,优先使用建议值
"portionDescription": string, // 份量描述(如"1碗"、"200g"等)
"estimatedCalories": number, // 估算总热量
"proteinGrams": number, // 蛋白质含量(克)
"carbohydrateGrams": number, // 碳水化合物含量(克)
"fatGrams": number, // 脂肪含量(克)
"fiberGrams": number, // 膳食纤维含量(克)
"nutritionDetails": { // 其他营养信息
"mainIngredients": string[], // 主要食材列表
"cookingMethod": string, // 烹饪方式
"foodCategories": string[] // 食物分类(如"主食"、"蛋白质"等)
}
},
"analysisText": string // 详细的文字分析说明
}
重要提示:
1. 如果图片模糊、无食物或无法识别设置shouldRecord为false
2. 营养数据要基于识别的食物种类和分量合理估算
3. foodName要简洁明了便于记录和查找
4. analysisText要详细说明识别的食物和营养分析`;
}
/**
* 解析和验证分析结果
* @param rawResult 原始结果字符串
* @param suggestedMealType 建议的餐次类型
* @returns 验证后的分析结果
*/
private parseAndValidateResult(rawResult: string, suggestedMealType: MealType): DietAnalysisResult {
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
} catch (parseError) {
this.logger.error(`JSON解析失败: ${parseError}`);
return {
shouldRecord: false,
confidence: 0,
analysisText: '图片分析失败:无法解析分析结果'
};
}
// 验证和标准化结果
const result: DietAnalysisResult = {
shouldRecord: parsedResult.shouldRecord || false,
confidence: Math.min(100, Math.max(0, parsedResult.confidence || 0)),
analysisText: parsedResult.analysisText || '未提供分析说明'
};
if (result.shouldRecord && parsedResult.extractedData) {
const data = parsedResult.extractedData;
result.extractedData = {
foodName: data.foodName || '未知食物',
mealType: this.validateMealType(data.mealType) || suggestedMealType,
portionDescription: data.portionDescription,
estimatedCalories: this.validateNumber(data.estimatedCalories, 0, 2000),
proteinGrams: this.validateNumber(data.proteinGrams, 0, 200),
carbohydrateGrams: this.validateNumber(data.carbohydrateGrams, 0, 500),
fatGrams: this.validateNumber(data.fatGrams, 0, 200),
fiberGrams: this.validateNumber(data.fiberGrams, 0, 50),
nutritionDetails: data.nutritionDetails
};
}
return result;
}
/**
* 构建营养汇总文本
* @param nutritionSummary 营养汇总数据
* @returns 汇总文本
*/
private buildNutritionSummaryText(nutritionSummary: any): string {
let text = `最近${nutritionSummary.recordCount}顿饮食汇总:\n`;
text += `- 总热量:${nutritionSummary.totalCalories.toFixed(0)}卡路里\n`;
text += `- 蛋白质:${nutritionSummary.totalProtein.toFixed(1)}g\n`;
text += `- 碳水化合物:${nutritionSummary.totalCarbohydrates.toFixed(1)}g\n`;
text += `- 脂肪:${nutritionSummary.totalFat.toFixed(1)}g\n`;
if (nutritionSummary.totalFiber > 0) {
text += `- 膳食纤维:${nutritionSummary.totalFiber.toFixed(1)}g\n`;
}
return text;
}
/**
* 构建餐次分布文本
* @param records 饮食记录
* @returns 分布文本
*/
private buildMealDistributionText(records: any[]): string {
const mealTypeCount: Record<string, number> = {};
records.forEach(record => {
mealTypeCount[record.mealType] = (mealTypeCount[record.mealType] || 0) + 1;
});
if (Object.keys(mealTypeCount).length === 0) {
return '';
}
let text = `\n餐次分布`;
Object.entries(mealTypeCount).forEach(([mealType, count]) => {
const mealTypeName = this.getMealTypeName(mealType);
text += ` ${mealTypeName}${count}`;
});
text += `\n`;
return text;
}
/**
* 构建最近饮食详情文本
* @param records 饮食记录
* @returns 详情文本
*/
private buildRecentMealsText(records: any[]): string {
if (records.length === 0) {
return '';
}
let text = `\n最近饮食记录\n`;
const recentMeals = records.slice(0, 3);
recentMeals.forEach((record, index) => {
const date = new Date(record.createdAt).toLocaleDateString('zh-CN');
const time = new Date(record.createdAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
const mealTypeName = this.getMealTypeName(record.mealType);
text += `${index + 1}. ${date} ${time} ${mealTypeName}${record.foodName}`;
if (record.estimatedCalories) {
text += ` (约${record.estimatedCalories}卡路里)`;
}
text += `\n`;
});
return text;
}
/**
* 构建营养趋势分析文本
* @param nutritionSummary 营养汇总数据
* @returns 趋势分析文本
*/
private buildNutritionTrendText(nutritionSummary: any): string {
const avgCaloriesPerMeal = nutritionSummary.totalCalories / nutritionSummary.recordCount;
const avgProteinPerMeal = nutritionSummary.totalProtein / nutritionSummary.recordCount;
let text = `\n营养趋势分析\n`;
if (avgCaloriesPerMeal < 300) {
text += `- 平均每餐热量偏低(${avgCaloriesPerMeal.toFixed(0)}卡路里),建议增加营养密度\n`;
} else if (avgCaloriesPerMeal > 800) {
text += `- 平均每餐热量较高(${avgCaloriesPerMeal.toFixed(0)}卡路里),建议注意控制分量\n`;
} else {
text += `- 平均每餐热量适中(${avgCaloriesPerMeal.toFixed(0)}卡路里)\n`;
}
if (avgProteinPerMeal < 15) {
text += `- 蛋白质摄入偏低,建议增加优质蛋白质食物\n`;
} else {
text += `- 蛋白质摄入良好\n`;
}
return text;
}
/**
* 获取餐次类型的中文名称
* @param mealType 餐次类型
* @returns 中文名称
*/
private getMealTypeName(mealType: string): string {
const mealTypeNames: Record<string, string> = {
[MealType.Breakfast]: '早餐',
[MealType.Lunch]: '午餐',
[MealType.Dinner]: '晚餐',
[MealType.Snack]: '加餐',
[MealType.Other]: '其他'
};
return mealTypeNames[mealType] || mealType;
}
/**
* 验证餐次类型
* @param mealType 餐次类型字符串
* @returns 验证后的餐次类型或null
*/
private validateMealType(mealType: string): MealType | null {
const validTypes = Object.values(MealType);
return validTypes.includes(mealType as MealType) ? (mealType as MealType) : null;
}
/**
* 验证数字范围
* @param value 值
* @param min 最小值
* @param max 最大值
* @returns 验证后的数字或undefined
*/
private validateNumber(value: any, min: number, max: number): number | undefined {
const num = parseFloat(value);
if (isNaN(num)) return undefined;
return Math.max(min, Math.min(max, num));
}
}

View File

@@ -0,0 +1,376 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, MaxLength, Min, Max, IsDateString } from 'class-validator';
import { MealType, DietRecordSource } from '../models/user-diet-history.model';
export class CreateDietRecordDto {
@ApiProperty({ enum: MealType, description: '餐次类型' })
@IsEnum(MealType)
mealType: MealType;
@ApiProperty({ description: '食物名称' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
foodName: string;
@ApiProperty({ description: '食物描述(详细信息)', required: false })
@IsOptional()
@IsString()
@MaxLength(500)
foodDescription?: string;
@ApiProperty({ description: '食物重量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(10000)
weightGrams?: number;
@ApiProperty({ description: '份量描述1碗、2片、100g等', required: false })
@IsOptional()
@IsString()
@MaxLength(50)
portionDescription?: string;
@ApiProperty({ description: '估算总热量(卡路里)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(10000)
estimatedCalories?: number;
@ApiProperty({ description: '蛋白质含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
proteinGrams?: number;
@ApiProperty({ description: '碳水化合物含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
carbohydrateGrams?: number;
@ApiProperty({ description: '脂肪含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
fatGrams?: number;
@ApiProperty({ description: '膳食纤维含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
fiberGrams?: number;
@ApiProperty({ description: '糖分含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
sugarGrams?: number;
@ApiProperty({ description: '钠含量(毫克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(10000)
sodiumMg?: number;
@ApiProperty({ description: '其他营养信息', required: false })
@IsOptional()
additionalNutrition?: Record<string, any>;
@ApiProperty({ enum: DietRecordSource, description: '记录来源', required: false })
@IsOptional()
@IsEnum(DietRecordSource)
source?: DietRecordSource;
@ApiProperty({ description: '用餐时间', required: false })
@IsOptional()
@IsDateString()
mealTime?: string;
@ApiProperty({ description: '食物图片URL', required: false })
@IsOptional()
@IsString()
imageUrl?: string;
@ApiProperty({ description: 'AI识别原始结果', required: false })
@IsOptional()
aiAnalysisResult?: Record<string, any>;
@ApiProperty({ description: '用户备注', required: false })
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
export class UpdateDietRecordDto {
@ApiProperty({ enum: MealType, description: '餐次类型', required: false })
@IsOptional()
@IsEnum(MealType)
mealType?: MealType;
@ApiProperty({ description: '食物名称', required: false })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
foodName?: string;
@ApiProperty({ description: '食物描述(详细信息)', required: false })
@IsOptional()
@IsString()
@MaxLength(500)
foodDescription?: string;
@ApiProperty({ description: '食物重量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(10000)
weightGrams?: number;
@ApiProperty({ description: '份量描述1碗、2片、100g等', required: false })
@IsOptional()
@IsString()
@MaxLength(50)
portionDescription?: string;
@ApiProperty({ description: '估算总热量(卡路里)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(10000)
estimatedCalories?: number;
@ApiProperty({ description: '蛋白质含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
proteinGrams?: number;
@ApiProperty({ description: '碳水化合物含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
carbohydrateGrams?: number;
@ApiProperty({ description: '脂肪含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
fatGrams?: number;
@ApiProperty({ description: '膳食纤维含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
fiberGrams?: number;
@ApiProperty({ description: '糖分含量(克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(1000)
sugarGrams?: number;
@ApiProperty({ description: '钠含量(毫克)', required: false })
@IsOptional()
@IsNumber()
@Min(0)
@Max(10000)
sodiumMg?: number;
@ApiProperty({ description: '其他营养信息', required: false })
@IsOptional()
additionalNutrition?: Record<string, any>;
@ApiProperty({ description: '用餐时间', required: false })
@IsOptional()
@IsDateString()
mealTime?: string;
@ApiProperty({ description: '食物图片URL', required: false })
@IsOptional()
@IsString()
imageUrl?: string;
@ApiProperty({ description: 'AI识别原始结果', required: false })
@IsOptional()
aiAnalysisResult?: Record<string, any>;
@ApiProperty({ description: '用户备注', required: false })
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
export class DietRecordResponseDto {
@ApiProperty()
id: number;
@ApiProperty({ enum: MealType })
mealType: MealType;
@ApiProperty()
foodName: string;
@ApiProperty({ required: false })
foodDescription?: string;
@ApiProperty({ required: false })
weightGrams?: number;
@ApiProperty({ required: false })
portionDescription?: string;
@ApiProperty({ required: false })
estimatedCalories?: number;
@ApiProperty({ required: false })
proteinGrams?: number;
@ApiProperty({ required: false })
carbohydrateGrams?: number;
@ApiProperty({ required: false })
fatGrams?: number;
@ApiProperty({ required: false })
fiberGrams?: number;
@ApiProperty({ required: false })
sugarGrams?: number;
@ApiProperty({ required: false })
sodiumMg?: number;
@ApiProperty({ required: false })
additionalNutrition?: Record<string, any>;
@ApiProperty({ enum: DietRecordSource })
source: DietRecordSource;
@ApiProperty({ required: false })
mealTime?: Date;
@ApiProperty({ required: false })
imageUrl?: string;
@ApiProperty({ required: false })
notes?: string;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
}
export class GetDietHistoryQueryDto {
@ApiProperty({ description: '开始日期', required: false })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiProperty({ description: '结束日期', required: false })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiProperty({ description: '餐次类型过滤', enum: MealType, required: false })
@IsOptional()
@IsEnum(MealType)
mealType?: MealType;
@ApiProperty({ description: '每页数量', required: false, default: 20 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
limit?: number;
@ApiProperty({ description: '页码', required: false, default: 1 })
@IsOptional()
@IsNumber()
@Min(1)
page?: number;
}
export class DietHistoryResponseDto {
@ApiProperty({ type: [DietRecordResponseDto] })
records: DietRecordResponseDto[];
@ApiProperty()
total: number;
@ApiProperty()
page: number;
@ApiProperty()
limit: number;
@ApiProperty()
totalPages: number;
}
export class NutritionSummaryDto {
@ApiProperty({ description: '总热量' })
totalCalories: number;
@ApiProperty({ description: '总蛋白质(克)' })
totalProtein: number;
@ApiProperty({ description: '总碳水化合物(克)' })
totalCarbohydrates: number;
@ApiProperty({ description: '总脂肪(克)' })
totalFat: number;
@ApiProperty({ description: '总膳食纤维(克)' })
totalFiber: number;
@ApiProperty({ description: '总糖分(克)' })
totalSugar: number;
@ApiProperty({ description: '总钠含量(毫克)' })
totalSodium: number;
@ApiProperty({ description: '记录条数' })
recordCount: number;
@ApiProperty({ description: '日期范围' })
dateRange: {
start: Date;
end: Date;
};
}
export class DietAnalysisResponseDto {
@ApiProperty({ type: NutritionSummaryDto })
nutritionSummary: NutritionSummaryDto;
@ApiProperty({ type: [DietRecordResponseDto] })
recentRecords: DietRecordResponseDto[];
@ApiProperty({ description: 'AI健康分析建议' })
healthAnalysis: string;
@ApiProperty({ description: '营养均衡评分 0-100' })
nutritionScore: number;
@ApiProperty({ description: '改善建议' })
recommendations: string[];
}

View File

@@ -0,0 +1,183 @@
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript';
export enum DietRecordSource {
Manual = 'manual',
Vision = 'vision',
Other = 'other',
}
export enum MealType {
Breakfast = 'breakfast',
Lunch = 'lunch',
Dinner = 'dinner',
Snack = 'snack',
Other = 'other',
}
@Table({
tableName: 't_user_diet_history',
underscored: true,
})
export class UserDietHistory extends Model {
@PrimaryKey
@Column({
type: DataType.BIGINT,
autoIncrement: true,
})
declare id: number;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '用户ID',
})
declare userId: string;
@Column({
type: DataType.ENUM('breakfast', 'lunch', 'dinner', 'snack', 'other'),
allowNull: false,
defaultValue: 'other',
comment: '餐次类型',
})
declare mealType: MealType;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '食物名称',
})
declare foodName: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物描述(详细信息)',
})
declare foodDescription: string | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '食物重量(克)',
})
declare weightGrams: number | null;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '份量描述1碗、2片、100g等',
})
declare portionDescription: string | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '估算总热量(卡路里)',
})
declare estimatedCalories: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '蛋白质含量(克)',
})
declare proteinGrams: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '碳水化合物含量(克)',
})
declare carbohydrateGrams: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '脂肪含量(克)',
})
declare fatGrams: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '膳食纤维含量(克)',
})
declare fiberGrams: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '糖分含量(克)',
})
declare sugarGrams: number | null;
@Column({
type: DataType.FLOAT,
allowNull: true,
comment: '钠含量(毫克)',
})
declare sodiumMg: number | null;
@Column({
type: DataType.JSON,
allowNull: true,
comment: '其他营养信息(维生素、矿物质等)',
})
declare additionalNutrition: Record<string, any> | null;
@Column({
type: DataType.ENUM('manual', 'vision', 'other'),
allowNull: false,
defaultValue: 'manual',
comment: '记录来源',
})
declare source: DietRecordSource;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '用餐时间',
})
declare mealTime: Date | null;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物图片URL',
})
declare imageUrl: string | null;
@Column({
type: DataType.JSON,
allowNull: true,
comment: 'AI识别原始结果',
})
declare aiAnalysisResult: Record<string, any> | null;
@Column({
type: DataType.TEXT,
allowNull: true,
comment: '用户备注',
})
declare notes: string | null;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare createdAt: Date;
@Column({
type: DataType.DATE,
defaultValue: DataType.NOW,
})
declare updatedAt: Date;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否已删除',
})
declare deleted: boolean;
}

View File

@@ -7,10 +7,13 @@ import {
HttpCode,
HttpStatus,
Put,
Delete,
Query,
Logger,
UseGuards,
Inject,
Req,
NotFoundException,
} from '@nestjs/common';
import { Request } from 'express';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
@@ -22,6 +25,7 @@ import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/s
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, DietAnalysisResponseDto } from './dto/diet-record.dto';
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
@@ -235,4 +239,177 @@ export class UsersController {
return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent);
}
// ==================== 饮食记录相关接口 ====================
/**
* 添加饮食记录
*/
@UseGuards(JwtAuthGuard)
@Post('diet-records')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '添加饮食记录' })
@ApiBody({ type: CreateDietRecordDto })
@ApiResponse({ status: 201, description: '成功添加饮食记录', type: DietRecordResponseDto })
async addDietRecord(
@Body() createDto: CreateDietRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<DietRecordResponseDto> {
this.logger.log(`添加饮食记录 - 用户ID: ${user.sub}, 食物: ${createDto.foodName}`);
return this.usersService.addDietRecord(user.sub, createDto);
}
/**
* 获取饮食记录历史
*/
@UseGuards(JwtAuthGuard)
@Get('diet-records')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取饮食记录历史' })
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
@ApiQuery({ name: 'mealType', required: false, description: '餐次类型' })
@ApiQuery({ name: 'page', required: false, description: '页码' })
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
@ApiResponse({ status: 200, description: '成功获取饮食记录', type: DietHistoryResponseDto })
async getDietHistory(
@Query() query: GetDietHistoryQueryDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<DietHistoryResponseDto> {
this.logger.log(`获取饮食记录 - 用户ID: ${user.sub}`);
return this.usersService.getDietHistory(user.sub, query);
}
/**
* 更新饮食记录
*/
@UseGuards(JwtAuthGuard)
@Put('diet-records/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新饮食记录' })
@ApiBody({ type: UpdateDietRecordDto })
@ApiResponse({ status: 200, description: '成功更新饮食记录', type: DietRecordResponseDto })
async updateDietRecord(
@Param('id') recordId: string,
@Body() updateDto: UpdateDietRecordDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<DietRecordResponseDto> {
this.logger.log(`更新饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
return this.usersService.updateDietRecord(user.sub, parseInt(recordId), updateDto);
}
/**
* 删除饮食记录
*/
@UseGuards(JwtAuthGuard)
@Delete('diet-records/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '删除饮食记录' })
@ApiResponse({ status: 204, description: '成功删除饮食记录' })
async deleteDietRecord(
@Param('id') recordId: string,
@CurrentUser() user: AccessTokenPayload,
): Promise<void> {
this.logger.log(`删除饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
const success = await this.usersService.deleteDietRecord(user.sub, parseInt(recordId));
if (!success) {
throw new NotFoundException('饮食记录不存在');
}
}
/**
* 获取营养汇总分析
*/
@UseGuards(JwtAuthGuard)
@Get('nutrition-summary')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '获取最近饮食的营养汇总分析' })
@ApiQuery({ name: 'mealCount', required: false, description: '分析最近几顿饮食默认10顿' })
@ApiResponse({ status: 200, description: '成功获取营养汇总', type: DietAnalysisResponseDto })
async getNutritionSummary(
@Query('mealCount') mealCount: string = '10',
@CurrentUser() user: AccessTokenPayload,
): Promise<DietAnalysisResponseDto> {
this.logger.log(`获取营养汇总 - 用户ID: ${user.sub}, 分析${mealCount}顿饮食`);
const count = Math.min(20, Math.max(1, parseInt(mealCount) || 10));
const nutritionSummary = await this.usersService.getRecentNutritionSummary(user.sub, count);
// 获取最近的饮食记录用于分析
const recentRecords = await this.usersService.getDietHistory(user.sub, { limit: count });
// 简单的营养评分算法(可以后续优化)
const nutritionScore = this.calculateNutritionScore(nutritionSummary);
// 生成基础建议后续可以接入AI分析
const recommendations = this.generateBasicRecommendations(nutritionSummary);
return {
nutritionSummary,
recentRecords: recentRecords.records,
healthAnalysis: '基于您最近的饮食记录,我将为您提供个性化的营养分析和健康建议。',
nutritionScore,
recommendations,
};
}
/**
* 简单的营养评分算法
*/
private calculateNutritionScore(summary: any): number {
let score = 50; // 基础分数
// 基于热量是否合理调整分数
const dailyCalories = summary.totalCalories / (summary.recordCount / 3); // 假设一天3餐
if (dailyCalories >= 1500 && dailyCalories <= 2500) score += 20;
else if (dailyCalories < 1200 || dailyCalories > 3000) score -= 20;
// 基于蛋白质摄入调整分数
const dailyProtein = summary.totalProtein / (summary.recordCount / 3);
if (dailyProtein >= 50 && dailyProtein <= 150) score += 15;
else if (dailyProtein < 30) score -= 15;
// 基于膳食纤维调整分数
const dailyFiber = summary.totalFiber / (summary.recordCount / 3);
if (dailyFiber >= 25) score += 15;
else if (dailyFiber < 10) score -= 10;
return Math.max(0, Math.min(100, score));
}
/**
* 生成基础营养建议
*/
private generateBasicRecommendations(summary: any): string[] {
const recommendations: string[] = [];
const dailyCalories = summary.totalCalories / (summary.recordCount / 3);
const dailyProtein = summary.totalProtein / (summary.recordCount / 3);
const dailyFiber = summary.totalFiber / (summary.recordCount / 3);
const dailySodium = summary.totalSodium / (summary.recordCount / 3);
if (dailyCalories < 1200) {
recommendations.push('您的日均热量摄入偏低,建议适当增加营养密度高的食物。');
} else if (dailyCalories > 2500) {
recommendations.push('您的日均热量摄入偏高建议控制portion size或选择低热量食物。');
}
if (dailyProtein < 50) {
recommendations.push('建议增加优质蛋白质摄入,如鸡胸肉、鱼类、豆制品等。');
}
if (dailyFiber < 25) {
recommendations.push('建议增加膳食纤维摄入,多吃蔬菜、水果和全谷物。');
}
if (dailySodium > 2000) {
recommendations.push('钠摄入偏高,建议减少加工食品和调味料的使用。');
}
if (recommendations.length === 0) {
recommendations.push('您的饮食结构相对均衡,继续保持良好的饮食习惯!');
}
return recommendations;
}
}

View File

@@ -5,6 +5,7 @@ import { UsersService } from "./users.service";
import { User } from "./models/user.model";
import { UserProfile } from "./models/user-profile.model";
import { UserWeightHistory } from "./models/user-weight-history.model";
import { UserDietHistory } from "./models/user-diet-history.model";
import { ApplePurchaseService } from "./services/apple-purchase.service";
import { EncryptionService } from "../common/encryption.service";
import { AppleAuthService } from "./services/apple-auth.service";
@@ -26,6 +27,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module';
RevenueCatEvent,
UserProfile,
UserWeightHistory,
UserDietHistory,
]),
forwardRef(() => ActivityLogsModule),
JwtModule.register({

View File

@@ -31,8 +31,10 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, A
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model';
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from './dto/diet-record.dto';
const DEFAULT_FREE_USAGE_COUNT = 10;
@@ -57,6 +59,8 @@ export class UsersService {
private userProfileModel: typeof UserProfile,
@InjectModel(UserWeightHistory)
private userWeightHistoryModel: typeof UserWeightHistory,
@InjectModel(UserDietHistory)
private userDietHistoryModel: typeof UserDietHistory,
@InjectConnection()
private sequelize: Sequelize,
private readonly activityLogsService: ActivityLogsService,
@@ -253,6 +257,275 @@ export class UsersService {
return rows.map(r => ({ weight: r.weight, source: r.source, createdAt: r.createdAt }));
}
/**
* 添加饮食记录
*/
async addDietRecord(userId: string, createDto: CreateDietRecordDto): Promise<DietRecordResponseDto> {
const t = await this.sequelize.transaction();
try {
const dietRecord = await this.userDietHistoryModel.create({
userId,
mealType: createDto.mealType,
foodName: createDto.foodName,
foodDescription: createDto.foodDescription || null,
weightGrams: createDto.weightGrams || null,
portionDescription: createDto.portionDescription || null,
estimatedCalories: createDto.estimatedCalories || null,
proteinGrams: createDto.proteinGrams || null,
carbohydrateGrams: createDto.carbohydrateGrams || null,
fatGrams: createDto.fatGrams || null,
fiberGrams: createDto.fiberGrams || null,
sugarGrams: createDto.sugarGrams || null,
sodiumMg: createDto.sodiumMg || null,
additionalNutrition: createDto.additionalNutrition || null,
source: createDto.source || DietRecordSource.Manual,
mealTime: createDto.mealTime ? new Date(createDto.mealTime) : null,
imageUrl: createDto.imageUrl || null,
aiAnalysisResult: createDto.aiAnalysisResult || null,
notes: createDto.notes || null,
deleted: false,
}, { transaction: t });
await t.commit();
// 记录活动日志
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.USER_PROFILE,
entityId: userId,
action: ActivityActionType.UPDATE,
changes: { diet_record_added: dietRecord.id },
metadata: {
source: createDto.source || 'manual',
mealType: createDto.mealType,
foodName: createDto.foodName
},
});
return this.mapDietRecordToDto(dietRecord);
} catch (e) {
await t.rollback();
this.logger.error(`addDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 通过视觉识别添加饮食记录
*/
async addDietRecordByVision(userId: string, dietData: CreateDietRecordDto): Promise<DietRecordResponseDto> {
return this.addDietRecord(userId, {
...dietData,
source: DietRecordSource.Vision
});
}
/**
* 获取饮食记录历史
*/
async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise<DietHistoryResponseDto> {
const where: any = { userId, deleted: false };
// 日期过滤
if (query.startDate || query.endDate) {
where.createdAt = {} as any;
if (query.startDate) where.createdAt[Op.gte] = new Date(query.startDate);
if (query.endDate) where.createdAt[Op.lte] = new Date(query.endDate);
}
// 餐次类型过滤
if (query.mealType) {
where.mealType = query.mealType;
}
const limit = Math.min(100, Math.max(1, query.limit || 20));
const page = Math.max(1, query.page || 1);
const offset = (page - 1) * limit;
const { rows, count } = await this.userDietHistoryModel.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
const totalPages = Math.ceil(count / limit);
return {
records: rows.map(record => this.mapDietRecordToDto(record)),
total: count,
page,
limit,
totalPages,
};
}
/**
* 更新饮食记录
*/
async updateDietRecord(userId: string, recordId: number, updateDto: UpdateDietRecordDto): Promise<DietRecordResponseDto> {
const t = await this.sequelize.transaction();
try {
const record = await this.userDietHistoryModel.findOne({
where: { id: recordId, userId, deleted: false },
transaction: t,
});
if (!record) {
throw new NotFoundException('饮食记录不存在');
}
// 更新字段
if (updateDto.mealType !== undefined) record.mealType = updateDto.mealType;
if (updateDto.foodName !== undefined) record.foodName = updateDto.foodName;
if (updateDto.foodDescription !== undefined) record.foodDescription = updateDto.foodDescription;
if (updateDto.weightGrams !== undefined) record.weightGrams = updateDto.weightGrams;
if (updateDto.portionDescription !== undefined) record.portionDescription = updateDto.portionDescription;
if (updateDto.estimatedCalories !== undefined) record.estimatedCalories = updateDto.estimatedCalories;
if (updateDto.proteinGrams !== undefined) record.proteinGrams = updateDto.proteinGrams;
if (updateDto.carbohydrateGrams !== undefined) record.carbohydrateGrams = updateDto.carbohydrateGrams;
if (updateDto.fatGrams !== undefined) record.fatGrams = updateDto.fatGrams;
if (updateDto.fiberGrams !== undefined) record.fiberGrams = updateDto.fiberGrams;
if (updateDto.sugarGrams !== undefined) record.sugarGrams = updateDto.sugarGrams;
if (updateDto.sodiumMg !== undefined) record.sodiumMg = updateDto.sodiumMg;
if (updateDto.additionalNutrition !== undefined) record.additionalNutrition = updateDto.additionalNutrition;
if (updateDto.mealTime !== undefined) record.mealTime = updateDto.mealTime ? new Date(updateDto.mealTime) : null;
if (updateDto.imageUrl !== undefined) record.imageUrl = updateDto.imageUrl;
if (updateDto.aiAnalysisResult !== undefined) record.aiAnalysisResult = updateDto.aiAnalysisResult;
if (updateDto.notes !== undefined) record.notes = updateDto.notes;
await record.save({ transaction: t });
await t.commit();
// 记录活动日志
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.USER_PROFILE,
entityId: userId,
action: ActivityActionType.UPDATE,
changes: { diet_record_updated: recordId },
metadata: { updateDto },
});
return this.mapDietRecordToDto(record);
} catch (e) {
await t.rollback();
this.logger.error(`updateDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 删除饮食记录
*/
async deleteDietRecord(userId: string, recordId: number): Promise<boolean> {
const t = await this.sequelize.transaction();
try {
const record = await this.userDietHistoryModel.findOne({
where: { id: recordId, userId, deleted: false },
transaction: t,
});
if (!record) {
return false;
}
record.deleted = true;
await record.save({ transaction: t });
await t.commit();
// 记录活动日志
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.USER_PROFILE,
entityId: userId,
action: ActivityActionType.DELETE,
changes: { diet_record_deleted: recordId },
metadata: { foodName: record.foodName, mealType: record.mealType },
});
return true;
} catch (e) {
await t.rollback();
this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 获取最近N顿饮食的营养汇总
*/
async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise<NutritionSummaryDto> {
const records = await this.userDietHistoryModel.findAll({
where: { userId, deleted: false },
order: [['created_at', 'DESC']],
limit: mealCount,
});
if (records.length === 0) {
throw new NotFoundException('暂无饮食记录');
}
const summary = records.reduce((acc, record) => {
acc.totalCalories += record.estimatedCalories || 0;
acc.totalProtein += record.proteinGrams || 0;
acc.totalCarbohydrates += record.carbohydrateGrams || 0;
acc.totalFat += record.fatGrams || 0;
acc.totalFiber += record.fiberGrams || 0;
acc.totalSugar += record.sugarGrams || 0;
acc.totalSodium += record.sodiumMg || 0;
return acc;
}, {
totalCalories: 0,
totalProtein: 0,
totalCarbohydrates: 0,
totalFat: 0,
totalFiber: 0,
totalSugar: 0,
totalSodium: 0,
});
const oldestRecord = records[records.length - 1];
const newestRecord = records[0];
return {
...summary,
recordCount: records.length,
dateRange: {
start: oldestRecord.createdAt,
end: newestRecord.createdAt,
},
};
}
/**
* 将数据库模型转换为DTO
*/
private mapDietRecordToDto(record: UserDietHistory): DietRecordResponseDto {
return {
id: record.id,
mealType: record.mealType,
foodName: record.foodName,
foodDescription: record.foodDescription || undefined,
weightGrams: record.weightGrams || undefined,
portionDescription: record.portionDescription || undefined,
estimatedCalories: record.estimatedCalories || undefined,
proteinGrams: record.proteinGrams || undefined,
carbohydrateGrams: record.carbohydrateGrams || undefined,
fatGrams: record.fatGrams || undefined,
fiberGrams: record.fiberGrams || undefined,
sugarGrams: record.sugarGrams || undefined,
sodiumMg: record.sodiumMg || undefined,
additionalNutrition: record.additionalNutrition || undefined,
source: record.source,
mealTime: record.mealTime || undefined,
imageUrl: record.imageUrl || undefined,
notes: record.notes || undefined,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
/**
* Apple 登录
*/