feat: 新增用户食物收藏功能
- 创建用户食物收藏表 `t_user_food_favorites`,用于存储用户收藏的食物信息。 - 在 `FoodLibraryController` 中添加收藏和取消收藏食物的 API 接口。 - 在 `FoodLibraryService` 中实现收藏和取消收藏的业务逻辑,并获取用户收藏的食物 ID 列表。 - 更新 DTO 以支持食物是否已收藏的状态。
This commit is contained in:
@@ -46,6 +46,9 @@ export class FoodItemDto {
|
||||
|
||||
@ApiProperty({ description: '是否为用户自定义食物', required: false })
|
||||
isCustom?: boolean;
|
||||
|
||||
@ApiProperty({ description: '是否已收藏', required: false })
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export class FoodCategoryDto {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/food-library/models/user-food-favorite.model.ts
Normal file
43
src/food-library/models/user-food-favorite.model.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user