feat: 支持用户自定义食物

This commit is contained in:
richarjiang
2025-08-29 15:57:28 +08:00
parent c0bdb3bf0a
commit 6542988cb6
9 changed files with 748 additions and 50 deletions

View 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);
```

View File

@@ -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<string, any>;
@ApiProperty({ description: '食物图片URL', required: false })
@IsOptional()
@IsString({ message: '图片URL必须是字符串' })
imageUrl?: string;
}

View File

@@ -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<FoodLibraryResponseDto> {
return this.foodLibraryService.getFoodLibrary();
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getFoodLibrary(@CurrentUser() user: AccessTokenPayload): Promise<FoodLibraryResponseDto> {
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<FoodItemDto[]> {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async searchFoods(
@Query('keyword') keyword: string,
@CurrentUser() user: AccessTokenPayload
): Promise<FoodItemDto[]> {
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<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')
@@ -46,8 +96,13 @@ export class FoodLibraryController {
status: 404,
description: '食物不存在',
})
async getFoodById(@Param('id', ParseIntPipe) id: number): Promise<FoodItemDto> {
const food = await this.foodLibraryService.getFoodById(id);
@ApiBearerAuth()
@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) {
throw new NotFoundException('食物不存在');
}

View File

@@ -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],

View File

@@ -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<FoodLibraryResponseDto> {
async getFoodLibrary(userId?: string): Promise<FoodLibraryResponseDto> {
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<string, FoodLibrary[]>();
allFoods.forEach(food => {
// 将系统食物按分类分组
const systemFoodsByCategory = new Map<string, FoodLibrary[]>();
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<FoodItemDto[]> {
async searchFoods(keyword: string, userId?: string): Promise<FoodItemDto[]> {
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<FoodItemDto | null> {
async getFoodById(id: number, userId?: string): Promise<FoodItemDto | null> {
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<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}`);
}
}
}

View 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;
}