feat(diet-records): 新增营养成分表图片分析功能

- 添加营养成分表图片识别API接口,支持通过AI模型分析食物营养成分
- 新增NutritionAnalysisService服务,集成GLM-4.5V和Qwen VL视觉模型
- 实现营养成分提取和健康建议生成功能
- 添加完整的API文档和TypeScript类型定义
- 支持多种营养素类型识别,包括热量、蛋白质、脂肪等20+种营养素
This commit is contained in:
richarjiang
2025-10-16 10:03:22 +08:00
parent cc83b84c80
commit 5c2c9dfae8
7 changed files with 684 additions and 3 deletions

View File

@@ -15,7 +15,10 @@ import {
} from '@nestjs/common';
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto';
import { NutritionAnalysisResponseDto } from './dto/nutrition-analysis.dto';
import { NutritionAnalysisRequestDto } from './dto/nutrition-analysis-request.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';
@@ -27,6 +30,7 @@ export class DietRecordsController {
constructor(
private readonly dietRecordsService: DietRecordsService,
private readonly nutritionAnalysisService: NutritionAnalysisService,
) { }
/**
@@ -161,4 +165,57 @@ export class DietRecordsController {
requestDto.mealType
);
}
/**
* 分析食物营养成分表图片
*/
@UseGuards(JwtAuthGuard)
@Post('analyze-nutrition-image')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '分析食物营养成分表图片' })
@ApiBody({ type: NutritionAnalysisRequestDto })
@ApiResponse({ status: 200, description: '成功分析营养成分表', type: NutritionAnalysisResponseDto })
@ApiResponse({ status: 400, description: '请求参数错误' })
@ApiResponse({ status: 401, description: '未授权访问' })
@ApiResponse({ status: 500, description: '服务器内部错误' })
async analyzeNutritionImage(
@Body() requestDto: NutritionAnalysisRequestDto,
@CurrentUser() user: AccessTokenPayload,
): Promise<NutritionAnalysisResponseDto> {
this.logger.log(`分析营养成分表 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`);
if (!requestDto.imageUrl) {
return {
success: false,
data: [],
message: '请提供图片URL'
};
}
// 验证URL格式
try {
new URL(requestDto.imageUrl);
} catch (error) {
return {
success: false,
data: [],
message: '图片URL格式不正确'
};
}
try {
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl);
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
return result;
} catch (error) {
this.logger.error(`营养成分表分析失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
};
}
}
}

View File

@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
import { DietRecordsController } from './diet-records.controller';
import { DietRecordsService } from './diet-records.service';
import { NutritionAnalysisService } from './services/nutrition-analysis.service';
import { UserDietHistory } from '../users/models/user-diet-history.model';
import { ActivityLog } from '../activity-logs/models/activity-log.model';
import { UsersModule } from '../users/users.module';
@@ -14,7 +15,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
forwardRef(() => AiCoachModule),
],
controllers: [DietRecordsController],
providers: [DietRecordsService],
exports: [DietRecordsService],
providers: [DietRecordsService, NutritionAnalysisService],
exports: [DietRecordsService, NutritionAnalysisService],
})
export class DietRecordsModule { }

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 营养成分分析请求DTO
*/
export class NutritionAnalysisRequestDto {
@ApiProperty({
description: '营养成分表图片URL',
example: 'https://example.com/nutrition-label.jpg',
required: true
})
imageUrl: string;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 营养成分分析结果项
*/
export class NutritionAnalysisItemDto {
@ApiProperty({ description: '营养素的唯一标识', example: 'energy_kcal' })
key: string;
@ApiProperty({ description: '营养素的中文名称', example: '热量' })
name: string;
@ApiProperty({ description: '从图片中识别的原始值和单位', example: '840千焦' })
value: string;
@ApiProperty({ description: '针对该营养素的详细健康建议', example: '840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。' })
analysis: string;
}
/**
* 营养成分分析响应DTO
*/
export class NutritionAnalysisResponseDto {
@ApiProperty({ description: '操作是否成功', example: true })
success: boolean;
@ApiProperty({ description: '营养成分分析结果数组', type: [NutritionAnalysisItemDto] })
data: NutritionAnalysisItemDto[];
@ApiProperty({ description: '响应消息', required: false })
message?: string;
}

View File

@@ -0,0 +1,282 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
/**
* 营养成分分析结果接口
*/
export interface NutritionAnalysisResult {
key: string; // 营养素的唯一标识,如 energy_kcal
name: string; // 营养素的中文名称,如"热量"
value: string; // 从图片中识别的原始值和单位,如"840千焦"
analysis: string; // 针对该营养素的详细健康建议
}
/**
* 营养成分分析响应接口
*/
export interface NutritionAnalysisResponse {
success: boolean;
data: NutritionAnalysisResult[];
message?: string;
}
/**
* 营养成分分析服务
* 负责处理食物营养成分表的AI分析
*
* 支持多种AI模型
* - GLM-4.5V (智谱AI) - 设置 AI_VISION_PROVIDER=glm
* - Qwen VL (阿里云DashScope) - 设置 AI_VISION_PROVIDER=dashscope (默认)
*/
@Injectable()
export class NutritionAnalysisService {
private readonly logger = new Logger(NutritionAnalysisService.name);
private readonly client: OpenAI;
private readonly visionModel: string;
private readonly apiProvider: string;
constructor(private readonly configService: ConfigService) {
// Support both GLM-4.5V and DashScope (Qwen) models
this.apiProvider = this.configService.get<string>('AI_VISION_PROVIDER') || 'dashscope';
if (this.apiProvider === 'glm') {
// GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
this.client = new OpenAI({
apiKey: glmApiKey,
baseURL: glmBaseURL,
});
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
} else {
// DashScope Configuration (default)
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 imageUrl 图片URL
* @returns 营养成分分析结果
*/
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
try {
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
const prompt = this.buildNutritionAnalysisPrompt();
const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
const rawResult = completion.choices?.[0]?.message?.content || '[]';
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
return this.parseNutritionAnalysisResult(rawResult);
} catch (error) {
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
};
}
}
/**
* 制作视觉模型API调用 - 兼容GLM-4.5V和DashScope
* @param prompt 提示文本
* @param imageUrls 图片URL数组
* @returns API响应
*/
private async makeVisionApiCall(prompt: string, imageUrls: string[]) {
const baseParams = {
model: this.visionModel,
temperature: 0.3,
response_format: { type: 'json_object' } as any,
};
// 处理图片URL
const processedImages = imageUrls.map((imageUrl) => ({
type: 'image_url',
image_url: { url: imageUrl } as any,
}));
if (this.apiProvider === 'glm') {
// GLM-4.5V format
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...processedImages,
] as any,
},
],
} as any);
} else {
// DashScope format (default)
return await this.client.chat.completions.create({
...baseParams,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
...processedImages,
] as any,
},
],
});
}
}
/**
* 构建营养成分分析提示
* @returns 提示文本
*/
private buildNutritionAnalysisPrompt(): string {
return `作为专业的营养分析师,请仔细分析这张图片中的营养成分表。
**任务要求:**
1. 识别图片中的营养成分表,提取所有可见的营养素信息
2. 为每个营养素提供详细的健康建议和分析
3. 返回严格的JSON数组格式不包含任何额外的解释或对话文本
**输出格式要求:**
请严格按照以下JSON数组格式返回每个对象包含四个字段
[
{
"key": "energy_kcal",
"name": "热量",
"value": "840千焦",
"analysis": "840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。"
},
{
"key": "protein",
"name": "蛋白质",
"value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
}
]
**营养素标识符对照表:**
- 热量/能量: energy_kcal
- 蛋白质: protein
- 脂肪: fat
- 碳水化合物: carbohydrate
- 膳食纤维: fiber
- 钠: sodium
- 钙: calcium
- 铁: iron
- 锌: zinc
- 维生素C: vitamin_c
- 维生素A: vitamin_a
- 维生素D: vitamin_d
- 维生素E: vitamin_e
- 维生素B1: vitamin_b1
- 维生素B2: vitamin_b2
- 维生素B6: vitamin_b6
- 维生素B12: vitamin_b12
- 叶酸: folic_acid
- 胆固醇: cholesterol
- 饱和脂肪: saturated_fat
- 反式脂肪: trans_fat
- 糖: sugar
- 其他营养素: other_nutrient
**分析要求:**
1. 如果图片中没有营养成分表,返回空数组 []
2. 为每个识别到的营养素提供具体的健康建议
3. 建议应包含营养素的作用、摄入量参考和健康影响
4. 数值分析要准确,建议要专业且实用
5. 只返回JSON数组不要包含任何其他文本
**重要提醒:**
- 严格按照JSON数组格式返回
- 不要添加任何解释性文字或对话内容
- 确保JSON格式正确可以被直接解析`;
}
/**
* 解析营养成分分析结果
* @param rawResult 原始结果字符串
* @returns 解析后的分析结果
*/
private parseNutritionAnalysisResult(rawResult: string): NutritionAnalysisResponse {
try {
// 尝试解析JSON
let parsedResult: any;
try {
parsedResult = JSON.parse(rawResult);
} catch (parseError) {
this.logger.error(`营养成分分析JSON解析失败: ${parseError}`);
this.logger.error(`原始结果: ${rawResult}`);
return {
success: false,
data: [],
message: '营养成分表解析失败,无法识别有效的营养信息'
};
}
// 确保结果是数组
if (!Array.isArray(parsedResult)) {
this.logger.error(`营养成分分析结果不是数组格式: ${typeof parsedResult}`);
return {
success: false,
data: [],
message: '营养成分表格式错误,无法识别有效的营养信息'
};
}
// 验证和标准化每个营养素项
const nutritionData: NutritionAnalysisResult[] = [];
for (const item of parsedResult) {
if (item && typeof item === 'object' && item.key && item.name && item.value && item.analysis) {
nutritionData.push({
key: String(item.key).trim(),
name: String(item.name).trim(),
value: String(item.value).trim(),
analysis: String(item.analysis).trim()
});
} else {
this.logger.warn(`跳过无效的营养素项: ${JSON.stringify(item)}`);
}
}
if (nutritionData.length === 0) {
return {
success: false,
data: [],
message: '图片中未检测到有效的营养成分表信息'
};
}
this.logger.log(`成功解析 ${nutritionData.length} 项营养素信息`);
return {
success: true,
data: nutritionData
};
} catch (error) {
this.logger.error(`营养成分分析结果处理失败: ${error instanceof Error ? error.message : String(error)}`);
return {
success: false,
data: [],
message: '营养成分表处理失败,请稍后重试'
};
}
}
}