feat: 新增饮食记录模块,含增删改查及营养汇总功能
This commit is contained in:
@@ -31,12 +31,12 @@ 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 { UserActivityService } from './services/user-activity.service';
|
||||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||
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 = 5;
|
||||
|
||||
@@ -61,8 +61,7 @@ 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,
|
||||
@@ -472,274 +471,15 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加饮食记录
|
||||
*/
|
||||
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 登录
|
||||
|
||||
Reference in New Issue
Block a user