基于AI图像识别与智能推荐的校园食堂评价系统研究 04-评价系统模块

04-评价系统模块

提示:本文档使用了颜色标注来突出重点内容:

  • 蓝色:文件路径和行号信息
  • 橙色:关键提示、重要注意和问题
  • 红色:抛出的问题

问题解答中的关键词语使用加粗标注。

模块说明

评价系统模块允许用户对菜品和店铺进行评价,包括评分、文字评价、图片上传、回复、点赞等功能。主要包括:

  • 查看评价列表:查看店铺下的所有评价
  • 发布评价:用户可以对店铺的菜品进行评价
  • 回复评价:用户可以对其他用户的评价进行回复
  • 评价点赞:用户可以对评价进行点赞或取消点赞
  • 评价删除:评价作者可以删除自己的评价

图片占位:评价列表区域截图


展示店铺详情页中的评价列表区域,包括评价卡片、用户名、评分、评价内容、图片等。


与前面模块的关联

评价系统模块是用户互动的重要功能,与多个模块紧密配合:

1. 依赖关系

  • 依赖于01-用户模块
    • 发布评价时,需要从Session中获取当前登录的用户信息(USER_SESSION
    • 评价记录需要关联用户ID,知道是谁发布的评价
    • 只有登录用户才能发布评价,未登录用户只能查看
    • 评价可以设置匿名(anonymous=1),但系统内部仍然记录用户ID

关键代码示例:从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:发布者ID
    • canteen_id:食堂ID
    • rating:评分(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: 店铺评分是根据所有评价的平均分计算的。

计算方式

  1. 查询该店铺下的所有评价
  2. 计算所有评价的rating字段的平均值
  3. 将平均值作为店铺评分

代码位置

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},找到ReviewControllergetReviewsByStore方法:

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)
第六步:数据返回流程

完整的数据返回路径

  1. 数据库 → Mapper
    • 数据库执行SELECT查询review表,返回评价记录列表给Mapper
    • 对每个评价,执行SELECT查询review_dishdish表,返回关联的菜品列表给Mapper
  2. Mapper → Service
    • Mapper将查询结果映射为List<Review>对象,返回给Service
    • Mapper将关联菜品映射为List<Dish>对象,返回给Service
  3. Service处理
    • Service遍历评价列表,为每个评价查询关联的菜品
    • 将菜品列表设置到评价对象的dishes属性中
  4. Service → Controller:Service返回包含关联菜品的评价列表给Controller
  5. Controller处理
    • Controller封装成Result对象,successtruedata为评价列表
    • 返回给前端(数据流向:Controller → 前端)
  6. 前端处理
    • JavaScript接收到Result对象
    • 判断result.success是否为true
    • 如果成功,调用renderReviewList(result.data)渲染评价列表
    • renderReviewList()遍历评价数组,为每个评价生成一个卡片,包含用户名、评分、评价内容、评价图片、关联的菜品、点赞数、回复列表等信息
    • 将HTML插入到页面的评价列表容器中

这样,用户打开"评价"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,找到ReviewControlleraddReviewWithDishes方法:

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)
第六步:数据返回流程

完整的数据返回路径

  1. 数据库 → Mapper
    • 数据库执行INSERT语句插入review表,返回受影响的行数(通常为1)给Mapper
    • 数据库执行批量INSERT语句插入review_dish表,返回受影响的行数(等于关联菜品数量)给Mapper
  2. Mapper → Service
    • Mapper返回受影响的行数给Service
    • Service判断行数大于0,返回true给Controller
  3. Service → Controller :Service返回true给Controller
  4. Controller处理
    • Controller封装成Result对象,successtruemessage为"发布评价成功"
    • 返回给前端(数据流向:Controller → 前端)
  5. 前端处理
    • JavaScript接收到Result对象
    • 判断res.success是否为true
    • 如果成功,弹出"发布成功"提示
    • 关闭模态窗口,清空表单,刷新评价列表

这样,用户填写评价信息,选择多个菜品,上传多张图片后,系统就保存评价到数据库(包括评价记录和评价-菜品关联记录),返回成功提示,整个流程就完成了。

发布评价完整流程时序图

数据库 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>标签可以方便地实现批量插入
    • 代码更简洁,易于维护
    • 减少循环代码,降低出错概率

图片占位:发布评价模态窗口截图

说明:展示发布评价弹窗的完整界面,包括评分选择、评价内容输入框、图片上传、关联菜品选择等。


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 确保点赞数和点赞记录的同步更新

相关推荐
C+-C资深大佬10 分钟前
SSM 框架(Spring + SpringMVC + MyBatis)
java·spring·mybatis
帅次29 分钟前
Android 17 开发者实战:核心更新与应用场景落地指南
android·java·ios·android studio·iphone·android jetpack·webview
Ramble_Naylor35 分钟前
东方通(TongWeb)SpringBoot开发指导
java·spring boot
大鹏说大话41 分钟前
SQL 排序与分组实战:解决“分组后取最新数据“
android·java·数据库
云烟成雨TD1 小时前
Spring AI Alibaba 1.x 系列【64】 ReactAgent 长期记忆
java·人工智能·spring
quan26311 小时前
20260529,日常开发-数据库主从问题
java·mysql·主从·延迟
JacksonMx1 小时前
@Transactional 最佳实践
java·spring boot·spring·性能优化
Sincerelyplz1 小时前
【AI会议纪要实践】mapReduce、RAG 与结构化输出
java·后端·agent
OpenTiny社区1 小时前
操作ArkTS页面跳转及路由相关心得
前端·typescript·web·opentiny
过期动态2 小时前
【LeetCode 热题 100】接雨水
java·数据结构·算法·leetcode·职场和发展