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/目录下
访问流程:
- 前端通过相对路径请求图片:
/img/stores/第一食堂_川菜馆.jpg - Tomcat服务器根据路径找到文件
- 返回图片给浏览器显示
代码位置:
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,找到FileController的uploadStoreImage方法:
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: false和contentType: 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,可以在浏览器中直接访问
第五步:数据返回流程
完整的数据返回路径:
- 文件系统 → 工具类 :文件保存成功后,工具类返回图片的相对路径URL(如
/img/stores/食堂名_店铺名.jpg) - 工具类 → Controller:工具类返回URL给Controller
- Controller处理 :
- Controller封装成
Result对象,success为true,data包含fileUrl、fileName、fileSize - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
response.success是否为true - 如果成功,从
response.data.fileUrl获取图片URL - 将图片URL保存到隐藏字段
#addImageUrl中(提交店铺信息时使用) - 更新图片预览区域的
src属性,显示上传的图片 - 显示"图片上传成功"的提示
- JavaScript接收到
这样,用户选择图片后,文件就从前端发送到后端,保存到服务器文件系统,然后图片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请求
- 不需要手动构建复杂的请求体
- HTML表单的
-
原因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,找到FileController的uploadDishImage方法:
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不同) - 其他流程与店铺图片上传相同
第五步:数据返回流程
完整的数据返回路径:
- 文件系统 → 工具类 :文件保存成功后,工具类返回图片的相对路径URL(如
/img/dishes/dish_123.jpg) - 工具类 → Controller:工具类返回URL给Controller
- Controller处理 :
- Controller封装成
Result对象,success为true,data包含fileUrl、fileName、fileSize - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
uploadResponse.success是否为true - 如果成功,从
uploadResponse.data.fileUrl获取图片URL - 调用
submitDishData()提交菜品信息,将图片URL一起保存到数据库
- JavaScript接收到
这样,菜品图片上传和店铺图片上传的流程基本相同,主要区别是文件命名规则和保存目录不同。
菜品图片上传与店铺图片上传对比
| 对比项 | 店铺图片 | 菜品图片 |
|---|---|---|
| 文件命名规则 | 食堂名_店铺名.扩展名 |
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,找到FileController的uploadReviewImage方法:
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(与店铺和菜品图片的目录不同)
第五步:数据返回流程
完整的数据返回路径:
- 文件系统 → 工具类 :每张图片保存成功后,工具类返回图片的相对路径URL(如
/img/review/张三_宫保鸡丁_0.jpg) - 工具类 → Controller:工具类返回URL给Controller
- Controller处理 :
- Controller封装成
Result对象,success为true,data包含fileUrl - 返回给前端(数据流向:Controller → 前端)
- Controller封装成
- 前端处理 :
- JavaScript接收到
Result对象 - 判断
uploadResponse.success是否为true - 如果成功,将图片URL添加到
imageUrls数组中 - 递归调用
uploadImages(index + 1)上传下一张图片 - 所有图片上传完成后,调用
submitReviewData()提交评价数据,将所有图片URL(逗号分隔)一起保存到数据库的images字段中
- JavaScript接收到
这样,评价图片支持多张上传,系统逐个处理,每张图片都有唯一的文件名(通过索引区分),整个流程就完成了。
多张图片上传时序图
文件系统 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:业务逻辑
- 符合实际业务场景,评价是用户对菜品的评价
- 文件名结构清晰,便于理解
- 便于后续的数据分析和统计
文件验证说明
无论是店铺图片、菜品图片还是评价图片,在上传前都会进行验证:
- 文件类型验证 :只允许上传图片文件(
image/*) - 文件大小验证:文件大小不能超过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行(示例代码,实际位置可能有差异)
总结
文件上传模块的完整流程:
-
前端准备:
- 用户在页面选择图片文件
- 前端验证文件类型和大小
- 创建FormData对象,添加文件数据
-
发送请求:
- 使用AJAX发送POST请求到
/file/upload/store、/file/upload/dish或/file/upload/review - 设置
processData: false和contentType: false,让浏览器自动处理multipart/form-data
- 使用AJAX发送POST请求到
-
后端接收:
- Controller使用
@RequestParam("file") MultipartFile file接收文件 - 获取webapp路径,调用工具类处理
- Controller使用
-
文件处理:
- 工具类验证文件类型和大小
- 生成规范的文件名(根据类型使用不同的命名规则)
- 创建目标目录(如果不存在)
- 使用
file.transferTo()保存文件到服务器
-
返回结果:
- 返回图片的相对路径URL(如
/img/stores/食堂名_店铺名.jpg) - 前端接收URL,更新预览或保存到隐藏字段
- 返回图片的相对路径URL(如
-
不同图片类型的区别:
- 店铺图片 :保存在
img/stores目录,命名规则为食堂名_店铺名.扩展名 - 菜品图片 :保存在
img/dishes目录,命名规则为dish_菜品ID.扩展名或dish_时间戳.扩展名 - 评价图片 :保存在
img/review目录,命名规则为用户名_菜品名_编号.扩展名,支持多张上传
- 店铺图片 :保存在
学习建议:
- 启动项目,尝试上传店铺图片、菜品图片、评价图片
- 观察文件保存的目录结构(
img/stores、img/dishes、img/review) - 理解文件命名规则的作用(便于管理和识别,避免文件名冲突)
- 理解FormData的使用和multipart/form-data请求的处理
- 理解前端递归上传多张图片的逻辑
注意:本模块已采用从用户操作视角逐步追踪数据流向的方式,便于理解整个流程。