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

@@ -1,8 +1,9 @@
# rule.md
这是一个 nodejs 基于 nestjs 框架的项目
你是一名拥有 20 年服务端开发经验的 javascript 工程师,这是一个 nodejs 基于 nestjs 框架的项目,与健康、健身、减肥相关
## 指导原则
- 不要随意新增 markdown 文档
- 代码提交 message 用中文
- 注意代码的可读性、架构实现要清晰

View File

@@ -0,0 +1,295 @@
# 营养成分表分析 API 文档
## 接口概述
本接口用于分析食物营养成分表图片通过AI大模型智能识别图片中的营养成分信息并为每个营养素提供详细的健康建议。
## 接口信息
- **接口地址**: `POST /diet-records/analyze-nutrition-image`
- **请求方式**: POST
- **内容类型**: `application/json`
- **认证方式**: Bearer Token (JWT)
## 请求参数
### Headers
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| Authorization | string | 是 | JWT认证令牌格式`Bearer {token}` |
| Content-Type | string | 是 | 固定值:`application/json` |
### Body 参数
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| imageUrl | string | 是 | 营养成分表图片的URL地址 | `https://example.com/nutrition-label.jpg` |
#### 请求示例
```json
{
"imageUrl": "https://example.com/nutrition-label.jpg"
}
```
## 响应格式
### 成功响应
```json
{
"success": true,
"data": [
{
"key": "energy_kcal",
"name": "热量",
"value": "840千焦",
"analysis": "840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。"
},
{
"key": "protein",
"name": "蛋白质",
"value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
},
{
"key": "fat",
"name": "脂肪",
"value": "6.8g",
"analysis": "6.8克脂肪含量适中,主要包含不饱和脂肪酸,有助于维持正常的生理功能。"
},
{
"key": "carbohydrate",
"name": "碳水化合物",
"value": "28.5g",
"analysis": "28.5克碳水化合物提供主要能量来源,建议搭配运动以充分利用能量。"
},
{
"key": "sodium",
"name": "钠",
"value": "480mg",
"analysis": "480毫克钠含量适中约占成人每日推荐摄入量的20%,高血压患者需注意控制总钠摄入。"
}
]
}
```
### 错误响应
```json
{
"success": false,
"data": [],
"message": "错误描述信息"
}
```
## 响应字段说明
### 通用字段
| 字段名 | 类型 | 说明 |
|--------|------|------|
| success | boolean | 操作是否成功 |
| data | array | 营养成分分析结果数组 |
| message | string | 错误信息(仅在失败时返回) |
### 营养成分项字段 (data数组中的对象)
| 字段名 | 类型 | 说明 | 示例 |
|--------|------|------|------|
| key | string | 营养素的唯一标识符 | `energy_kcal` |
| name | string | 营养素的中文名称 | `热量` |
| value | string | 从图片中识别的原始值和单位 | `840千焦` |
| analysis | string | 针对该营养素的详细健康建议 | `840千焦约等于201卡路里...` |
## 支持的营养素类型
| 营养素 | key值 | 中文名称 |
|--------|-------|----------|
| 热量/能量 | energy_kcal | 热量 |
| 蛋白质 | protein | 蛋白质 |
| 脂肪 | fat | 脂肪 |
| 碳水化合物 | carbohydrate | 碳水化合物 |
| 膳食纤维 | fiber | 膳食纤维 |
| 钠 | sodium | 钠 |
| 钙 | calcium | 钙 |
| 铁 | iron | 铁 |
| 锌 | zinc | 锌 |
| 维生素C | vitamin_c | 维生素C |
| 维生素A | vitamin_a | 维生素A |
| 维生素D | vitamin_d | 维生素D |
| 维生素E | vitamin_e | 维生素E |
| 维生素B1 | vitamin_b1 | 维生素B1 |
| 维生素B2 | vitamin_b2 | 维生素B2 |
| 维生素B6 | vitamin_b6 | 维生素B6 |
| 维生素B12 | vitamin_b12 | 维生素B12 |
| 叶酸 | folic_acid | 叶酸 |
| 胆固醇 | cholesterol | 胆固醇 |
| 饱和脂肪 | saturated_fat | 饱和脂肪 |
| 反式脂肪 | trans_fat | 反式脂肪 |
| 糖 | sugar | 糖 |
## 错误码说明
| HTTP状态码 | 错误信息 | 说明 |
|------------|----------|------|
| 400 | 请提供图片URL | 请求体中缺少imageUrl参数 |
| 400 | 图片URL格式不正确 | 提供的URL格式无效 |
| 401 | 未授权访问 | 缺少或无效的JWT令牌 |
| 500 | 营养成分表分析失败,请稍后重试 | AI模型调用失败或服务器内部错误 |
| 500 | 图片中未检测到有效的营养成分表信息 | 图片中未识别到营养成分表 |
## 使用注意事项
### 图片要求
1. **图片格式**: 支持 JPG、PNG、WebP 格式
2. **图片内容**: 必须包含清晰的营养成分表
3. **图片质量**: 建议使用高清、无模糊、光线充足的图片
4. **URL要求**: 图片URL必须是公网可访问的地址
### 最佳实践
1. **URL有效性**: 确保提供的图片URL在分析期间保持可访问
2. **图片预处理**: 建议在客户端对图片进行适当的裁剪,突出营养成分表部分
3. **错误处理**: 客户端应妥善处理各种错误情况,提供友好的用户提示
4. **重试机制**: 对于网络或服务器错误,建议实现适当的重试机制
### 限制说明
1. **调用频率**: 建议客户端控制调用频率,避免过于频繁的请求
2. **图片大小**: 虽然不直接限制图片大小,但过大的图片可能影响处理速度
3. **并发限制**: 服务端可能有并发请求限制,建议客户端实现队列机制
## 客户端集成示例
### JavaScript/TypeScript 示例
```typescript
interface NutritionAnalysisRequest {
imageUrl: string;
}
interface NutritionAnalysisItem {
key: string;
name: string;
value: string;
analysis: string;
}
interface NutritionAnalysisResponse {
success: boolean;
data: NutritionAnalysisItem[];
message?: string;
}
async function analyzeNutritionImage(
imageUrl: string,
token: string
): Promise<NutritionAnalysisResponse> {
try {
const response = await fetch('/diet-records/analyze-nutrition-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ imageUrl })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || '请求失败');
}
return result;
} catch (error) {
console.error('营养成分分析失败:', error);
throw error;
}
}
// 使用示例
const token = 'your-jwt-token';
const imageUrl = 'https://example.com/nutrition-label.jpg';
analyzeNutritionImage(imageUrl, token)
.then(result => {
if (result.success) {
console.log('识别到营养素数量:', result.data.length);
result.data.forEach(item => {
console.log(`${item.name}: ${item.value}`);
console.log(`建议: ${item.analysis}`);
});
} else {
console.error('分析失败:', result.message);
}
})
.catch(error => {
console.error('请求异常:', error);
});
```
### Swift 示例
```swift
struct NutritionAnalysisRequest: Codable {
let imageUrl: String
}
struct NutritionAnalysisItem: Codable {
let key: String
let name: String
let value: String
let analysis: String
}
struct NutritionAnalysisResponse: Codable {
let success: Bool
let data: [NutritionAnalysisItem]
let message: String?
}
class NutritionAnalysisService {
func analyzeNutritionImage(imageUrl: String, token: String) async throws -> NutritionAnalysisResponse {
guard let url = URL(string: "/diet-records/analyze-nutrition-image") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let requestBody = NutritionAnalysisRequest(imageUrl: imageUrl)
request.httpBody = try JSONEncoder().encode(requestBody)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200...299 ~= httpResponse.statusCode else {
throw NSError(domain: "APIError", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP Error"])
}
let result = try JSONDecoder().decode(NutritionAnalysisResponse.self, from: data)
return result
}
}
```
## 更新日志
| 版本 | 日期 | 更新内容 |
|------|------|----------|
| 1.0.0 | 2024-10-16 | 初始版本,支持营养成分表图片分析功能 |
## 技术支持
如有技术问题或集成困难,请联系开发团队获取支持。

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: '营养成分表处理失败,请稍后重试'
};
}
}
}