feat: 新增饮食记录模块,含增删改查及营养汇总功能
This commit is contained in:
@@ -8,11 +8,13 @@ import { AiMessage } from './models/ai-message.model';
|
|||||||
import { AiConversation } from './models/ai-conversation.model';
|
import { AiConversation } from './models/ai-conversation.model';
|
||||||
import { PostureAssessment } from './models/posture-assessment.model';
|
import { PostureAssessment } from './models/posture-assessment.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { DietRecordsModule } from '../diet-records/diet-records.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
DietRecordsModule,
|
||||||
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
|
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
|
||||||
],
|
],
|
||||||
controllers: [AiCoachController],
|
controllers: [AiCoachController],
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DietAnalysisService } from './diet-analysis.service';
|
import { DietAnalysisService } from './diet-analysis.service';
|
||||||
import { UsersService } from '../../users/users.service';
|
import { DietRecordsService } from '../../diet-records/diet-records.service';
|
||||||
|
|
||||||
describe('DietAnalysisService - Text Analysis', () => {
|
describe('DietAnalysisService - Text Analysis', () => {
|
||||||
let service: DietAnalysisService;
|
let service: DietAnalysisService;
|
||||||
let mockUsersService: Partial<UsersService>;
|
let mockDietRecordsService: Partial<DietRecordsService>;
|
||||||
let mockConfigService: Partial<ConfigService>;
|
let mockConfigService: Partial<ConfigService>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Mock services
|
// Mock services
|
||||||
mockUsersService = {
|
mockDietRecordsService = {
|
||||||
addDietRecord: jest.fn().mockResolvedValue({}),
|
addDietRecord: jest.fn().mockResolvedValue({}),
|
||||||
getDietHistory: jest.fn().mockResolvedValue({ total: 0, records: [] }),
|
getDietHistory: jest.fn().mockResolvedValue({ total: 0, records: [] }),
|
||||||
getRecentNutritionSummary: jest.fn().mockResolvedValue({
|
getRecentNutritionSummary: jest.fn().mockResolvedValue({
|
||||||
@@ -41,7 +41,7 @@ describe('DietAnalysisService - Text Analysis', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
DietAnalysisService,
|
DietAnalysisService,
|
||||||
{ provide: UsersService, useValue: mockUsersService },
|
{ provide: DietRecordsService, useValue: mockDietRecordsService },
|
||||||
{ provide: ConfigService, useValue: mockConfigService },
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -57,7 +57,7 @@ describe('DietAnalysisService - Text Analysis', () => {
|
|||||||
it('should build a proper prompt for text analysis', () => {
|
it('should build a proper prompt for text analysis', () => {
|
||||||
// 通过反射访问私有方法进行测试
|
// 通过反射访问私有方法进行测试
|
||||||
const prompt = (service as any).buildTextDietAnalysisPrompt('breakfast');
|
const prompt = (service as any).buildTextDietAnalysisPrompt('breakfast');
|
||||||
|
|
||||||
expect(prompt).toContain('作为专业营养分析师');
|
expect(prompt).toContain('作为专业营养分析师');
|
||||||
expect(prompt).toContain('breakfast');
|
expect(prompt).toContain('breakfast');
|
||||||
expect(prompt).toContain('shouldRecord');
|
expect(prompt).toContain('shouldRecord');
|
||||||
@@ -130,12 +130,12 @@ describe('DietAnalysisService - Text Analysis', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await service.processDietRecord('test-user-id', mockAnalysisResult);
|
const result = await service.processDietRecord('test-user-id', mockAnalysisResult);
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.foodName).toBe('燕麦粥');
|
expect(result?.foodName).toBe('燕麦粥');
|
||||||
expect(result?.source).toBe('manual'); // 文本记录应该是manual源
|
expect(result?.source).toBe('manual'); // 文本记录应该是manual源
|
||||||
expect(result?.imageUrl).toBeUndefined();
|
expect(result?.imageUrl).toBeUndefined();
|
||||||
expect(mockUsersService.addDietRecord).toHaveBeenCalledWith('test-user-id', expect.objectContaining({
|
expect(mockDietRecordsService.addDietRecord).toHaveBeenCalledWith('test-user-id', expect.objectContaining({
|
||||||
foodName: '燕麦粥',
|
foodName: '燕麦粥',
|
||||||
source: 'manual'
|
source: 'manual'
|
||||||
}));
|
}));
|
||||||
@@ -164,7 +164,7 @@ describe('DietAnalysisService - Text Analysis', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await service.processDietRecord('test-user-id', mockAnalysisResult, 'https://example.com/image.jpg');
|
const result = await service.processDietRecord('test-user-id', mockAnalysisResult, 'https://example.com/image.jpg');
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.foodName).toBe('鸡胸肉沙拉');
|
expect(result?.foodName).toBe('鸡胸肉沙拉');
|
||||||
expect(result?.source).toBe('vision'); // 有图片URL应该是vision源
|
expect(result?.source).toBe('vision'); // 有图片URL应该是vision源
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { OpenAI } from 'openai';
|
import { OpenAI } from 'openai';
|
||||||
import { UsersService } from '../../users/users.service';
|
import { DietRecordsService } from '../../diet-records/diet-records.service';
|
||||||
import { CreateDietRecordDto } from '../../users/dto/diet-record.dto';
|
import { CreateDietRecordDto } from '../../users/dto/diet-record.dto';
|
||||||
import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model';
|
import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model';
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ export class DietAnalysisService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly usersService: UsersService,
|
private readonly dietRecordsService: DietRecordsService,
|
||||||
) {
|
) {
|
||||||
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
const dashScopeApiKey = this.configService.get<string>('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143';
|
||||||
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
const baseURL = this.configService.get<string>('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1';
|
||||||
@@ -240,7 +240,7 @@ export class DietAnalysisService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.usersService.addDietRecord(userId, createDto);
|
await this.dietRecordsService.addDietRecord(userId, createDto);
|
||||||
return createDto;
|
return createDto;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`用户确认添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
this.logger.error(`用户确认添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
@@ -278,7 +278,7 @@ export class DietAnalysisService {
|
|||||||
aiAnalysisResult: analysisResult,
|
aiAnalysisResult: analysisResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.usersService.addDietRecord(userId, createDto);
|
await this.dietRecordsService.addDietRecord(userId, createDto);
|
||||||
return createDto;
|
return createDto;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`自动添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
this.logger.error(`自动添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
@@ -294,7 +294,7 @@ export class DietAnalysisService {
|
|||||||
async buildUserNutritionContext(userId: string): Promise<string> {
|
async buildUserNutritionContext(userId: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
// 获取最近10顿饮食记录
|
// 获取最近10顿饮食记录
|
||||||
const recentDietHistory = await this.usersService.getDietHistory(userId, { limit: 10 });
|
const recentDietHistory = await this.dietRecordsService.getDietHistory(userId, { limit: 10 });
|
||||||
|
|
||||||
if (recentDietHistory.total === 0) {
|
if (recentDietHistory.total === 0) {
|
||||||
return '\n\n=== 用户营养信息 ===\n这是用户的第一次饮食记录,请给予鼓励并介绍饮食记录的价值。\n';
|
return '\n\n=== 用户营养信息 ===\n这是用户的第一次饮食记录,请给予鼓励并介绍饮食记录的价值。\n';
|
||||||
@@ -303,7 +303,7 @@ export class DietAnalysisService {
|
|||||||
let context = '\n\n=== 用户最近饮食记录分析 ===\n';
|
let context = '\n\n=== 用户最近饮食记录分析 ===\n';
|
||||||
|
|
||||||
// 获取营养汇总
|
// 获取营养汇总
|
||||||
const nutritionSummary = await this.usersService.getRecentNutritionSummary(userId, 10);
|
const nutritionSummary = await this.dietRecordsService.getRecentNutritionSummary(userId, 10);
|
||||||
|
|
||||||
context += this.buildNutritionSummaryText(nutritionSummary);
|
context += this.buildNutritionSummaryText(nutritionSummary);
|
||||||
context += this.buildMealDistributionText(recentDietHistory.records);
|
context += this.buildMealDistributionText(recentDietHistory.records);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ExercisesModule } from './exercises/exercises.module';
|
|||||||
import { WorkoutsModule } from './workouts/workouts.module';
|
import { WorkoutsModule } from './workouts/workouts.module';
|
||||||
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
|
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
|
||||||
import { GoalsModule } from './goals/goals.module';
|
import { GoalsModule } from './goals/goals.module';
|
||||||
|
import { DietRecordsModule } from './diet-records/diet-records.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -35,6 +36,7 @@ import { GoalsModule } from './goals/goals.module';
|
|||||||
WorkoutsModule,
|
WorkoutsModule,
|
||||||
MoodCheckinsModule,
|
MoodCheckinsModule,
|
||||||
GoalsModule,
|
GoalsModule,
|
||||||
|
DietRecordsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -351,6 +351,12 @@ export class NutritionSummaryDto {
|
|||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ApiProperty({ description: '平均每餐热量' })
|
||||||
|
averageCaloriesPerMeal: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '餐次类型分布', example: { 'breakfast': 5, 'lunch': 3, 'dinner': 2 } })
|
||||||
|
mealTypeDistribution: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DietAnalysisResponseDto {
|
export class DietAnalysisResponseDto {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/s
|
|||||||
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
|
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
|
||||||
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
||||||
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
||||||
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, DietAnalysisResponseDto } from './dto/diet-record.dto';
|
|
||||||
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
|
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
|
||||||
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
|
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from './dto/app-store-notification.dto';
|
||||||
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
|
import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto';
|
||||||
@@ -276,83 +275,6 @@ export class UsersController {
|
|||||||
return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent);
|
return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 饮食记录相关接口 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 添加饮食记录
|
|
||||||
*/
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('diet-records')
|
|
||||||
@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.usersService.addDietRecord(user.sub, createDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取饮食记录历史
|
|
||||||
*/
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('diet-records')
|
|
||||||
@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.usersService.getDietHistory(user.sub, query);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新饮食记录
|
|
||||||
*/
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put('diet-records/: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.usersService.updateDietRecord(user.sub, parseInt(recordId), updateDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除饮食记录
|
|
||||||
*/
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Delete('diet-records/: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.usersService.deleteDietRecord(user.sub, parseInt(recordId));
|
|
||||||
if (!success) {
|
|
||||||
throw new NotFoundException('饮食记录不存在');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 用户活跃记录相关接口 ====================
|
// ==================== 用户活跃记录相关接口 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, A
|
|||||||
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
||||||
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
||||||
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.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 { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||||||
import { UserActivityService } from './services/user-activity.service';
|
import { UserActivityService } from './services/user-activity.service';
|
||||||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||||||
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
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;
|
const DEFAULT_FREE_USAGE_COUNT = 5;
|
||||||
|
|
||||||
@@ -61,8 +61,7 @@ export class UsersService {
|
|||||||
private userProfileModel: typeof UserProfile,
|
private userProfileModel: typeof UserProfile,
|
||||||
@InjectModel(UserWeightHistory)
|
@InjectModel(UserWeightHistory)
|
||||||
private userWeightHistoryModel: typeof UserWeightHistory,
|
private userWeightHistoryModel: typeof UserWeightHistory,
|
||||||
@InjectModel(UserDietHistory)
|
|
||||||
private userDietHistoryModel: typeof UserDietHistory,
|
|
||||||
@InjectConnection()
|
@InjectConnection()
|
||||||
private sequelize: Sequelize,
|
private sequelize: Sequelize,
|
||||||
private readonly activityLogsService: ActivityLogsService,
|
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 登录
|
* Apple 登录
|
||||||
|
|||||||
Reference in New Issue
Block a user