feat(diet-records): 新增营养成分分析记录功能

- 添加营养成分分析记录数据模型和数据库集成
- 实现分析记录保存功能,支持成功和失败状态记录
- 新增获取用户营养成分分析记录的API接口
- 支持按日期范围、状态等条件筛选查询
- 提供分页查询功能,优化大数据量场景性能
This commit is contained in:
richarjiang
2025-10-16 11:25:31 +08:00
parent 91cac3134e
commit 4d1bc9259b
9 changed files with 561 additions and 7 deletions

View File

@@ -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('获取营养成分分析记录失败,请稍后重试');
}
}
}

View File

@@ -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),
],

View 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;
}

View 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;
}

View File

@@ -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,
};
}
}