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

订单截图、货品图、签收图和异常证据都属于配送凭证。它们不能只保存一个公开 URL,因为图片包含订单信息,访问权限应和订单权限保持一致。本篇记录从上传到预览的完整后端链路。
1. 文件和业务记录分开保存
上传成功后,文件写入配置的 profile 目录,数据库只记录元数据:
orderId 关联订单orderVersion 上传时的订单版本proofType 凭证类型storagePath 服务端存储路径uploaderType WX_USER / SYS_USERuploaderId 上传人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 隐藏存储路径,读取时验证资源归属并限制路径落在指定根目录,才能形成闭合的文件安全链路。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























