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

相关推荐
Wpa.wk2 小时前
接口自动化 - 了解接口自动化框架RESTAssured (Java版)
java·数据库·自动化
wa的一声哭了2 小时前
内积空间 内积空间二
java·开发语言·python·spring·java-ee·django·maven
SadSunset2 小时前
Git常用命令
java·学习
晓13132 小时前
后端篇——第二章 Maven高级全面教程
java·maven
普兰店拉马努金2 小时前
【高中数学/排列组合】由字母AB构成的一个6位的序列,含有连续子序列ABA的序列有多少个?
java·排列组合
cike_y2 小时前
Spring使用注解开发
java·后端·spring·jdk1.8
wa的一声哭了2 小时前
内积空间 正交与正交系
java·c++·线性代数·算法·矩阵·eclipse·云计算
水龙吟啸2 小时前
项目设计与开发:智慧校园食堂系统
python·机器学习·前端框架·c#·团队开发·visual studio·数据库系统
彭于晏Yan2 小时前
Springboot集成Hutool导出CSV
java·spring boot·后端