feat(diet-records): 新增营养成分分析记录功能
- 添加营养成分分析记录数据模型和数据库集成 - 实现分析记录保存功能,支持成功和失败状态记录 - 新增获取用户营养成分分析记录的API接口 - 支持按日期范围、状态等条件筛选查询 - 提供分页查询功能,优化大数据量场景性能
This commit is contained in:
@@ -19,6 +19,7 @@ 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 { NutritionAnalysisRecordsResponseDto, GetNutritionAnalysisRecordsQueryDto, NutritionAnalysisRecordDto } from './dto/nutrition-analysis-record.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';
|
||||
@@ -189,7 +190,8 @@ export class DietRecordsController {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl);
|
||||
// 传递用户ID以便保存分析记录
|
||||
const result = await this.nutritionAnalysisService.analyzeNutritionImage(requestDto.imageUrl, user.sub);
|
||||
|
||||
this.logger.log(`营养成分表分析完成 - 用户ID: ${user.sub}, 成功: ${result.success}, 营养素数量: ${result.data.length}`);
|
||||
|
||||
@@ -204,4 +206,53 @@ export class DietRecordsController {
|
||||
return NutritionAnalysisResponseDto.createError('营养成分表分析失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取营养成分分析记录
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('nutrition-analysis-records')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '获取营养成分分析记录' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
|
||||
@ApiQuery({ name: 'status', required: false, description: '分析状态' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
@ApiResponse({ status: 200, description: '成功获取营养成分分析记录', type: NutritionAnalysisRecordsResponseDto })
|
||||
async getNutritionAnalysisRecords(
|
||||
@Query() query: GetNutritionAnalysisRecordsQueryDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<NutritionAnalysisRecordsResponseDto> {
|
||||
this.logger.log(`获取营养成分分析记录 - 用户ID: ${user.sub}`);
|
||||
|
||||
try {
|
||||
const result = await this.nutritionAnalysisService.getAnalysisRecords(user.sub, query);
|
||||
|
||||
// 转换为DTO格式
|
||||
const recordDtos: NutritionAnalysisRecordDto[] = result.records.map(record => ({
|
||||
id: record.id,
|
||||
userId: record.userId,
|
||||
imageUrl: record.imageUrl,
|
||||
analysisResult: record.analysisResult,
|
||||
status: record.status || '',
|
||||
message: record.message || '',
|
||||
aiProvider: record.aiProvider || '',
|
||||
aiModel: record.aiModel || '',
|
||||
nutritionCount: record.nutritionCount || 0,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
}));
|
||||
|
||||
return NutritionAnalysisRecordsResponseDto.createSuccess(
|
||||
recordDtos,
|
||||
result.total,
|
||||
result.page,
|
||||
result.limit
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取营养成分分析记录失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return NutritionAnalysisRecordsResponseDto.createError('获取营养成分分析记录失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@ 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 { NutritionAnalysisRecord } from './models/nutrition-analysis-record.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AiCoachModule } from '../ai-coach/ai-coach.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SequelizeModule.forFeature([UserDietHistory, ActivityLog]),
|
||||
SequelizeModule.forFeature([UserDietHistory, ActivityLog, NutritionAnalysisRecord]),
|
||||
UsersModule,
|
||||
forwardRef(() => AiCoachModule),
|
||||
],
|
||||
|
||||
114
src/diet-records/dto/nutrition-analysis-record.dto.ts
Normal file
114
src/diet-records/dto/nutrition-analysis-record.dto.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiResponseDto } from '../../base.dto';
|
||||
|
||||
/**
|
||||
* 营养成分分析记录项DTO
|
||||
*/
|
||||
export class NutritionAnalysisRecordDto {
|
||||
@ApiProperty({ description: '记录ID', example: 1 })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ description: '用户ID', example: 'user123' })
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({ description: '分析图片URL', example: 'https://example.com/nutrition-label.jpg' })
|
||||
imageUrl: string;
|
||||
|
||||
@ApiProperty({ description: '营养成分分析结果' })
|
||||
analysisResult: any;
|
||||
|
||||
@ApiProperty({ description: '分析状态', example: 'success' })
|
||||
status: string;
|
||||
|
||||
@ApiProperty({ description: '分析消息', example: '分析成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: 'AI模型提供商', example: 'dashscope' })
|
||||
aiProvider: string;
|
||||
|
||||
@ApiProperty({ description: '使用的AI模型', example: 'qwen-vl-max' })
|
||||
aiModel: string;
|
||||
|
||||
@ApiProperty({ description: '识别到的营养素数量', example: 15 })
|
||||
nutritionCount: number;
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty({ description: '更新时间' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* 营养成分分析记录列表响应DTO
|
||||
*/
|
||||
export class NutritionAnalysisRecordsResponseDto extends ApiResponseDto<{
|
||||
records: NutritionAnalysisRecordDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
constructor(code: number, message: string, data: {
|
||||
records: NutritionAnalysisRecordDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}) {
|
||||
super(code, message, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应
|
||||
*/
|
||||
static createSuccess(
|
||||
records: NutritionAnalysisRecordDto[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
message: string = '获取营养分析记录成功'
|
||||
): NutritionAnalysisRecordsResponseDto {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
return new NutritionAnalysisRecordsResponseDto(0, message, {
|
||||
records,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败响应
|
||||
*/
|
||||
static createError(message: string = '获取营养分析记录失败'): NutritionAnalysisRecordsResponseDto {
|
||||
return new NutritionAnalysisRecordsResponseDto(1, message, {
|
||||
records: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询营养分析记录请求DTO
|
||||
*/
|
||||
export class GetNutritionAnalysisRecordsQueryDto {
|
||||
@ApiProperty({ description: '页码', example: 1, required: false })
|
||||
page?: number;
|
||||
|
||||
@ApiProperty({ description: '每页数量', example: 20, required: false })
|
||||
limit?: number;
|
||||
|
||||
@ApiProperty({ description: '开始日期', example: '2023-01-01', required: false })
|
||||
startDate?: string;
|
||||
|
||||
@ApiProperty({ description: '结束日期', example: '2023-12-31', required: false })
|
||||
endDate?: string;
|
||||
|
||||
@ApiProperty({ description: '分析状态', example: 'success', required: false })
|
||||
status?: string;
|
||||
}
|
||||
90
src/diet-records/models/nutrition-analysis-record.model.ts
Normal file
90
src/diet-records/models/nutrition-analysis-record.model.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||
|
||||
@Table({
|
||||
tableName: 't_nutrition_analysis_records',
|
||||
underscored: true,
|
||||
})
|
||||
export class NutritionAnalysisRecord extends Model {
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
autoIncrement: true,
|
||||
})
|
||||
declare id: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '分析图片URL',
|
||||
})
|
||||
declare imageUrl: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: false,
|
||||
comment: '营养成分分析结果',
|
||||
})
|
||||
declare analysisResult: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true,
|
||||
comment: '分析状态',
|
||||
})
|
||||
declare status: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '分析消息',
|
||||
})
|
||||
declare message: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true,
|
||||
comment: 'AI模型提供商',
|
||||
})
|
||||
declare aiProvider: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true,
|
||||
comment: '使用的AI模型',
|
||||
})
|
||||
declare aiModel: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '识别到的营养素数量',
|
||||
})
|
||||
declare nutritionCount: number | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare createdAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare updatedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: '是否已删除',
|
||||
})
|
||||
declare deleted: boolean;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 营养成分分析结果接口
|
||||
@@ -38,7 +41,11 @@ export class NutritionAnalysisService {
|
||||
private readonly visionModel: string;
|
||||
private readonly apiProvider: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
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';
|
||||
|
||||
@@ -72,9 +79,9 @@ export class NutritionAnalysisService {
|
||||
* @param imageUrl 图片URL
|
||||
* @returns 营养成分分析结果
|
||||
*/
|
||||
async analyzeNutritionImage(imageUrl: string): Promise<NutritionAnalysisResponse> {
|
||||
async analyzeNutritionImage(imageUrl: string, userId?: string): Promise<NutritionAnalysisResponse> {
|
||||
try {
|
||||
this.logger.log(`开始分析营养成分表图片: ${imageUrl}`);
|
||||
this.logger.log(`开始分析营养成分表图片: ${imageUrl}, 用户ID: ${userId}`);
|
||||
|
||||
const prompt = this.buildNutritionAnalysisPrompt();
|
||||
|
||||
@@ -83,9 +90,26 @@ export class NutritionAnalysisService {
|
||||
const rawResult = completion.choices?.[0]?.message?.content || '{"code": 1, "msg": "未获取到AI模型响应", "data": []}';
|
||||
this.logger.log(`营养成分分析原始结果: ${rawResult}`);
|
||||
|
||||
return this.parseNutritionAnalysisResult(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: [],
|
||||
@@ -318,4 +342,90 @@ export class NutritionAnalysisService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存营养成分分析记录
|
||||
* @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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user