07-收藏与最近浏览模块
提示:本文档使用了颜色标注来突出重点内容:
- 蓝色:文件路径和行号信息
- 橙色:关键提示、重要注意和问题
- 红色:抛出的问题
问题解答中的关键词语使用加粗标注。
学习这个模块时最好启动项目学习,这样会更直观。
模块说明
收藏与最近浏览模块提供用户个性化功能,帮助用户快速访问感兴趣的内容。主要包括:
- 收藏功能:用户可以收藏喜欢的菜品,方便以后快速找到
- 取消收藏:用户可以取消已收藏的内容
- 收藏列表:查看自己收藏的所有内容
- 最近浏览:系统自动记录用户最近查看的菜品,方便快速回访



与前面模块的关联
收藏与最近浏览模块是用户个性化功能,为用户提供便捷的内容访问方式:
1. 依赖关系
- 依赖于01-用户模块 :
- 收藏和浏览记录都需要用户ID,系统从Session中获取当前登录的用户信息(
USER_SESSION) - 只有登录用户才能收藏和查看浏览记录,未登录用户无法使用这些功能
- 不同用户的收藏列表和浏览记录是独立的
- 收藏和浏览记录都需要用户ID,系统从Session中获取当前登录的用户信息(
关键代码示例:从Session获取用户ID
java
// 文件:src/main/java/com/scfs/controller/FavoriteController.java
// 位置:第31-58行,addFavorite方法
@PostMapping("/add")
@ResponseBody
public Result addFavorite(@RequestParam Long dishId, HttpSession session) {
try {
// 从Session中获取当前登录用户
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, 401, "请先登录", null);
}
Long userId = user.getUserId();
if (userId == null) {
return new Result(false, 401, "请先登录", null);
}
// 调用Service添加收藏
boolean success = favoriteService.addFavorite(userId, dishId);
if (success) {
return new Result(true, "收藏成功");
}
} catch (Exception e) {
return new Result(false, "收藏失败:" + e.getMessage());
}
}
- 依赖于02-数据展示模块 :
- 最近浏览功能基于店铺详情页的访问
- 用户查看店铺详情页中的菜品时,系统自动记录到最近浏览
- 收藏列表和浏览记录中的内容,点击后会跳转到店铺详情页
关键代码示例:自动记录最近浏览
java
// 文件:src/main/java/com/scfs/controller/DishController.java
// 位置:第158-167行,getDishDetail方法中记录浏览
@GetMapping("/get/{dishId}")
@ResponseBody
public Result getDishDetail(@PathVariable Long dishId, HttpSession session) {
try {
Dish dish = dishService.findById(dishId);
if (dish != null) {
// 记录最近浏览(如果用户已登录)
try {
User user = (User) session.getAttribute("USER_SESSION");
if (user != null && user.getUserId() != null) {
recentViewService.recordView(user.getUserId(), dishId); // 调用07-收藏与最近浏览模块
}
} catch (Exception e) {
logger.warn("记录最近浏览失败", e);
}
return new Result(true, "获取菜品信息成功", dish);
}
}
}
- 依赖于03-菜品管理模块 :
- 收藏的对象是菜品(不是店铺)
- 用户收藏的是03-菜品管理模块中的菜品
- 收藏列表中显示的是菜品信息(名称、价格、图片等)
关键代码示例:收藏菜品
java
// 文件:src/main/java/com/scfs/service/impl/FavoriteServiceImpl.java
// 位置:第26-44行,addFavorite方法
@Override
@Transactional
public boolean addFavorite(Long userId, Long dishId) {
if (userId == null || dishId == null) {
return false;
}
try {
// 检查是否已收藏
Favorite existing = favoriteMapper.findByUserIdAndDishId(userId, dishId);
if (existing != null) {
return true; // 已收藏,返回成功
}
// 添加收藏
Favorite favorite = new Favorite(userId, dishId);
return favoriteMapper.insert(favorite) > 0;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
2. 数据流转
收藏菜品时的数据流转:
用户在store-detail.jsp查看菜品列表(02-数据展示模块)
↓
用户点击某个菜品的收藏按钮
↓
前端JavaScript调用 /favorite/add 接口
↓
07-收藏与最近浏览模块的Controller从Session获取用户ID(01-用户模块)
↓
Controller调用Service添加收藏记录(用户ID + 菜品ID)
↓
Service调用Mapper插入favorite表
↓
返回成功,前端更新收藏按钮状态(灰色→黄色)
最近浏览的记录:
用户在store-detail.jsp查看某个菜品(02-数据展示模块)
↓
页面加载时调用 /dish/get/{dishId} 接口获取菜品详情
↓
03-菜品管理模块的Controller处理请求
↓
Controller在返回菜品详情前,调用RecentViewService.recordView()(07-收藏与最近浏览模块)
↓
Service记录或更新用户的浏览记录(用户ID + 菜品ID + 浏览时间)
↓
用户下次在个人中心查看"最近浏览"时,可以看到之前浏览过的菜品
3. 数据表关系
收藏表(favorite):
- 存储用户ID和菜品ID的对应关系
- 一个用户可以有多个收藏(一对多)
- 一个菜品可以被多个用户收藏(一对多)
最近浏览表(recent_view):
- 存储用户ID、菜品ID和浏览时间
- 系统会自动更新浏览时间(同一用户多次浏览同一菜品时)
- 可以设置保留最近N条浏览记录
4. 学习建议
-
在学习本模块前:
- 建议先理解01-用户模块的Session管理(如何获取当前用户ID)
- 理解02-数据展示模块的店铺详情页(收藏按钮在哪里)
- 理解03-菜品管理模块的菜品数据结构(收藏的是什么)
-
学习时可以结合:
- 实际操作收藏和取消收藏,观察按钮状态的变化
- 查看个人中心的收藏列表和最近浏览列表
- 查看数据库中的
favorite表和recent_view表 - 观察收藏/浏览记录的创建时间和更新时间
- 理解为什么需要这两个表(收藏和浏览的区别)
-
学习本模块后:
- 可以思考如何优化收藏功能(分组、标签等)
- 思考如何优化最近浏览(限制数量、定期清理等)
- 理解用户个性化功能如何提升用户体验
5. 问题解答
Q1:收藏和浏览的区别是什么?为什么要分开存储?
A: 收藏和浏览有不同的业务含义和使用场景。
收藏(favorite):
- 用户主动操作:用户点击"收藏"按钮才会记录
- 表示用户兴趣:用户明确表示喜欢这个菜品
- 长期保存:收藏记录会一直保存,直到用户取消收藏
- 用途:用户可以快速找到自己喜欢的菜品
最近浏览(recent_view):
- 系统自动记录:用户查看菜品详情时自动记录
- 表示浏览历史:记录用户最近查看过的菜品
- 有时间限制:通常只保留最近N条(如20条)
- 用途:用户可以快速找到最近查看过的菜品
为什么要分开存储:
- 业务逻辑不同:收藏是主动的,浏览是被动的
- 数据管理不同:收藏需要手动删除,浏览可以自动清理
- 查询需求不同:收藏列表按收藏时间排序,浏览列表按浏览时间排序
- 用户体验不同:收藏是"我喜欢",浏览是"我看过"
代码位置:
java
// 文件:src/main/java/com/scfs/service/impl/RecentViewServiceImpl.java
// 位置:第27-38行,recordView方法
@Override
@Transactional
public boolean recordView(Long userId, Long dishId) {
if (userId == null || dishId == null) {
return false;
}
try {
RecentView recentView = new RecentView(userId, dishId);
// 使用INSERT ... ON DUPLICATE KEY UPDATE,如果已存在则更新浏览时间
return recentViewMapper.insertOrUpdate(recentView) > 0;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
Q2:最近浏览是如何自动记录的?
A: 在用户查看菜品详情时自动记录。
记录时机:
- 用户访问店铺详情页,查看某个菜品时
- 系统在返回菜品详情前,自动调用
recentViewService.recordView()
代码位置:
java
// 文件:src/main/java/com/scfs/controller/DishController.java
// 位置:第158-167行,getDishDetail方法
@GetMapping("/get/{dishId}")
@ResponseBody
public Result getDishDetail(@PathVariable Long dishId, HttpSession session) {
try {
Dish dish = dishService.findById(dishId);
if (dish != null) {
// 记录最近浏览(如果用户已登录)(第159-167行)
try {
User user = (User) session.getAttribute("USER_SESSION");
if (user != null && user.getUserId() != null) {
recentViewService.recordView(user.getUserId(), dishId);
}
} catch (Exception e) {
logger.warn("记录最近浏览失败", e); // 记录失败不影响获取菜品信息
}
return new Result(true, "获取菜品信息成功", dish);
}
}
}
为什么用try-catch包裹:
- 记录浏览失败不应该影响获取菜品信息
- 如果记录失败,只是没有记录浏览历史,不影响主要功能
Q3:如果用户多次浏览同一个菜品,会创建多条记录吗?
A: 不会,使用INSERT ... ON DUPLICATE KEY UPDATE更新浏览时间。
数据库设计:
recent_view表有唯一索引:uk_user_dish (user_id, dish_id)- 同一用户多次浏览同一菜品,只会更新
view_time字段,不会创建新记录
代码位置:
java
// 文件:src/main/java/com/scfs/mapper/RecentViewMapper.java
// 位置:第21-24行,insertOrUpdate方法
@Insert("INSERT INTO recent_view (user_id, dish_id, view_time) VALUES (#{userId}, #{dishId}, NOW()) " +
"ON DUPLICATE KEY UPDATE view_time = NOW()") // 第21-22行
@Options(useGeneratedKeys = true, keyProperty = "viewId", keyColumn = "view_id")
int insertOrUpdate(RecentView recentView);
工作原理:
- 第一次浏览:插入新记录,
view_time为当前时间 - 再次浏览:由于唯一索引冲突,执行
UPDATE view_time = NOW() - 结果:只有一条记录,但
view_time更新为最新浏览时间
优点:
- 避免数据冗余
- 浏览时间始终是最新的
- 查询时按
view_time排序,最近浏览的菜品排在前面
功能一:收藏菜品
功能说明
收藏功能是用户可以收藏喜欢的菜品,方便以后快速找到。
抛出问题:用户点击收藏按钮后,系统是怎么把收藏记录保存到数据库的?数据是怎么返回页面更新按钮状态的?
逐步追踪
第一步:找到用户操作的入口
用户在浏览店铺详情页(store-detail.jsp)时,会看到每个菜品卡片都有一个收藏按钮。这个按钮在my.js的displayDishList函数中生成:
javascript
// 收藏按钮(所有登录用户可见)
var currentUserId = getCurrentUserId();
if (currentUserId) {
html += '<button class="btn btn-primary btn-sm action-btn favorite-btn" id="favoriteBtn-' + dish.dishId + '" onclick="toggleDishFavorite(' + dish.dishId + ', event)" data-dish-id="' + dish.dishId + '">';
html += '<span class="glyphicon glyphicon-star" id="favoriteIcon-' + dish.dishId + '"></span> <span id="favoriteText-' + dish.dishId + '">收藏</span>';
html += '</button>';
}
位置 :src/main/webapp/js/my.js 第814-819行
当用户点击这个收藏按钮时,会触发toggleDishFavorite函数。

第二步:追踪前端JavaScript处理
用户点击收藏按钮,触发toggleDishFavorite函数:
javascript
// [1] 切换收藏状态函数(数据流向:前端 → Controller)
function toggleDishFavorite(dishId, event) {
// [2] 阻止事件冒泡
if (event) {
event.stopPropagation();
}
if (!dishId) {
return;
}
// [3] 获取按钮和文字元素
var btn = $('#favoriteBtn-' + dishId);
var text = $('#favoriteText-' + dishId);
// [4] 判断当前收藏状态
var isFavorite = text.text() === '已收藏';
// [5] 根据状态决定请求URL和操作类型
var url = isFavorite ? getApiUrl('favorite', 'remove') : getApiUrl('favorite', 'add');
var action = isFavorite ? '取消收藏' : '收藏';
// [6] 禁用按钮,防止重复点击
btn.prop('disabled', true);
// [7] 发送AJAX POST请求(数据流向:前端 → Controller)
$.ajax({
url: url,
type: 'POST',
data: { dishId: dishId }, // 菜品ID
// [8] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
success: function (response) {
if (response.success) {
// [9] 更新按钮状态(已收藏/收藏)
updateDishFavoriteButton(dishId, !isFavorite);
var toastMsg = action + '成功';
} else {
// [10] 操作失败:显示错误信息
alert(action + '失败:' + (response.message || '请稍后重试'));
}
// [11] 重新启用按钮
btn.prop('disabled', false);
},
error: function () {
alert(action + '失败,请稍后重试');
btn.prop('disabled', false);
}
});
}
位置 :src/main/webapp/js/my.js 第2522-2559行
第三步:追踪到Controller层
根据请求URL/favorite/add或/favorite/remove,找到FavoriteController的方法:
java
// [1] 处理POST请求 /favorite/add(数据流向:前端 → Controller)
@PostMapping("/add")
@ResponseBody
public Result addFavorite(@RequestParam Long dishId, HttpSession session) {
try {
// [2] 从Session中获取当前登录用户(01-用户模块提供的USER_SESSION)
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, 401, "请先登录", null);
}
Long userId = user.getUserId();
// [3] 参数验证:检查菜品ID
if (dishId == null) {
return new Result(false, "菜品ID不能为空");
}
// [4] 调用Service层添加收藏(数据流向:Controller → Service)
boolean success = favoriteService.addFavorite(userId, dishId);
// [5] 根据Service返回结果构建响应(数据流向:Controller → 前端)
if (success) {
return new Result(true, "收藏成功");
} else {
return new Result(false, "收藏失败,可能已收藏过");
}
} catch (Exception e) {
logger.error("添加收藏失败", e);
return new Result(false, "添加收藏失败");
}
}
// [1] 处理POST请求 /favorite/remove(数据流向:前端 → Controller)
@PostMapping("/remove")
@ResponseBody
public Result removeFavorite(@RequestParam Long dishId, HttpSession session) {
try {
// [2] 从Session中获取当前登录用户
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, 401, "请先登录", null);
}
Long userId = user.getUserId();
// [3] 参数验证:检查菜品ID
if (dishId == null) {
return new Result(false, "菜品ID不能为空");
}
// [4] 调用Service层取消收藏(数据流向:Controller → Service)
boolean success = favoriteService.removeFavorite(userId, dishId);
// [5] 根据Service返回结果构建响应(数据流向:Controller → 前端)
if (success) {
return new Result(true, "取消收藏成功");
} else {
return new Result(false, "取消收藏失败");
}
} catch (Exception e) {
logger.error("取消收藏失败", e);
return new Result(false, "取消收藏失败");
}
}
位置 :src/main/java/com/scfs/controller/FavoriteController.java 第28-80行
第四步:追踪到Service层
Controller调用favoriteService.addFavorite()或favoriteService.removeFavorite(),Service层实现:
java
// [1] 添加收藏(数据流向:Service → Mapper)
@Override
public boolean addFavorite(Long userId, Long dishId) {
try {
// [2] 检查是否已收藏(避免重复收藏)
Favorite existing = favoriteMapper.findByUserIdAndDishId(userId, dishId);
if (existing != null) {
return false; // 已收藏,返回false
}
// [3] 创建收藏记录
Favorite favorite = new Favorite();
favorite.setUserId(userId);
favorite.setDishId(dishId);
favorite.setCreateTime(new Date());
// [4] 插入数据库(数据流向:Service → Mapper)
int result = favoriteMapper.insert(favorite);
return result > 0;
} catch (Exception e) {
logger.error("添加收藏失败", e);
return false;
}
}
// [1] 取消收藏(数据流向:Service → Mapper)
@Override
public boolean removeFavorite(Long userId, Long dishId) {
try {
// [2] 删除收藏记录(数据流向:Service → Mapper)
int result = favoriteMapper.deleteByUserIdAndDishId(userId, dishId);
return result > 0;
} catch (Exception e) {
logger.error("取消收藏失败", e);
return false;
}
}
位置 :src/main/java/com/scfs/service/impl/FavoriteServiceImpl.java 第28-60行
第五步:追踪到Mapper层
Service调用favoriteMapper.insert()或favoriteMapper.deleteByUserIdAndDishId(),Mapper层实现:
java
// [1] 插入收藏记录到数据库(数据流向:Mapper → 数据库)
@Insert("INSERT INTO favorite (user_id, dish_id, create_time) " +
"VALUES (#{userId}, #{dishId}, #{createTime})")
int insert(Favorite favorite);
// [2] 根据用户ID和菜品ID删除收藏记录(数据流向:Mapper → 数据库)
@Delete("DELETE FROM favorite WHERE user_id = #{userId} AND dish_id = #{dishId}")
int deleteByUserIdAndDishId(@Param("userId") Long userId, @Param("dishId") Long dishId);
位置 :src/main/java/com/scfs/mapper/FavoriteMapper.java 第28-35行
执行顺序说明:
- 添加收藏:先检查是否已收藏,如果未收藏则插入新记录
- 取消收藏:直接删除收藏记录
- 数据库执行SQL后,返回受影响的行数(数据流向:数据库 → Mapper → Service)
第六步:数据返回流程
完整的数据返回路径:
- 数据库 → Mapper:数据库执行INSERT或DELETE语句,返回受影响的行数给Mapper
- Mapper → Service:Mapper返回受影响的行数给Service
- Service → Controller :Service判断行数大于0,返回
true给Controller - Controller处理 :
- Controller封装成
Result对象,success为true,message为"收藏成功"或"取消收藏成功" - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
response.success是否为true - 如果成功,调用
updateDishFavoriteButton(dishId, !isFavorite)更新按钮状态 - 按钮文字从"收藏"变为"已收藏",或从"已收藏"变为"收藏"
- 按钮样式也会相应更新(如星星图标填充颜色)
- JavaScript接收到
这样,用户点击收藏按钮后,系统就保存或删除收藏记录到数据库,返回成功提示,页面更新按钮状态,整个流程就完成了。
收藏操作完整流程时序图
数据库 FavoriteMapper FavoriteService FavoriteController JavaScript store-detail.jsp 用户 数据库 FavoriteMapper FavoriteService FavoriteController JavaScript store-detail.jsp 用户 alt [未收藏] [已收藏] alt [添加收藏] [取消收藏] [1] 点击收藏按钮 [2] 触发toggleDishFavorite() [3] 判断当前收藏状态 [4] 决定请求URL (/favorite/add 或 /favorite/remove) [5] 禁用按钮,防止重复点击 [6] AJAX POST /favorite/add (dishId) [7] 从Session获取用户信息 [8] 调用favoriteService.addFavorite() [9] 检查是否已收藏 [10] SELECT * FROM favorite WHERE user_id = ? AND dish_id = ? [11] 返回查询结果 [12] 返回Favorite对象(或null) [13] 创建Favorite对象 [14] 调用favoriteMapper.insert() [15] INSERT INTO favorite (user_id, dish_id, create_time) [16] 返回受影响行数(1) [17] 返回受影响行数 [18] 返回true [19] 返回Result(true, "收藏成功") [20] 更新按钮状态为"已收藏" [21] 返回false [22] 返回Result(false, "已收藏过") [23] 从Session获取用户信息 [24] 调用favoriteService.removeFavorite() [25] 调用favoriteMapper.deleteByUserIdAndDishId() [26] DELETE FROM favorite WHERE user_id = ? AND dish_id = ? [27] 返回受影响行数(1) [28] 返回受影响行数 [29] 返回true [30] 返回Result(true, "取消收藏成功") [31] 更新按钮状态为"收藏" [32] 重新启用按钮 [33] 显示更新后的按钮状态
为什么这样设计
为什么添加收藏前要检查是否已收藏?
-
原因1:数据一致性
- 避免重复插入相同的收藏记录
- 保证数据库中的数据唯一性
- 符合业务逻辑,一个用户对一个菜品只能收藏一次
-
原因2:用户体验
- 如果已收藏,可以给用户友好的提示
- 避免用户重复点击导致的问题
- 提升用户体验
-
原因3:性能优化
-
检查比直接插入更轻量
-
可以提前发现重复操作
-
减少不必要的数据库操作
if (success) {
return new Result(true, "收藏成功");
} else {
return new Result(false, "收藏失败");
}
} catch (Exception e) {
e.printStackTrace();
return new Result(false, "收藏失败:" + e.getMessage());
}
}位置:
src/main/java/com/scfs/controller/FavoriteController.java第31-58行Controller接收请求后,先从Session中获取当前登录用户的ID。如果用户未登录,返回错误提示;如果已登录,调用
favoriteService.addFavorite方法,传入用户ID和菜品ID。第四步:追踪到Service层
FavoriteService在FavoriteServiceImpl中实现。Service先检查用户是否已经收藏过这个菜品:java@Override @Transactional public boolean addFavorite(Long userId, Long dishId) { if (userId == null || dishId == null) { return false; } try { // 检查是否已收藏 Favorite existing = favoriteMapper.findByUserIdAndDishId(userId, dishId); if (existing != null) { return true; // 已收藏,返回成功 } // 添加收藏 Favorite favorite = new Favorite(userId, dishId); return favoriteMapper.insert(favorite) > 0; } catch (Exception e) { e.printStackTrace(); return false; } }
-
位置 :src/main/java/com/scfs/service/impl/FavoriteServiceImpl.java 第26-44行
如果已经收藏过,直接返回成功;如果未收藏,创建一个新的Favorite对象,调用favoriteMapper.insert方法插入数据库。
第五步:追踪到Mapper层
Service调用FavoriteMapper的insert方法操作数据库。Mapper使用@Insert注解定义SQL语句:
java
@Insert("INSERT INTO favorite (user_id, dish_id) VALUES (#{userId}, #{dishId})")
@Options(useGeneratedKeys = true, keyProperty = "favoriteId", keyColumn = "favorite_id")
int insert(Favorite favorite);
位置 :src/main/java/com/scfs/mapper/FavoriteMapper.java 第20-22行
这个SQL语句直接插入favorite表中的一条记录,包含用户ID和菜品ID。数据库操作成功后,返回受影响的行数。
第六步:数据如何返回
数据库操作成功后,后端返回Result对象,success为true,message为"收藏成功"。前端JavaScript接收到响应后,调用updateDishFavoriteButton函数更新按钮状态:
javascript
function updateDishFavoriteButton(dishId, isFavorite) {
var btn = $('#favoriteBtn-' + dishId);
var icon = $('#favoriteIcon-' + dishId);
var text = $('#favoriteText-' + dishId);
if (isFavorite) {
icon.removeClass('glyphicon-star').addClass('glyphicon-star-empty');
text.text('已收藏');
btn.removeClass('btn-primary').addClass('btn-warning');
} else {
icon.removeClass('glyphicon-star-empty').addClass('glyphicon-star');
text.text('收藏');
btn.removeClass('btn-warning').addClass('btn-primary');
}
}
位置 :src/main/webapp/js/my.js 第2487-2507行
如果收藏成功,按钮文字从"收藏"变成"已收藏",按钮样式从蓝色变成黄色;如果取消收藏成功,按钮文字从"已收藏"变成"收藏",按钮样式从黄色变成蓝色。
这样,用户点击收藏按钮,数据就从页面发送到后端,保存到数据库,然后页面按钮状态更新,整个流程就完成了。
功能二:查看收藏列表
功能说明
收藏列表功能是用户可以查看自己收藏的所有菜品,方便快速访问。
抛出问题:用户进入用户中心查看收藏列表时,系统是怎么把收藏记录从数据库读取出来并展示到页面上的?
逐步追踪
第一步:找到用户操作的入口
用户在用户中心页面(user-center.jsp)点击"我的收藏"标签页,页面会自动调用loadMyFavorites函数加载收藏列表。
第二步:追踪前端JavaScript处理
在user-center.jsp中,找到loadMyFavorites函数:
javascript
// [1] 加载我的收藏函数(数据流向:前端 → Controller)
function loadMyFavorites() {
// [2] 发送AJAX GET请求获取收藏列表
$.ajax({
url: '${pageContext.request.contextPath}/user/getMyFavorites',
type: 'GET',
// [3] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
success: function (response) {
if (response.success) {
// [4] 调用渲染函数,将收藏列表显示到页面
renderFavoriteList(response.data);
}
}
});
}
位置 :src/main/webapp/jsp/user-center.jsp 第353-364行
渲染函数 :renderFavoriteList(data)将后端返回的收藏列表渲染到页面:
javascript
// [1] 渲染收藏列表函数
function renderFavoriteList(data) {
const container = $('#favoriteList');
container.empty(); // [2] 清空容器
// [3] 判断是否有收藏记录
if (data.length === 0) {
container.html('<div class="empty-state">暂无收藏记录</div>');
return;
}
// [4] 遍历收藏列表,为每个收藏项生成HTML
data.forEach(function (item) {
const html = '<div class="favorite-item">' +
'<div class="item-info">' +
'<h3>' + item.dishName + ' - ¥' + item.price + '</h3>' +
'<p>' + item.storeName + '(' + item.canteenName + ')</p>' +
'</div>' +
'<div class="item-actions">' +
'<button class="btn-primary" onclick="viewDish(' + item.dishId + ')">查看详情</button>' +
'<button class="btn-danger" onclick="removeFavorite(' + item.dishId + ')">取消收藏</button>' +
'</div>' +
'</div>';
container.append(html);
});
}
位置 :src/main/webapp/jsp/user-center.jsp 第418-441行
第三步:追踪到Controller层
根据请求URL/user/getMyFavorites,找到UserController的getMyFavorites方法:
java
// [1] 处理GET请求 /user/getMyFavorites(数据流向:前端 → Controller)
@RequestMapping("/getMyFavorites")
@ResponseBody
public Result getMyFavorites(HttpServletRequest request) {
try {
// [2] 从Session中获取当前登录用户(01-用户模块提供的USER_SESSION)
HttpSession session = request.getSession();
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, 401, "请先登录", null);
}
Long userId = user.getUserId();
// [3] 调用Service层获取收藏列表(数据流向:Controller → Service)
List<Favorite> favorites = favoriteService.getUserFavorites(userId);
// [4] 封装成Result对象返回(数据流向:Controller → 前端)
return new Result(true, "获取收藏列表成功", favorites);
} catch (Exception e) {
logger.error("获取收藏列表失败", e);
return new Result(false, "获取收藏列表失败:" + e.getMessage());
}
}
位置 :src/main/java/com/scfs/controller/UserController.java 第238-259行
第四步:追踪到Service层
Controller调用favoriteService.getUserFavorites(userId),Service层实现:
java
// [1] 获取用户收藏列表(数据流向:Service → Mapper)
@Override
public List<Favorite> getUserFavorites(Long userId) {
if (userId == null) {
return null;
}
try {
// [2] 调用Mapper查询收藏列表(数据流向:Service → Mapper)
return favoriteMapper.findByUserId(userId);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
位置 :src/main/java/com/scfs/service/impl/FavoriteServiceImpl.java 第60-71行
第五步:追踪到Mapper层
Service调用favoriteMapper.findByUserId(userId),Mapper层实现:
java
// [1] 根据用户ID查询收藏列表(数据流向:Mapper → 数据库)
@Select("SELECT f.favorite_id AS favoriteId, f.user_id AS userId, f.dish_id AS dishId, " +
"f.create_time AS createTime, " +
"d.dish_name AS dishName, d.price, d.image_url AS imageUrl, " +
"s.store_name AS storeName, c.canteen_name AS canteenName " +
"FROM favorite f " +
"LEFT JOIN dish d ON f.dish_id = d.dish_id " +
"LEFT JOIN store s ON d.store_id = s.store_id " +
"LEFT JOIN canteen c ON s.canteen_id = c.canteen_id " +
"WHERE f.user_id = #{userId} " +
"ORDER BY f.create_time DESC")
List<Favorite> findByUserId(@Param("userId") Long userId);
位置 :src/main/java/com/scfs/mapper/FavoriteMapper.java 第44-65行
执行顺序说明:
FROM favorite f:从收藏表开始查询LEFT JOIN dish d:关联菜品表,获取菜品信息LEFT JOIN store s:关联店铺表,获取店铺信息LEFT JOIN canteen c:关联食堂表,获取食堂信息WHERE f.user_id = #{userId}:只查询当前用户的收藏记录ORDER BY f.create_time DESC:按收藏时间倒序排列(最新的在前)- 数据库执行SQL查询后,返回
List<Favorite>(数据流向:数据库 → Mapper → Service)
第六步:数据返回流程
完整的数据返回路径:
- 数据库 → Mapper:数据库执行SELECT查询,通过JOIN关联查询获取收藏记录及关联的菜品、店铺、食堂信息,返回给Mapper
- Mapper → Service :Mapper将查询结果映射为
List<Favorite>对象,返回给Service - Service → Controller:Service直接返回收藏列表给Controller
- Controller处理 :
- Controller封装成
Result对象,success为true,data为收藏列表 - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
response.success是否为true - 如果成功,调用
renderFavoriteList(response.data)渲染收藏列表 renderFavoriteList()遍历收藏列表数组,为每个收藏项生成HTML,包含菜品名称、价格、店铺名称、食堂名称,以及"查看详情"和"取消收藏"按钮- 将HTML插入到页面的收藏列表容器中
- JavaScript接收到
这样,用户点击"我的收藏"标签页,系统就从数据库查询收藏记录,通过JOIN查询获取完整信息,然后渲染到页面上,整个流程就完成了。
收藏列表加载时序图
数据库 FavoriteMapper FavoriteService UserController JavaScript user-center.jsp 用户 数据库 FavoriteMapper FavoriteService UserController JavaScript user-center.jsp 用户 [1] 点击"我的收藏"标签页 [2] 触发loadMyFavorites() [3] AJAX GET /user/getMyFavorites [4] 从Session获取用户信息 [5] 调用favoriteService.getUserFavorites() [6] 调用favoriteMapper.findByUserId() [7] 执行SQL: SELECT f.*, d.*, s.*, c.* FROM favorite f LEFT JOIN dish d ON f.dish_id = d.dish_id LEFT JOIN store s ON d.store_id = s.store_id LEFT JOIN canteen c ON s.canteen_id = c.canteen_id WHERE f.user_id = ? ORDER BY f.create_time DESC [8] 返回收藏记录列表(包含关联信息) [9] 返回List<Favorite> [10] 返回List<Favorite> [11] 封装成Result对象 [12] 返回Result对象 (success, message, data=收藏列表) [13] 判断response.success [14] 调用renderFavoriteList(response.data) [15] 遍历收藏列表,生成HTML卡片 (包含菜品、店铺、食堂信息) [16] 将HTML插入到收藏列表容器 [17] 显示收藏列表
为什么这样设计
为什么使用LEFT JOIN关联查询,而不是分别查询?
-
原因1:性能优化
- 一次SQL查询获取所有需要的数据,减少数据库交互次数
- 避免N+1查询问题(如果分别查询,需要1次查询收藏列表 + N次查询菜品信息)
- 提升查询性能,减少网络传输开销
-
原因2:数据完整性
- 确保获取的数据是同一时刻的快照,避免数据不一致
- 如果分别查询,可能在两次查询之间数据发生变化
- 保证数据的准确性和一致性
-
原因3:代码简洁
- 一次查询获取所有数据,代码逻辑更简单
- 减少Service层的处理逻辑
- 便于维护和扩展

功能三:最近浏览记录
功能说明
最近浏览功能是系统自动记录用户查看过的菜品,方便用户快速回访。
抛出问题:用户浏览菜品详情页时,系统是怎么自动记录浏览记录的?浏览记录是怎么保存到数据库的?
逐步追踪
第一步:找到用户操作的入口
用户在店铺详情页点击某个菜品,或者在其他页面点击查看菜品详情,会跳转到菜品详情页(dish.jsp)。当页面加载时,系统会自动记录这次浏览。
第二步:追踪到Controller层
在DishController的getDishById方法中,当获取菜品详情时,会自动记录浏览记录:
java
// [1] 在获取菜品详情时自动记录浏览(数据流向:Controller → Service)
@GetMapping("/{dishId}")
@ResponseBody
public Result getDishById(@PathVariable Long dishId, HttpSession session) {
try {
// [2] 获取菜品详情(省略其他代码)
Dish dish = dishService.findById(dishId);
// [3] 从Session中获取当前登录用户
User user = (User) session.getAttribute("USER_SESSION");
// [4] 如果用户已登录,自动记录浏览记录
if (user != null && user.getUserId() != null) {
try {
// [5] 获取RecentViewService服务
RecentViewService recentViewService = applicationContext.getBean(RecentViewService.class);
// [6] 调用Service记录浏览(数据流向:Controller → Service)
recentViewService.recordView(user.getUserId(), dishId);
} catch (Exception e) {
logger.warn("记录浏览历史失败", e);
// 记录失败不影响菜品详情返回
}
}
return new Result(true, "获取菜品详情成功", dish);
} catch (Exception e) {
logger.error("获取菜品详情失败", e);
return new Result(false, "获取菜品详情失败");
}
}
位置 :src/main/java/com/scfs/controller/DishController.java 第162行
关键点:浏览记录是自动记录的,不需要用户主动操作。当用户访问菜品详情页时,系统会自动记录这次浏览。
第三步:追踪到Service层
Controller调用recentViewService.recordView(userId, dishId),Service层实现:
java
// [1] 记录浏览记录(数据流向:Service → Mapper)
@Override
@Transactional // [2] 使用事务,确保数据一致性
public boolean recordView(Long userId, Long dishId) {
// [3] 参数验证
if (userId == null || dishId == null) {
return false;
}
try {
// [4] 创建浏览记录对象
RecentView recentView = new RecentView(userId, dishId);
// [5] 插入或更新浏览记录(数据流向:Service → Mapper)
return recentViewMapper.insertOrUpdate(recentView) > 0;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
位置 :src/main/java/com/scfs/service/impl/RecentViewServiceImpl.java 第26-38行
第四步:追踪到Mapper层
Service调用recentViewMapper.insertOrUpdate(recentView),Mapper层实现:
java
// [1] 插入或更新浏览记录到数据库(数据流向:Mapper → 数据库)
@Insert("INSERT INTO recent_view (user_id, dish_id, view_time) VALUES (#{userId}, #{dishId}, NOW()) " +
"ON DUPLICATE KEY UPDATE view_time = NOW()")
@Options(useGeneratedKeys = true, keyProperty = "viewId", keyColumn = "view_id")
int insertOrUpdate(RecentView recentView);
位置 :src/main/java/com/scfs/mapper/RecentViewMapper.java 第21-24行
执行顺序说明:
INSERT INTO recent_view:尝试插入新的浏览记录ON DUPLICATE KEY UPDATE view_time = NOW():如果存在相同的user_id和dish_id组合(通过唯一索引uk_user_dish保证),则更新浏览时间为当前时间- 这样确保同一个菜品只保留一条浏览记录,但浏览时间总是最新的
- 数据库执行SQL后,返回受影响的行数(数据流向:数据库 → Mapper → Service)
第五步:数据返回流程
完整的数据返回路径:
- 数据库 → Mapper:数据库执行INSERT或UPDATE语句,返回受影响的行数(通常为1)给Mapper
- Mapper → Service:Mapper返回受影响的行数给Service
- Service → Controller :Service判断行数大于0,返回
true给Controller - Controller处理:浏览记录保存成功,继续返回菜品详情给前端
这样,用户浏览菜品详情页时,系统就自动记录浏览记录,使用ON DUPLICATE KEY UPDATE确保同一菜品只保留最新的浏览时间,整个流程就完成了。
浏览记录自动记录时序图
数据库 RecentViewMapper RecentViewService DishController JavaScript dish.jsp 用户 数据库 RecentViewMapper RecentViewService DishController JavaScript dish.jsp 用户 alt [记录不存在] [记录已存在] alt [用户已登录] [1] 访问菜品详情页 [2] 页面加载 [3] AJAX GET /dish/{dishId} [4] 获取菜品详情 [5] 从Session获取用户信息 [6] 判断用户是否已登录 [7] 调用recentViewService.recordView() [8] 开始事务 [9] 调用recentViewMapper.insertOrUpdate() [10] 执行SQL: INSERT INTO recent_view (user_id, dish_id, view_time) VALUES (?, ?, NOW()) ON DUPLICATE KEY UPDATE view_time = NOW() [11] 返回受影响行数(1,插入) [12] 返回受影响行数(1,更新) [13] 返回受影响行数 [14] 提交事务 [15] 返回true [16] 返回Result对象 (success, message, data=菜品详情) [17] 显示菜品详情 [18] 页面显示完成 (浏览记录已自动记录)
为什么这样设计
为什么使用ON DUPLICATE KEY UPDATE而不是先查询再插入/更新?
-
原因1:性能优化
- 一次SQL操作完成插入或更新,减少数据库交互次数
- 避免先查询再判断的两次数据库操作
- 提升性能,减少数据库负载
-
原因2:原子性
- 在同一个SQL语句中完成,保证操作的原子性
- 避免并发情况下的数据不一致问题
- 使用唯一索引保证数据唯一性
-
原因3:代码简洁
- 不需要在Service层判断记录是否存在
- 代码逻辑更简单,易于维护
- 减少出错的可能性
功能四:查看最近浏览列表
功能说明
最近浏览列表功能是用户可以查看自己最近浏览过的菜品,方便快速回访。
抛出问题:用户进入用户中心查看最近浏览列表时,系统是怎么把浏览记录从数据库读取出来并展示到页面上的?
逐步追踪
第一步:找到用户操作的入口
用户在用户中心页面(user-center.jsp)点击"最近浏览"标签页,页面会自动调用loadRecentViews函数加载浏览列表。
第二步:追踪前端JavaScript处理
在user-center.jsp中,找到loadRecentViews函数:
javascript
// [1] 加载最近浏览函数(数据流向:前端 → Controller)
function loadRecentViews() {
// [2] 发送AJAX GET请求获取最近浏览列表
$.ajax({
url: '${pageContext.request.contextPath}/user/getRecentViews',
type: 'GET',
// [3] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
success: function (response) {
if (response.success) {
// [4] 调用渲染函数,将最近浏览列表显示到页面
renderRecentList(response.data);
}
}
});
}
位置 :src/main/webapp/jsp/user-center.jsp 第366-377行
渲染函数 :renderRecentList(data)将后端返回的最近浏览列表渲染到页面(与收藏列表类似)。
第三步:追踪到Controller层
根据请求URL/user/getRecentViews,找到UserController的getRecentViews方法:
java
// [1] 处理GET请求 /user/getRecentViews(数据流向:前端 → Controller)
@RequestMapping("/getRecentViews")
@ResponseBody
public Result getRecentViews(HttpServletRequest request) {
try {
// [2] 从Session中获取当前登录用户(01-用户模块提供的USER_SESSION)
HttpSession session = request.getSession();
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, 401, "请先登录", null);
}
Long userId = user.getUserId();
// [3] 获取RecentViewService服务
RecentViewService recentViewService = org.springframework.web.context.support.WebApplicationContextUtils
.getWebApplicationContext(request.getServletContext())
.getBean(RecentViewService.class);
// [4] 调用Service层获取最近浏览列表(数据流向:Controller → Service)
List<RecentView> recentViews = recentViewService.getUserRecentViews(userId, 20); // 限制20条
// [5] 封装成Result对象返回(数据流向:Controller → 前端)
return new Result(true, "获取最近浏览列表成功", recentViews);
} catch (Exception e) {
logger.error("获取最近浏览列表失败", e);
return new Result(false, "获取最近浏览列表失败:" + e.getMessage());
}
}
位置 :src/main/java/com/scfs/controller/UserController.java 第266-290行
第四步:追踪到Service层
Controller调用recentViewService.getUserRecentViews(userId, 20),Service层实现:
java
// [1] 获取用户最近浏览列表(数据流向:Service → Mapper)
@Override
public List<RecentView> getUserRecentViews(Long userId, Integer limit) {
// [2] 参数验证
if (userId == null) {
return null;
}
// [3] 设置默认限制数量
if (limit == null || limit <= 0) {
limit = 20; // 默认20条
}
try {
// [4] 调用Mapper查询最近浏览列表(数据流向:Service → Mapper)
return recentViewMapper.findByUserId(userId, limit);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
位置 :src/main/java/com/scfs/service/impl/RecentViewServiceImpl.java 第40-54行
第五步:追踪到Mapper层
Service调用recentViewMapper.findByUserId(userId, limit),Mapper层实现:
java
// [1] 根据用户ID查询最近浏览列表(数据流向:Mapper → 数据库)
@Select("SELECT rv.view_id AS viewId, rv.user_id AS userId, rv.dish_id AS dishId, " +
"rv.view_time AS viewTime, " +
"d.dish_name AS dishName, d.price, d.image_url AS imageUrl, " +
"s.store_name AS storeName, c.canteen_name AS canteenName " +
"FROM recent_view rv " +
"LEFT JOIN dish d ON rv.dish_id = d.dish_id " +
"LEFT JOIN store s ON d.store_id = s.store_id " +
"LEFT JOIN canteen c ON s.canteen_id = c.canteen_id " +
"WHERE rv.user_id = #{userId} " +
"ORDER BY rv.view_time DESC " +
"LIMIT #{limit}")
List<RecentView> findByUserId(@Param("userId") Long userId, @Param("limit") Integer limit);
位置 :src/main/java/com/scfs/mapper/RecentViewMapper.java 第26-35行
执行顺序说明:
FROM recent_view rv:从最近浏览表开始查询LEFT JOIN dish d:关联菜品表,获取菜品信息LEFT JOIN store s:关联店铺表,获取店铺信息LEFT JOIN canteen c:关联食堂表,获取食堂信息WHERE rv.user_id = #{userId}:只查询当前用户的浏览记录ORDER BY rv.view_time DESC:按浏览时间倒序排列(最新的在前)LIMIT #{limit}:限制返回的记录数(默认20条)- 数据库执行SQL查询后,返回
List<RecentView>(数据流向:数据库 → Mapper → Service)
第六步:数据返回流程
完整的数据返回路径:
- 数据库 → Mapper:数据库执行SELECT查询,通过JOIN关联查询获取浏览记录及关联的菜品、店铺、食堂信息,返回给Mapper
- Mapper → Service :Mapper将查询结果映射为
List<RecentView>对象,返回给Service - Service → Controller:Service直接返回最近浏览列表给Controller
- Controller处理 :
- Controller封装成
Result对象,success为true,data为最近浏览列表 - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
response.success是否为true - 如果成功,调用
renderRecentList(response.data)渲染最近浏览列表 renderRecentList()遍历浏览列表数组,为每个浏览项生成HTML,包含菜品名称、价格、店铺名称、食堂名称、浏览时间等信息- 将HTML插入到页面的最近浏览列表容器中
- JavaScript接收到
这样,用户点击"最近浏览"标签页,系统就从数据库查询浏览记录,通过JOIN查询获取完整信息,然后渲染到页面上,整个流程就完成了。
最近浏览列表加载时序图
数据库 RecentViewMapper RecentViewService UserController JavaScript user-center.jsp 用户 数据库 RecentViewMapper RecentViewService UserController JavaScript user-center.jsp 用户 [1] 点击"最近浏览"标签页 [2] 触发loadRecentViews() [3] AJAX GET /user/getRecentViews [4] 从Session获取用户信息 [5] 调用recentViewService.getUserRecentViews(userId, 20) [6] 调用recentViewMapper.findByUserId() [7] 执行SQL: SELECT rv.*, d.*, s.*, c.* FROM recent_view rv LEFT JOIN dish d ON rv.dish_id = d.dish_id LEFT JOIN store s ON d.store_id = s.store_id LEFT JOIN canteen c ON s.canteen_id = c.canteen_id WHERE rv.user_id = ? ORDER BY rv.view_time DESC LIMIT 20 [8] 返回浏览记录列表(包含关联信息) [9] 返回List<RecentView> [10] 返回List<RecentView> [11] 封装成Result对象 [12] 返回Result对象 (success, message, data=浏览列表) [13] 判断response.success [14] 调用renderRecentList(response.data) [15] 遍历浏览列表,生成HTML卡片 (包含菜品、店铺、食堂信息、浏览时间) [16] 将HTML插入到浏览列表容器 [17] 显示最近浏览列表
为什么这样设计
为什么限制最近浏览列表为20条?
-
原因1:性能优化
- 限制返回数量可以减少数据库查询和网络传输开销
- 避免用户浏览记录过多导致查询缓慢
- 提升页面加载速度
-
原因2:用户体验
- 20条记录足够用户查看最近的浏览历史
- 如果记录太多,用户可能不会全部查看
- 符合实际使用场景
-
原因3:数据管理
-
可以定期清理旧的浏览记录
-
避免数据库数据无限增长
-
便于后续的数据维护
"LIMIT #{limit}")
List findByUserId(@Param("userId") Long userId, @Param("limit") Integer limit);位置:
src/main/java/com/scfs/mapper/RecentViewMapper.java第35-56行这个SQL查询通过JOIN关联查询,不仅获取浏览记录,还获取菜品名称、价格、图片、店铺名称、食堂名称等信息。查询结果按浏览时间倒序排列,只返回最近的N条记录(默认20条)。
第六步:数据如何返回
数据库查询成功后,后端返回
Result对象,包含浏览列表数据。前端JavaScript接收到响应后,调用renderRecentList函数渲染列表:javascriptfunction renderRecentList(data) { const container = $('#recentList'); container.empty(); if (data.length === 0) { container.html('<div class="empty-state">暂无浏览记录</div>'); return; } data.forEach(function (item) { // 点击整个菜品框进入菜品页面 const html = '<div class="recent-item" style="cursor: pointer;" onclick="viewDish(' + item.dishId + ', event)">' + '<div class="item-info">' + '<h3 style="font-size: 14px; margin-bottom: 5px;">' + (item.dishName || '未知菜品') + ' - ¥' + (item.price || '0') + '</h3>' + '<p style="font-size: 12px; color: #666; margin: 0;">' + (item.storeName || '') + '(' + (item.canteenName || '') + ')</p>' + '</div>' + '</div>'; container.append(html); }); }
-
位置 :src/main/webapp/jsp/user-center.jsp 第443-463行
函数遍历浏览列表数据,为每个浏览项生成HTML,包括菜品名称、价格、店铺名称、食堂名称。点击整个菜品框可以跳转到菜品详情页。
这样,用户点击"最近浏览"标签页,系统就从数据库查询浏览记录,通过JOIN查询获取完整信息,然后渲染到页面上,整个流程就完成了。

总结
收藏与最近浏览模块的完整流程:
-
收藏功能:
- 用户点击收藏按钮,前端调用
toggleDishFavorite函数 - 函数判断当前状态,构造请求URL(
/favorite/add或/favorite/remove) - 发送POST请求到后端,携带菜品ID
- 后端Controller从Session获取用户ID,调用Service层
- Service层检查是否已收藏,未收藏则插入记录,已收藏则删除记录
- Mapper层执行SQL插入或删除
favorite表中的记录 - 后端返回结果,前端更新按钮状态
- 用户点击收藏按钮,前端调用
-
收藏列表:
- 用户点击"我的收藏"标签页,前端调用
loadMyFavorites函数 - 发送GET请求到
/user/getMyFavorites接口 - 后端Controller获取用户ID,调用Service层
- Service层调用Mapper层查询数据库
- Mapper层通过JOIN查询获取收藏记录和菜品、店铺、食堂的完整信息
- 后端返回收藏列表,前端渲染到页面
- 用户点击"我的收藏"标签页,前端调用
-
最近浏览记录:
- 用户浏览菜品详情页时,Controller自动调用
RecentViewService.recordView方法 - Service层创建浏览记录,调用Mapper层
- Mapper层使用
ON DUPLICATE KEY UPDATE语法,如果已存在则更新浏览时间,如果不存在则插入新记录 - 确保同一个菜品只保留一条浏览记录,但浏览时间总是最新的
- 用户浏览菜品详情页时,Controller自动调用
-
最近浏览列表:
- 用户点击"最近浏览"标签页,前端调用
loadRecentViews函数 - 发送GET请求到
/user/getRecentViews接口,默认返回20条 - 后端Controller获取用户ID,调用Service层
- Service层调用Mapper层查询数据库
- Mapper层通过JOIN查询获取浏览记录和菜品、店铺、食堂的完整信息,按浏览时间倒序排列
- 后端返回浏览列表,前端渲染到页面
- 用户点击"最近浏览"标签页,前端调用
学习建议:
- 启动项目,登录后访问菜品详情页,观察收藏按钮的状态变化
- 点击收藏按钮,查看数据库
favorite表的变化 - 在用户中心查看收藏列表和最近浏览列表
- 理解
favorite表和recent_view表的结构和关系 - 注意
ON DUPLICATE KEY UPDATE的使用场景和效果,理解为什么同一个菜品只保留一条浏览记录