From 74faebd73d4114c7761dd6e9c079c50ace75a25d Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 29 Aug 2025 09:06:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A3=9F=E7=89=A9?= =?UTF-8?q?=E5=BA=93=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=90=AB=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E3=80=81=E6=9C=8D=E5=8A=A1=E3=80=81API=E5=8F=8A=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql-scripts/food-library-sample-data.sql | 85 +++++++++++ sql-scripts/food-library-tables-create.sql | 80 ++++++++++ src/app.module.ts | 2 + src/food-library/README.md | 94 ++++++++++++ src/food-library/dto/food-library.dto.ts | 70 +++++++++ src/food-library/food-library.controller.ts | 56 +++++++ src/food-library/food-library.module.ts | 16 ++ src/food-library/food-library.service.ts | 138 ++++++++++++++++++ .../models/food-category.model.ts | 54 +++++++ src/food-library/models/food-library.model.ts | 126 ++++++++++++++++ 10 files changed, 721 insertions(+) create mode 100644 sql-scripts/food-library-sample-data.sql create mode 100644 sql-scripts/food-library-tables-create.sql create mode 100644 src/food-library/README.md create mode 100644 src/food-library/dto/food-library.dto.ts create mode 100644 src/food-library/food-library.controller.ts create mode 100644 src/food-library/food-library.module.ts create mode 100644 src/food-library/food-library.service.ts create mode 100644 src/food-library/models/food-category.model.ts create mode 100644 src/food-library/models/food-library.model.ts diff --git a/sql-scripts/food-library-sample-data.sql b/sql-scripts/food-library-sample-data.sql new file mode 100644 index 0000000..a58247d --- /dev/null +++ b/sql-scripts/food-library-sample-data.sql @@ -0,0 +1,85 @@ +-- 插入更多示例食物数据 +-- 清空现有数据(如果需要重新初始化) +-- DELETE FROM `t_food_library`; +-- DELETE FROM `t_food_categories`; + +-- 插入食物分类数据 +INSERT IGNORE INTO `t_food_categories` (`key`, `name`, `sort_order`, `is_system`) VALUES +('common', '常见', 1, 1), +('fruits_vegetables', '水果蔬菜', 2, 1), +('meat_eggs_dairy', '肉蛋奶', 3, 1), +('beans_nuts', '豆类坚果', 4, 1), +('snacks_drinks', '零食饮料', 5, 1), +('staple_food', '主食', 6, 1), +('dishes', '菜肴', 7, 1); + +-- 插入常见食物(这些食物会显示在"常见"分类中) +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +-- 常见食物(会显示在常见分类中) +('无糖美式咖啡', 'snacks_drinks', 1, 0.1, 0, 0, 0, 0, 2, 1, 1), +('荷包蛋(油煎)', 'meat_eggs_dairy', 195, 13.3, 0.7, 15.3, 0, 0.7, 124, 1, 2), +('鸡蛋', 'meat_eggs_dairy', 139, 13.3, 2.8, 8.8, 0, 2.8, 131, 1, 3), +('香蕉', 'fruits_vegetables', 93, 1.4, 22.8, 0.2, 1.7, 17.2, 1, 1, 4), +('猕猴桃', 'fruits_vegetables', 61, 1.1, 14.7, 0.5, 3, 9, 3, 1, 5), +('苹果', 'fruits_vegetables', 53, 0.3, 14.1, 0.2, 2.4, 10.4, 1, 1, 6), +('草莓', 'fruits_vegetables', 32, 0.7, 7.7, 0.3, 2, 4.9, 1, 1, 7), +('蛋烧麦', 'staple_food', 157, 6.2, 22.2, 5.2, 1.1, 1.8, 230, 1, 8), +('米饭', 'staple_food', 116, 2.6, 25.9, 0.3, 0.3, 0.1, 5, 1, 9); + +-- 插入水果蔬菜分类的其他食物 +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +('橙子', 'fruits_vegetables', 47, 0.9, 11.8, 0.1, 2.4, 9.4, 0, 0, 1), +('葡萄', 'fruits_vegetables', 69, 0.7, 17.2, 0.2, 0.9, 16.3, 2, 0, 2), +('西瓜', 'fruits_vegetables', 30, 0.6, 7.6, 0.2, 0.4, 6.2, 1, 0, 3), +('菠菜', 'fruits_vegetables', 23, 2.9, 3.6, 0.4, 2.2, 0.4, 79, 0, 4), +('西兰花', 'fruits_vegetables', 34, 2.8, 7, 0.4, 2.6, 1.5, 33, 0, 5), +('胡萝卜', 'fruits_vegetables', 41, 0.9, 9.6, 0.2, 2.8, 4.7, 69, 0, 6), +('西红柿', 'fruits_vegetables', 18, 0.9, 3.9, 0.2, 1.2, 2.6, 5, 0, 7); + +-- 插入肉蛋奶分类的其他食物 +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +('鸡胸肉', 'meat_eggs_dairy', 165, 31, 0, 3.6, 0, 0, 74, 0, 1), +('牛肉', 'meat_eggs_dairy', 250, 26, 0, 15, 0, 0, 72, 0, 2), +('猪肉', 'meat_eggs_dairy', 242, 27, 0, 14, 0, 0, 58, 0, 3), +('三文鱼', 'meat_eggs_dairy', 208, 25, 0, 12, 0, 0, 44, 0, 4), +('牛奶', 'meat_eggs_dairy', 54, 3.4, 5.1, 1.9, 0, 5.1, 44, 0, 5), +('酸奶', 'meat_eggs_dairy', 99, 10, 3.6, 5.3, 0, 3.2, 36, 0, 6), +('奶酪', 'meat_eggs_dairy', 113, 25, 1.3, 0.2, 0, 1.3, 515, 0, 7); + +-- 插入豆类坚果分类的食物 +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +('黄豆', 'beans_nuts', 446, 36, 30, 20, 15, 7, 2, 0, 1), +('黑豆', 'beans_nuts', 341, 21, 63, 1.4, 15, 2.1, 2, 0, 2), +('红豆', 'beans_nuts', 309, 20, 63, 0.5, 12, 2.2, 2, 0, 3), +('核桃', 'beans_nuts', 654, 15, 14, 65, 6.7, 2.6, 2, 0, 4), +('杏仁', 'beans_nuts', 579, 21, 22, 50, 12, 4.4, 1, 0, 5), +('花生', 'beans_nuts', 567, 26, 16, 49, 8.5, 4.7, 18, 0, 6), +('腰果', 'beans_nuts', 553, 18, 30, 44, 3.3, 5.9, 12, 0, 7); + +-- 插入主食分类的其他食物 +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +('白面包', 'staple_food', 265, 9, 49, 3.2, 2.7, 5.7, 491, 0, 1), +('全麦面包', 'staple_food', 247, 13, 41, 4.2, 7, 6, 396, 0, 2), +('燕麦', 'staple_food', 389, 17, 66, 6.9, 10, 0.99, 2, 0, 3), +('小米', 'staple_food', 378, 11, 73, 4.2, 8.5, 1.7, 5, 0, 4), +('玉米', 'staple_food', 365, 9.4, 74, 4.7, 7.3, 6.3, 35, 0, 5), +('红薯', 'staple_food', 86, 1.6, 20, 0.1, 3, 4.2, 54, 0, 6), +('土豆', 'staple_food', 77, 2, 17, 0.1, 2.2, 0.8, 6, 0, 7); + +-- 插入零食饮料分类的其他食物 +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +('绿茶', 'snacks_drinks', 1, 0, 0, 0, 0, 0, 3, 0, 1), +('红茶', 'snacks_drinks', 1, 0, 0.3, 0, 0, 0, 3, 0, 2), +('柠檬水', 'snacks_drinks', 22, 0.4, 6.9, 0.2, 1.6, 1.5, 1, 0, 3), +('苏打水', 'snacks_drinks', 0, 0, 0, 0, 0, 0, 21, 0, 4), +('黑巧克力', 'snacks_drinks', 546, 7.8, 61, 31, 11, 48, 20, 0, 5), +('饼干', 'snacks_drinks', 502, 5.9, 68, 23, 2.1, 27, 386, 0, 6); + +-- 插入菜肴分类的食物 +INSERT IGNORE INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +('宫保鸡丁', 'dishes', 194, 18, 8, 11, 2, 4, 590, 0, 1), +('麻婆豆腐', 'dishes', 164, 11, 6, 12, 2, 3, 680, 0, 2), +('红烧肉', 'dishes', 395, 15, 8, 35, 1, 6, 720, 0, 3), +('清蒸鱼', 'dishes', 112, 20, 2, 3, 0, 1, 280, 0, 4), +('蒸蛋羹', 'dishes', 62, 5.8, 1.2, 4.1, 0, 1, 156, 0, 5), +('凉拌黄瓜', 'dishes', 16, 0.7, 3.6, 0.1, 0.5, 1.7, 6, 0, 6); \ No newline at end of file diff --git a/sql-scripts/food-library-tables-create.sql b/sql-scripts/food-library-tables-create.sql new file mode 100644 index 0000000..382311c --- /dev/null +++ b/sql-scripts/food-library-tables-create.sql @@ -0,0 +1,80 @@ +-- 创建食物分类表 +-- 该表用于存储食物的分类信息,如常见、水果蔬菜、肉蛋奶等 +CREATE TABLE IF NOT EXISTS `t_food_categories` ( + `key` varchar(50) NOT NULL COMMENT '分类唯一键(英文/下划线)', + `name` varchar(50) NOT NULL COMMENT '分类中文名称', + `icon` varchar(100) DEFAULT NULL COMMENT '分类图标', + `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序(升序)', + `is_system` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否系统分类(1:系统,0:用户自定义)', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`key`), + KEY `idx_sort_order` (`sort_order`), + KEY `idx_is_system` (`is_system`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物分类表'; + +-- 创建食物库表 +-- 该表用于存储食物的基本信息和营养成分 +CREATE TABLE IF NOT EXISTS `t_food_library` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `name` varchar(100) NOT NULL COMMENT '食物名称', + `description` varchar(500) DEFAULT NULL COMMENT '食物描述', + `category_key` varchar(50) NOT 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 '其他营养信息(维生素、矿物质等)', + `is_common` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否常见食物(1:常见,0:不常见)', + `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_category_key` (`category_key`), + KEY `idx_is_common` (`is_common`), + KEY `idx_name` (`name`), + KEY `idx_category_sort` (`category_key`, `sort_order`), + CONSTRAINT `fk_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='食物库表'; + +-- 插入食物分类数据 +INSERT INTO `t_food_categories` (`key`, `name`, `sort_order`, `is_system`) VALUES +('common', '常见', 1, 1), +('fruits_vegetables', '水果蔬菜', 2, 1), +('meat_eggs_dairy', '肉蛋奶', 3, 1), +('beans_nuts', '豆类坚果', 4, 1), +('snacks_drinks', '零食饮料', 5, 1), +('staple_food', '主食', 6, 1), +('dishes', '菜肴', 7, 1); + +-- 插入示例食物数据 +INSERT INTO `t_food_library` (`name`, `category_key`, `calories_per_100g`, `protein_per_100g`, `carbohydrate_per_100g`, `fat_per_100g`, `fiber_per_100g`, `sugar_per_100g`, `sodium_per_100g`, `is_common`, `sort_order`) VALUES +-- 常见食物 +('无糖美式咖啡', 'common', 1, 0.1, 0, 0, 0, 0, 2, 1, 1), +('荷包蛋(油煎)', 'common', 195, 13.3, 0.7, 15.3, 0, 0.7, 124, 1, 2), +('鸡蛋', 'common', 139, 13.3, 2.8, 8.8, 0, 2.8, 131, 1, 3), + +-- 水果蔬菜 +('香蕉', 'fruits_vegetables', 93, 1.4, 22.8, 0.2, 1.7, 17.2, 1, 1, 1), +('猕猴桃', 'fruits_vegetables', 61, 1.1, 14.7, 0.5, 3, 9, 3, 1, 2), +('苹果', 'fruits_vegetables', 53, 0.3, 14.1, 0.2, 2.4, 10.4, 1, 1, 3), +('草莓', 'fruits_vegetables', 32, 0.7, 7.7, 0.3, 2, 4.9, 1, 1, 4), + +-- 主食 +('蛋烧麦', 'staple_food', 157, 6.2, 22.2, 5.2, 1.1, 1.8, 230, 1, 1), +('米饭', 'staple_food', 116, 2.6, 25.9, 0.3, 0.3, 0.1, 5, 1, 2), + +-- 零食饮料 +('无糖美式咖啡', 'snacks_drinks', 1, 0.1, 0, 0, 0, 0, 2, 0, 1), + +-- 肉蛋奶 +('鸡蛋', 'meat_eggs_dairy', 139, 13.3, 2.8, 8.8, 0, 2.8, 131, 0, 1), +('荷包蛋(油煎)', 'meat_eggs_dairy', 195, 13.3, 0.7, 15.3, 0, 0.7, 124, 0, 2); + +-- 创建索引以优化查询性能 +CREATE INDEX `idx_food_common_category` ON `t_food_library` (`is_common`, `category_key`); +CREATE INDEX `idx_food_name_search` ON `t_food_library` (`name`); \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 495df97..f53e0a3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/food-library/README.md b/src/food-library/README.md new file mode 100644 index 0000000..01a5a3c --- /dev/null +++ b/src/food-library/README.md @@ -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克) +- 添加按钮(+) + +## 扩展功能 + +未来可以扩展的功能: +- 用户自定义食物 +- 收藏食物功能 +- 食物图片上传 +- 营养成分详细分析 +- 食物推荐算法 \ 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 new file mode 100644 index 0000000..a32ad2e --- /dev/null +++ b/src/food-library/dto/food-library.dto.ts @@ -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; + + @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[]; +} \ No newline at end of file diff --git a/src/food-library/food-library.controller.ts b/src/food-library/food-library.controller.ts new file mode 100644 index 0000000..06a9403 --- /dev/null +++ b/src/food-library/food-library.controller.ts @@ -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 { + 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 { + 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 { + const food = await this.foodLibraryService.getFoodById(id); + if (!food) { + throw new NotFoundException('食物不存在'); + } + return food; + } +} \ No newline at end of file diff --git a/src/food-library/food-library.module.ts b/src/food-library/food-library.module.ts new file mode 100644 index 0000000..7e34729 --- /dev/null +++ b/src/food-library/food-library.module.ts @@ -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 { } \ No newline at end of file diff --git a/src/food-library/food-library.service.ts b/src/food-library/food-library.service.ts new file mode 100644 index 0000000..ab1f971 --- /dev/null +++ b/src/food-library/food-library.service.ts @@ -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 { + // 获取所有分类,按排序顺序 + 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 { + 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 { + 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, + }; + } +} \ No newline at end of file diff --git a/src/food-library/models/food-category.model.ts b/src/food-library/models/food-category.model.ts new file mode 100644 index 0000000..2b4223a --- /dev/null +++ b/src/food-library/models/food-category.model.ts @@ -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; +} \ No newline at end of file diff --git a/src/food-library/models/food-library.model.ts b/src/food-library/models/food-library.model.ts new file mode 100644 index 0000000..82b089d --- /dev/null +++ b/src/food-library/models/food-library.model.ts @@ -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; + + @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; +} \ No newline at end of file