diff --git a/sql-scripts/user-food-favorites-table-create.sql b/sql-scripts/user-food-favorites-table-create.sql new file mode 100644 index 0000000..7ec208c --- /dev/null +++ b/sql-scripts/user-food-favorites-table-create.sql @@ -0,0 +1,14 @@ +-- 用户食物收藏表 +CREATE TABLE IF NOT EXISTS `t_user_food_favorites` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` VARCHAR(255) NOT NULL COMMENT '用户ID', + `food_id` BIGINT NOT NULL COMMENT '食物ID', + `food_type` ENUM('system', 'custom') NOT NULL DEFAULT 'system' COMMENT '食物类型(system: 系统食物, custom: 用户自定义食物)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_food` (`user_id`, `food_id`, `food_type`), + KEY `idx_user_id` (`user_id`), + KEY `idx_food_id` (`food_id`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户食物收藏表'; \ No newline at end of file diff --git a/src/food-library/dto/food-library.dto.ts b/src/food-library/dto/food-library.dto.ts index 24f4eb4..d7c0a40 100644 --- a/src/food-library/dto/food-library.dto.ts +++ b/src/food-library/dto/food-library.dto.ts @@ -46,6 +46,9 @@ export class FoodItemDto { @ApiProperty({ description: '是否为用户自定义食物', required: false }) isCustom?: boolean; + + @ApiProperty({ description: '是否已收藏', required: false }) + isFavorite?: boolean; } export class FoodCategoryDto { diff --git a/src/food-library/food-library.controller.ts b/src/food-library/food-library.controller.ts index 3cd336e..1e321e1 100644 --- a/src/food-library/food-library.controller.ts +++ b/src/food-library/food-library.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Delete, Query, Param, ParseIntPipe, NotFoundException, Body, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Delete, Query, Param, ParseIntPipe, NotFoundException, Body, UseGuards, HttpStatus } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; import { FoodLibraryService } from './food-library.service'; import { FoodLibraryResponseDto, FoodItemDto, CreateCustomFoodDto } from './dto/food-library.dto'; @@ -108,4 +108,45 @@ export class FoodLibraryController { } return food; } + + @Post(':id/favorite') + @ApiOperation({ summary: '收藏食物' }) + @ApiParam({ name: 'id', description: '食物ID', type: 'number' }) + @ApiResponse({ + status: 201, + description: '成功收藏食物', + }) + @ApiResponse({ + status: 404, + description: '食物不存在', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async favoriteFood( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AccessTokenPayload + ): Promise<{ success: boolean }> { + const success = await this.foodLibraryService.favoriteFood(user.sub, id); + if (!success) { + throw new NotFoundException('食物不存在'); + } + return { success }; + } + + @Delete(':id/favorite') + @ApiOperation({ summary: '取消收藏食物' }) + @ApiParam({ name: 'id', description: '食物ID', type: 'number' }) + @ApiResponse({ + status: 200, + description: '成功取消收藏食物', + }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async unfavoriteFood( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AccessTokenPayload + ): Promise<{ success: boolean }> { + const success = await this.foodLibraryService.unfavoriteFood(user.sub, id); + return { success }; + } } \ No newline at end of file diff --git a/src/food-library/food-library.module.ts b/src/food-library/food-library.module.ts index 545c8c4..03f4dee 100644 --- a/src/food-library/food-library.module.ts +++ b/src/food-library/food-library.module.ts @@ -5,11 +5,12 @@ 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 { UserFoodFavorite } from './models/user-food-favorite.model'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ - SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood]), + SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood, UserFoodFavorite]), UsersModule, ], controllers: [FoodLibraryController], diff --git a/src/food-library/food-library.service.ts b/src/food-library/food-library.service.ts index 9373b3a..d177835 100644 --- a/src/food-library/food-library.service.ts +++ b/src/food-library/food-library.service.ts @@ -4,6 +4,7 @@ import { Op } from 'sequelize'; import { FoodCategory } from './models/food-category.model'; import { FoodLibrary } from './models/food-library.model'; import { UserCustomFood } from './models/user-custom-food.model'; +import { UserFoodFavorite } from './models/user-food-favorite.model'; import { FoodCategoryDto, FoodItemDto, FoodLibraryResponseDto, CreateCustomFoodDto } from './dto/food-library.dto'; @Injectable() @@ -15,12 +16,14 @@ export class FoodLibraryService { private readonly foodLibraryModel: typeof FoodLibrary, @InjectModel(UserCustomFood) private readonly userCustomFoodModel: typeof UserCustomFood, + @InjectModel(UserFoodFavorite) + private readonly userFoodFavoriteModel: typeof UserFoodFavorite, ) { } /** * 将系统食物模型转换为DTO */ - private mapFoodToDto(food: FoodLibrary): FoodItemDto { + private mapFoodToDto(food: FoodLibrary, isFavorite: boolean = false): FoodItemDto { return { id: food.id, name: food.name, @@ -37,13 +40,14 @@ export class FoodLibraryService { imageUrl: food.imageUrl, sortOrder: food.sortOrder, isCustom: false, + isFavorite, }; } /** * 将用户自定义食物模型转换为DTO */ - private mapCustomFoodToDto(food: UserCustomFood): FoodItemDto { + private mapCustomFoodToDto(food: UserCustomFood, isFavorite: boolean = false): FoodItemDto { return { id: food.id, name: food.name, @@ -60,6 +64,7 @@ export class FoodLibraryService { imageUrl: food.imageUrl, sortOrder: food.sortOrder, isCustom: true, + isFavorite, }; } @@ -69,6 +74,9 @@ export class FoodLibraryService { */ async getFoodLibrary(userId?: string): Promise { try { + // 获取用户收藏的食物ID + const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set(), customIds: new Set() }; + // 分别获取所有数据 const [categories, allSystemFoods, commonFoods, userCustomFoods] = await Promise.all([ // 获取所有分类 @@ -107,14 +115,14 @@ export class FoodLibraryService { if (category.key === 'common') { // 常见分类:使用常见食物(只包含系统食物) - allFoods = commonFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)); + allFoods = commonFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id))); } else if (category.key === 'custom') { // 自定义分类:只包含用户自定义食物 - allFoods = userCustomFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food)); + allFoods = userCustomFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food, favoriteIds.customIds.has(food.id))); } else { // 其他分类:只包含系统食物 const systemFoods = systemFoodsByCategory.get(category.key) || []; - allFoods = systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)); + allFoods = systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id))); } return { @@ -142,6 +150,9 @@ export class FoodLibraryService { return []; } + // 获取用户收藏的食物ID + const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set(), customIds: new Set() }; + const [systemFoods, customFoods] = await Promise.all([ // 搜索系统食物 this.foodLibraryModel.findAll({ @@ -168,8 +179,8 @@ export class FoodLibraryService { // 合并结果,用户自定义食物优先显示 const allFoods: FoodItemDto[] = [ - ...customFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food)), - ...systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)), + ...customFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food, favoriteIds.customIds.has(food.id))), + ...systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id))), ]; return allFoods; @@ -187,10 +198,13 @@ export class FoodLibraryService { return null; } + // 获取用户收藏的食物ID + const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set(), customIds: new Set() }; + // 先尝试从系统食物中查找 const systemFood = await this.foodLibraryModel.findByPk(id); if (systemFood) { - return this.mapFoodToDto(systemFood); + return this.mapFoodToDto(systemFood, favoriteIds.systemIds.has(id)); } // 如果提供了用户ID,则从用户自定义食物中查找 @@ -199,7 +213,7 @@ export class FoodLibraryService { where: { id, userId } }); if (customFood) { - return this.mapCustomFoodToDto(customFood); + return this.mapCustomFoodToDto(customFood, favoriteIds.customIds.has(id)); } } @@ -236,7 +250,7 @@ export class FoodLibraryService { sortOrder: maxSortOrder + 1, }); - return this.mapCustomFoodToDto(customFood); + return this.mapCustomFoodToDto(customFood, false); // 新创建的食物默认未收藏 } catch (error) { throw new Error(`Failed to create custom food: ${error.message}`); } @@ -259,4 +273,79 @@ export class FoodLibraryService { throw new Error(`Failed to delete custom food: ${error.message}`); } } + + /** + * 获取用户收藏的食物ID列表 + */ + private async getUserFavoriteIds(userId: string): Promise<{ systemIds: Set, customIds: Set }> { + try { + const favorites = await this.userFoodFavoriteModel.findAll({ + where: { userId }, + attributes: ['foodId', 'foodType'] + }); + + const systemIds = new Set(); + const customIds = new Set(); + + favorites.forEach(fav => { + if (fav.foodType === 'system') { + systemIds.add(fav.foodId); + } else { + customIds.add(fav.foodId); + } + }); + + return { systemIds, customIds }; + } catch (error) { + throw new Error(`Failed to get user favorite ids: ${error.message}`); + } + } + + /** + * 收藏食物 + */ + async favoriteFood(userId: string, foodId: number): Promise { + try { + // 首先检查食物是否存在(系统食物或用户自定义食物) + const [systemFood, customFood] = await Promise.all([ + this.foodLibraryModel.findByPk(foodId), + this.userCustomFoodModel.findOne({ where: { id: foodId, userId } }) + ]); + + if (!systemFood && !customFood) { + return false; + } + + const foodType = systemFood ? 'system' : 'custom'; + + // 使用 upsert 来处理重复收藏 + await this.userFoodFavoriteModel.upsert({ + userId, + foodId, + foodType + }); + + return true; + } catch (error) { + throw new Error(`Failed to favorite food: ${error.message}`); + } + } + + /** + * 取消收藏食物 + */ + async unfavoriteFood(userId: string, foodId: number): Promise { + try { + const result = await this.userFoodFavoriteModel.destroy({ + where: { + userId, + foodId + } + }); + + return result > 0; + } catch (error) { + throw new Error(`Failed to unfavorite food: ${error.message}`); + } + } } \ No newline at end of file diff --git a/src/food-library/models/user-food-favorite.model.ts b/src/food-library/models/user-food-favorite.model.ts new file mode 100644 index 0000000..bc4324d --- /dev/null +++ b/src/food-library/models/user-food-favorite.model.ts @@ -0,0 +1,43 @@ +import { Column, DataType, Model, Table } from 'sequelize-typescript'; + +@Table({ + tableName: 't_user_food_favorites', + underscored: true, +}) +export class UserFoodFavorite 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.BIGINT, + allowNull: false, + comment: '食物ID', + }) + declare foodId: number; + + @Column({ + type: DataType.ENUM('system', 'custom'), + allowNull: false, + defaultValue: 'system', + comment: '食物类型(system: 系统食物, custom: 用户自定义食物)', + }) + declare foodType: 'system' | 'custom'; + + @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