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('AI_VISION_PROVIDER') || 'dashscope'; if (this.apiProvider === 'glm') { // GLM-4.5V Configuration const glmApiKey = this.configService.get('GLM_API_KEY'); const glmBaseURL = this.configService.get('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4'; this.client = new OpenAI({ apiKey: glmApiKey, baseURL: glmBaseURL, }); this.visionModel = this.configService.get('GLM_VISION_MODEL') || 'glm-4v-plus'; } else { // DashScope Configuration (default) const dashScopeApiKey = this.configService.get('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143'; const baseURL = this.configService.get('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; this.client = new OpenAI({ apiKey: dashScopeApiKey, baseURL, }); this.visionModel = this.configService.get('DASHSCOPE_VISION_MODEL') || 'qwen-vl-max'; } } /** * 分析食物营养成分表图片 * @param imageUrl 图片URL * @returns 营养成分分析结果 */ async analyzeNutritionImage(imageUrl: string, userId?: string): Promise { 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设为0,msg为"分析成功" 2. 如果无法识别或分析失败,code设为1,msg详细说明失败原因 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 { 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 { 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; } } }