feat: 新增用户食物收藏功能
- 创建用户食物收藏表 `t_user_food_favorites`,用于存储用户收藏的食物信息。 - 在 `FoodLibraryController` 中添加收藏和取消收藏食物的 API 接口。 - 在 `FoodLibraryService` 中实现收藏和取消收藏的业务逻辑,并获取用户收藏的食物 ID 列表。 - 更新 DTO 以支持食物是否已收藏的状态。
This commit is contained in:
14
sql-scripts/user-food-favorites-table-create.sql
Normal file
14
sql-scripts/user-food-favorites-table-create.sql
Normal 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='用户食物收藏表';
|
||||||
@@ -46,6 +46,9 @@ export class FoodItemDto {
|
|||||||
|
|
||||||
@ApiProperty({ description: '是否为用户自定义食物', required: false })
|
@ApiProperty({ description: '是否为用户自定义食物', required: false })
|
||||||
isCustom?: boolean;
|
isCustom?: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '是否已收藏', required: false })
|
||||||
|
isFavorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FoodCategoryDto {
|
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 { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { FoodLibraryService } from './food-library.service';
|
import { FoodLibraryService } from './food-library.service';
|
||||||
import { FoodLibraryResponseDto, FoodItemDto, CreateCustomFoodDto } from './dto/food-library.dto';
|
import { FoodLibraryResponseDto, FoodItemDto, CreateCustomFoodDto } from './dto/food-library.dto';
|
||||||
@@ -108,4 +108,45 @@ export class FoodLibraryController {
|
|||||||
}
|
}
|
||||||
return food;
|
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 { FoodCategory } from './models/food-category.model';
|
||||||
import { FoodLibrary } from './models/food-library.model';
|
import { FoodLibrary } from './models/food-library.model';
|
||||||
import { UserCustomFood } from './models/user-custom-food.model';
|
import { UserCustomFood } from './models/user-custom-food.model';
|
||||||
|
import { UserFoodFavorite } from './models/user-food-favorite.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood]),
|
SequelizeModule.forFeature([FoodCategory, FoodLibrary, UserCustomFood, UserFoodFavorite]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [FoodLibraryController],
|
controllers: [FoodLibraryController],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Op } from 'sequelize';
|
|||||||
import { FoodCategory } from './models/food-category.model';
|
import { FoodCategory } from './models/food-category.model';
|
||||||
import { FoodLibrary } from './models/food-library.model';
|
import { FoodLibrary } from './models/food-library.model';
|
||||||
import { UserCustomFood } from './models/user-custom-food.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';
|
import { FoodCategoryDto, FoodItemDto, FoodLibraryResponseDto, CreateCustomFoodDto } from './dto/food-library.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -15,12 +16,14 @@ export class FoodLibraryService {
|
|||||||
private readonly foodLibraryModel: typeof FoodLibrary,
|
private readonly foodLibraryModel: typeof FoodLibrary,
|
||||||
@InjectModel(UserCustomFood)
|
@InjectModel(UserCustomFood)
|
||||||
private readonly userCustomFoodModel: typeof UserCustomFood,
|
private readonly userCustomFoodModel: typeof UserCustomFood,
|
||||||
|
@InjectModel(UserFoodFavorite)
|
||||||
|
private readonly userFoodFavoriteModel: typeof UserFoodFavorite,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将系统食物模型转换为DTO
|
* 将系统食物模型转换为DTO
|
||||||
*/
|
*/
|
||||||
private mapFoodToDto(food: FoodLibrary): FoodItemDto {
|
private mapFoodToDto(food: FoodLibrary, isFavorite: boolean = false): FoodItemDto {
|
||||||
return {
|
return {
|
||||||
id: food.id,
|
id: food.id,
|
||||||
name: food.name,
|
name: food.name,
|
||||||
@@ -37,13 +40,14 @@ export class FoodLibraryService {
|
|||||||
imageUrl: food.imageUrl,
|
imageUrl: food.imageUrl,
|
||||||
sortOrder: food.sortOrder,
|
sortOrder: food.sortOrder,
|
||||||
isCustom: false,
|
isCustom: false,
|
||||||
|
isFavorite,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将用户自定义食物模型转换为DTO
|
* 将用户自定义食物模型转换为DTO
|
||||||
*/
|
*/
|
||||||
private mapCustomFoodToDto(food: UserCustomFood): FoodItemDto {
|
private mapCustomFoodToDto(food: UserCustomFood, isFavorite: boolean = false): FoodItemDto {
|
||||||
return {
|
return {
|
||||||
id: food.id,
|
id: food.id,
|
||||||
name: food.name,
|
name: food.name,
|
||||||
@@ -60,6 +64,7 @@ export class FoodLibraryService {
|
|||||||
imageUrl: food.imageUrl,
|
imageUrl: food.imageUrl,
|
||||||
sortOrder: food.sortOrder,
|
sortOrder: food.sortOrder,
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
|
isFavorite,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +74,9 @@ export class FoodLibraryService {
|
|||||||
*/
|
*/
|
||||||
async getFoodLibrary(userId?: string): Promise<FoodLibraryResponseDto> {
|
async getFoodLibrary(userId?: string): Promise<FoodLibraryResponseDto> {
|
||||||
try {
|
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([
|
const [categories, allSystemFoods, commonFoods, userCustomFoods] = await Promise.all([
|
||||||
// 获取所有分类
|
// 获取所有分类
|
||||||
@@ -107,14 +115,14 @@ export class FoodLibraryService {
|
|||||||
|
|
||||||
if (category.key === 'common') {
|
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') {
|
} 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 {
|
} else {
|
||||||
// 其他分类:只包含系统食物
|
// 其他分类:只包含系统食物
|
||||||
const systemFoods = systemFoodsByCategory.get(category.key) || [];
|
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 {
|
return {
|
||||||
@@ -142,6 +150,9 @@ export class FoodLibraryService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户收藏的食物ID
|
||||||
|
const favoriteIds = userId ? await this.getUserFavoriteIds(userId) : { systemIds: new Set<number>(), customIds: new Set<number>() };
|
||||||
|
|
||||||
const [systemFoods, customFoods] = await Promise.all([
|
const [systemFoods, customFoods] = await Promise.all([
|
||||||
// 搜索系统食物
|
// 搜索系统食物
|
||||||
this.foodLibraryModel.findAll({
|
this.foodLibraryModel.findAll({
|
||||||
@@ -168,8 +179,8 @@ export class FoodLibraryService {
|
|||||||
|
|
||||||
// 合并结果,用户自定义食物优先显示
|
// 合并结果,用户自定义食物优先显示
|
||||||
const allFoods: FoodItemDto[] = [
|
const allFoods: FoodItemDto[] = [
|
||||||
...customFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food)),
|
...customFoods.map((food: UserCustomFood) => this.mapCustomFoodToDto(food, favoriteIds.customIds.has(food.id))),
|
||||||
...systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food)),
|
...systemFoods.map((food: FoodLibrary) => this.mapFoodToDto(food, favoriteIds.systemIds.has(food.id))),
|
||||||
];
|
];
|
||||||
|
|
||||||
return allFoods;
|
return allFoods;
|
||||||
@@ -187,10 +198,13 @@ export class FoodLibraryService {
|
|||||||
return null;
|
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);
|
const systemFood = await this.foodLibraryModel.findByPk(id);
|
||||||
if (systemFood) {
|
if (systemFood) {
|
||||||
return this.mapFoodToDto(systemFood);
|
return this.mapFoodToDto(systemFood, favoriteIds.systemIds.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果提供了用户ID,则从用户自定义食物中查找
|
// 如果提供了用户ID,则从用户自定义食物中查找
|
||||||
@@ -199,7 +213,7 @@ export class FoodLibraryService {
|
|||||||
where: { id, userId }
|
where: { id, userId }
|
||||||
});
|
});
|
||||||
if (customFood) {
|
if (customFood) {
|
||||||
return this.mapCustomFoodToDto(customFood);
|
return this.mapCustomFoodToDto(customFood, favoriteIds.customIds.has(id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +250,7 @@ export class FoodLibraryService {
|
|||||||
sortOrder: maxSortOrder + 1,
|
sortOrder: maxSortOrder + 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.mapCustomFoodToDto(customFood);
|
return this.mapCustomFoodToDto(customFood, false); // 新创建的食物默认未收藏
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to create custom food: ${error.message}`);
|
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}`);
|
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