添加删除营养成分分析记录的API端点,支持软删除机制 - 新增DELETE /nutrition-analysis-records/:id接口 - 添加DeleteNutritionAnalysisRecordResponseDto响应DTO - 在NutritionAnalysisService中实现deleteAnalysisRecord方法 - 包含完整的权限验证和错误处理逻辑
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
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设为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<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;
|
||
}
|
||
}
|
||
} |