Files
plates-server/src/diet-records/services/nutrition-analysis.service.ts
richarjiang 66a9e65d9b feat(diet-records): 新增营养成分分析记录删除功能
添加删除营养成分分析记录的API端点,支持软删除机制
- 新增DELETE /nutrition-analysis-records/:id接口
- 添加DeleteNutritionAnalysisRecordResponseDto响应DTO
- 在NutritionAnalysisService中实现deleteAnalysisRecord方法
- 包含完整的权限验证和错误处理逻辑
2025-10-16 16:43:42 +08:00

457 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OpenAI } from 'openai';
import { InjectModel } from '@nestjs/sequelize';
import { Op } from 'sequelize';
import { ResponseCode } from '../../base.dto';
import { NutritionAnalysisRecord } from '../models/nutrition-analysis-record.model';
/**
* 营养成分分析结果接口
*/
export interface NutritionAnalysisResult {
key: string; // 营养素的唯一标识,如 energy_kcal
name: string; // 营养素的中文名称,如"热量"
value: string; // 从图片中识别的原始值和单位,如"840千焦"
analysis: string; // 针对该营养素的详细健康建议
}
/**
* 营养成分分析响应接口
* 保持向后兼容,内部使用,外部使用 NutritionAnalysisResponseDto
*/
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,
@InjectModel(NutritionAnalysisRecord)
private readonly nutritionAnalysisRecordModel: typeof NutritionAnalysisRecord,
) {
// 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, userId?: string): Promise<NutritionAnalysisResponse> {
try {
this.logger.log(`开始分析营养成分表图片: ${imageUrl}, 用户ID: ${userId}`);
const prompt = this.buildNutritionAnalysisPrompt();
const completion = await this.makeVisionApiCall(prompt, [imageUrl]);
const rawResult = completion.choices?.[0]?.message?.content || '{"code": 1, "msg": "未获取到AI模型响应", "data": []}';
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
const result = this.parseNutritionAnalysisResult(rawResult);
// 如果提供了用户ID保存分析记录
if (userId) {
await this.saveAnalysisRecord(userId, imageUrl, result);
}
return result;
} catch (error) {
this.logger.error(`营养成分表分析失败: ${error instanceof Error ? error.message : String(error)}`);
// 如果提供了用户ID保存失败记录
if (userId) {
await this.saveAnalysisRecord(userId, imageUrl, {
success: false,
data: [],
message: '营养成分表分析失败,请稍后重试'
});
}
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格式包含code、msg和data字段
**输出格式要求:**
请严格按照以下JSON格式返回
{
"code": 0,
"msg": "分析成功",
"data": [
{
"key": "energy_kcal",
"name": "热量",
"value": "840千焦",
"analysis": "840千焦约等于201卡路里占成人每日推荐摄入总热量的10%,属于中等热量水平。"
},
{
"key": "protein",
"name": "蛋白质",
"value": "12.5g",
"analysis": "12.5克蛋白质占成人每日推荐摄入量的21%,是良好的蛋白质来源,有助于肌肉修复和生长。"
}
]
}
**失败情况格式:**
如果无法识别营养成分表或分析失败,请返回:
{
"code": 1,
"msg": "失败原因的具体描述",
"data": []
}
**营养素标识符对照表:**
- 热量/能量: 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. 如果成功识别营养成分表code设为0msg为"分析成功"
2. 如果无法识别或分析失败code设为1msg详细说明失败原因
3. 为每个识别到的营养素提供具体的健康建议
4. 建议应包含营养素的作用、摄入量参考和健康影响
5. 数值分析要准确,建议要专业且实用
6. 只返回JSON对象不要包含任何其他文本
**重要提醒:**
- 严格按照JSON对象格式返回包含code、msg和data字段
- 不要添加任何解释性文字或对话内容
- 确保JSON格式正确可以被直接解析
- 必须返回完整的JSON结构即使分析失败也要返回code和msg字段`;
}
/**
* 解析营养成分分析结果
* @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: '营养成分表解析失败,无法识别有效的营养信息'
};
}
// 检查响应格式 {code, msg, data}
if (!parsedResult || typeof parsedResult !== 'object' || !('code' in parsedResult)) {
this.logger.error(`营养成分分析结果格式不正确: ${JSON.stringify(parsedResult)}`);
return {
success: false,
data: [],
message: '营养成分表格式错误,无法识别有效的营养信息'
};
}
const { code, msg, data } = parsedResult;
// 如果大模型返回失败状态
if (code === ResponseCode.ERROR) {
this.logger.warn(`大模型分析失败: ${msg || '未知错误'}`);
return {
success: false,
data: [],
message: msg || '营养成分表分析失败'
};
}
// 检查data是否为数组
if (!Array.isArray(data)) {
this.logger.error(`营养成分分析data字段不是数组格式: ${typeof data}`);
return {
success: false,
data: [],
message: '营养成分表格式错误data字段应为数组'
};
}
// 验证和标准化每个营养素项
const nutritionData: NutritionAnalysisResult[] = [];
for (const item of data) {
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: msg || '图片中未检测到有效的营养成分表信息'
};
}
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: '营养成分表处理失败,请稍后重试'
};
}
}
/**
* 保存营养成分分析记录
* @param userId 用户ID
* @param imageUrl 图片URL
* @param result 分析结果
*/
private async saveAnalysisRecord(
userId: string,
imageUrl: string,
result: NutritionAnalysisResponse
): Promise<void> {
try {
await this.nutritionAnalysisRecordModel.create({
userId,
imageUrl,
analysisResult: result,
status: result.success ? 'success' : 'failed',
message: result.message || '',
aiProvider: this.apiProvider,
aiModel: this.visionModel,
nutritionCount: result.data.length,
});
this.logger.log(`营养成分分析记录已保存 - 用户ID: ${userId}, 成功: ${result.success}`);
} catch (error) {
this.logger.error(`保存营养成分分析记录失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 获取用户的营养成分分析记录
* @param userId 用户ID
* @param query 查询参数
* @returns 分析记录列表
*/
async getAnalysisRecords(
userId: string,
query: {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
status?: string;
}
): Promise<{
records: NutritionAnalysisRecord[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
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.status) {
where.status = query.status;
}
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.nutritionAnalysisRecordModel.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
const totalPages = Math.ceil(count / limit);
return {
records: rows,
total: count,
page,
limit,
totalPages,
};
}
/**
* 删除营养成分分析记录(软删除)
* @param userId 用户ID
* @param recordId 记录ID
* @returns 删除结果
*/
async deleteAnalysisRecord(userId: string, recordId: number): Promise<boolean> {
try {
const record = await this.nutritionAnalysisRecordModel.findOne({
where: { id: recordId, userId, deleted: false }
});
if (!record) {
this.logger.warn(`未找到要删除的营养分析记录 - 用户ID: ${userId}, 记录ID: ${recordId}`);
return false;
}
await record.update({ deleted: true });
this.logger.log(`营养分析记录已删除 - 用户ID: ${userId}, 记录ID: ${recordId}`);
return true;
} catch (error) {
this.logger.error(`删除营养分析记录失败 - 用户ID: ${userId}, 记录ID: ${recordId}, 错误: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
}
}