feat: 新增饮食记录和分析功能

- 创建饮食记录相关的数据库模型、DTO和API接口,支持用户手动添加和AI视觉识别记录饮食。
- 实现饮食分析服务,提供营养分析和健康建议,优化AI教练服务以集成饮食分析功能。
- 更新用户控制器,添加饮食记录的增删查改接口,增强用户饮食管理体验。
- 提供详细的API使用指南和数据库创建脚本,确保功能的完整性和可用性。
This commit is contained in:
richarjiang
2025-08-18 16:27:01 +08:00
parent 3d36ee90f0
commit 485ba1f67c
19 changed files with 2031 additions and 52 deletions

View File

@@ -31,8 +31,10 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, A
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model';
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from './dto/diet-record.dto';
const DEFAULT_FREE_USAGE_COUNT = 10;
@@ -57,6 +59,8 @@ export class UsersService {
private userProfileModel: typeof UserProfile,
@InjectModel(UserWeightHistory)
private userWeightHistoryModel: typeof UserWeightHistory,
@InjectModel(UserDietHistory)
private userDietHistoryModel: typeof UserDietHistory,
@InjectConnection()
private sequelize: Sequelize,
private readonly activityLogsService: ActivityLogsService,
@@ -253,6 +257,275 @@ export class UsersService {
return rows.map(r => ({ weight: r.weight, source: r.source, createdAt: r.createdAt }));
}
/**
* 添加饮食记录
*/
async addDietRecord(userId: string, createDto: CreateDietRecordDto): Promise<DietRecordResponseDto> {
const t = await this.sequelize.transaction();
try {
const dietRecord = await this.userDietHistoryModel.create({
userId,
mealType: createDto.mealType,
foodName: createDto.foodName,
foodDescription: createDto.foodDescription || null,
weightGrams: createDto.weightGrams || null,
portionDescription: createDto.portionDescription || null,
estimatedCalories: createDto.estimatedCalories || null,
proteinGrams: createDto.proteinGrams || null,
carbohydrateGrams: createDto.carbohydrateGrams || null,
fatGrams: createDto.fatGrams || null,
fiberGrams: createDto.fiberGrams || null,
sugarGrams: createDto.sugarGrams || null,
sodiumMg: createDto.sodiumMg || null,
additionalNutrition: createDto.additionalNutrition || null,
source: createDto.source || DietRecordSource.Manual,
mealTime: createDto.mealTime ? new Date(createDto.mealTime) : null,
imageUrl: createDto.imageUrl || null,
aiAnalysisResult: createDto.aiAnalysisResult || null,
notes: createDto.notes || null,
deleted: false,
}, { transaction: t });
await t.commit();
// 记录活动日志
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.USER_PROFILE,
entityId: userId,
action: ActivityActionType.UPDATE,
changes: { diet_record_added: dietRecord.id },
metadata: {
source: createDto.source || 'manual',
mealType: createDto.mealType,
foodName: createDto.foodName
},
});
return this.mapDietRecordToDto(dietRecord);
} catch (e) {
await t.rollback();
this.logger.error(`addDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 通过视觉识别添加饮食记录
*/
async addDietRecordByVision(userId: string, dietData: CreateDietRecordDto): Promise<DietRecordResponseDto> {
return this.addDietRecord(userId, {
...dietData,
source: DietRecordSource.Vision
});
}
/**
* 获取饮食记录历史
*/
async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise<DietHistoryResponseDto> {
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.mealType) {
where.mealType = query.mealType;
}
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.userDietHistoryModel.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
const totalPages = Math.ceil(count / limit);
return {
records: rows.map(record => this.mapDietRecordToDto(record)),
total: count,
page,
limit,
totalPages,
};
}
/**
* 更新饮食记录
*/
async updateDietRecord(userId: string, recordId: number, updateDto: UpdateDietRecordDto): Promise<DietRecordResponseDto> {
const t = await this.sequelize.transaction();
try {
const record = await this.userDietHistoryModel.findOne({
where: { id: recordId, userId, deleted: false },
transaction: t,
});
if (!record) {
throw new NotFoundException('饮食记录不存在');
}
// 更新字段
if (updateDto.mealType !== undefined) record.mealType = updateDto.mealType;
if (updateDto.foodName !== undefined) record.foodName = updateDto.foodName;
if (updateDto.foodDescription !== undefined) record.foodDescription = updateDto.foodDescription;
if (updateDto.weightGrams !== undefined) record.weightGrams = updateDto.weightGrams;
if (updateDto.portionDescription !== undefined) record.portionDescription = updateDto.portionDescription;
if (updateDto.estimatedCalories !== undefined) record.estimatedCalories = updateDto.estimatedCalories;
if (updateDto.proteinGrams !== undefined) record.proteinGrams = updateDto.proteinGrams;
if (updateDto.carbohydrateGrams !== undefined) record.carbohydrateGrams = updateDto.carbohydrateGrams;
if (updateDto.fatGrams !== undefined) record.fatGrams = updateDto.fatGrams;
if (updateDto.fiberGrams !== undefined) record.fiberGrams = updateDto.fiberGrams;
if (updateDto.sugarGrams !== undefined) record.sugarGrams = updateDto.sugarGrams;
if (updateDto.sodiumMg !== undefined) record.sodiumMg = updateDto.sodiumMg;
if (updateDto.additionalNutrition !== undefined) record.additionalNutrition = updateDto.additionalNutrition;
if (updateDto.mealTime !== undefined) record.mealTime = updateDto.mealTime ? new Date(updateDto.mealTime) : null;
if (updateDto.imageUrl !== undefined) record.imageUrl = updateDto.imageUrl;
if (updateDto.aiAnalysisResult !== undefined) record.aiAnalysisResult = updateDto.aiAnalysisResult;
if (updateDto.notes !== undefined) record.notes = updateDto.notes;
await record.save({ transaction: t });
await t.commit();
// 记录活动日志
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.USER_PROFILE,
entityId: userId,
action: ActivityActionType.UPDATE,
changes: { diet_record_updated: recordId },
metadata: { updateDto },
});
return this.mapDietRecordToDto(record);
} catch (e) {
await t.rollback();
this.logger.error(`updateDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 删除饮食记录
*/
async deleteDietRecord(userId: string, recordId: number): Promise<boolean> {
const t = await this.sequelize.transaction();
try {
const record = await this.userDietHistoryModel.findOne({
where: { id: recordId, userId, deleted: false },
transaction: t,
});
if (!record) {
return false;
}
record.deleted = true;
await record.save({ transaction: t });
await t.commit();
// 记录活动日志
await this.activityLogsService.record({
userId,
entityType: ActivityEntityType.USER_PROFILE,
entityId: userId,
action: ActivityActionType.DELETE,
changes: { diet_record_deleted: recordId },
metadata: { foodName: record.foodName, mealType: record.mealType },
});
return true;
} catch (e) {
await t.rollback();
this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
throw e;
}
}
/**
* 获取最近N顿饮食的营养汇总
*/
async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise<NutritionSummaryDto> {
const records = await this.userDietHistoryModel.findAll({
where: { userId, deleted: false },
order: [['created_at', 'DESC']],
limit: mealCount,
});
if (records.length === 0) {
throw new NotFoundException('暂无饮食记录');
}
const summary = records.reduce((acc, record) => {
acc.totalCalories += record.estimatedCalories || 0;
acc.totalProtein += record.proteinGrams || 0;
acc.totalCarbohydrates += record.carbohydrateGrams || 0;
acc.totalFat += record.fatGrams || 0;
acc.totalFiber += record.fiberGrams || 0;
acc.totalSugar += record.sugarGrams || 0;
acc.totalSodium += record.sodiumMg || 0;
return acc;
}, {
totalCalories: 0,
totalProtein: 0,
totalCarbohydrates: 0,
totalFat: 0,
totalFiber: 0,
totalSugar: 0,
totalSodium: 0,
});
const oldestRecord = records[records.length - 1];
const newestRecord = records[0];
return {
...summary,
recordCount: records.length,
dateRange: {
start: oldestRecord.createdAt,
end: newestRecord.createdAt,
},
};
}
/**
* 将数据库模型转换为DTO
*/
private mapDietRecordToDto(record: UserDietHistory): DietRecordResponseDto {
return {
id: record.id,
mealType: record.mealType,
foodName: record.foodName,
foodDescription: record.foodDescription || undefined,
weightGrams: record.weightGrams || undefined,
portionDescription: record.portionDescription || undefined,
estimatedCalories: record.estimatedCalories || undefined,
proteinGrams: record.proteinGrams || undefined,
carbohydrateGrams: record.carbohydrateGrams || undefined,
fatGrams: record.fatGrams || undefined,
fiberGrams: record.fiberGrams || undefined,
sugarGrams: record.sugarGrams || undefined,
sodiumMg: record.sodiumMg || undefined,
additionalNutrition: record.additionalNutrition || undefined,
source: record.source,
mealTime: record.mealTime || undefined,
imageUrl: record.imageUrl || undefined,
notes: record.notes || undefined,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
/**
* Apple 登录
*/