feat: 新增食物库模块,含模型、服务、API及初始化数据

This commit is contained in:
richarjiang
2025-08-29 09:06:18 +08:00
parent a1c21d8a23
commit 74faebd73d
10 changed files with 721 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ import { WorkoutsModule } from './workouts/workouts.module';
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
import { GoalsModule } from './goals/goals.module';
import { DietRecordsModule } from './diet-records/diet-records.module';
import { FoodLibraryModule } from './food-library/food-library.module';
@Module({
imports: [
@@ -37,6 +38,7 @@ import { DietRecordsModule } from './diet-records/diet-records.module';
MoodCheckinsModule,
GoalsModule,
DietRecordsModule,
FoodLibraryModule,
],
controllers: [AppController],
providers: [AppService],

View File

@@ -0,0 +1,94 @@
# 食物库功能
## 功能概述
食物库功能提供了一个完整的食物数据库,包含各种食物的营养信息。用户可以通过分类浏览或搜索来查找食物。
## 数据库设计
### 食物分类表 (t_food_categories)
- `key`: 分类唯一键common, fruits_vegetables等
- `name`: 分类中文名称(如:常见、水果蔬菜等)
- `icon`: 分类图标(可选)
- `sort_order`: 排序顺序
- `is_system`: 是否系统分类
### 食物库表 (t_food_library)
- `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格式
- `is_common`: 是否常见食物
- `image_url`: 食物图片URL
- `sort_order`: 排序顺序
## 特殊逻辑
### 常见食物分类
- 标记为 `is_common = true` 的食物会显示在"常见"分类中
- 其他分类只显示 `is_common = false` 的食物
- 这样避免了食物在多个分类中重复显示
## API接口
### 1. 获取食物库列表
```
GET /food-library
```
返回按分类组织的食物列表,常见食物会归类到"常见"分类中。
### 2. 搜索食物
```
GET /food-library/search?keyword=关键词
```
根据关键词搜索食物,常见食物会优先显示。
### 3. 获取食物详情
```
GET /food-library/:id
```
根据ID获取特定食物的详细信息。
## 数据初始化
1. 执行表结构创建脚本:
```bash
mysql -u root -p pilates_db < sql-scripts/food-library-tables-create.sql
```
2. 插入示例数据:
```bash
mysql -u root -p pilates_db < sql-scripts/food-library-sample-data.sql
```
## 客户端界面对应
根据提供的客户端界面,食物库包含以下分类:
- 常见:显示标记为常见的食物
- 水果蔬菜fruits_vegetables 分类的非常见食物
- 肉蛋奶meat_eggs_dairy 分类的非常见食物
- 豆类坚果beans_nuts 分类的非常见食物
- 零食饮料snacks_drinks 分类的非常见食物
- 主食staple_food 分类的非常见食物
- 菜肴dishes 分类的非常见食物
每个食物显示:
- 食物名称
- 营养信息139千卡/100克
- 添加按钮(+
## 扩展功能
未来可以扩展的功能:
- 用户自定义食物
- 收藏食物功能
- 食物图片上传
- 营养成分详细分析
- 食物推荐算法

View File

@@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger';
export class FoodItemDto {
@ApiProperty({ description: '食物ID' })
id: number;
@ApiProperty({ description: '食物名称' })
name: string;
@ApiProperty({ description: '食物描述', required: false })
description?: string;
@ApiProperty({ description: '每100克热量卡路里', required: false })
caloriesPer100g?: number;
@ApiProperty({ description: '每100克蛋白质含量', required: false })
proteinPer100g?: number;
@ApiProperty({ description: '每100克碳水化合物含量', required: false })
carbohydratePer100g?: number;
@ApiProperty({ description: '每100克脂肪含量', required: false })
fatPer100g?: number;
@ApiProperty({ description: '每100克膳食纤维含量', required: false })
fiberPer100g?: number;
@ApiProperty({ description: '每100克糖分含量', required: false })
sugarPer100g?: number;
@ApiProperty({ description: '每100克钠含量毫克', required: false })
sodiumPer100g?: number;
@ApiProperty({ description: '其他营养信息', required: false })
additionalNutrition?: Record<string, any>;
@ApiProperty({ description: '是否常见食物' })
isCommon: boolean;
@ApiProperty({ description: '食物图片URL', required: false })
imageUrl?: string;
@ApiProperty({ description: '排序', required: false })
sortOrder?: number;
}
export class FoodCategoryDto {
@ApiProperty({ description: '分类键' })
key: string;
@ApiProperty({ description: '分类名称' })
name: string;
@ApiProperty({ description: '分类图标', required: false })
icon?: string;
@ApiProperty({ description: '排序', required: false })
sortOrder?: number;
@ApiProperty({ description: '是否系统分类' })
isSystem: boolean;
@ApiProperty({ description: '该分类下的食物列表', type: [FoodItemDto] })
foods: FoodItemDto[];
}
export class FoodLibraryResponseDto {
@ApiProperty({ description: '食物分类列表', type: [FoodCategoryDto] })
categories: FoodCategoryDto[];
}

View File

@@ -0,0 +1,56 @@
import { Controller, Get, Query, Param, ParseIntPipe, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam } from '@nestjs/swagger';
import { FoodLibraryService } from './food-library.service';
import { FoodLibraryResponseDto, FoodItemDto } from './dto/food-library.dto';
@ApiTags('食物库')
@Controller('food-library')
export class FoodLibraryController {
constructor(private readonly foodLibraryService: FoodLibraryService) { }
@Get()
@ApiOperation({ summary: '获取食物库列表' })
@ApiResponse({
status: 200,
description: '成功获取食物库列表',
type: FoodLibraryResponseDto,
})
async getFoodLibrary(): Promise<FoodLibraryResponseDto> {
return this.foodLibraryService.getFoodLibrary();
}
@Get('search')
@ApiOperation({ summary: '搜索食物' })
@ApiQuery({ name: 'keyword', description: '搜索关键词', required: true })
@ApiResponse({
status: 200,
description: '成功搜索食物',
type: [FoodItemDto],
})
async searchFoods(@Query('keyword') keyword: string): Promise<FoodItemDto[]> {
if (!keyword || keyword.trim().length === 0) {
return [];
}
return this.foodLibraryService.searchFoods(keyword.trim());
}
@Get(':id')
@ApiOperation({ summary: '根据ID获取食物详情' })
@ApiParam({ name: 'id', description: '食物ID', type: 'number' })
@ApiResponse({
status: 200,
description: '成功获取食物详情',
type: FoodItemDto,
})
@ApiResponse({
status: 404,
description: '食物不存在',
})
async getFoodById(@Param('id', ParseIntPipe) id: number): Promise<FoodItemDto> {
const food = await this.foodLibraryService.getFoodById(id);
if (!food) {
throw new NotFoundException('食物不存在');
}
return food;
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { SequelizeModule } from '@nestjs/sequelize';
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';
@Module({
imports: [
SequelizeModule.forFeature([FoodCategory, FoodLibrary]),
],
controllers: [FoodLibraryController],
providers: [FoodLibraryService],
exports: [FoodLibraryService],
})
export class FoodLibraryModule { }

View File

@@ -0,0 +1,138 @@
import { Injectable } from '@nestjs/common';
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';
@Injectable()
export class FoodLibraryService {
constructor(
@InjectModel(FoodCategory)
private readonly foodCategoryModel: typeof FoodCategory,
@InjectModel(FoodLibrary)
private readonly foodLibraryModel: typeof FoodLibrary,
) { }
/**
* 获取食物库列表,按分类组织
* 常见食物会被归类到"常见"分类中
*/
async getFoodLibrary(): Promise<FoodLibraryResponseDto> {
// 获取所有分类,按排序顺序
const categories = await this.foodCategoryModel.findAll({
order: [['sortOrder', 'ASC']],
});
const result: FoodCategoryDto[] = [];
for (const category of categories) {
let foods: FoodLibrary[] = [];
if (category.key === 'common') {
// 常见分类:获取所有标记为常见的食物
foods = await this.foodLibraryModel.findAll({
where: { isCommon: true },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
});
} else {
// 其他分类:获取该分类下的非常见食物
foods = await this.foodLibraryModel.findAll({
where: {
categoryKey: category.key,
isCommon: false
},
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
});
}
const foodDtos: FoodItemDto[] = foods.map(food => ({
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: food.isCommon,
imageUrl: food.imageUrl,
sortOrder: food.sortOrder,
}));
result.push({
key: category.key,
name: category.name,
icon: category.icon,
sortOrder: category.sortOrder,
isSystem: category.isSystem,
foods: foodDtos,
});
}
return { categories: result };
}
/**
* 根据关键词搜索食物
*/
async searchFoods(keyword: string): Promise<FoodItemDto[]> {
const foods = await this.foodLibraryModel.findAll({
where: {
name: {
[Op.like]: `%${keyword}%`
}
},
order: [['isCommon', 'DESC'], ['name', 'ASC']],
limit: 50, // 限制搜索结果数量
});
return foods.map(food => ({
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: food.isCommon,
imageUrl: food.imageUrl,
sortOrder: food.sortOrder,
}));
}
/**
* 根据ID获取食物详情
*/
async getFoodById(id: number): Promise<FoodItemDto | null> {
const food = await this.foodLibraryModel.findByPk(id);
if (!food) {
return null;
}
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: food.isCommon,
imageUrl: food.imageUrl,
sortOrder: food.sortOrder,
};
}
}

View File

@@ -0,0 +1,54 @@
import { Column, DataType, HasMany, Model, Table } from 'sequelize-typescript';
import { FoodLibrary } from './food-library.model';
@Table({
tableName: 't_food_categories',
underscored: true,
})
export class FoodCategory extends Model {
@Column({
type: DataType.STRING,
primaryKey: true,
comment: '分类唯一键(英文/下划线)',
})
declare key: string;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '分类中文名称',
})
declare name: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '分类图标',
})
declare icon: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序(升序)',
})
declare sortOrder: number;
@Column({
type: DataType.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: '是否系统分类true系统false用户自定义',
})
declare isSystem: boolean;
@HasMany(() => FoodLibrary, { foreignKey: 'categoryKey', sourceKey: 'key' })
declare foods: FoodLibrary[];
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
}

View File

@@ -0,0 +1,126 @@
import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript';
import { FoodCategory } from './food-category.model';
@Table({
tableName: 't_food_library',
underscored: true,
})
export class FoodLibrary extends Model {
@Column({
type: DataType.BIGINT,
primaryKey: true,
autoIncrement: true,
comment: '主键ID',
})
declare id: number;
@Column({
type: DataType.STRING,
allowNull: false,
comment: '食物名称',
})
declare name: string;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物描述',
})
declare description: string;
@ForeignKey(() => FoodCategory)
@Column({
type: DataType.STRING,
allowNull: false,
comment: '分类键',
})
declare categoryKey: 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.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: '是否常见食物true常见false不常见',
})
declare isCommon: boolean;
@Column({
type: DataType.STRING,
allowNull: true,
comment: '食物图片URL',
})
declare imageUrl: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '排序(分类内)',
})
declare sortOrder: number;
@BelongsTo(() => FoodCategory, { foreignKey: 'categoryKey', targetKey: 'key' })
declare category: FoodCategory;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare createdAt: Date;
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
declare updatedAt: Date;
}