From 6542988cb6390962745c60ebe58139285fe6872b Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 29 Aug 2025 15:57:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E9=A3=9F=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CUSTOM_FOODS_IMPLEMENTATION.md | 130 ++++++++++++ sql-scripts/user-custom-foods-table.sql | 26 +++ src/food-library/USER_CUSTOM_FOODS.md | 127 ++++++++++++ src/food-library/dto/food-library.dto.ts | 74 +++++++ src/food-library/food-library.controller.ts | 73 ++++++- src/food-library/food-library.module.ts | 5 +- src/food-library/food-library.service.ts | 196 ++++++++++++++---- .../models/user-custom-food.model.ts | 114 ++++++++++ test-custom-foods.sh | 53 +++++ 9 files changed, 748 insertions(+), 50 deletions(-) create mode 100644 CUSTOM_FOODS_IMPLEMENTATION.md create mode 100644 sql-scripts/user-custom-foods-table.sql create mode 100644 src/food-library/USER_CUSTOM_FOODS.md create mode 100644 src/food-library/models/user-custom-food.model.ts create mode 100644 test-custom-foods.sh diff --git a/CUSTOM_FOODS_IMPLEMENTATION.md b/CUSTOM_FOODS_IMPLEMENTATION.md new file mode 100644 index 0000000..175ce5e --- /dev/null +++ b/CUSTOM_FOODS_IMPLEMENTATION.md @@ -0,0 +1,130 @@ +# 用户自定义食物功能实现总结 + +## 实现概述 + +已成功实现用户添加自定义食物的功能,包括数据库表设计、后端API接口和完整的业务逻辑。用户可以创建、查看、搜索和删除自己的自定义食物,这些食物会与系统食物一起显示在食物库中。 + +## 实现的功能 + +### 1. 数据库层面 +- ✅ 创建了 `t_user_custom_foods` 表 +- ✅ 包含与系统食物库相同的营养字段 +- ✅ 通过 `user_id` 字段关联用户 +- ✅ 通过外键约束确保分类的有效性 + +### 2. 模型层面 +- ✅ 创建了 `UserCustomFood` Sequelize模型 +- ✅ 定义了完整的字段映射和关联关系 +- ✅ 更新了食物库模块以包含新模型 + +### 3. 服务层面 +- ✅ 扩展了 `FoodLibraryService` 以支持用户自定义食物 +- ✅ 实现了创建自定义食物的方法 +- ✅ 实现了删除自定义食物的方法 +- ✅ 更新了获取食物库列表的方法,合并系统食物和用户自定义食物 +- ✅ 更新了搜索食物的方法,包含用户自定义食物 +- ✅ 更新了获取食物详情的方法,支持系统食物和自定义食物 + +### 4. 控制器层面 +- ✅ 添加了创建自定义食物的 POST 接口 +- ✅ 添加了删除自定义食物的 DELETE 接口 +- ✅ 更新了现有接口以支持用户认证和自定义食物 +- ✅ 添加了完整的 Swagger 文档注解 + +### 5. DTO层面 +- ✅ 创建了 `CreateCustomFoodDto` 用于创建自定义食物 +- ✅ 添加了完整的验证规则 +- ✅ 扩展了 `FoodItemDto` 以标识是否为自定义食物 + +## 核心特性 + +### 权限控制 +- 所有接口都需要用户认证 +- 用户只能看到和操作自己的自定义食物 +- 系统食物对所有用户可见 + +### 数据隔离 +- 用户自定义食物通过 `user_id` 字段实现数据隔离 +- 搜索和列表查询都会自动过滤用户权限 + +### 智能合并 +- 获取食物库列表时,自动合并系统食物和用户自定义食物 +- 常见分类只显示系统食物,其他分类显示合并后的食物 +- 搜索结果中用户自定义食物优先显示 + +### 数据验证 +- 食物名称和分类键为必填项 +- 营养成分有合理的数值范围限制 +- 分类键必须是有效的系统分类 + +## API接口 + +### 获取食物库列表 +``` +GET /food-library +Authorization: Bearer +``` + +### 搜索食物 +``` +GET /food-library/search?keyword=关键词 +Authorization: Bearer +``` + +### 创建自定义食物 +``` +POST /food-library/custom +Authorization: Bearer +Content-Type: application/json +``` + +### 删除自定义食物 +``` +DELETE /food-library/custom/{id} +Authorization: Bearer +``` + +### 获取食物详情 +``` +GET /food-library/{id} +Authorization: Bearer +``` + +## 文件清单 + +### 新增文件 +- `sql-scripts/user-custom-foods-table.sql` - 数据库表创建脚本 +- `src/food-library/models/user-custom-food.model.ts` - 用户自定义食物模型 +- `src/food-library/USER_CUSTOM_FOODS.md` - 功能说明文档 +- `test-custom-foods.sh` - 功能测试脚本 + +### 修改文件 +- `src/food-library/food-library.module.ts` - 添加新模型到模块 +- `src/food-library/food-library.service.ts` - 扩展服务以支持自定义食物 +- `src/food-library/food-library.controller.ts` - 添加新接口和更新现有接口 +- `src/food-library/dto/food-library.dto.ts` - 添加新DTO和扩展现有DTO + +## 使用说明 + +1. **运行数据库脚本**:执行 `sql-scripts/user-custom-foods-table.sql` 创建用户自定义食物表 + +2. **重启应用**:重启NestJS应用以加载新的模型和接口 + +3. **测试功能**:使用 `test-custom-foods.sh` 脚本测试各个接口(需要先获取有效的访问令牌) + +4. **前端集成**:前端可以通过新的API接口实现用户自定义食物的增删查功能 + +## 注意事项 + +- 所有接口都需要用户认证,确保在请求头中包含有效的 Bearer token +- 创建自定义食物时,分类键必须是系统中已存在的分类 +- 用户只能删除自己创建的自定义食物 +- 营养成分字段都是可选的,但建议提供准确的营养信息 + +## 扩展建议 + +1. **图片上传**:可以添加图片上传功能,让用户为自定义食物添加图片 +2. **营养计算**:可以添加营养成分的自动计算功能 +3. **食物分享**:可以考虑添加用户间分享自定义食物的功能 +4. **批量导入**:可以添加批量导入自定义食物的功能 +5. **食物模板**:可以提供常见食物的营养模板,方便用户快速创建 \ No newline at end of file diff --git a/sql-scripts/user-custom-foods-table.sql b/sql-scripts/user-custom-foods-table.sql new file mode 100644 index 0000000..8d3fa53 --- /dev/null +++ b/sql-scripts/user-custom-foods-table.sql @@ -0,0 +1,26 @@ +-- 创建用户自定义食物表 +-- 该表用于存储用户自定义添加的食物信息 +CREATE TABLE IF NOT EXISTS `t_user_custom_foods` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` varchar(255) NOT NULL COMMENT '用户ID', + `name` varchar(100) NOT NULL COMMENT '食物名称', + `description` varchar(500) DEFAULT NULL COMMENT '食物描述', + `calories_per_100g` float DEFAULT NULL COMMENT '每100克热量(卡路里)', + `protein_per_100g` float DEFAULT NULL COMMENT '每100克蛋白质含量(克)', + `carbohydrate_per_100g` float DEFAULT NULL COMMENT '每100克碳水化合物含量(克)', + `fat_per_100g` float DEFAULT NULL COMMENT '每100克脂肪含量(克)', + `fiber_per_100g` float DEFAULT NULL COMMENT '每100克膳食纤维含量(克)', + `sugar_per_100g` float DEFAULT NULL COMMENT '每100克糖分含量(克)', + `sodium_per_100g` float DEFAULT NULL COMMENT '每100克钠含量(毫克)', + `additional_nutrition` json DEFAULT NULL COMMENT '其他营养信息(维生素、矿物质等)', + `image_url` varchar(500) DEFAULT NULL COMMENT '食物图片URL', + `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序(分类内)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_category_key` (`category_key`), + KEY `idx_name` (`name`), + KEY `idx_user_category_sort` (`user_id`, `category_key`, `sort_order`), + CONSTRAINT `fk_user_custom_food_category` FOREIGN KEY (`category_key`) REFERENCES `t_food_categories` (`key`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自定义食物表'; \ No newline at end of file diff --git a/src/food-library/USER_CUSTOM_FOODS.md b/src/food-library/USER_CUSTOM_FOODS.md new file mode 100644 index 0000000..46223ed --- /dev/null +++ b/src/food-library/USER_CUSTOM_FOODS.md @@ -0,0 +1,127 @@ +# 用户自定义食物功能 + +## 功能概述 + +用户自定义食物功能允许用户添加自己的食物到食物库中,这些自定义食物只对创建它们的用户可见。 + +## 数据库设计 + +### 用户自定义食物表 (t_user_custom_foods) +- `id`: 自定义食物唯一ID +- `user_id`: 用户ID(关联到用户) +- `name`: 食物名称 +- `category_key`: 所属分类 +- `calories_per_100g`: 每100克热量 +- `protein_per_100g`: 每100克蛋白质含量 +- `carbohydrate_per_100g`: 每100克碳水化合物含量 +- `fat_per_100g`: 每100克脂肪含量 +- `fiber_per_100g`: 每100克膳食纤维含量 +- `sugar_per_100g`: 每100克糖分含量 +- `sodium_per_100g`: 每100克钠含量 +- `additional_nutrition`: 其他营养信息(JSON格式) +- `image_url`: 食物图片URL +- `sort_order`: 排序顺序 + +## API接口 + +### 1. 获取食物库列表(包含用户自定义食物) +``` +GET /food-library +Authorization: Bearer +``` +返回按分类组织的食物列表,包含系统食物和用户自定义食物。用户自定义食物会显示在对应的分类中。 + +### 2. 搜索食物(包含用户自定义食物) +``` +GET /food-library/search?keyword=关键词 +Authorization: Bearer +``` +根据关键词搜索食物,包含系统食物和用户自定义食物。用户自定义食物会优先显示。 + +### 3. 创建用户自定义食物 +``` +POST /food-library/custom +Authorization: Bearer +Content-Type: application/json + +{ + "name": "我的自制沙拉", + "description": "自制蔬菜沙拉", + "categoryKey": "dishes", + "caloriesPer100g": 120, + "proteinPer100g": 5.2, + "carbohydratePer100g": 15.3, + "fatPer100g": 8.1, + "fiberPer100g": 3.2, + "sugarPer100g": 2.5, + "sodiumPer100g": 150, + "imageUrl": "https://example.com/image.jpg" +} +``` + +### 4. 删除用户自定义食物 +``` +DELETE /food-library/custom/{id} +Authorization: Bearer +``` + +### 5. 获取食物详情(支持系统食物和用户自定义食物) +``` +GET /food-library/{id} +Authorization: Bearer +``` + +## 特殊逻辑 + +### 食物显示规则 +1. **常见分类**: 只显示系统食物中标记为常见的食物,不包含用户自定义食物 +2. **其他分类**: 显示该分类下的系统食物和用户自定义食物 +3. **搜索结果**: 用户自定义食物优先显示,然后是系统食物 + +### 权限控制 +- 用户只能看到自己创建的自定义食物 +- 用户只能删除自己创建的自定义食物 +- 所有接口都需要用户认证 + +### 数据验证 +- 食物名称:必填,字符串类型 +- 分类键:必填,必须是有效的分类 +- 营养成分:可选,数值类型,有合理的范围限制 +- 图片URL:可选,字符串类型 + +## 使用示例 + +### 创建自定义食物 +```javascript +const response = await fetch('/food-library/custom', { + method: 'POST', + headers: { + 'Authorization': 'Bearer your_access_token', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: '我的蛋白质奶昔', + description: '自制高蛋白奶昔', + categoryKey: 'snacks_drinks', + caloriesPer100g: 180, + proteinPer100g: 25, + carbohydratePer100g: 10, + fatPer100g: 5 + }) +}); + +const customFood = await response.json(); +console.log('创建的自定义食物:', customFood); +``` + +### 获取包含自定义食物的食物库 +```javascript +const response = await fetch('/food-library', { + headers: { + 'Authorization': 'Bearer your_access_token' + } +}); + +const foodLibrary = await response.json(); +console.log('食物库(包含自定义食物):', foodLibrary); +``` \ No newline at end of file diff --git a/src/food-library/dto/food-library.dto.ts b/src/food-library/dto/food-library.dto.ts index a32ad2e..24f4eb4 100644 --- a/src/food-library/dto/food-library.dto.ts +++ b/src/food-library/dto/food-library.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator'; export class FoodItemDto { @ApiProperty({ description: '食物ID' }) @@ -42,6 +43,9 @@ export class FoodItemDto { @ApiProperty({ description: '排序', required: false }) sortOrder?: number; + + @ApiProperty({ description: '是否为用户自定义食物', required: false }) + isCustom?: boolean; } export class FoodCategoryDto { @@ -67,4 +71,74 @@ export class FoodCategoryDto { export class FoodLibraryResponseDto { @ApiProperty({ description: '食物分类列表', type: [FoodCategoryDto] }) categories: FoodCategoryDto[]; +} + +export class CreateCustomFoodDto { + @ApiProperty({ description: '食物名称', example: '我的自制沙拉' }) + @IsString({ message: '食物名称必须是字符串' }) + @IsNotEmpty({ message: '食物名称不能为空' }) + name: string; + + @ApiProperty({ description: '食物描述', required: false, example: '自制蔬菜沙拉' }) + @IsOptional() + @IsString({ message: '食物描述必须是字符串' }) + description?: string; + + @ApiProperty({ description: '每100克热量(卡路里)', required: false, example: 120 }) + @IsOptional() + @IsNumber({}, { message: '热量必须是数字' }) + @Min(0, { message: '热量不能为负数' }) + @Max(9999, { message: '热量不能超过9999' }) + caloriesPer100g?: number; + + @ApiProperty({ description: '每100克蛋白质含量(克)', required: false, example: 5.2 }) + @IsOptional() + @IsNumber({}, { message: '蛋白质含量必须是数字' }) + @Min(0, { message: '蛋白质含量不能为负数' }) + @Max(100, { message: '蛋白质含量不能超过100克' }) + proteinPer100g?: number; + + @ApiProperty({ description: '每100克碳水化合物含量(克)', required: false, example: 15.3 }) + @IsOptional() + @IsNumber({}, { message: '碳水化合物含量必须是数字' }) + @Min(0, { message: '碳水化合物含量不能为负数' }) + @Max(100, { message: '碳水化合物含量不能超过100克' }) + carbohydratePer100g?: number; + + @ApiProperty({ description: '每100克脂肪含量(克)', required: false, example: 8.1 }) + @IsOptional() + @IsNumber({}, { message: '脂肪含量必须是数字' }) + @Min(0, { message: '脂肪含量不能为负数' }) + @Max(100, { message: '脂肪含量不能超过100克' }) + fatPer100g?: number; + + @ApiProperty({ description: '每100克膳食纤维含量(克)', required: false, example: 3.2 }) + @IsOptional() + @IsNumber({}, { message: '膳食纤维含量必须是数字' }) + @Min(0, { message: '膳食纤维含量不能为负数' }) + @Max(100, { message: '膳食纤维含量不能超过100克' }) + fiberPer100g?: number; + + @ApiProperty({ description: '每100克糖分含量(克)', required: false, example: 2.5 }) + @IsOptional() + @IsNumber({}, { message: '糖分含量必须是数字' }) + @Min(0, { message: '糖分含量不能为负数' }) + @Max(100, { message: '糖分含量不能超过100克' }) + sugarPer100g?: number; + + @ApiProperty({ description: '每100克钠含量(毫克)', required: false, example: 150 }) + @IsOptional() + @IsNumber({}, { message: '钠含量必须是数字' }) + @Min(0, { message: '钠含量不能为负数' }) + @Max(99999, { message: '钠含量不能超过99999毫克' }) + sodiumPer100g?: number; + + @ApiProperty({ description: '其他营养信息', required: false }) + @IsOptional() + additionalNutrition?: Record; + + @ApiProperty({ description: '食物图片URL', required: false }) + @IsOptional() + @IsString({ message: '图片URL必须是字符串' }) + imageUrl?: string; } \ No newline at end of file diff --git a/src/food-library/food-library.controller.ts b/src/food-library/food-library.controller.ts index 06a9403..3cd336e 100644 --- a/src/food-library/food-library.controller.ts +++ b/src/food-library/food-library.controller.ts @@ -1,7 +1,10 @@ -import { Controller, Get, Query, Param, ParseIntPipe, NotFoundException } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { Controller, Get, Post, Delete, Query, Param, ParseIntPipe, NotFoundException, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; import { FoodLibraryService } from './food-library.service'; -import { FoodLibraryResponseDto, FoodItemDto } from './dto/food-library.dto'; +import { FoodLibraryResponseDto, FoodItemDto, CreateCustomFoodDto } from './dto/food-library.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('食物库') @Controller('food-library') @@ -15,8 +18,10 @@ export class FoodLibraryController { description: '成功获取食物库列表', type: FoodLibraryResponseDto, }) - async getFoodLibrary(): Promise { - return this.foodLibraryService.getFoodLibrary(); + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async getFoodLibrary(@CurrentUser() user: AccessTokenPayload): Promise { + return this.foodLibraryService.getFoodLibrary(user.sub); } @Get('search') @@ -27,11 +32,56 @@ export class FoodLibraryController { description: '成功搜索食物', type: [FoodItemDto], }) - async searchFoods(@Query('keyword') keyword: string): Promise { + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async searchFoods( + @Query('keyword') keyword: string, + @CurrentUser() user: AccessTokenPayload + ): Promise { if (!keyword || keyword.trim().length === 0) { return []; } - return this.foodLibraryService.searchFoods(keyword.trim()); + return this.foodLibraryService.searchFoods(keyword.trim(), user.sub); + } + + @Post('custom') + @ApiOperation({ summary: '创建用户自定义食物' }) + @ApiResponse({ + status: 201, + description: '成功创建自定义食物', + type: FoodItemDto, + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async createCustomFood( + @Body() createCustomFoodDto: CreateCustomFoodDto, + @CurrentUser() user: AccessTokenPayload + ): Promise { + return this.foodLibraryService.createCustomFood(user.sub, createCustomFoodDto); + } + + @Delete('custom/:id') + @ApiOperation({ summary: '删除用户自定义食物' }) + @ApiParam({ name: 'id', description: '自定义食物ID', type: 'number' }) + @ApiResponse({ + status: 200, + description: '成功删除自定义食物', + }) + @ApiResponse({ + status: 404, + description: '自定义食物不存在', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async deleteCustomFood( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AccessTokenPayload + ): Promise<{ success: boolean }> { + const success = await this.foodLibraryService.deleteCustomFood(user.sub, id); + if (!success) { + throw new NotFoundException('自定义食物不存在'); + } + return { success }; } @Get(':id') @@ -46,8 +96,13 @@ export class FoodLibraryController { status: 404, description: '食物不存在', }) - async getFoodById(@Param('id', ParseIntPipe) id: number): Promise { - const food = await this.foodLibraryService.getFoodById(id); + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async getFoodById( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AccessTokenPayload + ): Promise { + const food = await this.foodLibraryService.getFoodById(id, user.sub); if (!food) { throw new NotFoundException('食物不存在'); } diff --git a/src/food-library/food-library.module.ts b/src/food-library/food-library.module.ts index 7e34729..545c8c4 100644 --- a/src/food-library/food-library.module.ts +++ b/src/food-library/food-library.module.ts @@ -4,10 +4,13 @@ import { FoodLibraryController } from './food-library.controller'; import { FoodLibraryService } from './food-library.service'; import { FoodCategory } from './models/food-category.model'; import { FoodLibrary } from './models/food-library.model'; +import { UserCustomFood } from './models/user-custom-food.model'; +import { UsersModule } from '../users/users.module'; @Module({ imports: [ - SequelizeModule.forFeature([FoodCategory, FoodLibrary]), + SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood]), + UsersModule, ], controllers: [FoodLibraryController], providers: [FoodLibraryService], diff --git a/src/food-library/food-library.service.ts b/src/food-library/food-library.service.ts index f339ff9..9373b3a 100644 --- a/src/food-library/food-library.service.ts +++ b/src/food-library/food-library.service.ts @@ -3,7 +3,8 @@ import { InjectModel } from '@nestjs/sequelize'; import { Op } from 'sequelize'; import { FoodCategory } from './models/food-category.model'; import { FoodLibrary } from './models/food-library.model'; -import { FoodCategoryDto, FoodItemDto, FoodLibraryResponseDto } from './dto/food-library.dto'; +import { UserCustomFood } from './models/user-custom-food.model'; +import { FoodCategoryDto, FoodItemDto, FoodLibraryResponseDto, CreateCustomFoodDto } from './dto/food-library.dto'; @Injectable() export class FoodLibraryService { @@ -12,10 +13,12 @@ export class FoodLibraryService { private readonly foodCategoryModel: typeof FoodCategory, @InjectModel(FoodLibrary) private readonly foodLibraryModel: typeof FoodLibrary, + @InjectModel(UserCustomFood) + private readonly userCustomFoodModel: typeof UserCustomFood, ) { } /** - * 将食物模型转换为DTO + * 将系统食物模型转换为DTO */ private mapFoodToDto(food: FoodLibrary): FoodItemDto { return { @@ -33,24 +36,47 @@ export class FoodLibraryService { isCommon: food.isCommon, imageUrl: food.imageUrl, sortOrder: food.sortOrder, + isCustom: false, + }; + } + + /** + * 将用户自定义食物模型转换为DTO + */ + private mapCustomFoodToDto(food: UserCustomFood): FoodItemDto { + return { + id: food.id, + name: food.name, + description: food.description, + caloriesPer100g: food.caloriesPer100g, + proteinPer100g: food.proteinPer100g, + carbohydratePer100g: food.carbohydratePer100g, + fatPer100g: food.fatPer100g, + fiberPer100g: food.fiberPer100g, + sugarPer100g: food.sugarPer100g, + sodiumPer100g: food.sodiumPer100g, + additionalNutrition: food.additionalNutrition, + isCommon: false, // 用户自定义食物不会是常见食物 + imageUrl: food.imageUrl, + sortOrder: food.sortOrder, + isCustom: true, }; } /** * 获取食物库列表,按分类组织 - * 常见食物会被归类到"常见"分类中 + * 常见食物会被归类到"常见"分类中,用户自定义食物会被归类到"自定义"分类中 */ - async getFoodLibrary(): Promise { + async getFoodLibrary(userId?: string): Promise { try { - // 一次性获取所有分类和食物数据,避免N+1查询 - const [categories, allFoods, commonFoods] = await Promise.all([ + // 分别获取所有数据 + const [categories, allSystemFoods, commonFoods, userCustomFoods] = await Promise.all([ // 获取所有分类 this.foodCategoryModel.findAll({ order: [['sortOrder', 'ASC']], }), - // 获取所有非常见食物 + // 获取所有系统食物 this.foodLibraryModel.findAll({ - where: { isCommon: false }, order: [['sortOrder', 'ASC'], ['name', 'ASC']], }), // 获取所有常见食物 @@ -58,28 +84,37 @@ export class FoodLibraryService { where: { isCommon: true }, order: [['sortOrder', 'ASC'], ['name', 'ASC']], }), + // 获取用户自定义食物(如果有用户ID) + userId ? this.userCustomFoodModel.findAll({ + where: { userId }, + order: [['sortOrder', 'ASC'], ['name', 'ASC']], + }) : Promise.resolve([]), ]); - // 将食物按分类分组 - const foodsByCategory = new Map(); - allFoods.forEach(food => { + // 将系统食物按分类分组 + const systemFoodsByCategory = new Map(); + allSystemFoods.forEach((food: FoodLibrary) => { const categoryKey = food.categoryKey; - if (!foodsByCategory.has(categoryKey)) { - foodsByCategory.set(categoryKey, []); + if (!systemFoodsByCategory.has(categoryKey)) { + systemFoodsByCategory.set(categoryKey, []); } - foodsByCategory.get(categoryKey)!.push(food); + systemFoodsByCategory.get(categoryKey)!.push(food); }); // 构建结果 - const result: FoodCategoryDto[] = categories.map(category => { - let foods: FoodLibrary[] = []; + const result: FoodCategoryDto[] = categories.map((category: FoodCategory) => { + let allFoods: FoodItemDto[] = []; if (category.key === 'common') { - // 常见分类:使用常见食物 - foods = commonFoods; + // 常见分类:使用常见食物(只包含系统食物) + allFoods = commonFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)); + } else if (category.key === 'custom') { + // 自定义分类:只包含用户自定义食物 + allFoods = userCustomFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food)); } else { - // 其他分类:使用该分类下的非常见食物 - foods = foodsByCategory.get(category.key) || []; + // 其他分类:只包含系统食物 + const systemFoods = systemFoodsByCategory.get(category.key) || []; + allFoods = systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)); } return { @@ -88,7 +123,7 @@ export class FoodLibraryService { icon: category.icon, sortOrder: category.sortOrder, isSystem: category.isSystem, - foods: foods.map(food => this.mapFoodToDto(food)), + foods: allFoods, }; }); @@ -99,48 +134,129 @@ export class FoodLibraryService { } /** - * 根据关键词搜索食物 + * 根据关键词搜索食物(包含系统食物和用户自定义食物) */ - async searchFoods(keyword: string): Promise { + async searchFoods(keyword: string, userId?: string): Promise { try { if (!keyword || keyword.trim().length === 0) { return []; } - const foods = await this.foodLibraryModel.findAll({ - where: { - name: { - [Op.like]: `%${keyword.trim()}%` - } - }, - order: [['isCommon', 'DESC'], ['name', 'ASC']], - limit: 50, - }); + const [systemFoods, customFoods] = await Promise.all([ + // 搜索系统食物 + this.foodLibraryModel.findAll({ + where: { + name: { + [Op.like]: `%${keyword.trim()}%` + } + }, + order: [['isCommon', 'DESC'], ['name', 'ASC']], + limit: 25, + }), + // 搜索用户自定义食物(如果有用户ID) + userId ? this.userCustomFoodModel.findAll({ + where: { + userId, + name: { + [Op.like]: `%${keyword.trim()}%` + } + }, + order: [['name', 'ASC']], + limit: 25, + }) : Promise.resolve([]), + ]); - return foods.map(food => this.mapFoodToDto(food)); + // 合并结果,用户自定义食物优先显示 + const allFoods: FoodItemDto[] = [ + ...customFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food)), + ...systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)), + ]; + + return allFoods; } catch (error) { throw new Error(`Failed to search foods: ${error.message}`); } } /** - * 根据ID获取食物详情 + * 根据ID获取食物详情(支持系统食物和用户自定义食物) */ - async getFoodById(id: number): Promise { + async getFoodById(id: number, userId?: string): Promise { try { if (!id || id <= 0) { return null; } - const food = await this.foodLibraryModel.findByPk(id); - - if (!food) { - return null; + // 先尝试从系统食物中查找 + const systemFood = await this.foodLibraryModel.findByPk(id); + if (systemFood) { + return this.mapFoodToDto(systemFood); } - return this.mapFoodToDto(food); + // 如果提供了用户ID,则从用户自定义食物中查找 + if (userId) { + const customFood = await this.userCustomFoodModel.findOne({ + where: { id, userId } + }); + if (customFood) { + return this.mapCustomFoodToDto(customFood); + } + } + + return null; } catch (error) { throw new Error(`Failed to get food by id: ${error.message}`); } } + + /** + * 创建用户自定义食物 + */ + async createCustomFood(userId: string, createCustomFoodDto: CreateCustomFoodDto): Promise { + try { + // 获取用户自定义食物的最大排序值 + const maxSortOrder = await this.userCustomFoodModel.max('sortOrder', { + where: { userId } + }) as number || 0; + + // 创建用户自定义食物 + const customFood = await this.userCustomFoodModel.create({ + userId, + name: createCustomFoodDto.name, + description: createCustomFoodDto.description, + caloriesPer100g: createCustomFoodDto.caloriesPer100g, + proteinPer100g: createCustomFoodDto.proteinPer100g, + carbohydratePer100g: createCustomFoodDto.carbohydratePer100g, + fatPer100g: createCustomFoodDto.fatPer100g, + fiberPer100g: createCustomFoodDto.fiberPer100g, + sugarPer100g: createCustomFoodDto.sugarPer100g, + sodiumPer100g: createCustomFoodDto.sodiumPer100g, + additionalNutrition: createCustomFoodDto.additionalNutrition, + imageUrl: createCustomFoodDto.imageUrl, + sortOrder: maxSortOrder + 1, + }); + + return this.mapCustomFoodToDto(customFood); + } catch (error) { + throw new Error(`Failed to create custom food: ${error.message}`); + } + } + + /** + * 删除用户自定义食物 + */ + async deleteCustomFood(userId: string, foodId: number): Promise { + try { + const result = await this.userCustomFoodModel.destroy({ + where: { + id: foodId, + userId + } + }); + + return result > 0; + } catch (error) { + throw new Error(`Failed to delete custom food: ${error.message}`); + } + } } \ No newline at end of file diff --git a/src/food-library/models/user-custom-food.model.ts b/src/food-library/models/user-custom-food.model.ts new file mode 100644 index 0000000..be9d05a --- /dev/null +++ b/src/food-library/models/user-custom-food.model.ts @@ -0,0 +1,114 @@ +import { Column, DataType, Model, Table } from 'sequelize-typescript'; + +@Table({ + tableName: 't_user_custom_foods', + underscored: true, +}) +export class UserCustomFood extends Model { + @Column({ + type: DataType.BIGINT, + primaryKey: true, + autoIncrement: true, + comment: '主键ID', + }) + declare id: number; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '食物名称', + }) + declare name: string; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '食物描述', + }) + declare description: string; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克热量(卡路里)', + }) + declare caloriesPer100g: number; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克蛋白质含量(克)', + }) + declare proteinPer100g: number; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克碳水化合物含量(克)', + }) + declare carbohydratePer100g: number; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克脂肪含量(克)', + }) + declare fatPer100g: number; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克膳食纤维含量(克)', + }) + declare fiberPer100g: number; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克糖分含量(克)', + }) + declare sugarPer100g: number; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '每100克钠含量(毫克)', + }) + declare sodiumPer100g: number; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '其他营养信息(维生素、矿物质等)', + }) + declare additionalNutrition: Record; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '食物图片URL', + }) + declare imageUrl: string; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + defaultValue: 0, + comment: '排序(分类内)', + }) + declare sortOrder: number; + + + @Column({ type: DataType.DATE, defaultValue: DataType.NOW }) + declare createdAt: Date; + + @Column({ type: DataType.DATE, defaultValue: DataType.NOW }) + declare updatedAt: Date; +} \ No newline at end of file diff --git a/test-custom-foods.sh b/test-custom-foods.sh new file mode 100644 index 0000000..5deb3bf --- /dev/null +++ b/test-custom-foods.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# 用户自定义食物功能测试脚本 + +echo "=== 用户自定义食物功能测试 ===" + +# 设置基础URL和测试用户token +BASE_URL="http://localhost:3000" +# 请替换为实际的用户token +ACCESS_TOKEN="your_access_token_here" + +echo "1. 测试获取食物库列表(包含用户自定义食物)" +curl -X GET "$BASE_URL/food-library" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" + +echo -e "\n\n2. 测试创建用户自定义食物" +curl -X POST "$BASE_URL/food-library/custom" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "我的自制沙拉", + "description": "自制蔬菜沙拉", + "categoryKey": "dishes", + "caloriesPer100g": 120, + "proteinPer100g": 5.2, + "carbohydratePer100g": 15.3, + "fatPer100g": 8.1, + "fiberPer100g": 3.2, + "sugarPer100g": 2.5, + "sodiumPer100g": 150 + }' + +echo -e "\n\n3. 测试搜索食物(包含用户自定义食物)" +curl -X GET "$BASE_URL/food-library/search?keyword=沙拉" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" + +echo -e "\n\n4. 测试获取食物详情" +# 请替换为实际的食物ID +FOOD_ID="1" +curl -X GET "$BASE_URL/food-library/$FOOD_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" + +echo -e "\n\n5. 测试删除用户自定义食物" +# 请替换为实际的自定义食物ID +CUSTOM_FOOD_ID="1" +curl -X DELETE "$BASE_URL/food-library/custom/$CUSTOM_FOOD_ID" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H "Content-Type: application/json" + +echo -e "\n\n=== 测试完成 ===" \ No newline at end of file