feat: 支持用户自定义食物
This commit is contained in:
130
CUSTOM_FOODS_IMPLEMENTATION.md
Normal file
130
CUSTOM_FOODS_IMPLEMENTATION.md
Normal file
@@ -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 <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 搜索食物
|
||||||
|
```
|
||||||
|
GET /food-library/search?keyword=关键词
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建自定义食物
|
||||||
|
```
|
||||||
|
POST /food-library/custom
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除自定义食物
|
||||||
|
```
|
||||||
|
DELETE /food-library/custom/{id}
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取食物详情
|
||||||
|
```
|
||||||
|
GET /food-library/{id}
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
- `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. **食物模板**:可以提供常见食物的营养模板,方便用户快速创建
|
||||||
26
sql-scripts/user-custom-foods-table.sql
Normal file
26
sql-scripts/user-custom-foods-table.sql
Normal file
@@ -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='用户自定义食物表';
|
||||||
127
src/food-library/USER_CUSTOM_FOODS.md
Normal file
127
src/food-library/USER_CUSTOM_FOODS.md
Normal file
@@ -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 <access_token>
|
||||||
|
```
|
||||||
|
返回按分类组织的食物列表,包含系统食物和用户自定义食物。用户自定义食物会显示在对应的分类中。
|
||||||
|
|
||||||
|
### 2. 搜索食物(包含用户自定义食物)
|
||||||
|
```
|
||||||
|
GET /food-library/search?keyword=关键词
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
根据关键词搜索食物,包含系统食物和用户自定义食物。用户自定义食物会优先显示。
|
||||||
|
|
||||||
|
### 3. 创建用户自定义食物
|
||||||
|
```
|
||||||
|
POST /food-library/custom
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
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 <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 获取食物详情(支持系统食物和用户自定义食物)
|
||||||
|
```
|
||||||
|
GET /food-library/{id}
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特殊逻辑
|
||||||
|
|
||||||
|
### 食物显示规则
|
||||||
|
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);
|
||||||
|
```
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, IsNotEmpty, IsOptional, IsNumber, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
export class FoodItemDto {
|
export class FoodItemDto {
|
||||||
@ApiProperty({ description: '食物ID' })
|
@ApiProperty({ description: '食物ID' })
|
||||||
@@ -42,6 +43,9 @@ export class FoodItemDto {
|
|||||||
|
|
||||||
@ApiProperty({ description: '排序', required: false })
|
@ApiProperty({ description: '排序', required: false })
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否为用户自定义食物', required: false })
|
||||||
|
isCustom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FoodCategoryDto {
|
export class FoodCategoryDto {
|
||||||
@@ -68,3 +72,73 @@ export class FoodLibraryResponseDto {
|
|||||||
@ApiProperty({ description: '食物分类列表', type: [FoodCategoryDto] })
|
@ApiProperty({ description: '食物分类列表', type: [FoodCategoryDto] })
|
||||||
categories: 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<string, any>;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '食物图片URL', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: '图片URL必须是字符串' })
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Controller, Get, Query, Param, ParseIntPipe, NotFoundException } from '@nestjs/common';
|
import { Controller, Get, Post, Delete, Query, Param, ParseIntPipe, NotFoundException, Body, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { FoodLibraryService } from './food-library.service';
|
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('食物库')
|
@ApiTags('食物库')
|
||||||
@Controller('food-library')
|
@Controller('food-library')
|
||||||
@@ -15,8 +18,10 @@ export class FoodLibraryController {
|
|||||||
description: '成功获取食物库列表',
|
description: '成功获取食物库列表',
|
||||||
type: FoodLibraryResponseDto,
|
type: FoodLibraryResponseDto,
|
||||||
})
|
})
|
||||||
async getFoodLibrary(): Promise<FoodLibraryResponseDto> {
|
@ApiBearerAuth()
|
||||||
return this.foodLibraryService.getFoodLibrary();
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getFoodLibrary(@CurrentUser() user: AccessTokenPayload): Promise<FoodLibraryResponseDto> {
|
||||||
|
return this.foodLibraryService.getFoodLibrary(user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('search')
|
@Get('search')
|
||||||
@@ -27,11 +32,56 @@ export class FoodLibraryController {
|
|||||||
description: '成功搜索食物',
|
description: '成功搜索食物',
|
||||||
type: [FoodItemDto],
|
type: [FoodItemDto],
|
||||||
})
|
})
|
||||||
async searchFoods(@Query('keyword') keyword: string): Promise<FoodItemDto[]> {
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async searchFoods(
|
||||||
|
@Query('keyword') keyword: string,
|
||||||
|
@CurrentUser() user: AccessTokenPayload
|
||||||
|
): Promise<FoodItemDto[]> {
|
||||||
if (!keyword || keyword.trim().length === 0) {
|
if (!keyword || keyword.trim().length === 0) {
|
||||||
return [];
|
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<FoodItemDto> {
|
||||||
|
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')
|
@Get(':id')
|
||||||
@@ -46,8 +96,13 @@ export class FoodLibraryController {
|
|||||||
status: 404,
|
status: 404,
|
||||||
description: '食物不存在',
|
description: '食物不存在',
|
||||||
})
|
})
|
||||||
async getFoodById(@Param('id', ParseIntPipe) id: number): Promise<FoodItemDto> {
|
@ApiBearerAuth()
|
||||||
const food = await this.foodLibraryService.getFoodById(id);
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getFoodById(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@CurrentUser() user: AccessTokenPayload
|
||||||
|
): Promise<FoodItemDto> {
|
||||||
|
const food = await this.foodLibraryService.getFoodById(id, user.sub);
|
||||||
if (!food) {
|
if (!food) {
|
||||||
throw new NotFoundException('食物不存在');
|
throw new NotFoundException('食物不存在');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { FoodLibraryController } from './food-library.controller';
|
|||||||
import { FoodLibraryService } from './food-library.service';
|
import { FoodLibraryService } from './food-library.service';
|
||||||
import { FoodCategory } from './models/food-category.model';
|
import { FoodCategory } from './models/food-category.model';
|
||||||
import { FoodLibrary } from './models/food-library.model';
|
import { FoodLibrary } from './models/food-library.model';
|
||||||
|
import { UserCustomFood } from './models/user-custom-food.model';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SequelizeModule.forFeature([FoodCategory, FoodLibrary]),
|
SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood]),
|
||||||
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [FoodLibraryController],
|
controllers: [FoodLibraryController],
|
||||||
providers: [FoodLibraryService],
|
providers: [FoodLibraryService],
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { InjectModel } from '@nestjs/sequelize';
|
|||||||
import { Op } from 'sequelize';
|
import { Op } from 'sequelize';
|
||||||
import { FoodCategory } from './models/food-category.model';
|
import { FoodCategory } from './models/food-category.model';
|
||||||
import { FoodLibrary } from './models/food-library.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()
|
@Injectable()
|
||||||
export class FoodLibraryService {
|
export class FoodLibraryService {
|
||||||
@@ -12,10 +13,12 @@ export class FoodLibraryService {
|
|||||||
private readonly foodCategoryModel: typeof FoodCategory,
|
private readonly foodCategoryModel: typeof FoodCategory,
|
||||||
@InjectModel(FoodLibrary)
|
@InjectModel(FoodLibrary)
|
||||||
private readonly foodLibraryModel: typeof FoodLibrary,
|
private readonly foodLibraryModel: typeof FoodLibrary,
|
||||||
|
@InjectModel(UserCustomFood)
|
||||||
|
private readonly userCustomFoodModel: typeof UserCustomFood,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将食物模型转换为DTO
|
* 将系统食物模型转换为DTO
|
||||||
*/
|
*/
|
||||||
private mapFoodToDto(food: FoodLibrary): FoodItemDto {
|
private mapFoodToDto(food: FoodLibrary): FoodItemDto {
|
||||||
return {
|
return {
|
||||||
@@ -33,24 +36,47 @@ export class FoodLibraryService {
|
|||||||
isCommon: food.isCommon,
|
isCommon: food.isCommon,
|
||||||
imageUrl: food.imageUrl,
|
imageUrl: food.imageUrl,
|
||||||
sortOrder: food.sortOrder,
|
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<FoodLibraryResponseDto> {
|
async getFoodLibrary(userId?: string): Promise<FoodLibraryResponseDto> {
|
||||||
try {
|
try {
|
||||||
// 一次性获取所有分类和食物数据,避免N+1查询
|
// 分别获取所有数据
|
||||||
const [categories, allFoods, commonFoods] = await Promise.all([
|
const [categories, allSystemFoods, commonFoods, userCustomFoods] = await Promise.all([
|
||||||
// 获取所有分类
|
// 获取所有分类
|
||||||
this.foodCategoryModel.findAll({
|
this.foodCategoryModel.findAll({
|
||||||
order: [['sortOrder', 'ASC']],
|
order: [['sortOrder', 'ASC']],
|
||||||
}),
|
}),
|
||||||
// 获取所有非常见食物
|
// 获取所有系统食物
|
||||||
this.foodLibraryModel.findAll({
|
this.foodLibraryModel.findAll({
|
||||||
where: { isCommon: false },
|
|
||||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||||
}),
|
}),
|
||||||
// 获取所有常见食物
|
// 获取所有常见食物
|
||||||
@@ -58,28 +84,37 @@ export class FoodLibraryService {
|
|||||||
where: { isCommon: true },
|
where: { isCommon: true },
|
||||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||||
}),
|
}),
|
||||||
|
// 获取用户自定义食物(如果有用户ID)
|
||||||
|
userId ? this.userCustomFoodModel.findAll({
|
||||||
|
where: { userId },
|
||||||
|
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||||
|
}) : Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 将食物按分类分组
|
// 将系统食物按分类分组
|
||||||
const foodsByCategory = new Map<string, FoodLibrary[]>();
|
const systemFoodsByCategory = new Map<string, FoodLibrary[]>();
|
||||||
allFoods.forEach(food => {
|
allSystemFoods.forEach((food: FoodLibrary) => {
|
||||||
const categoryKey = food.categoryKey;
|
const categoryKey = food.categoryKey;
|
||||||
if (!foodsByCategory.has(categoryKey)) {
|
if (!systemFoodsByCategory.has(categoryKey)) {
|
||||||
foodsByCategory.set(categoryKey, []);
|
systemFoodsByCategory.set(categoryKey, []);
|
||||||
}
|
}
|
||||||
foodsByCategory.get(categoryKey)!.push(food);
|
systemFoodsByCategory.get(categoryKey)!.push(food);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 构建结果
|
// 构建结果
|
||||||
const result: FoodCategoryDto[] = categories.map(category => {
|
const result: FoodCategoryDto[] = categories.map((category: FoodCategory) => {
|
||||||
let foods: FoodLibrary[] = [];
|
let allFoods: FoodItemDto[] = [];
|
||||||
|
|
||||||
if (category.key === 'common') {
|
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 {
|
} else {
|
||||||
// 其他分类:使用该分类下的非常见食物
|
// 其他分类:只包含系统食物
|
||||||
foods = foodsByCategory.get(category.key) || [];
|
const systemFoods = systemFoodsByCategory.get(category.key) || [];
|
||||||
|
allFoods = systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -88,7 +123,7 @@ export class FoodLibraryService {
|
|||||||
icon: category.icon,
|
icon: category.icon,
|
||||||
sortOrder: category.sortOrder,
|
sortOrder: category.sortOrder,
|
||||||
isSystem: category.isSystem,
|
isSystem: category.isSystem,
|
||||||
foods: foods.map(food => this.mapFoodToDto(food)),
|
foods: allFoods,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,48 +134,129 @@ export class FoodLibraryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据关键词搜索食物
|
* 根据关键词搜索食物(包含系统食物和用户自定义食物)
|
||||||
*/
|
*/
|
||||||
async searchFoods(keyword: string): Promise<FoodItemDto[]> {
|
async searchFoods(keyword: string, userId?: string): Promise<FoodItemDto[]> {
|
||||||
try {
|
try {
|
||||||
if (!keyword || keyword.trim().length === 0) {
|
if (!keyword || keyword.trim().length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const foods = await this.foodLibraryModel.findAll({
|
const [systemFoods, customFoods] = await Promise.all([
|
||||||
where: {
|
// 搜索系统食物
|
||||||
name: {
|
this.foodLibraryModel.findAll({
|
||||||
[Op.like]: `%${keyword.trim()}%`
|
where: {
|
||||||
}
|
name: {
|
||||||
},
|
[Op.like]: `%${keyword.trim()}%`
|
||||||
order: [['isCommon', 'DESC'], ['name', 'ASC']],
|
}
|
||||||
limit: 50,
|
},
|
||||||
});
|
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) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to search foods: ${error.message}`);
|
throw new Error(`Failed to search foods: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据ID获取食物详情
|
* 根据ID获取食物详情(支持系统食物和用户自定义食物)
|
||||||
*/
|
*/
|
||||||
async getFoodById(id: number): Promise<FoodItemDto | null> {
|
async getFoodById(id: number, userId?: string): Promise<FoodItemDto | null> {
|
||||||
try {
|
try {
|
||||||
if (!id || id <= 0) {
|
if (!id || id <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const food = await this.foodLibraryModel.findByPk(id);
|
// 先尝试从系统食物中查找
|
||||||
|
const systemFood = await this.foodLibraryModel.findByPk(id);
|
||||||
if (!food) {
|
if (systemFood) {
|
||||||
return null;
|
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) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to get food by id: ${error.message}`);
|
throw new Error(`Failed to get food by id: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户自定义食物
|
||||||
|
*/
|
||||||
|
async createCustomFood(userId: string, createCustomFoodDto: CreateCustomFoodDto): Promise<FoodItemDto> {
|
||||||
|
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<boolean> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
114
src/food-library/models/user-custom-food.model.ts
Normal file
114
src/food-library/models/user-custom-food.model.ts
Normal file
@@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
53
test-custom-foods.sh
Normal file
53
test-custom-foods.sh
Normal file
@@ -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=== 测试完成 ==="
|
||||||
Reference in New Issue
Block a user