feat: 新增饮食记录模块,含增删改查及营养汇总功能
This commit is contained in:
124
src/diet-records/diet-records.controller.ts
Normal file
124
src/diet-records/diet-records.controller.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Put,
|
||||
Delete,
|
||||
Query,
|
||||
Logger,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||
import { DietRecordsService } from './diet-records.service';
|
||||
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from '../users/dto/diet-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';
|
||||
|
||||
@ApiTags('diet-records')
|
||||
@Controller('diet-records')
|
||||
export class DietRecordsController {
|
||||
private readonly logger = new Logger(DietRecordsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly dietRecordsService: DietRecordsService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 添加饮食记录
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '添加饮食记录' })
|
||||
@ApiBody({ type: CreateDietRecordDto })
|
||||
@ApiResponse({ status: 201, description: '成功添加饮食记录', type: DietRecordResponseDto })
|
||||
async addDietRecord(
|
||||
@Body() createDto: CreateDietRecordDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<DietRecordResponseDto> {
|
||||
this.logger.log(`添加饮食记录 - 用户ID: ${user.sub}, 食物: ${createDto.foodName}`);
|
||||
return this.dietRecordsService.addDietRecord(user.sub, createDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取饮食记录历史
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '获取饮食记录历史' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: '开始日期' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: '结束日期' })
|
||||
@ApiQuery({ name: 'mealType', required: false, description: '餐次类型' })
|
||||
@ApiQuery({ name: 'page', required: false, description: '页码' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '每页数量' })
|
||||
@ApiResponse({ status: 200, description: '成功获取饮食记录', type: DietHistoryResponseDto })
|
||||
async getDietHistory(
|
||||
@Query() query: GetDietHistoryQueryDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<DietHistoryResponseDto> {
|
||||
this.logger.log(`获取饮食记录 - 用户ID: ${user.sub}`);
|
||||
return this.dietRecordsService.getDietHistory(user.sub, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新饮食记录
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '更新饮食记录' })
|
||||
@ApiBody({ type: UpdateDietRecordDto })
|
||||
@ApiResponse({ status: 200, description: '成功更新饮食记录', type: DietRecordResponseDto })
|
||||
async updateDietRecord(
|
||||
@Param('id') recordId: string,
|
||||
@Body() updateDto: UpdateDietRecordDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<DietRecordResponseDto> {
|
||||
this.logger.log(`更新饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
|
||||
return this.dietRecordsService.updateDietRecord(user.sub, parseInt(recordId), updateDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除饮食记录
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除饮食记录' })
|
||||
@ApiResponse({ status: 204, description: '成功删除饮食记录' })
|
||||
async deleteDietRecord(
|
||||
@Param('id') recordId: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<void> {
|
||||
this.logger.log(`删除饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`);
|
||||
const success = await this.dietRecordsService.deleteDietRecord(user.sub, parseInt(recordId));
|
||||
if (!success) {
|
||||
throw new NotFoundException('饮食记录不存在');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取营养汇总分析
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('nutrition-summary')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '获取营养汇总分析' })
|
||||
@ApiQuery({ name: 'mealCount', required: false, description: '分析的餐次数量,默认10' })
|
||||
@ApiResponse({ status: 200, description: '成功获取营养汇总', type: NutritionSummaryDto })
|
||||
async getNutritionSummary(
|
||||
@Query('mealCount') mealCount: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<NutritionSummaryDto> {
|
||||
this.logger.log(`获取营养汇总 - 用户ID: ${user.sub}`);
|
||||
const count = mealCount ? parseInt(mealCount) : 10;
|
||||
return this.dietRecordsService.getRecentNutritionSummary(user.sub, count);
|
||||
}
|
||||
}
|
||||
18
src/diet-records/diet-records.module.ts
Normal file
18
src/diet-records/diet-records.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { DietRecordsController } from './diet-records.controller';
|
||||
import { DietRecordsService } from './diet-records.service';
|
||||
import { UserDietHistory } from '../users/models/user-diet-history.model';
|
||||
import { ActivityLog } from '../activity-logs/models/activity-log.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SequelizeModule.forFeature([UserDietHistory, ActivityLog]),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [DietRecordsController],
|
||||
providers: [DietRecordsService],
|
||||
exports: [DietRecordsService],
|
||||
})
|
||||
export class DietRecordsModule { }
|
||||
310
src/diet-records/diet-records.service.ts
Normal file
310
src/diet-records/diet-records.service.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import { Sequelize } from 'sequelize-typescript';
|
||||
import { UserDietHistory } from '../users/models/user-diet-history.model';
|
||||
import { ActivityLog } from '../activity-logs/models/activity-log.model';
|
||||
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from '../users/dto/diet-record.dto';
|
||||
import { DietRecordSource } from '../users/models/user-diet-history.model';
|
||||
import { ResponseCode } from '../base.dto';
|
||||
|
||||
@Injectable()
|
||||
export class DietRecordsService {
|
||||
private readonly logger = new Logger(DietRecordsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(UserDietHistory)
|
||||
private readonly userDietHistoryModel: typeof UserDietHistory,
|
||||
@InjectModel(ActivityLog)
|
||||
private readonly activityLogModel: typeof ActivityLog,
|
||||
private readonly sequelize: Sequelize,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 添加饮食记录
|
||||
*/
|
||||
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,
|
||||
weightGrams: createDto.weightGrams,
|
||||
portionDescription: createDto.portionDescription,
|
||||
estimatedCalories: createDto.estimatedCalories,
|
||||
proteinGrams: createDto.proteinGrams,
|
||||
carbohydrateGrams: createDto.carbohydrateGrams,
|
||||
fatGrams: createDto.fatGrams,
|
||||
fiberGrams: createDto.fiberGrams,
|
||||
sugarGrams: createDto.sugarGrams,
|
||||
sodiumMg: createDto.sodiumMg,
|
||||
additionalNutrition: createDto.additionalNutrition,
|
||||
source: createDto.source || DietRecordSource.Manual,
|
||||
mealTime: createDto.mealTime ? new Date(createDto.mealTime) : new Date(),
|
||||
imageUrl: createDto.imageUrl,
|
||||
aiAnalysisResult: createDto.aiAnalysisResult,
|
||||
notes: createDto.notes,
|
||||
}, { transaction: t });
|
||||
|
||||
// 记录活动日志
|
||||
await this.activityLogModel.create({
|
||||
userId,
|
||||
action: 'diet_record_added',
|
||||
details: {
|
||||
recordId: dietRecord.id,
|
||||
foodName: createDto.foodName,
|
||||
mealType: createDto.mealType,
|
||||
calories: createDto.estimatedCalories,
|
||||
source: createDto.source || DietRecordSource.Manual,
|
||||
},
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
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('饮食记录不存在');
|
||||
}
|
||||
|
||||
// 更新记录
|
||||
await record.update({
|
||||
mealType: updateDto.mealType ?? record.mealType,
|
||||
foodName: updateDto.foodName ?? record.foodName,
|
||||
foodDescription: updateDto.foodDescription ?? record.foodDescription,
|
||||
weightGrams: updateDto.weightGrams ?? record.weightGrams,
|
||||
portionDescription: updateDto.portionDescription ?? record.portionDescription,
|
||||
estimatedCalories: updateDto.estimatedCalories ?? record.estimatedCalories,
|
||||
proteinGrams: updateDto.proteinGrams ?? record.proteinGrams,
|
||||
carbohydrateGrams: updateDto.carbohydrateGrams ?? record.carbohydrateGrams,
|
||||
fatGrams: updateDto.fatGrams ?? record.fatGrams,
|
||||
fiberGrams: updateDto.fiberGrams ?? record.fiberGrams,
|
||||
sugarGrams: updateDto.sugarGrams ?? record.sugarGrams,
|
||||
sodiumMg: updateDto.sodiumMg ?? record.sodiumMg,
|
||||
additionalNutrition: updateDto.additionalNutrition ?? record.additionalNutrition,
|
||||
mealTime: updateDto.mealTime ? new Date(updateDto.mealTime) : record.mealTime,
|
||||
imageUrl: updateDto.imageUrl ?? record.imageUrl,
|
||||
notes: updateDto.notes ?? record.notes,
|
||||
}, { transaction: t });
|
||||
|
||||
// 记录活动日志
|
||||
await this.activityLogModel.create({
|
||||
userId,
|
||||
action: 'diet_record_updated',
|
||||
details: {
|
||||
recordId: record.id,
|
||||
foodName: record.foodName,
|
||||
changes: updateDto,
|
||||
},
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 软删除
|
||||
await record.update({ deleted: true }, { transaction: t });
|
||||
|
||||
// 记录活动日志
|
||||
await this.activityLogModel.create({
|
||||
userId,
|
||||
action: 'diet_record_deleted',
|
||||
details: {
|
||||
recordId: record.id,
|
||||
foodName: record.foodName,
|
||||
mealType: record.mealType,
|
||||
},
|
||||
}, { transaction: t });
|
||||
|
||||
await t.commit();
|
||||
return true;
|
||||
} catch (e) {
|
||||
await t.rollback();
|
||||
this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的营养汇总
|
||||
*/
|
||||
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) {
|
||||
const now = new Date();
|
||||
return {
|
||||
totalCalories: 0,
|
||||
totalProtein: 0,
|
||||
totalCarbohydrates: 0,
|
||||
totalFat: 0,
|
||||
totalFiber: 0,
|
||||
totalSugar: 0,
|
||||
totalSodium: 0,
|
||||
recordCount: 0,
|
||||
dateRange: {
|
||||
start: now,
|
||||
end: now,
|
||||
},
|
||||
averageCaloriesPerMeal: 0,
|
||||
mealTypeDistribution: {},
|
||||
};
|
||||
}
|
||||
|
||||
const totalCalories = records.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0);
|
||||
const totalProtein = records.reduce((sum, r) => sum + (r.proteinGrams || 0), 0);
|
||||
const totalCarbohydrates = records.reduce((sum, r) => sum + (r.carbohydrateGrams || 0), 0);
|
||||
const totalFat = records.reduce((sum, r) => sum + (r.fatGrams || 0), 0);
|
||||
const totalFiber = records.reduce((sum, r) => sum + (r.fiberGrams || 0), 0);
|
||||
const totalSugar = records.reduce((sum, r) => sum + (r.sugarGrams || 0), 0);
|
||||
const totalSodium = records.reduce((sum, r) => sum + (r.sodiumMg || 0), 0);
|
||||
|
||||
// 餐次分布统计
|
||||
const mealTypeDistribution = records.reduce((dist, record) => {
|
||||
const mealType = record.mealType;
|
||||
dist[mealType] = (dist[mealType] || 0) + 1;
|
||||
return dist;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return {
|
||||
totalCalories,
|
||||
totalProtein,
|
||||
totalCarbohydrates,
|
||||
totalFat,
|
||||
totalFiber,
|
||||
totalSugar,
|
||||
totalSodium,
|
||||
recordCount: records.length,
|
||||
dateRange: {
|
||||
start: records[records.length - 1].createdAt,
|
||||
end: records[0].createdAt,
|
||||
},
|
||||
averageCaloriesPerMeal: records.length > 0 ? totalCalories / records.length : 0,
|
||||
mealTypeDistribution,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数据库记录映射为DTO
|
||||
*/
|
||||
private mapDietRecordToDto(record: UserDietHistory): any {
|
||||
return {
|
||||
id: record.id,
|
||||
mealType: record.mealType,
|
||||
foodName: record.foodName,
|
||||
foodDescription: record.foodDescription,
|
||||
weightGrams: record.weightGrams,
|
||||
portionDescription: record.portionDescription,
|
||||
estimatedCalories: record.estimatedCalories,
|
||||
proteinGrams: record.proteinGrams,
|
||||
carbohydrateGrams: record.carbohydrateGrams,
|
||||
fatGrams: record.fatGrams,
|
||||
fiberGrams: record.fiberGrams,
|
||||
sugarGrams: record.sugarGrams,
|
||||
sodiumMg: record.sodiumMg,
|
||||
additionalNutrition: record.additionalNutrition,
|
||||
source: record.source,
|
||||
mealTime: record.mealTime,
|
||||
imageUrl: record.imageUrl,
|
||||
aiAnalysisResult: record.aiAnalysisResult,
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user