后端开发日记⑥:配送凭证与受保护文件流

1755 字
9 分钟
后端开发日记⑥:配送凭证与受保护文件流

订单截图、货品图、签收图和异常证据都属于配送凭证。它们不能只保存一个公开 URL,因为图片包含订单信息,访问权限应和订单权限保持一致。本篇记录从上传到预览的完整后端链路。

1. 文件和业务记录分开保存#

上传成功后,文件写入配置的 profile 目录,数据库只记录元数据:

orderId 关联订单
orderVersion 上传时的订单版本
proofType 凭证类型
storagePath 服务端存储路径
uploaderType WX_USER / SYS_USER
uploaderId 上传人
proofStatus 凭证状态

这样可以按订单、版本和类型查询凭证,也可以在不搬动文件的情况下修改业务状态。

2. 上传前先校验订单#

用户端上传必须确认订单属于当前登录用户;管理端上传至少确认订单存在且未删除。权限检查发生在保存文件之前,避免无权请求先占用磁盘空间。

checkCurrentUserOrderOwner(orderId);
return uploadDeliveryProof(file, orderId, proofType, sortNo,
"WX_USER", SecurityUtils.getUserId());

公共上传方法负责文件非空、订单 ID、凭证类型等通用校验,两端入口只处理各自的身份规则。

3. 凭证绑定订单版本#

订单可能被驳回后修改。新上传的图片如果仍和旧资料混在一起,管理端无法判断它对应哪次提交。

上传时查询该订单已有的最大历史版本,没有历史时视为第一版;有历史时使用下一版本号。凭证因此可以随订单版本一起回溯。

4. 列表不返回真实路径#

返回对象 HyDeliveryProofVo 包含凭证 ID、类型、排序、上传时间和 hasImage,但不返回 storagePath。前端需要图片时,再携带 Token 请求专门的图片接口。

这避免了两个问题:

  • 客户端拿到服务器目录结构;
  • 用户绕过业务接口,拼接静态路径访问其他订单图片。

5. 安全解析本地路径#

图片读取统一经过 ProtectedFileUtils。工具先要求路径使用系统资源前缀,再把相对路径解析到 profile 根目录:

Path root = Paths.get(SystemConfig.getProfile())
.toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new ServiceException("文件路径非法");
}

normalize() 与 startsWith(root) 可以拦截通过 ../ 逃逸到上传目录之外的路径。随后还要确认目标是普通文件,而不是只检查路径存在。

6. 通过鉴权接口输出文件流#

Controller 根据文件类型设置 Content-Type,使用 inline 让浏览器或小程序直接预览,并通过 Files.copy 写入响应流。用户端读取前再次校验订单归属,不能因为知道 proofId 就获得图片。

删除凭证同样执行“查凭证 -> 查订单归属 -> 删除记录”的顺序。文件访问权限必须覆盖查询、读取和删除三个入口,而不是只保护上传。

7. 公共上传方法逐行说明#

用户端与管理端最终都进入同一个上传方法:

private HyDeliveryProofVo uploadDeliveryProof(
MultipartFile file,
Long orderId,
String proofType,
Long sortNo,
String uploaderType,
Long uploaderId) throws Exception {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
}
if (orderId == null) {
throw new ServiceException("订单ID不能为空");
}
if (isBlank(proofType)) {
throw new ServiceException("凭证类型不能为空");
}
String fileName = FileUploadUtils.upload(
SystemConfig.getUploadPath(), file);
HyDeliveryProof proof = new HyDeliveryProof();
proof.setOrderId(orderId);
proof.setOrderVersion(getCurrentOrderVersionNo(orderId));
proof.setProofType(proofType);
proof.setSortNo(sortNo);
proof.setStoragePath(fileName);
proof.setProofStatus("NORMAL");
proof.setUploaderType(uploaderType);
proof.setUploaderId(uploaderId);
proof.setUploadTime(new Date());
mapper.insertHyDeliveryProof(proof);
return toVo(proof);
}

参数校验发生在磁盘写入之前,避免明显错误请求留下孤立文件。uploaderType 和 uploaderId 由不同入口传入,但前端不能自定义:用户入口固定为 WX_USER,管理端固定为 SYS_USER。

上传成功后立即写数据库。如果数据库插入失败,磁盘文件不会随数据库事务自动删除,这是文件系统和数据库跨资源一致性问题。可以在捕获异常时删除刚上传文件,或者先写临时目录,数据库成功后再转为正式文件。

8. 版本号计算代表哪一版#

private Long getCurrentOrderVersionNo(Long orderId) {
Long maxVersionNo = historyMapper
.selectMaxVersionNoByOrderId(orderId);
return maxVersionNo == null ? 1L : maxVersionNo + 1L;
}

历史表保存修改前版本,因此没有历史时主订单是第一版;存在第 1 版历史时,当前主订单已经是第二版。凭证上传时使用 max + 1,正好对应当前主订单版本。

理解这个约定很重要。如果历史表改成保存修改后版本,这段算法也必须随之调整,否则图片会错绑版本。

9. VO 转换如何隐藏路径#

private HyDeliveryProofVo toVo(HyDeliveryProof proof) {
HyDeliveryProofVo vo = new HyDeliveryProofVo();
vo.setProofId(proof.getProofId());
vo.setOrderId(proof.getOrderId());
vo.setOrderVersion(proof.getOrderVersion());
vo.setProofType(proof.getProofType());
vo.setSortNo(proof.getSortNo());
vo.setUploaderType(proof.getUploaderType());
vo.setUploadTime(proof.getUploadTime());
vo.setHasImage(StringUtils.isNotEmpty(
proof.getStoragePath()));
return vo;
}

VO 不设置 storagePath,而是把它折叠成布尔值。页面知道“有图片”,需要显示时使用 proofId 请求鉴权接口。

这样即使服务器从本地磁盘迁移到对象存储,前端接口也不需要跟着改变路径格式。VO 同时承担了安全隔离和存储实现解耦。

10. 受保护路径如何拦截目录穿越#

String relativePath = resourcePath.substring(
Constants.RESOURCE_PREFIX.length() + 1);
Path root = Paths.get(SystemConfig.getProfile())
.toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new ServiceException("文件路径非法");
}
if (!Files.isRegularFile(target)) {
throw new ServiceException("文件不存在");
}

假设根目录是 D:/upload,恶意路径包含 ../config.yml。resolve 后可能暂时得到 D:/upload/../config.yml,normalize 会把它化简为 D:/config.yml,随后 startsWith(root) 返回 false。

只做字符串包含判断并不安全,例如 D:/upload-backup 也包含 D:/upload。使用规范化后的 Path 关系更可靠。

11. Controller 怎样输出图片#

private void writeFile(
HttpServletResponse response,
FileStreamVo file) throws IOException {
response.setContentType(file.getContentType());
response.setHeader(
"Cache-Control", "private, max-age=300");
response.setHeader(
"Content-Disposition",
"inline; filename=\"" + encode(file.getFileName()) + "\"");
Files.copy(file.getPath(), response.getOutputStream());
}

inline 表示浏览器可以直接预览,private 表示共享缓存不应该保存这份鉴权资源,max-age=300 允许用户自己的浏览器短时间缓存。

文件名进入响应头前必须编码,内容类型优先通过系统探测,无法识别时回退到 application/octet-stream。对于大文件还可以增加分段读取、响应长度与限速,避免一次把整个文件加载进内存。

12. 删除凭证还缺少哪一步#

当前删除方法校验凭证存在和订单归属后删除数据库记录:

HyDeliveryProof proof = getExistingProof(proofId);
checkCurrentUserOrderOwner(proof.getOrderId());
return mapper.deleteHyDeliveryProofByProofId(proofId);

如果业务要求物理删除文件,还需要处理数据库与磁盘的一致性;如果为了审计保留文件,则可以采用逻辑删除并设置保留期限。无论选择哪种,都不应在没有明确策略时让数据库记录和磁盘文件无限增长。

13. 本篇小结#

受保护图片的关键不是换一个上传组件,而是把业务权限放到文件之前。数据库保存可查询的元数据,VO 隐藏存储路径,读取时验证资源归属并限制路径落在指定根目录,才能形成闭合的文件安全链路。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端开发日记⑥:配送凭证与受保护文件流
https://firefly.cuteleaf.cn/posts/backend-dev-diary-6/
作者
Yore
发布于
2026-07-13
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0