feat: 新增用户食物收藏功能

- 创建用户食物收藏表 `t_user_food_favorites`,用于存储用户收藏的食物信息。
- 在 `FoodLibraryController` 中添加收藏和取消收藏食物的 API 接口。
- 在 `FoodLibraryService` 中实现收藏和取消收藏的业务逻辑,并获取用户收藏的食物 ID 列表。
- 更新 DTO 以支持食物是否已收藏的状态。
This commit is contained in:
2025-08-29 21:03:55 +08:00
parent 6542988cb6
commit d0b02b6228
6 changed files with 203 additions and 12 deletions

View File

@@ -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='用户食物收藏表';

View File

@@ -46,6 +46,9 @@ export class FoodItemDto {
@ApiProperty({ description: '是否为用户自定义食物', required: false })
isCustom?: boolean;
@ApiProperty({ description: '是否已收藏', required: false })
isFavorite?: boolean;
}
export class FoodCategoryDto {

View File

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

View File

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

View File

@@ -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<FoodLibraryResponseDto> {
try {
// 获取用户收藏的食物ID
const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set<number>(), customIds: new Set<number>() };
// 分别获取所有数据
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<number>(), customIds: new Set<number>() };
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<number>(), customIds: new Set<number>() };
// 先尝试从系统食物中查找
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<number>, customIds: Set<number> }> {
try {
const favorites = await this.userFoodFavoriteModel.findAll({
where: { userId },
attributes: ['foodId', 'foodType']
});
const systemIds = new Set<number>();
const customIds = new Set<number>();
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<boolean> {
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<boolean> {
try {
const result = await this.userFoodFavoriteModel.destroy({
where: {
userId,
foodId
}
});
return result > 0;
} catch (error) {
throw new Error(`Failed to unfavorite food: ${error.message}`);
}
}
}

View File

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