04-评价系统模块
提示:本文档使用了颜色标注来突出重点内容:
- 蓝色:文件路径和行号信息
- 橙色:关键提示、重要注意和问题
- 红色:抛出的问题
问题解答中的关键词语使用加粗标注。
模块说明
评价系统模块允许用户对菜品和店铺进行评价,包括评分、文字评价、图片上传、回复、点赞等功能。主要包括:
- 查看评价列表:查看店铺下的所有评价
- 发布评价:用户可以对店铺的菜品进行评价
- 回复评价:用户可以对其他用户的评价进行回复
- 评价点赞:用户可以对评价进行点赞或取消点赞
- 评价删除:评价作者可以删除自己的评价
图片占位:评价列表区域截图

展示店铺详情页中的评价列表区域,包括评价卡片、用户名、评分、评价内容、图片等。
与前面模块的关联
评价系统模块是用户互动的重要功能,与多个模块紧密配合:
1. 依赖关系
- 依赖于01-用户模块 :
- 发布评价时,需要从Session中获取当前登录的用户信息(
USER_SESSION) - 评价记录需要关联用户ID,知道是谁发布的评价
- 只有登录用户才能发布评价,未登录用户只能查看
- 评价可以设置匿名(
anonymous=1),但系统内部仍然记录用户ID
- 发布评价时,需要从Session中获取当前登录的用户信息(
关键代码示例:从Session获取用户信息
java
// 文件:src/main/java/com/scfs/controller/ReviewController.java
// 位置:第260-294行,addReviewWithDishes方法
@PostMapping("/addWithDishes")
@ResponseBody
public Result addReviewWithDishes(@RequestBody ReviewRequest request, 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);
}
Review review = request.getReview();
// 设置用户ID
review.setUserId(userId);
// 获取关联的菜品ID列表
List<Long> dishIds = request.getDishIds() != null ? request.getDishIds() : new ArrayList<>();
// 调用Service添加评价和关联菜品
boolean success = reviewService.addReviewWithDishes(review, dishIds);
// ... 返回结果
}
}
- 依赖于02-数据展示模块 :
- 评价列表显示在店铺详情页中(
store-detail.jsp是02-数据展示模块的一部分) - 用户在查看店铺详情时,可以看到该店铺下的所有评价
- 发布评价时,需要知道是在哪个店铺下发布的(从店铺详情页获取
storeId)
- 评价列表显示在店铺详情页中(
关键代码示例:从店铺详情页获取storeId并提交评价
javascript
// 文件:src/main/webapp/js/my.js
// 位置:第1911-1970行,submitReview函数
function submitReview() {
// 获取评价表单数据
var rating = parseInt($('#reviewRating').val() || '0', 10);
var content = $('#reviewContent').val().trim();
var anonymous = $('#reviewAnonymous').is(':checked') ? 1 : 0;
var storeId = getUrlParameter('storeId'); // 从URL参数获取店铺ID
if (!storeId) { alert('缺少店铺ID参数'); return; }
// 获取关联的菜品ID
var dishIds = $('#reviewDishes').val() || [];
var parsedDishIds = dishIds.map(function (id) { return parseInt(id, 10); }).filter(function (id) { return !isNaN(id); });
if (!parsedDishIds.length) {
alert('请至少选择一个菜品后再发布评价。');
return;
}
// 先上传图片,然后提交评价
uploadReviewImages(function (imageUrls) {
getStoreContext(parseInt(storeId, 10)).then(function (context) {
var imagesStr = imageUrls.length > 0 ? imageUrls.join(',') : null; // 图片URL用逗号分隔
var payload = {
review: {
canteenId: context.canteenId, // 从店铺上下文获取食堂ID
dishId: parsedDishIds[0],
rating: rating,
content: content,
anonymous: anonymous,
images: imagesStr
},
dishIds: parsedDishIds // 关联的菜品ID数组
};
$.ajax({
url: getApiUrl('review', 'addWithDishes'),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function (res) {
if (res.success) {
loadReviewList(parseInt(storeId, 10)); // 刷新评价列表
}
}
});
});
});
}
- 依赖于03-菜品管理模块 :
- 评价可以关联一个或多个菜品(一个评价可以评价多个菜品)
- 用户在发布评价时,需要选择评价的是哪些菜品
- 评价列表中会显示评价关联的菜品信息
- 系统使用中间表
review_dish存储评价和菜品的多对多关系
关键代码示例:评价关联多个菜品(多对多关系)
java
// 文件:src/main/java/com/scfs/service/impl/ReviewServiceImpl.java
// 位置:第289-318行,addReviewWithDishes方法
@Override
@Transactional
public boolean addReviewWithDishes(Review review, List<Long> dishIds) {
try {
// 插入评价记录到review表
int result = reviewMapper.insert(review);
if (result <= 0) {
return false;
}
// 关联菜品:插入到review_dish中间表
if (dishIds != null && !dishIds.isEmpty()) {
// 将菜品ID列表转换为ReviewDish对象列表
List<ReviewDish> reviewDishes = dishIds.stream()
.map(dishId -> new ReviewDish(review.getReviewId(), dishId))
.collect(Collectors.toList());
// 批量插入到review_dish表
if (!reviewDishes.isEmpty()) {
reviewExtMapper.batchInsertReviewDish(reviewDishes);
}
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
数据库表结构:
-
review表:存储评价基本信息(review_id, user_id, canteen_id, rating, content等) -
review_dish表:存储评价和菜品的关联关系(review_id, dish_id) -
一个评价可以关联多个菜品,一个菜品可以被多个评价关联
-
依赖于06-文件上传模块:
- 评价可以上传最多4张图片
- 图片上传由06-文件上传模块处理,返回图片URL
- 评价对象中的
images字段存储多个图片URL(用逗号分隔)
关键代码示例:上传多张评价图片
javascript
// 文件:src/main/webapp/js/my.js
// 位置:第1849-1909行,uploadReviewImages函数
function uploadReviewImages(callback) {
var fileInput = document.getElementById('reviewImageFiles');
if (!fileInput || !fileInput.files || fileInput.files.length === 0) {
callback([]);
return;
}
var files = Array.from(fileInput.files); // 获取所有选择的文件
var maxFiles = Math.min(files.length, 4); // 最多4张图片
var uploadedUrls = [];
var uploadCount = 0;
// 上传每张图片
files.slice(0, maxFiles).forEach(function (file, index) {
var formData = new FormData();
formData.append('file', file);
formData.append('userName', userName);
formData.append('dishName', dishName);
formData.append('index', index);
$.ajax({
url: window.SCFS_CONFIG.basePath + '/file/upload/review', // 调用06-文件上传模块的接口
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (res) {
if (res.success && res.data && res.data.fileUrl) {
uploadedUrls.push(res.data.fileUrl); // 保存图片URL
}
uploadCount++;
// 所有图片上传完成后,调用回调函数
if (uploadCount === maxFiles) {
callback(uploadedUrls);
}
}
});
});
}
2. 被依赖关系
-
被02-数据展示模块使用:
- 店铺详情页需要显示该店铺下的所有评价
- 店铺的评分是根据所有评价的平均分计算的
-
被07-收藏与最近浏览模块使用:
- 用户查看评价时,可能会因为评价而查看相关菜品
- 评价关联的菜品会被记录到最近浏览中
3. 数据流转
发布评价时的完整数据流转:
用户在store-detail.jsp点击"写评价"按钮
↓
弹出评价模态窗口(02-数据展示模块提供页面)
↓
用户选择要评价的菜品(来自03-菜品管理模块)
↓
用户填写评价内容,上传图片(最多4张)
↓
前端上传图片到 /file/upload/review 接口(06-文件上传模块)
↓
所有图片上传成功后获得图片URL数组
↓
前端发送评价信息到 /review/addWithDishes 接口
↓
04-评价系统模块的Controller从Session获取用户信息(01-用户模块)
↓
Controller调用Service添加评价
↓
Service插入评价记录(review表),同时插入评价-菜品关联记录(review_dish表)
↓
评价保存成功后,前端刷新评价列表显示新评价
4. 数据关系说明
评价和菜品的关系:
- 一个评价可以关联多个菜品(多对多关系)
- 使用中间表
review_dish存储关联关系 - 例如:用户发布一条评价"这个川菜馆的麻婆豆腐和宫保鸡丁都很好吃",这条评价关联了2个菜品
评价和用户的关系:
- 一个用户可以对多个菜品/店铺发布多条评价
- 评价表中的
user_id字段记录发布者 - 如果设置了匿名(
anonymous=1),前端显示时隐藏用户名
5. 学习建议
-
在学习本模块前:
- 建议先理解01-用户模块的Session管理(如何获取当前用户)
- 理解02-数据展示模块的店铺详情页结构(评价列表在哪里显示)
- 理解03-菜品管理模块的菜品数据结构(评价如何关联菜品)
- 理解06-文件上传模块的多文件上传流程(如何上传多张图片)
-
学习时可以结合:
- 在店铺详情页实际操作发布评价,选择多个菜品
- 使用浏览器Network面板查看上传图片和提交评价的请求
- 查看数据库中的
review表和review_dish表,理解多对多关系 - 观察评价发布后,店铺评分是如何更新的
-
学习本模块后:
- 可以尝试理解评价的回复功能(评价可以回复评价,形成树形结构)
- 理解评价的点赞功能(需要额外的表存储点赞关系)
- 思考如何优化评价的显示(分页、排序等)
6. 问题解答
Q1:评价和菜品是多对多关系,是如何存储的?
A: 使用中间表review_dish存储多对多关系。
数据库表结构:
-
review表:存储评价基本信息review_id:评价ID(主键)user_id:发布者IDcanteen_id:食堂IDrating:评分(1-5)content:评价内容images:图片URL(多个用逗号分隔)
-
review_dish表:存储评价和菜品的关联关系(中间表)review_id:评价ID(外键,关联review表)dish_id:菜品ID(外键,关联dish表)- 主键:
(review_id, dish_id)组合
代码实现:
java
// 文件:src/main/java/com/scfs/service/impl/ReviewServiceImpl.java
// 位置:第304-311行,addReviewWithDishes方法中
// 将菜品ID列表转换为ReviewDish对象列表(第305-307行)
List<ReviewDish> reviewDishes = dishIds.stream()
.map(dishId -> new ReviewDish(review.getReviewId(), dishId))
.collect(Collectors.toList());
// 批量插入到review_dish表(第309行)
if (!reviewDishes.isEmpty()) {
reviewExtMapper.batchInsertReviewDish(reviewDishes);
}
实际例子:
- 用户发布评价:"这个川菜馆的麻婆豆腐和宫保鸡丁都很好吃"
- 这条评价关联了2个菜品:麻婆豆腐(dish_id=1)和宫保鸡丁(dish_id=2)
- 在
review_dish表中会插入2条记录:(review_id=100, dish_id=1)(review_id=100, dish_id=2)
Q2:评价如何影响店铺评分?
A: 店铺评分是根据所有评价的平均分计算的。
计算方式:
- 查询该店铺下的所有评价
- 计算所有评价的
rating字段的平均值 - 将平均值作为店铺评分
代码位置:
java
// 文件:src/main/java/com/scfs/service/impl/ReviewServiceImpl.java
// 位置:getAverageRatingByStoreId方法
public Double getAverageRatingByStoreId(Long storeId) {
// 查询该店铺下所有评价的评分
// 计算平均值
// 返回平均分
}
更新时机:
- 用户发布新评价后,店铺评分会自动更新
- 用户修改评价评分后,店铺评分会重新计算
- 用户删除评价后,店铺评分会重新计算
Q3:为什么评价的图片URL用逗号分隔存储?
A: 这是一种简单的存储方式,适合小规模数据。
存储方式:
images字段存储格式:"url1,url2,url3,url4"- 最多存储4张图片
- 用逗号分隔多个URL
优点:
- 简单直接,不需要额外的表
- 查询和显示都很方便
缺点:
- 如果图片数量很多,这种方式不够灵活
- 如果需要单独管理每张图片(如删除某张),需要字符串操作
代码示例:
javascript
// 文件:src/main/webapp/js/my.js
// 位置:第1931行,submitReview函数中
var imagesStr = imageUrls.length > 0 ? imageUrls.join(',') : null; // 用逗号连接多个URL
// 存储到数据库:images字段 = "url1,url2,url3"
// 读取时:
var imageUrls = review.images ? review.images.split(',') : []; // 用逗号分割
更好的设计(如果图片很多):
- 可以创建
review_image表,单独存储每张图片 - 表结构:
review_image_id,review_id,image_url,sort_order - 这样可以更好地管理图片,支持排序、删除等操作
功能一:查看评价列表功能
抛出问题:用户在店铺详情页打开"评价"Tab时,系统是怎么加载该店铺下的所有评价并展示的?一个评价如何关联多个菜品?
功能说明
在店铺详情页面展示该店铺下的所有评价,包括评分、评价内容、评价图片、点赞数、回复列表等信息。
逐步追踪
第一步:找到用户操作的入口
用户在店铺详情页面点击"评价"Tab,页面需要加载该店铺的评价列表。找到加载评价列表的函数。
第二步:追踪前端JavaScript处理
在店铺详情页面初始化时,会调用loadReviewList(storeId)函数加载评价列表:
javascript
// [1] 加载评价列表函数(数据流向:前端 → Controller)
function loadReviewList(storeId) {
// [2] 发送AJAX请求获取评价列表
$.ajax({
url: getApiUrl('review', 'byStore') + '/' + storeId,
type: 'GET',
dataType: 'json',
// [3] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
success: function (result) {
if (result.success) {
// [4] 调用渲染函数,将评价列表显示到页面
renderReviewList(result.data);
} else {
// [5] 请求失败:记录错误日志
console.error('获取评价失败:', result.message);
}
},
error: function () {
console.error('获取评价失败');
}
});
}
位置 :src/main/webapp/js/my.js 第867-988行
第三步:追踪到Controller层
根据请求URL/review/byStore/{storeId},找到ReviewController的getReviewsByStore方法:
java
// [1] 处理GET请求 /review/byStore/{storeId}
@GetMapping("/byStore/{storeId}")
@ResponseBody
public Result getReviewsByStore(@PathVariable Long storeId) {
try {
// [2] 从URL路径中提取storeId参数(@PathVariable)
// [3] 调用Service层获取评价列表(数据流向:Controller → Service)
List<Review> reviews = reviewService.findByStoreId(storeId);
// [4] 封装成Result对象返回(数据流向:Controller → 前端)
return new Result(true, "获取评价列表成功", reviews);
} catch (Exception e) {
logger.error("获取评价列表失败", e);
return new Result(false, "获取评价列表失败");
}
}
位置 :src/main/java/com/scfs/controller/ReviewController.java 第45-55行
第四步:追踪到Service层
Controller调用reviewService.findByStoreId(storeId),Service层实现:
java
// [1] 根据店铺ID查找评价列表(数据流向:Service → Mapper)
@Override
public List<Review> findByStoreId(Long storeId) {
// [2] 调用Mapper查询评价列表(包含关联的菜品信息)
List<Review> reviews = reviewMapper.findByStoreId(storeId);
// [3] 为每个评价加载关联的菜品列表
for (Review review : reviews) {
// [4] 查询该评价关联的所有菜品(数据流向:Service → Mapper)
List<Dish> dishes = reviewDishMapper.findDishesByReviewId(review.getReviewId());
// [5] 设置评价的关联菜品列表
review.setDishes(dishes);
}
// [6] 返回评价列表(数据流向:Service → Controller)
return reviews;
}
位置 :src/main/java/com/scfs/service/impl/ReviewServiceImpl.java 第89-102行
第五步:追踪到Mapper层
5.1 查询评价列表
Service调用reviewMapper.findByStoreId(storeId),Mapper层实现:
java
// [1] 根据店铺ID查询评价列表(数据流向:Mapper → 数据库)
@Select("SELECT review_id AS reviewId, store_id AS storeId, user_id AS userId, " +
"rating, content, images, anonymous, likes, create_time AS createTime " +
"FROM review WHERE store_id = #{storeId} " +
"ORDER BY create_time DESC")
List<Review> findByStoreId(@Param("storeId") Long storeId);
位置 :src/main/java/com/scfs/mapper/ReviewMapper.java 第35-40行
5.2 查询评价关联的菜品
Service调用reviewDishMapper.findDishesByReviewId(reviewId),Mapper层实现:
java
// [1] 根据评价ID查询关联的菜品列表(数据流向:Mapper → 数据库)
@Select("SELECT d.dish_id AS dishId, d.dish_name AS dishName, d.image_url " +
"FROM dish d " +
"INNER JOIN review_dish rd ON d.dish_id = rd.dish_id " +
"WHERE rd.review_id = #{reviewId}")
List<Dish> findDishesByReviewId(@Param("reviewId") Long reviewId);
位置 :src/main/java/com/scfs/mapper/ReviewDishMapper.java 第28-33行
执行顺序说明:
- 第一步:查询
review表,获取该店铺的所有评价(WHERE store_id = #{storeId}) - 第二步:对每个评价,查询
review_dish中间表,获取关联的菜品ID - 第三步:通过
INNER JOIN关联dish表,获取菜品的详细信息(名称、图片等) - 数据库执行SQL查询后,返回
List<Dish>(数据流向:数据库 → Mapper → Service)
第六步:数据返回流程
完整的数据返回路径:
- 数据库 → Mapper :
- 数据库执行SELECT查询
review表,返回评价记录列表给Mapper - 对每个评价,执行SELECT查询
review_dish和dish表,返回关联的菜品列表给Mapper
- 数据库执行SELECT查询
- Mapper → Service :
- Mapper将查询结果映射为
List<Review>对象,返回给Service - Mapper将关联菜品映射为
List<Dish>对象,返回给Service
- Mapper将查询结果映射为
- Service处理 :
- Service遍历评价列表,为每个评价查询关联的菜品
- 将菜品列表设置到评价对象的
dishes属性中
- Service → Controller:Service返回包含关联菜品的评价列表给Controller
- Controller处理 :
- Controller封装成
Result对象,success为true,data为评价列表 - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
result.success是否为true - 如果成功,调用
renderReviewList(result.data)渲染评价列表 renderReviewList()遍历评价数组,为每个评价生成一个卡片,包含用户名、评分、评价内容、评价图片、关联的菜品、点赞数、回复列表等信息- 将HTML插入到页面的评价列表容器中
- JavaScript接收到
这样,用户打开"评价"Tab,系统就加载该店铺下的所有评价(包括关联的菜品),数据从数据库传递到前端,最后显示在页面上,整个流程就完成了。
评价列表加载时序图
数据库 ReviewDishMapper ReviewMapper ReviewService ReviewController JavaScript store-detail.jsp 用户 数据库 ReviewDishMapper ReviewMapper ReviewService ReviewController JavaScript store-detail.jsp 用户 loop [遍历每个评价] [1] 打开店铺详情页 点击"评价"Tab [2] Tab切换事件触发 [3] 调用loadReviewList(storeId) [4] AJAX GET /review/byStore/{storeId} [5] 接收GET请求 (@PathVariable提取storeId) [6] 调用reviewService.findByStoreId() [7] 调用reviewMapper.findByStoreId() [8] 执行SQL: SELECT * FROM review WHERE store_id = ? ORDER BY create_time DESC [9] 返回评价记录列表 [10] 返回List<Review> [11] 调用reviewDishMapper.findDishesByReviewId() [12] 执行SQL: SELECT d.* FROM dish d INNER JOIN review_dish rd ON d.dish_id = rd.dish_id WHERE rd.review_id = ? [13] 返回关联的菜品列表 [14] 返回List<Dish> [15] 设置review.setDishes(dishes) [16] 返回List<Review>(包含关联菜品) [17] 封装成Result对象 [18] 返回Result对象 (success, message, data=评价列表) [19] 判断result.success [20] 调用renderReviewList(result.data) [21] 遍历评价数组,生成HTML卡片 (包含关联菜品信息) [22] 将HTML插入到评价列表容器 [23] 显示评价卡片列表
评价与菜品关联关系图
一个评价关联多个菜品
一个菜品可以被多个评价关联
REVIEW
long
review_id
PK
long
store_id
FK
long
user_id
FK
int
rating
string
content
string
images
int
anonymous
int
likes
date
create_time
REVIEW_DISH
long
review_id
PK_FK
long
dish_id
PK_FK
DISH
long
dish_id
PK
long
store_id
FK
string
dish_name
double
price
string
image_url
评价列表加载流程图
是
否
用户打开店铺详情页
点击评价Tab
调用loadReviewList函数
发送AJAX请求
GET /review/byStore/storeId
Controller接收请求
Service查询评价列表
Mapper执行SQL查询
SELECT * FROM review
WHERE store_id = ?
数据库返回评价列表
Service遍历每个评价
查询该评价关联的菜品
SELECT d.* FROM dish d
INNER JOIN review_dish rd
ON d.dish_id = rd.dish_id
WHERE rd.review_id = ?
数据库返回关联菜品列表
设置评价的关联菜品
还有更多评价?
数据层层返回
前端接收Result对象
调用renderReviewList渲染
显示评价卡片列表
(包含关联菜品)
为什么这样设计
为什么使用中间表review_dish来关联评价和菜品?
-
原因1:多对多关系
- 一个评价可以关联多个菜品(用户可能评价多个菜品)
- 一个菜品可以被多个评价关联(多个用户评价同一个菜品)
- 使用中间表可以灵活地表示这种多对多关系
-
原因2:数据规范化
- 符合数据库设计第三范式(3NF)
- 避免数据冗余,减少存储空间
- 便于维护和扩展
-
原因3:查询灵活性
- 可以方便地查询某个评价关联的所有菜品
- 可以方便地查询某个菜品被哪些评价关联
- 可以方便地统计菜品的评价数量
为什么在Service层加载关联菜品,而不是在Mapper层一次性查询?
-
原因1:代码清晰
- Service层负责业务逻辑,Mapper层负责数据访问
- 分开查询,代码结构更清晰,便于维护
-
原因2:性能考虑
- 如果使用JOIN一次性查询,会产生大量重复数据(一个评价关联多个菜品)
- 分开查询可以避免数据重复,减少网络传输量
- 如果评价数量很多,可以优化为批量查询
-
原因3:灵活性
- 可以根据需要决定是否加载关联菜品
- 可以延迟加载,提升性能
- 便于后续优化和扩展
功能二:发布评价功能
抛出问题:用户填写评价信息,选择多个菜品,上传多张图片后,点击"发布评价"按钮,系统是怎么保存评价的?多个菜品是怎么关联到一个评价上的?
功能说明
用户可以对店铺的菜品进行评价,包括评分(1-5分)、文字内容、图片(最多4张)、关联菜品、匿名选项等。
第二步:追踪前端JavaScript处理
用户填写评价信息后,点击"发布评价"按钮,触发submitReview()函数:
javascript
// [1] 提交评价函数(数据流向:前端 → 文件上传服务 → Controller)
function submitReview() {
// [2] 收集表单数据:评分、内容、匿名选项
var rating = parseInt($('#reviewRating').val() || '0', 10);
var content = $('#reviewContent').val().trim();
var anonymous = $('#reviewAnonymous').is(':checked') ? 1 : 0;
// [3] 从URL参数获取店铺ID
var storeId = getUrlParameter('storeId');
// [4] 前端验证:检查必填项
if (!storeId) { alert('缺少店铺ID参数'); return; }
if (!rating || rating < 1 || rating > 5) { alert('请进行1-5分评分'); return; }
if (!content) { alert('评价内容不能为空'); return; }
// [5] 获取关联的菜品ID列表(多选)
var dishIds = $('#reviewDishes').val() || [];
var parsedDishIds = dishIds.map(function (id) {
return parseInt(id, 10);
}).filter(function (id) {
return !isNaN(id);
});
// [6] 验证至少选择一个菜品
if (!parsedDishIds.length) {
alert('请至少选择一个菜品后再发布评价。');
return;
}
// [7] 先上传图片(最多4张)(数据流向:前端 → 文件上传服务)
uploadReviewImages(function (imageUrls) {
// [8] 获取店铺上下文信息(包含canteenId等)
getStoreContext(parseInt(storeId, 10)).then(function (context) {
// [9] 将图片URL数组转换为逗号分隔的字符串
var imagesStr = imageUrls.length > 0 ? imageUrls.join(',') : null;
// [10] 构建提交数据(数据流向:前端 → Controller)
var payload = {
review: {
canteenId: context.canteenId,
dishId: parsedDishIds[0], // 主菜品ID(用于兼容)
rating: rating,
content: content,
anonymous: anonymous,
images: imagesStr // 图片URL字符串(逗号分隔)
},
dishIds: parsedDishIds // 关联的菜品ID数组(多个菜品)
};
// [11] 发送AJAX请求提交评价数据
$.ajax({
url: getApiUrl('review', 'addWithDishes'),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
// [12] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
success: function (res) {
if (res.success) {
// [13] 提交成功:提示用户,关闭模态窗口,清空表单,刷新评价列表
alert('发布成功');
$('#writeReviewModal').modal('hide');
// 清空表单...
loadReviewList(parseInt(storeId, 10));
} else {
// [14] 提交失败:显示错误信息
alert('发布失败:' + (res.message || '请稍后重试'));
}
},
error: function () { alert('网络异常,请重试'); }
});
});
});
}
位置 :src/main/webapp/js/my.js 第1911-1970行
第三步:追踪到Controller层
根据请求URL/review/addWithDishes,找到ReviewController的addReviewWithDishes方法:
java
// [1] 处理POST请求 /review/addWithDishes(数据流向:前端 → Controller)
@PostMapping("/addWithDishes")
@ResponseBody
public Result addReviewWithDishes(@RequestBody Map<String, Object> requestData,
HttpSession session) {
try {
// [2] 从请求体中提取评价数据和菜品ID列表
Map<String, Object> reviewData = (Map<String, Object>) requestData.get("review");
List<Integer> dishIdsInt = (List<Integer>) requestData.get("dishIds");
// [3] 转换为Review对象
Review review = new Review();
review.setStoreId(Long.valueOf((Integer) reviewData.get("storeId")));
review.setCanteenId(Long.valueOf((Integer) reviewData.get("canteenId")));
review.setRating((Integer) reviewData.get("rating"));
review.setContent((String) reviewData.get("content"));
review.setAnonymous((Integer) reviewData.get("anonymous"));
review.setImages((String) reviewData.get("images"));
// [4] 从Session中获取当前登录用户(01-用户模块提供的USER_SESSION)
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, "请先登录");
}
review.setUserId(user.getUserId());
// [5] 转换菜品ID列表为Long类型
List<Long> dishIds = dishIdsInt.stream()
.map(Integer::longValue)
.collect(Collectors.toList());
// [6] 调用Service层添加评价(数据流向:Controller → Service)
boolean success = reviewService.addReviewWithDishes(review, dishIds);
// [7] 根据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/ReviewController.java 第78-115行
第四步:追踪到Service层
Controller调用reviewService.addReviewWithDishes(review, dishIds),Service层实现:
java
// [1] 添加评价并关联多个菜品(数据流向:Service → Mapper)
@Override
@Transactional // [2] 使用事务,确保数据一致性
public boolean addReviewWithDishes(Review review, List<Long> dishIds) {
try {
// [3] 设置默认值(如创建时间等)
applyDefaultValues(review);
// [4] 如果评价的主菜品ID为空,使用第一个关联菜品ID
if ((dishIds != null && !dishIds.isEmpty()) && review.getDishId() == null) {
review.setDishId(dishIds.get(0));
}
// [5] 插入评价记录到review表(数据流向:Service → Mapper)
int result = reviewMapper.insert(review);
if (result <= 0) {
return false;
}
// [6] 关联多个菜品到评价(数据流向:Service → Mapper)
if (dishIds != null && !dishIds.isEmpty()) {
// [7] 将菜品ID列表转换为ReviewDish对象列表
List<ReviewDish> reviewDishes = dishIds.stream()
.map(dishId -> new ReviewDish(review.getReviewId(), dishId))
.collect(Collectors.toList());
// [8] 批量插入到review_dish中间表
if (!reviewDishes.isEmpty()) {
reviewExtMapper.batchInsertReviewDish(reviewDishes);
}
}
// [9] 返回成功(数据流向:Service → Controller)
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
位置 :src/main/java/com/scfs/service/impl/ReviewServiceImpl.java 第289-318行
第五步:追踪到Mapper层
5.1 插入评价记录
Service调用reviewMapper.insert(review),Mapper层实现:
java
// [1] 插入评价记录到数据库(数据流向:Mapper → 数据库)
@Insert("INSERT INTO review (store_id, canteen_id, user_id, dish_id, rating, " +
"content, images, anonymous, likes, create_time) " +
"VALUES (#{storeId}, #{canteenId}, #{userId}, #{dishId}, #{rating}, " +
"#{content}, #{images}, #{anonymous}, 0, #{createTime})")
@Options(useGeneratedKeys = true, keyProperty = "reviewId")
int insert(Review review);
位置 :src/main/java/com/scfs/mapper/ReviewMapper.java 第42-46行
5.2 批量插入评价-菜品关联
Service调用reviewExtMapper.batchInsertReviewDish(reviewDishes),Mapper层实现:
java
// [1] 批量插入评价-菜品关联记录到数据库(数据流向:Mapper → 数据库)
@Insert("<script>" +
"INSERT INTO review_dish (review_id, dish_id) VALUES " +
"<foreach collection='list' item='item' separator=','>" +
"(#{item.reviewId}, #{item.dishId})" +
"</foreach>" +
"</script>")
int batchInsertReviewDish(@Param("list") List<ReviewDish> reviewDishes);
位置 :src/main/java/com/scfs/mapper/ReviewExtMapper.java 第15-22行
执行顺序说明:
- 第一步:插入
review表,插入评价记录(INSERT INTO review) - 第二步:获取自动生成的主键
reviewId(@Options(useGeneratedKeys = true)) - 第三步:批量插入
review_dish表,插入评价-菜品关联记录(INSERT INTO review_dish) - 使用
<foreach>标签批量插入多条关联记录,提升性能 - 数据库执行INSERT后,返回受影响的行数(数据流向:数据库 → Mapper → Service)
第六步:数据返回流程
完整的数据返回路径:
- 数据库 → Mapper :
- 数据库执行INSERT语句插入
review表,返回受影响的行数(通常为1)给Mapper - 数据库执行批量INSERT语句插入
review_dish表,返回受影响的行数(等于关联菜品数量)给Mapper
- 数据库执行INSERT语句插入
- Mapper → Service :
- Mapper返回受影响的行数给Service
- Service判断行数大于0,返回
true给Controller
- Service → Controller :Service返回
true给Controller - Controller处理 :
- Controller封装成
Result对象,success为true,message为"发布评价成功" - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
res.success是否为true - 如果成功,弹出"发布成功"提示
- 关闭模态窗口,清空表单,刷新评价列表
- JavaScript接收到
这样,用户填写评价信息,选择多个菜品,上传多张图片后,系统就保存评价到数据库(包括评价记录和评价-菜品关联记录),返回成功提示,整个流程就完成了。
发布评价完整流程时序图
数据库 ReviewExtMapper ReviewMapper ReviewService ReviewController FileController JavaScript store-detail.jsp 用户 数据库 ReviewExtMapper ReviewMapper ReviewService ReviewController FileController JavaScript store-detail.jsp 用户 [1] 填写评价信息 (评分、内容、匿名选项) [2] 选择多个关联菜品 [3] 上传多张图片(最多4张) [4] 点击"发布评价"按钮 [5] 触发submitReview() [6] 收集表单数据 [7] 前端验证(必填项、评分范围、至少一个菜品) [8] 上传多张图片 POST /file/upload/review (FormData上传) [9] 保存图片到服务器 [10] 返回图片URL数组 [11] 获取店铺上下文信息 [12] 构建payload (review对象 + dishIds数组) [13] POST /review/addWithDishes (JSON数据) [14] 接收请求参数(@RequestBody) [15] 从Session获取用户信息 [16] 调用reviewService.addReviewWithDishes() [17] 设置默认值(createTime等) [18] 调用reviewMapper.insert() [19] 执行SQL: INSERT INTO review (store_id, user_id, rating, content, ...) [20] 返回受影响行数(1) 并返回自动生成的reviewId [21] 返回受影响行数 [22] 构建ReviewDish对象列表 (reviewId, dishId1), (reviewId, dishId2), ... [23] 调用reviewExtMapper.batchInsertReviewDish() [24] 执行SQL: INSERT INTO review_dish (review_id, dish_id) VALUES (?, ?), (?, ?), ... [25] 返回受影响行数(等于菜品数量) [26] 返回受影响行数 [27] 返回true [28] 返回Result(true, "发布评价成功") [29] 提示"发布成功" [30] 关闭模态窗口,清空表单 [31] 刷新评价列表
发布评价流程图(包含多图片上传和多菜品关联)
否
是
失败
成功
否
是
否
是
用户填写评价信息
选择多个关联菜品
上传多张图片(最多4张)
点击发布评价按钮
前端验证
验证通过?
提示错误信息
上传图片到服务器
图片上传成功?
提示图片上传失败
获取图片URL数组
获取店铺上下文信息
构建payload
(review对象 + dishIds数组)
发送AJAX请求
POST /review/addWithDishes
Controller接收请求
从Session获取用户信息
Service开始事务
插入评价记录到review表
插入成功?
回滚事务,返回失败
获取自动生成的reviewId
构建ReviewDish对象列表
批量插入到review_dish表
批量插入成功?
回滚事务,返回失败
提交事务
返回成功提示
关闭模态窗口,清空表单
刷新评价列表
评价与菜品关联数据流转图
前端提交
review对象
{rating, content, images, ...}
dishIds数组
dishId1, dishId2, dishId3
插入review表
获取reviewId
reviewId = 100
构建ReviewDish列表
ReviewDish对象列表
{reviewId:100, dishId:1}, {reviewId:100, dishId:2}, {reviewId:100, dishId:3}
批量插入review_dish表
数据库中的关联记录
review_id=100, dish_id=1
review_id=100, dish_id=2
review_id=100, dish_id=3
为什么这样设计
为什么一个评价可以关联多个菜品?
-
原因1:业务需求
- 用户可能同时评价多个菜品(如套餐、组合餐等)
- 一个评价可以涵盖多个菜品的整体体验
- 符合实际使用场景,提升用户体验
-
原因2:数据灵活性
- 使用中间表
review_dish可以灵活地表示多对多关系 - 可以方便地查询某个评价关联的所有菜品
- 可以方便地查询某个菜品被哪些评价关联
- 使用中间表
-
原因3:数据统计
- 可以统计某个菜品被评价的次数
- 可以统计某个菜品的平均评分
- 便于后续的数据分析和推荐
为什么使用@Transactional事务?
-
原因1:数据一致性
- 插入评价记录和插入关联记录必须同时成功或同时失败
- 如果插入评价成功但插入关联失败,会导致数据不一致
- 使用事务可以确保数据的一致性
-
原因2:原子性
- 整个操作要么全部成功,要么全部失败
- 如果中途出错,可以自动回滚,保证数据完整性
- 避免产生"半成品"数据
-
原因3:错误处理
- 如果插入关联记录失败,可以自动回滚评价记录
- 避免产生孤立数据,提升系统的可靠性
为什么使用批量插入而不是循环插入?
-
原因1:性能优化
- 批量插入可以减少数据库交互次数
- 一次SQL执行比多次SQL执行效率更高
- 减少网络传输开销,提升性能
-
原因2:事务效率
- 批量插入在同一个事务中完成,减少事务时间
- 减少数据库锁定时间,提升并发性能
- 降低数据库负载
-
原因3:代码简洁
- 使用MyBatis的
<foreach>标签可以方便地实现批量插入 - 代码更简洁,易于维护
- 减少循环代码,降低出错概率
- 使用MyBatis的
图片占位:发布评价模态窗口截图
说明:展示发布评价弹窗的完整界面,包括评分选择、评价内容输入框、图片上传、关联菜品选择等。
3. 回复评价功能
功能说明
用户可以对其他用户的评价进行回复,形成评价下的对话。
3.1 JavaScript交互
片段来自:src/main/webapp/js/my.js 第 1086-1116 行
javascript
function submitReviewReply(reviewId) {
var editor = $('#replyContent-' + reviewId);
if (!editor.length) {
return;
}
var content = editor.val().trim();
if (!content) {
alert('回复内容不能为空');
return;
}
$.ajax({
url: getApiUrl('review', 'replyAdd') + '/' + reviewId,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ content: content }),
success: function (res) {
if (res.success) {
editor.val('');
cancelReply(reviewId);
if (window.currentStoreId) {
loadReviewList(window.currentStoreId);
}
} else {
alert('回复失败:' + (res.message || '请稍后重试'));
}
},
error: function () {
alert('回复失败,请稍后重试');
}
});
}
4. 评价点赞功能
功能说明
用户可以对评价进行点赞或取消点赞。点赞状态会影响点赞按钮的显示。
4.1 控制器层(Controller)
片段来自:src/main/java/com/scfs/controller/ReviewController.java 第 362-381 行
java
@PostMapping("/like/{reviewId}")
@ResponseBody
public Result likeReview(@PathVariable Long reviewId, HttpSession session) {
try {
User user = (User) session.getAttribute("USER_SESSION");
if (user == null) {
return new Result(false, 401, "请先登录", null);
}
Long userId = user.getUserId();
boolean isLiked = reviewService.likeReview(reviewId, userId);
return new Result(true, isLiked ? "点赞成功" : "取消点赞成功", isLiked);
} catch (Exception e) {
e.printStackTrace();
return new Result(false, "操作失败");
}
}
4.2 服务层(Service)
片段来自:src/main/java/com/scfs/service/impl/ReviewServiceImpl.java 第 322-343 行
java
@Override
@Transactional
public boolean likeReview(Long reviewId, Long userId) {
try {
ReviewLike existingLike = reviewExtMapper.findReviewLikeByReviewAndUser(reviewId, userId);
if (existingLike != null) {
// 已点赞,取消点赞
reviewExtMapper.deleteReviewLike(reviewId, userId);
reviewMapper.updateLikeCount(reviewId, -1);
return false; // 表示取消点赞
} else {
// 未点赞,添加点赞
ReviewLike like = new ReviewLike();
like.setReviewId(reviewId);
like.setUserId(userId);
reviewExtMapper.insertReviewLike(like);
reviewMapper.updateLikeCount(reviewId, 1);
return true; // 表示点赞
}
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
代码解释:
- 检查用户是否已点赞该评价
- 如果已点赞,删除点赞记录,点赞数-1
- 如果未点赞,添加点赞记录,点赞数+1
- 使用
@Transactional确保点赞数和点赞记录的同步更新