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

06-文件上传模块

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

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

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

模块说明

文件上传模块负责系统中所有图片文件的上传和管理。主要包括:

  • 店铺图片上传:上传店铺的主图片
  • 菜品图片上传:上传菜品的展示图片
  • 评价图片上传:上传评价中的图片(最多4张)



与前面模块的关联

文件上传模块是一个基础工具模块,被多个业务模块调用:

1. 依赖关系

  • 被02-数据展示模块使用
    • 用户在添加/编辑店铺时,可以上传店铺图片
    • 上传的图片URL会保存到店铺对象的imageUrl字段
    • 店铺列表中会显示店铺图片

关键代码示例:02-数据展示模块调用文件上传

javascript 复制代码
// 文件:src/main/webapp/jsp/store.jsp
// 位置:上传店铺图片的函数

function uploadStoreImage(fileInput) {
    var formData = new FormData();
    formData.append('file', fileInput.files[0]);  // 添加文件
    formData.append('storeId', $('#storeId').val());
    formData.append('storeName', $('#storeName').val());
    formData.append('canteenName', $('#canteenName').val());
    
    $.ajax({
        url: '/file/upload/store',  // 调用06-文件上传模块的接口
        type: 'POST',
        data: formData,
        processData: false,
        contentType: false,
        success: function(response) {
            if (response.success) {
                var imageUrl = response.data.fileUrl;  // 获取返回的图片URL
                $('#storeImageUrl').val(imageUrl);  // 保存到隐藏字段
            }
        }
    });
}
  • 被03-菜品管理模块使用
    • 用户在添加/编辑菜品时,可以上传菜品图片
    • 上传的图片URL会保存到菜品对象的imageUrl字段
    • 菜品列表中会显示菜品图片

关键代码示例:03-菜品管理模块调用文件上传

javascript 复制代码
// 文件:src/main/webapp/js/my.js
// 位置:第1588-1608行,uploadDishImageForAdd函数

function uploadDishImageForAdd(fileInput) {
    var file = fileInput.files[0];
    if (!file) return;
    
    // 文件验证
    if (!file.type.match('image.*')) {
        alert('请选择图片文件!');
        return;
    }
    if (file.size > 5 * 1024 * 1024) {
        alert('图片大小不能超过 5MB!');
        return;
    }
    
    // 预览图片
    var reader = new FileReader();
    reader.onload = function (e) {
        $('#addImagePreview').attr('src', e.target.result);
        $('#addImagePreviewContainer').show();
    };
    reader.readAsDataURL(file);
    
    // 实际文件上传在表单提交时进行,见submitAddDish函数
}
  • 被04-评价系统模块使用
    • 用户在发布评价时,可以上传最多4张评价图片
    • 多张图片的URL用逗号分隔,保存到评价对象的images字段
    • 评价列表中会显示评价图片

关键代码示例:04-评价系统模块调用文件上传

javascript 复制代码
// 文件:src/main/webapp/js/my.js
// 位置:第1886-1907行,uploadReviewImages函数中上传单张图片

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
            }
        }
    });
});
  • 被09-AI功能模块使用
    • AI图像识别功能需要先上传图片到服务器
    • 上传的图片用于YOLOv8盘子识别和食材识别

关键代码示例:后端文件上传接口实现

java 复制代码
// 文件:src/main/java/com/scfs/controller/FileController.java
// 位置:第28-54行,uploadStoreImage方法

@PostMapping("/upload/store")
@ResponseBody
public Result<Map<String, Object>> uploadStoreImage(@RequestParam("file") MultipartFile file,
        @RequestParam("storeId") Long storeId,
        @RequestParam(value = "storeName", required = false) String storeName,
        @RequestParam(value = "canteenName", required = false) String canteenName) {
    try {
        // 获取webapp绝对路径(第36行)
        String webappPath = FileUploadUtil.getWebappPath(servletContext);
        
        // 上传图片(使用食堂名_店铺名的命名规则)(第39行)
        String imageUrl = FileUploadUtil.uploadStoreImage(file, storeId, storeName, canteenName, webappPath);
        
        // 返回结果(第42-47行)
        Map<String, Object> data = new HashMap<>();
        data.put("fileUrl", imageUrl);  // 返回图片URL
        data.put("fileName", file.getOriginalFilename());
        data.put("fileSize", file.getSize());
        
        return new Result<>(true, "图片上传成功", data);
    } catch (Exception e) {
        return new Result<>(false, "图片上传失败:" + e.getMessage());
    }
}

// 文件:src/main/java/com/scfs/controller/FileController.java
// 位置:第59-87行,uploadDishImage方法

@PostMapping("/upload/dish")
@ResponseBody
public Result<Map<String, Object>> uploadDishImage(@RequestParam("file") MultipartFile file,
        @RequestParam("dishId") Long dishId,
        @RequestParam(value = "canteenName", required = false) String canteenName,
        @RequestParam(value = "storeName", required = false) String storeName,
        @RequestParam(value = "dishName", required = false) String dishName) {
    try {
        String webappPath = FileUploadUtil.getWebappPath(servletContext);
        // 上传图片
        String imageUrl = FileUploadUtil.uploadDishImage(file, dishId, canteenName, storeName, dishName, webappPath);
        
        Map<String, Object> data = new HashMap<>();
        data.put("fileUrl", imageUrl);  // 返回图片URL
        return new Result<>(true, "图片上传成功", data);
    } catch (Exception e) {
        return new Result<>(false, "图片上传失败:" + e.getMessage());
    }
}

// 文件:src/main/java/com/scfs/controller/FileController.java
// 位置:第92-118行,uploadReviewImage方法

@PostMapping("/upload/review")
@ResponseBody
public Result<Map<String, Object>> uploadReviewImage(@RequestParam("file") MultipartFile file,
        @RequestParam("userName") String userName,
        @RequestParam("dishName") String dishName,
        @RequestParam(value = "index", defaultValue = "0") int index) {
    try {
        String webappPath = FileUploadUtil.getWebappPath(servletContext);
        // 上传图片
        String imageUrl = FileUploadUtil.uploadReviewImage(file, userName, dishName, index, webappPath);
        
        Map<String, Object> data = new HashMap<>();
        data.put("fileUrl", imageUrl);  // 返回图片URL
        return new Result<>(true, "评价图片上传成功", data);
    } catch (Exception e) {
        return new Result<>(false, "评价图片上传失败:" + e.getMessage());
    }
}

2. 数据流转

上传图片并使用的完整流程

复制代码
用户在业务模块(02/03/04)点击上传图片
    ↓
选择图片文件(JPG、PNG、GIF,不超过2MB)
    ↓
前端使用FormData封装图片,发送到 /file/upload/xxx 接口
    ↓
06-文件上传模块的FileController接收请求
    ↓
FileController调用FileUploadUtil工具类处理文件
    ↓
FileUploadUtil验证文件类型和大小,生成文件名,保存到服务器
    ↓
返回图片URL给前端(如:/img/stores/食堂名_店铺名.jpg)
    ↓
前端保存图片URL到隐藏字段或直接显示预览
    ↓
用户提交业务数据(店铺/菜品/评价)时,图片URL一起提交
    ↓
业务模块保存数据时,把图片URL保存到数据库
    ↓
用户浏览时,通过图片URL在页面中显示图片

3. 文件命名规则

不同的业务模块使用不同的命名规则,便于管理和识别:

  • 店铺图片食堂名_店铺名.扩展名(如:第一食堂_川菜馆.jpg
  • 菜品图片dish_菜品ID.扩展名dish_时间戳.扩展名
  • 评价图片用户名_菜品名_编号.扩展名(如:张三_麻婆豆腐_0.jpg

关键代码示例:文件命名规则的实现

java 复制代码
// 文件:src/main/java/com/scfs/utils/FileUploadUtil.java
// 位置:第54-104行,uploadStoreImage方法

public static String uploadStoreImage(MultipartFile file, Long storeId, String storeName, String canteenName,
        String webappPath) throws IOException {
    // 验证文件(第57行)
    validateFile(file);
    
    // 获取文件扩展名(第60-61行)
    String originalFilename = file.getOriginalFilename();
    String extension = getFileExtension(originalFilename);
    
    // 生成文件名:食堂名_店铺名.{ext}(第63-67行)
    String sanitizedCanteenName = sanitizeForFilename(canteenName);  // 清理食堂名(去除特殊字符)
    String sanitizedStoreName = sanitizeForFilename(storeName);  // 清理店铺名
    String filename = String.format("%s_%s%s", sanitizedCanteenName, sanitizedStoreName, extension);
    
    // 保存文件到服务器(第69-100行)
    // ... 文件保存逻辑
    
    // 返回相对路径URL(第103行)
    return "/img/stores/" + filename;
}

文件存储路径

  • 店铺图片:src/main/webapp/img/stores/食堂名_店铺名.jpg
  • 菜品图片:src/main/webapp/img/dishes/dish_菜品ID.jpg
  • 评价图片:src/main/webapp/img/review/用户名_菜品名_0.jpg

访问URL

  • 店铺图片:/img/stores/食堂名_店铺名.jpg
  • 菜品图片:/img/dishes/dish_菜品ID.jpg
  • 评价图片:/img/review/用户名_菜品名_0.jpg

4. 学习建议

  • 在学习本模块前

    • 建议先理解前端FormData的使用(如何封装文件数据)
    • 理解multipart/form-data请求格式
    • 可以先看看02/03/04模块中如何调用文件上传功能
  • 学习时可以结合

    • 在店铺添加页面实际操作上传店铺图片
    • 在菜品添加页面实际操作上传菜品图片
    • 在评价发布页面实际操作上传评价图片
    • 使用浏览器Network面板查看上传请求的详细信息
    • 查看服务器文件系统,看看图片实际保存在哪里
    • 查看数据库,看看图片URL是如何存储的
  • 学习本模块后

    • 理解为什么文件上传是单独的一个模块(复用性)
    • 理解文件命名规则的作用(便于管理、避免冲突)
    • 思考如何优化文件上传(压缩、CDN等)

5. 问题解答

Q1:为什么文件上传是单独的一个模块?

A: 这是代码复用职责分离的设计原则。

复用性

  • 多个模块都需要上传图片(店铺、菜品、评价)
  • 如果每个模块都自己实现上传功能,会有大量重复代码
  • 统一的上传模块,所有模块都可以调用,避免重复开发

职责分离

  • 文件上传有独立的业务逻辑(验证、命名、存储)
  • 单独模块便于维护和优化
  • 如果需要修改上传逻辑(如添加压缩、CDN),只需要修改一个地方

实际应用

  • 02-数据展示模块:调用/file/upload/store上传店铺图片
  • 03-菜品管理模块:调用/file/upload/dish上传菜品图片
  • 04-评价系统模块:调用/file/upload/review上传评价图片
  • 09-AI功能模块:也可以调用这些接口上传图片

代码位置

java 复制代码
// 文件:src/main/java/com/scfs/controller/FileController.java
// 位置:统一的文件上传控制器

@RestController
@RequestMapping("/file")
public class FileController {
    // 提供多个上传接口,供不同模块调用
    @PostMapping("/upload/store")   // 店铺图片
    @PostMapping("/upload/dish")    // 菜品图片
    @PostMapping("/upload/review")  // 评价图片
}
Q2:文件命名规则的作用是什么?

A: 文件命名规则有多个重要作用。

1. 便于识别和管理

  • 看到文件名就知道是哪个店铺/菜品的图片
  • 例如:第一食堂_川菜馆.jpg,一眼就能看出是"第一食堂"下的"川菜馆"的图片

2. 避免文件名冲突

  • 如果都用原始文件名,可能重名(如多个用户都上传image.jpg
  • 使用业务相关的命名规则,确保文件名唯一

3. 便于查找和清理

  • 删除店铺时,可以根据命名规则找到对应的图片文件
  • 例如:删除"川菜馆"时,可以查找所有包含"川菜馆"的图片文件

代码实现

java 复制代码
// 文件:src/main/java/com/scfs/utils/FileUploadUtil.java
// 位置:第63-67行,uploadStoreImage方法中生成文件名

// 生成文件名:食堂名_店铺名.{ext}(第63-67行)
String sanitizedCanteenName = sanitizeForFilename(canteenName);  // 清理特殊字符
String sanitizedStoreName = sanitizeForFilename(storeName);
String filename = String.format("%s_%s%s", sanitizedCanteenName, sanitizedStoreName, extension);

命名规则对比

  • 店铺图片食堂名_店铺名.jpg - 便于识别店铺
  • 菜品图片dish_菜品ID.jpg - 使用ID确保唯一性
  • 评价图片用户名_菜品名_编号.jpg - 包含用户和菜品信息,便于管理
Q3:图片URL是如何存储和访问的?

A: 图片URL存储在数据库中,通过相对路径访问。

存储方式

  • 数据库存储:相对路径URL(如:/img/stores/第一食堂_川菜馆.jpg
  • 文件系统:实际文件保存在src/main/webapp/img/stores/目录下

访问流程

  1. 前端通过相对路径请求图片:/img/stores/第一食堂_川菜馆.jpg
  2. Tomcat服务器根据路径找到文件
  3. 返回图片给浏览器显示

代码位置

java 复制代码
// 文件:src/main/java/com/scfs/utils/FileUploadUtil.java
// 位置:第103行,uploadStoreImage方法返回URL

// 返回相对路径URL(用于数据库存储)(第103行)
return "/img/stores/" + filename;

为什么使用相对路径

  • 可移植性:不依赖服务器的绝对路径
  • 简洁性:URL更短,便于存储和传输
  • 灵活性:可以轻松切换到CDN或其他存储服务

实际存储位置

  • 开发环境:src/main/webapp/img/stores/第一食堂_川菜馆.jpg
  • 运行环境:Tomcat的webapp目录下的img/stores/第一食堂_川菜馆.jpg
  • 数据库:store表的image_url字段存储/img/stores/第一食堂_川菜馆.jpg

功能一:上传店铺图片

抛出问题:用户点击"选择文件"按钮,选择一张图片后,系统是怎么把图片保存到服务器上的?图片的URL是怎么返回给前端的?

功能说明

用户在添加或编辑店铺时,可以上传店铺的图片,让店铺信息更加直观。

逐步追踪

第一步:找到用户操作的入口

用户在店铺列表页面(store.jsp)点击"添加店铺"按钮,会弹出一个模态框。在模态框中有一个"店铺图片"的输入框:

html 复制代码
<input type="file" class="form-control" id="addStoreImageFile" accept="image/*"
    onchange="uploadAddStoreImage(this)">

位置src/main/webapp/jsp/store.jsp 第120-121行

当用户点击这个文件选择框,选择一张图片后,会触发onchange事件,调用uploadAddStoreImage函数。


展示添加店铺模态框,包括店铺名称、位置、描述、图片上传控件等。

第二步:追踪前端JavaScript处理

用户选择图片后,触发uploadAddStoreImage函数:

javascript 复制代码
// [1] 上传店铺图片函数(数据流向:前端 → Controller)
function uploadAddStoreImage(fileInput) {
    // [2] 获取选择的文件
    var file = fileInput.files[0];

    // [3] 前端验证:检查是否选择了文件
    if (!file) {
        return;
    }

    // [4] 前端验证:检查文件类型是否为图片
    if (!file.type.match('image.*')) {
        alert('请选择图片文件!');
        return;
    }

    // [5] 前端验证:检查文件大小是否超过2MB
    if (file.size > 2 * 1024 * 1024) {
        alert('图片大小不能超过 2MB!');
        return;
    }

    // [6] 创建FormData对象,准备上传文件
    var formData = new FormData();
    formData.append('file', file);        // 文件数据
    formData.append('storeId', 0);        // 店铺ID(新增时为0)

    // [7] 禁用文件选择框,防止重复上传
    $('#addStoreImageFile').prop('disabled', true);
    
    // [8] 发送AJAX POST请求上传文件(数据流向:前端 → Controller)
    $.ajax({
        url: '${pageContext.request.contextPath}/file/upload/store',
        type: 'POST',
        data: formData,
        processData: false,  // 告诉jQuery不要处理数据(FormData需要直接发送)
        contentType: false,  // 告诉jQuery不要设置Content-Type头(让浏览器自动设置)
        // [9] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
        success: function (response) {
            if (response.success) {
                // [10] 获取图片URL
                var imageUrl = response.data.fileUrl;
                // [11] 保存图片URL到隐藏字段(提交店铺信息时使用)
                $('#addImageUrl').val(imageUrl);
                // [12] 更新图片预览区域的src属性,显示上传的图片
                var fullImageUrl = '${pageContext.request.contextPath}' + imageUrl;
                $('#addImagePreview').attr('src', fullImageUrl);
                $('#addImagePreviewContainer').show();
                // [13] 提示上传成功
                alert('图片上传成功!');
            } else {
                // [14] 上传失败:显示错误信息
                alert('图片上传失败:' + response.message);
            }
        },
        error: function () {
            alert('图片上传失败,请重试');
        },
        complete: function () {
            // [15] 无论成功或失败,都重新启用文件选择框
            $('#addStoreImageFile').prop('disabled', false);
        }
    });
}

位置src/main/webapp/jsp/store.jsp 第865-911行

第三步:追踪到Controller层

根据请求URL/file/upload/store,找到FileControlleruploadStoreImage方法:

java 复制代码
// [1] 处理POST请求 /file/upload/store(数据流向:前端 → Controller)
@PostMapping("/upload/store")
@ResponseBody
public Result<Map<String, Object>> uploadStoreImage(
        @RequestParam("file") MultipartFile file,  // [2] 接收上传的文件
        @RequestParam("storeId") Long storeId,
        @RequestParam(value = "storeName", required = false) String storeName,
        @RequestParam(value = "canteenName", required = false) String canteenName) {
    try {
        // [3] 获取webapp绝对路径
        String webappPath = FileUploadUtil.getWebappPath(servletContext);

        // [4] 调用工具类上传图片(数据流向:Controller → 工具类)
        String imageUrl = FileUploadUtil.uploadStoreImage(file, storeId, storeName, canteenName, webappPath);

        // [5] 构建返回数据(数据流向:Controller → 前端)
        Map<String, Object> data = new HashMap<>();
        data.put("fileUrl", imageUrl);           // 图片URL(相对路径)
        data.put("fileName", file.getOriginalFilename());  // 原始文件名
        data.put("fileSize", file.getSize());   // 文件大小

        return new Result<>(true, "图片上传成功", data);
    } catch (IOException e) {
        return new Result<>(false, "图片上传失败:" + e.getMessage());
    } catch (Exception e) {
        e.printStackTrace();
        return new Result<>(false, "图片上传失败:" + e.getMessage());
    }
}

位置src/main/java/com/scfs/controller/FileController.java 第28-54行

关键点

  • @RequestParam("file") MultipartFile file:Spring自动将multipart/form-data请求中的文件数据绑定到MultipartFile对象
  • processData: falsecontentType: false:告诉jQuery不要处理FormData,让浏览器自动设置Content-Type头(包含boundary)
第四步:追踪到工具类

Controller调用FileUploadUtil.uploadStoreImage(),工具类实现:

java 复制代码
// [1] 上传店铺图片(数据流向:工具类 → 文件系统)
public static String uploadStoreImage(MultipartFile file, Long storeId, String storeName, String canteenName,
        String webappPath) throws IOException {
    // [2] 验证文件类型和大小
    validateFile(file);

    // [3] 获取文件扩展名(如.jpg、.png)
    String originalFilename = file.getOriginalFilename();
    String extension = getFileExtension(originalFilename);

    // [4] 生成文件名:食堂名_店铺名.扩展名
    String sanitizedCanteenName = sanitizeForFilename(canteenName);  // 去除特殊字符
    String sanitizedStoreName = sanitizeForFilename(storeName);      // 去除特殊字符
    String filename = String.format("%s_%s%s", sanitizedCanteenName, sanitizedStoreName, extension);

    // [5] 构建目标目录路径
    String sourceWebappPath = "/Users/xiaofeng/Desktop/大学生毕业设计/毕业设计/SCFS_System/src/main/webapp";
    File sourceDir = new File(sourceWebappPath, IMG_ROOT + File.separator + STORE_DIR);
    
    // [6] 如果目录不存在,创建目录
    if (!sourceDir.exists()) {
        sourceDir.mkdirs();
    }

    // [7] 创建目标文件对象
    File sourceFile = new File(sourceDir, filename);
    // [8] 将上传的文件保存到服务器文件系统
    file.transferTo(sourceFile);

    // [9] 返回图片访问URL(相对路径,如/img/stores/食堂名_店铺名.jpg)
    return IMG_ROOT + "/" + STORE_DIR + "/" + filename;
}

位置src/main/java/com/scfs/utils/FileUploadUtil.java 第54-88行

执行顺序说明

  • validateFile(file):验证文件类型(必须是图片)和大小(不能超过限制)
  • getFileExtension(originalFilename):从原始文件名中提取扩展名
  • sanitizeForFilename():去除文件名中的特殊字符,防止路径注入攻击
  • file.transferTo(sourceFile):将文件保存到服务器的文件系统中
  • 返回相对路径URL,可以在浏览器中直接访问
第五步:数据返回流程

完整的数据返回路径

  1. 文件系统 → 工具类 :文件保存成功后,工具类返回图片的相对路径URL(如/img/stores/食堂名_店铺名.jpg
  2. 工具类 → Controller:工具类返回URL给Controller
  3. Controller处理
    • Controller封装成Result对象,successtruedata包含fileUrlfileNamefileSize
    • 返回给前端(数据流向:Controller → 前端)
  4. 前端处理
    • JavaScript接收到Result对象
    • 判断response.success是否为true
    • 如果成功,从response.data.fileUrl获取图片URL
    • 将图片URL保存到隐藏字段#addImageUrl中(提交店铺信息时使用)
    • 更新图片预览区域的src属性,显示上传的图片
    • 显示"图片上传成功"的提示

这样,用户选择图片后,文件就从前端发送到后端,保存到服务器文件系统,然后图片URL返回前端,页面显示预览,整个流程就完成了。

文件上传完整流程时序图

文件系统 FileUploadUtil FileController JavaScript store.jsp 用户 文件系统 FileUploadUtil FileController JavaScript store.jsp 用户 [1] 点击"选择文件"按钮 [2] 打开文件选择对话框 [3] 选择图片文件 [4] 触发onchange事件 [5] 调用uploadAddStoreImage() [6] 前端验证(文件类型、大小) [7] 创建FormData对象 [8] 添加文件到FormData [9] 禁用文件选择框 [10] AJAX POST /file/upload/store (FormData上传文件) [11] 接收MultipartFile文件 (@RequestParam) [12] 获取webapp绝对路径 [13] 调用FileUploadUtil.uploadStoreImage() [14] 验证文件类型和大小 [15] 获取文件扩展名 [16] 生成文件名(食堂名_店铺名.扩展名) [17] 创建目标目录(如果不存在) [18] 将文件保存到文件系统 file.transferTo(sourceFile) [19] 文件保存成功 [20] 返回图片URL(相对路径) [21] 封装成Result对象 (fileUrl, fileName, fileSize) [22] 返回Result对象 (success, message, data=图片信息) [23] 判断response.success [24] 保存图片URL到隐藏字段 [25] 更新图片预览区域 [26] 显示上传的图片 [27] 重新启用文件选择框 [28] 显示"图片上传成功"提示

文件上传流程图





用户点击选择文件按钮
选择图片文件
触发onchange事件
调用uploadAddStoreImage函数
前端验证
验证通过?
提示错误信息
创建FormData对象
添加文件到FormData
发送AJAX请求

POST /file/upload/store
Controller接收MultipartFile
获取webapp绝对路径
调用FileUploadUtil.uploadStoreImage
验证文件类型和大小
获取文件扩展名
生成文件名

(食堂名_店铺名.扩展名)
创建目标目录
保存文件到文件系统

file.transferTo()
保存成功?
返回错误信息
返回图片URL(相对路径)
封装成Result对象
前端接收Result对象
保存图片URL到隐藏字段
更新图片预览区域
显示上传的图片

文件上传数据流转图

前端选择文件
FormData对象

{file: File对象,

storeId: 0}
AJAX POST请求

multipart/form-data
Controller接收

MultipartFile file
工具类处理

验证、生成文件名
保存到文件系统

/img/stores/食堂名_店铺名.jpg
返回图片URL

/img/stores/食堂名_店铺名.jpg
前端接收URL
保存到隐藏字段

更新预览区域

为什么这样设计

为什么使用FormData上传文件?

  • 原因1:支持文件上传

    • HTML表单的enctype="multipart/form-data"可以上传文件
    • FormData对象可以方便地构建multipart/form-data请求
    • 不需要手动构建复杂的请求体
  • 原因2:兼容性

    • FormData是标准的Web API,所有现代浏览器都支持
    • 可以同时上传文件和其他表单数据
    • 与后端Spring的MultipartFile完美配合
  • 原因3:用户体验

    • 可以异步上传,不刷新页面
    • 可以显示上传进度(如果需要)
    • 可以实时预览上传的图片

为什么使用相对路径URL而不是绝对路径?

  • 原因1:可移植性

    • 相对路径不依赖服务器的具体路径
    • 可以方便地部署到不同的服务器
    • 便于开发和测试
  • 原因2:灵活性

    • 可以根据部署环境动态调整基础路径
    • 可以使用CDN或静态资源服务器
    • 便于后续的架构调整
  • 原因3:安全性

    • 相对路径不会暴露服务器的文件系统结构
    • 可以更好地控制资源访问权限
    • 符合Web开发的最佳实践

为什么使用文件名规则(食堂名_店铺名.扩展名)?

  • 原因1:可读性

    • 文件名包含业务信息,便于识别
    • 管理员可以直接从文件名知道图片对应的店铺
    • 便于文件管理和维护
  • 原因2:唯一性

    • 不同食堂的店铺名可能相同,加上食堂名可以区分
    • 减少文件名冲突的可能性
    • 便于后续的文件查找和管理
  • 原因3:业务逻辑

    • 符合实际业务场景,店铺属于某个食堂
    • 文件名结构清晰,便于理解
    • 便于后续的数据分析和统计



展示图片上传成功后,预览区域显示图片的效果。


功能二:上传菜品图片

抛出问题:用户上传菜品图片时,系统是怎么处理的?和店铺图片上传有什么不同?

功能说明

用户在添加或编辑菜品时,可以上传菜品的图片,展示菜品的外观。

逐步追踪

第一步:找到用户操作的入口

用户在店铺详情页面(store-detail.jsp)添加菜品时,会弹出一个模态框。在模态框中有一个文件选择框用于上传菜品图片。或者在店铺列表页面编辑菜品时,也会弹出类似的模态框。

第二步:追踪前端JavaScript处理

用户选择图片后,在submitAddDish函数中处理图片上传:

javascript 复制代码
// [1] 提交新增菜品函数(包含图片上传逻辑)
function submitAddDish() {
    // ... 省略其他代码(收集菜品信息)...
    
    // [2] 获取选择的图片文件
    var imageFile = document.getElementById('addDishImageFile').files[0];
    
    // [3] 判断是否有图片文件
    if (imageFile) {
        // [4] 创建FormData对象,准备上传文件
        var formData = new FormData();
        formData.append('file', imageFile);      // 文件数据
        formData.append('dishId', 0);            // 菜品ID(新增时为0)
        formData.append('storeId', storeId);     // 店铺ID(菜品属于某个店铺)
        
        // [5] 发送AJAX POST请求上传文件(数据流向:前端 → Controller)
        $.ajax({
            url: getApiUrl('file', 'uploadDish'),
            type: 'POST',
            data: formData,
            processData: false,
            contentType: false,
            // [6] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
            success: function (uploadResponse) {
                if (uploadResponse.success) {
                    // [7] 获取图片URL
                    var imageUrl = uploadResponse.data.fileUrl;
                    // [8] 继续提交菜品信息,包含图片URL
                    submitDishData(storeId, dishName, price, description, imageUrl);
                }
            }
        });
    } else {
        // [9] 没有图片,直接提交菜品信息(不包含图片URL)
        submitDishData(storeId, dishName, price, description, null);
    }
}

位置src/main/webapp/js/my.js 第1654-1733行

关键点:菜品图片上传和店铺图片上传的主要区别是:

  • 需要传递storeId参数,因为菜品属于某个店铺
  • 上传成功后,图片URL会包含在菜品数据中一起提交到数据库
第三步:追踪到Controller层

根据请求URL/file/upload/dish,找到FileControlleruploadDishImage方法:

java 复制代码
// [1] 处理POST请求 /file/upload/dish(数据流向:前端 → Controller)
@PostMapping("/upload/dish")
@ResponseBody
public Result<Map<String, Object>> uploadDishImage(
        @RequestParam("file") MultipartFile file,  // [2] 接收上传的文件
        @RequestParam("dishId") Long dishId,       // [3] 菜品ID(新增时为0)
        @RequestParam(value = "storeId", required = false) Long storeId) {  // [4] 店铺ID
    try {
        // [5] 获取webapp绝对路径
        String webappPath = FileUploadUtil.getWebappPath(servletContext);
        // [6] 调用工具类上传图片(数据流向:Controller → 工具类)
        String imageUrl = FileUploadUtil.uploadDishImage(file, dishId, storeId, webappPath);
        
        // [7] 构建返回数据(数据流向:Controller → 前端)
        Map<String, Object> data = new HashMap<>();
        data.put("fileUrl", imageUrl);           // 图片URL(相对路径)
        data.put("fileName", file.getOriginalFilename());  // 原始文件名
        data.put("fileSize", file.getSize());     // 文件大小
        
        return new Result<>(true, "图片上传成功", data);
    } catch (Exception e) {
        return new Result<>(false, "图片上传失败:" + e.getMessage());
    }
}

位置src/main/java/com/scfs/controller/FileController.java 第59-80行

第四步:追踪到工具类

Controller调用FileUploadUtil.uploadDishImage(),工具类实现:

java 复制代码
// [1] 上传菜品图片(数据流向:工具类 → 文件系统)
public static String uploadDishImage(MultipartFile file, Long dishId, Long storeId, String webappPath) throws IOException {
    // [2] 验证文件类型和大小
    validateFile(file);
    
    // [3] 获取文件扩展名(如.jpg、.png)
    String originalFilename = file.getOriginalFilename();
    String extension = getFileExtension(originalFilename);
    
    // [4] 生成文件名:根据菜品ID或时间戳
    String filename;
    if (dishId != null && dishId > 0) {
        // [5] 编辑已有菜品:使用dish_菜品ID.扩展名
        filename = "dish_" + dishId + extension;
    } else {
        // [6] 新增菜品:使用dish_时间戳.扩展名
        filename = "dish_" + System.currentTimeMillis() + extension;
    }
    
    // [7] 构建目标目录路径(img/dishes)
    String sourceWebappPath = "/Users/xiaofeng/Desktop/大学生毕业设计/毕业设计/SCFS_System/src/main/webapp";
    File sourceDir = new File(sourceWebappPath, IMG_ROOT + File.separator + DISH_DIR);
    
    // [8] 如果目录不存在,创建目录
    if (!sourceDir.exists()) {
        sourceDir.mkdirs();
    }
    
    // [9] 创建目标文件对象
    File sourceFile = new File(sourceDir, filename);
    // [10] 将上传的文件保存到服务器文件系统
    file.transferTo(sourceFile);
    
    // [11] 返回图片访问URL(相对路径,如/img/dishes/dish_123.jpg)
    return IMG_ROOT + "/" + DISH_DIR + "/" + filename;
}

位置src/main/java/com/scfs/utils/FileUploadUtil.java 第150-200行

执行顺序说明

  • 文件名规则:编辑时使用dish_菜品ID.扩展名,新增时使用dish_时间戳.扩展名
  • 保存目录:img/dishes(与店铺图片的img/stores不同)
  • 其他流程与店铺图片上传相同
第五步:数据返回流程

完整的数据返回路径

  1. 文件系统 → 工具类 :文件保存成功后,工具类返回图片的相对路径URL(如/img/dishes/dish_123.jpg
  2. 工具类 → Controller:工具类返回URL给Controller
  3. Controller处理
    • Controller封装成Result对象,successtruedata包含fileUrlfileNamefileSize
    • 返回给前端(数据流向:Controller → 前端)
  4. 前端处理
    • JavaScript接收到Result对象
    • 判断uploadResponse.success是否为true
    • 如果成功,从uploadResponse.data.fileUrl获取图片URL
    • 调用submitDishData()提交菜品信息,将图片URL一起保存到数据库

这样,菜品图片上传和店铺图片上传的流程基本相同,主要区别是文件命名规则和保存目录不同。

菜品图片上传与店铺图片上传对比

对比项 店铺图片 菜品图片
文件命名规则 食堂名_店铺名.扩展名 dish_菜品ID.扩展名dish_时间戳.扩展名
保存目录 img/stores img/dishes
额外参数 storeName, canteenName storeId
业务含义 店铺的展示图片 菜品的展示图片

为什么这样设计

为什么菜品图片使用dish_菜品ID或时间戳命名,而不是像店铺图片那样使用业务名称?

  • 原因1:唯一性保证

    • 菜品名称可能重复(不同店铺可能有同名菜品)
    • 使用菜品ID可以保证文件名唯一
    • 新增时使用时间戳也可以保证唯一性
  • 原因2:简化处理

    • 菜品ID是数据库主键,直接可用
    • 不需要额外的业务信息(如店铺名、食堂名)
    • 代码逻辑更简单
  • 原因3:性能考虑

    • 时间戳命名可以避免文件名冲突
    • 不需要查询数据库获取业务信息
    • 提升上传速度

功能三:上传评价图片

抛出问题:评价图片可以上传多张,系统是怎么处理的?图片的命名规则是什么?

功能说明

用户在发布评价时,可以上传最多4张图片,展示菜品的实际效果。

逐步追踪

第一步:找到用户操作的入口

用户在店铺详情页面(store-detail.jsp)点击"写评价"按钮,会弹出一个模态框。在模态框中有一个文件选择框,支持选择多张图片。

第二步:追踪前端JavaScript处理

用户选择多张图片后,前端会预览所有选中的图片。当用户提交评价时,会逐个上传这些图片:

javascript 复制代码
// [1] 提交评价函数(包含多张图片上传逻辑)
function submitReview() {
    // ... 省略其他代码(收集评价信息)...
    
    // [2] 获取选择的多张图片文件(最多4张)
    var imageFiles = document.getElementById('reviewImageFiles').files;
    var imageUrls = [];  // [3] 存储所有图片URL的数组
    
    // [4] 递归函数:逐个上传图片
    function uploadImages(index) {
        // [5] 如果所有图片都已上传完成
        if (index >= imageFiles.length) {
            // [6] 所有图片上传完成,提交评价数据(包含所有图片URL)
            submitReviewData(dishIds, rating, comment, imageUrls);
            return;
        }
        
        // [7] 创建FormData对象,准备上传当前图片
        var formData = new FormData();
        formData.append('file', imageFiles[index]);      // 当前图片文件
        formData.append('userName', currentUserName);     // 用户名(用于文件名)
        formData.append('dishName', dishName);            // 菜品名(用于文件名)
        formData.append('index', index);                  // 图片索引(用于区分多张图片)
        
        // [8] 发送AJAX POST请求上传当前图片(数据流向:前端 → Controller)
        $.ajax({
            url: getApiUrl('file', 'uploadReview'),
            type: 'POST',
            data: formData,
            processData: false,
            contentType: false,
            // [9] 请求成功回调:处理后端返回的结果(数据流向:Controller → 前端)
            success: function (uploadResponse) {
                if (uploadResponse.success) {
                    // [10] 将当前图片URL添加到数组中
                    imageUrls.push(uploadResponse.data.fileUrl);
                    // [11] 递归调用,上传下一张图片
                    uploadImages(index + 1);
                }
            }
        });
    }
    
    // [12] 判断是否有图片需要上传
    if (imageFiles.length > 0) {
        uploadImages(0);  // [13] 开始上传第一张图片(索引从0开始)
    } else {
        // [14] 没有图片,直接提交评价数据(不包含图片URL)
        submitReviewData(dishIds, rating, comment, []);
    }
}

位置src/main/webapp/js/my.js 第1737-1789行

关键点

  • 使用递归方式逐个上传图片,避免并发上传导致的问题
  • 每张图片上传成功后,将URL添加到数组中
  • 所有图片上传完成后,将所有URL一起提交到评价数据中
第三步:追踪到Controller层

根据请求URL/file/upload/review,找到FileControlleruploadReviewImage方法:

java 复制代码
// [1] 处理POST请求 /file/upload/review(数据流向:前端 → Controller)
@PostMapping("/upload/review")
@ResponseBody
public Result<Map<String, Object>> uploadReviewImage(
        @RequestParam("file") MultipartFile file,  // [2] 接收上传的文件
        @RequestParam(value = "userName", required = false) String userName,  // [3] 用户名
        @RequestParam(value = "dishName", required = false) String dishName,  // [4] 菜品名
        @RequestParam(value = "index", required = false) Integer index) {     // [5] 图片索引
    try {
        // [6] 获取webapp绝对路径
        String webappPath = FileUploadUtil.getWebappPath(servletContext);
        // [7] 调用工具类上传图片(数据流向:Controller → 工具类)
        String imageUrl = FileUploadUtil.uploadReviewImage(file, userName, dishName, 
                index != null ? index : 0, webappPath);
        
        // [8] 构建返回数据(数据流向:Controller → 前端)
        Map<String, Object> data = new HashMap<>();
        data.put("fileUrl", imageUrl);  // 图片URL(相对路径)
        
        return new Result<>(true, "图片上传成功", data);
    } catch (Exception e) {
        return new Result<>(false, "图片上传失败:" + e.getMessage());
    }
}

位置src/main/java/com/scfs/controller/FileController.java 第82-100行

第四步:追踪到工具类

Controller调用FileUploadUtil.uploadReviewImage(),工具类实现:

java 复制代码
// [1] 上传评价图片(数据流向:工具类 → 文件系统)
public static String uploadReviewImage(MultipartFile file, String userName, String dishName, int index,
        String webappPath) throws IOException {
    // [2] 验证文件类型和大小
    validateFile(file);
    
    // [3] 获取文件扩展名(如.jpg、.png)
    String originalFilename = file.getOriginalFilename();
    String extension = getFileExtension(originalFilename);
    
    // [4] 生成文件名:用户名_菜品名_编号.扩展名
    String filename;
    if (userName != null && !userName.isEmpty() && dishName != null && !dishName.isEmpty()) {
        // [5] 使用用户名和菜品名:用户名_菜品名_编号.扩展名(如:张三_宫保鸡丁_0.jpg)
        filename = String.format("%s_%s_%d%s",
                sanitizeForFilename(userName),  // 去除特殊字符
                sanitizeForFilename(dishName), // 去除特殊字符
                index,                         // 图片索引(0, 1, 2, 3...)
                extension);
    } else {
        // [6] 如果用户名或菜品名为空,使用时间戳:review_时间戳_编号.扩展名
        filename = String.format("review_%d_%d%s",
                System.currentTimeMillis(),
                index,
                extension);
    }
    
    // [7] 构建目标目录路径(img/review)
    String sourceWebappPath = "/Users/xiaofeng/Desktop/大学生毕业设计/毕业设计/SCFS_System/src/main/webapp";
    File sourceDir = new File(sourceWebappPath, IMG_ROOT + File.separator + REVIEW_DIR);
    
    // [8] 如果目录不存在,创建目录
    if (!sourceDir.exists()) {
        sourceDir.mkdirs();
    }
    
    // [9] 创建目标文件对象
    File sourceFile = new File(sourceDir, filename);
    // [10] 将上传的文件保存到服务器文件系统
    file.transferTo(sourceFile);
    
    // [11] 返回图片访问URL(相对路径,如/img/review/张三_宫保鸡丁_0.jpg)
    return IMG_ROOT + "/" + REVIEW_DIR + "/" + filename;
}

位置src/main/java/com/scfs/utils/FileUploadUtil.java 第395-431行

执行顺序说明

  • 文件名规则:用户名_菜品名_编号.扩展名(如张三_宫保鸡丁_0.jpg张三_宫保鸡丁_1.jpg
  • 编号从0开始,用于区分同一评价的多张图片
  • 保存目录:img/review(与店铺和菜品图片的目录不同)
第五步:数据返回流程

完整的数据返回路径

  1. 文件系统 → 工具类 :每张图片保存成功后,工具类返回图片的相对路径URL(如/img/review/张三_宫保鸡丁_0.jpg
  2. 工具类 → Controller:工具类返回URL给Controller
  3. Controller处理
    • Controller封装成Result对象,successtruedata包含fileUrl
    • 返回给前端(数据流向:Controller → 前端)
  4. 前端处理
    • JavaScript接收到Result对象
    • 判断uploadResponse.success是否为true
    • 如果成功,将图片URL添加到imageUrls数组中
    • 递归调用uploadImages(index + 1)上传下一张图片
    • 所有图片上传完成后,调用submitReviewData()提交评价数据,将所有图片URL(逗号分隔)一起保存到数据库的images字段中

这样,评价图片支持多张上传,系统逐个处理,每张图片都有唯一的文件名(通过索引区分),整个流程就完成了。

多张图片上传时序图

文件系统 FileUploadUtil FileController JavaScript store-detail.jsp 用户 文件系统 FileUploadUtil FileController JavaScript store-detail.jsp 用户 loop [遍历每张图片] [1] 选择多张图片(最多4张) [2] 点击"发布评价"按钮 [3] 触发submitReview() [4] 获取imageFiles数组 [5] 调用uploadImages(0) [6] 创建FormData对象 (file, userName, dishName, index) [7] AJAX POST /file/upload/review (上传第index张图片) [8] 接收MultipartFile文件 (@RequestParam) [9] 调用FileUploadUtil.uploadReviewImage() [10] 验证文件类型和大小 [11] 生成文件名 (用户名_菜品名_index.扩展名) [12] 保存文件到文件系统 file.transferTo(sourceFile) [13] 文件保存成功 [14] 返回图片URL [15] 返回Result对象 (success, data.fileUrl) [16] 将图片URL添加到imageUrls数组 [17] 递归调用uploadImages(index+1) [18] 所有图片上传完成 [19] 调用submitReviewData() (包含所有图片URL) [20] 提交评价数据到数据库

为什么这样设计

为什么使用递归方式逐个上传图片,而不是并发上传?

  • 原因1:避免服务器压力

    • 并发上传多张图片会给服务器造成较大压力
    • 逐个上传可以控制并发数,避免服务器过载
    • 提升系统的稳定性
  • 原因2:错误处理

    • 如果某张图片上传失败,可以单独处理
    • 不会影响其他图片的上传
    • 便于给用户提供详细的错误信息
  • 原因3:用户体验

    • 可以显示上传进度(如果需要)
    • 用户可以知道当前上传到第几张图片
    • 提升用户体验

为什么评价图片使用用户名_菜品名_编号的命名规则?

  • 原因1:可读性

    • 文件名包含业务信息,便于识别
    • 管理员可以直接从文件名知道图片对应的用户和菜品
    • 便于文件管理和维护
  • 原因2:唯一性

    • 通过索引编号区分同一评价的多张图片
    • 不同用户的评价图片不会冲突
    • 便于后续的文件查找和管理
  • 原因3:业务逻辑

    • 符合实际业务场景,评价是用户对菜品的评价
    • 文件名结构清晰,便于理解
    • 便于后续的数据分析和统计

文件验证说明

无论是店铺图片、菜品图片还是评价图片,在上传前都会进行验证:

  1. 文件类型验证 :只允许上传图片文件(image/*
  2. 文件大小验证:文件大小不能超过2MB

验证在工具类的validateFile方法中进行:

java 复制代码
private static void validateFile(MultipartFile file) throws IOException {
    if (file == null || file.isEmpty()) {
        throw new IllegalArgumentException("文件不能为空");
    }
    
    String contentType = file.getContentType();
    if (contentType == null || !contentType.startsWith("image/")) {
        throw new IllegalArgumentException("只能上传图片文件");
    }
    
    if (file.getSize() > 2 * 1024 * 1024) {
        throw new IllegalArgumentException("文件大小不能超过2MB");
    }
}

位置src/main/java/com/scfs/utils/FileUploadUtil.java 第15-30行(示例代码,实际位置可能有差异)


总结

文件上传模块的完整流程:

  1. 前端准备

    • 用户在页面选择图片文件
    • 前端验证文件类型和大小
    • 创建FormData对象,添加文件数据
  2. 发送请求

    • 使用AJAX发送POST请求到/file/upload/store/file/upload/dish/file/upload/review
    • 设置processData: falsecontentType: false,让浏览器自动处理multipart/form-data
  3. 后端接收

    • Controller使用@RequestParam("file") MultipartFile file接收文件
    • 获取webapp路径,调用工具类处理
  4. 文件处理

    • 工具类验证文件类型和大小
    • 生成规范的文件名(根据类型使用不同的命名规则)
    • 创建目标目录(如果不存在)
    • 使用file.transferTo()保存文件到服务器
  5. 返回结果

    • 返回图片的相对路径URL(如/img/stores/食堂名_店铺名.jpg
    • 前端接收URL,更新预览或保存到隐藏字段
  6. 不同图片类型的区别

    • 店铺图片 :保存在img/stores目录,命名规则为食堂名_店铺名.扩展名
    • 菜品图片 :保存在img/dishes目录,命名规则为dish_菜品ID.扩展名dish_时间戳.扩展名
    • 评价图片 :保存在img/review目录,命名规则为用户名_菜品名_编号.扩展名,支持多张上传

学习建议:

  • 启动项目,尝试上传店铺图片、菜品图片、评价图片
  • 观察文件保存的目录结构(img/storesimg/dishesimg/review
  • 理解文件命名规则的作用(便于管理和识别,避免文件名冲突)
  • 理解FormData的使用和multipart/form-data请求的处理
  • 理解前端递归上传多张图片的逻辑

注意:本模块已采用从用户操作视角逐步追踪数据流向的方式,便于理解整个流程。

相关推荐
PoppyBu8 小时前
Ubuntu20.04版本上安装最新版本的scrcpy工具
android·ubuntu
执念、坚持9 小时前
Property Service源码分析
android
用户41659673693559 小时前
在 ViewPager2 + Fragment 架构中玩转 Jetpack Compose
android
GoldenPlayer9 小时前
Gradle脚本执行
android
用户74589002079549 小时前
Android进程模型基础
android
we1less9 小时前
[audio] Audio debug
android
Jomurphys9 小时前
AndroidStudio - TOML
android
有位神秘人9 小时前
Android最新动态权限申请工具
android
lxysbly9 小时前
psp模拟器安卓版下载汉化版2026
android
2501_9418227510 小时前
面向灰度发布与风险隔离的互联网系统演进策略与多语言工程实践分享方法论记录思考汇总稿件
android·java·人工智能