写在前面
做文件上传的时候,我一开始的想法特别简单:前端把文件 POST 过来,后端存到对象存储,返回一个 URL,完事。
真做起来才发现这条路走不通。企业系统里的文件不是"存下来就行",它有一堆必须回答的问题:
- 用户点了两次上传,会不会存两份?
- 同一个文件被三个人分别上传,要占三份空间吗?
- 文件被聊天、审批、头像同时引用,某处删除了,物理文件能跟着删吗?
- 用户传了一半断网了,那个半成品文件谁来清理?
这篇记录一下我在 Workforce Hub 里设计文件中心的思路。核心是三个决定:上传拆成两阶段、内容按哈希去重、引用与存储分离。
一、把上传拆成两阶段
最直接的做法是让文件流经后端:前端 → 我的服务器 → 对象存储。但这样有个致命问题:大文件会把应用服务器的内存和带宽吃满。
所以改成了两阶段:
第一阶段 客户端请求「上传票据」
后端校验权限、配额,返回票据 + 对象存储的上传地址
第二阶段 客户端拿票据直传对象存储(不经过我的服务器)
确认 客户端告诉后端「传完了」,后端校验后创建引用记录
第一阶段签发票据的核心逻辑:
public UploadTicketResponse createEmployeeUploadTicket(CreateUploadTicketRequest request) {
Long userId = CurrentUserHolder.requireUserId();
fileUploadRules.validateEmployeeOwner(request.getOwnerType(), request.getOwnerId());
fileUploadRules.validateEmployeeFolder(request.getOwnerType(), userId, request.getFolderId());
ensureUploadPolicy(request);
fileUploadRules.ensureEmployeeQuota(request.getOwnerType(), userId, request.getFileSize());
fileQuotaPolicyService.ensureGlobalQuota(request.getFileSize());
// ...
return createPendingUploadTicket("USER", userId, request, "FILE_UPLOAD_TICKET_CREATED");
}
关键在于票据是在校验通过后才签发的。权限、目录归属、个人配额、全局配额——全部在这一步查完。如果这里不放行,客户端连对象存储的地址都拿不到,根本传不上去。
这样应用服务器只处理轻量的元数据,文件本体走对象存储的直连通道。
二、确认阶段要校验六件事
文件传完了不代表可以入库。客户端调确认接口时,后端要逐项核对:
UploadTicketRow ticket = getUploadTicketOrThrow(request.getUploadTicketId());
// 1. 票据归属:是不是本人创建的票据
if (!"USER".equals(ticket.uploaderType()) || !userId.equals(ticket.uploaderId())) {
throw new BizException(403, "无权确认该上传票据");
}
// 2. 重复确认:已经确认过就直接返回,不报错
Long existingRefId = attachmentRefRepository.findIdByUploadTicket(ticket.id());
if (existingRefId != null) { /* 返回已有结果 */ }
// 3. 票据状态与有效期
if ("CONFIRMED".equals(ticket.status())) {
throw new BizException(409, "上传票据已确认");
}
if (ticket.expireAt().isBefore(LocalDateTime.now())) {
throw new BizException(409, "上传票据已过期");
}
// 4. 对象路径必须与票据一致
if (!ticket.objectKey().equals(request.getObjectKey())) {
throw new BizException(422, "文件对象不匹配,请重新上传");
}
// 5. 内容指纹与大小必须一致
if (!ticket.contentSha256().equalsIgnoreCase(request.getContentSha256())
|| !ticket.fileSize().equals(request.getFileSize())) {
throw new BizException(422, "文件校验失败,请重新上传");
}
// 6. 业务归属不能变
if (!ticket.bizType().equals(request.getOwnerType()) || !sameId(ticket.bizId(), request.getOwnerId())) {
throw new BizException(422, "业务归属不匹配,请重新申请上传票据");
}
这几道校验不是凑数的,每一道都对应一种真实的攻击或故障:
| 校验项 | 防的是什么 |
|---|---|
| 票据归属 | 拿别人的票据确认,把文件挂到自己名下 |
| 重复确认 | 客户端重试导致创建多条引用 |
| 有效期 | 票据泄露后被长期复用 |
| 对象路径 | 确认时偷换对象,指向别人的文件 |
| SHA256 + 大小 | 传了个 A 文件,确认为 B 文件 |
| 业务归属 | 申请时挂给消息,确认时改成审批附件 |
第 5 项最容易被忽略。 如果确认时只信客户端报的哈希,攻击者可以先合法上传一个小文件拿到票据,再确认时声称这是另一个大文件的哈希。所以我用 verifyObject 向对象存储反查实际内容:
storageProviderRegistry.require(ticket.storageProvider())
.verifyObject(ticket.bucketName(), request.getObjectKey(),
request.getContentSha256(), request.getFileSize());
存储层说这个对象的 SHA256 确实是它,才放行。
三、按内容哈希去重(秒传)
一张公司logo可能被十几个页面引用,如果每次都存一份,存储会被吃爆。所以文件对象表里存了 content_sha256,上传前先查有没有现成的:
FileObjectRow existing = fileObjectRepository.findReusableForUploader(
"USER", userId, request.getContentSha256(), request.getFileSize()
);
if (existing != null) {
// 命中:不重复存文件,直接建引用
deduplicatedTicketId = createConfirmedDeduplicatedUploadTicket("USER", userId, request, existing);
attachmentRefId = createAttachmentRef(..., existing.id(), deduplicatedTicketId, ...);
fileObjectRepository.increaseRefCount(existing.id());
// ...
return deduplicatedResponse(deduplicatedTicketId, "UP" + deduplicatedTicketId, existing, attachmentRefId);
}
命中时票据直接是「已确认」状态——因为文件本来就在存储里,不需要再传一遍。这就是网盘"秒传"的原理。
⚠️ 注意这里有个安全细节:查重范围限制在 ("USER", userId)。不能让用户通过秒传探测到别人上传过什么文件——如果全局查重,A 用户传一个文件发现秒传成功,就等于确认了"这个文件公司里有人传过"。
四、引用计数:文件什么时候才能真正删
这是整个设计里我觉得最关键的一层。
假设一张图片同时被「聊天消息」和「审批附件」引用。用户在聊天里删了这条消息,这张图片能删吗?不能,审批那边还在用。
所以我把「文件对象」和「业务引用」拆成两张表:
file_object 物理文件(sha256、大小、存储路径、ref_count)
attachment_ref 业务引用(谁在引用它:消息?审批?头像?)
删文件时不是直接删物理对象,而是标记引用失效:
LocalDateTime deletedAt = LocalDateTime.now();
LocalDateTime purgeAfter = deletedAt.plusDays(30);
int updated = jdbcTemplate.update("""
UPDATE sys_attachment_ref
SET deleted_at = ?, deleted_by = ?, purge_after = ?, updated_by = ?
WHERE id = ? AND is_deleted = 0 AND deleted_at IS NULL
""", deletedAt, userId, purgeAfter, userId, attachmentRefId);
这里有两个设计点:
第一,purge_after 给了 30 天宽限期。 用户误删了还能恢复:
public FileRestoreResponse restoreEmployeeFile(Long attachmentRefId, FileTrashRequest request) {
AttachmentRow attachment = attachmentRefRepository.getAttachmentOrThrow(attachmentRefId);
ensureUploaderOwnsAttachment(userId, attachment);
if (attachment.deletedAt() == null) {
return buildRestoreResponse(attachmentRefId); // 本来就没删,幂等返回
}
// ...
}
第二,UPDATE 语句里带了 AND deleted_at IS NULL。 这不是多余的——updated == 0 时我再去查一次当前状态:
if (updated == 0) {
AttachmentRow latest = attachmentRefRepository.getAttachmentOrThrow(attachmentRefId);
if (latest.isDeleted() != null && latest.isDeleted() == 1) {
throw new BizException(404, "附件引用不存在");
}
return buildTrashResponse(attachmentRefId, latest.deletedAt(), latest.purgeAfter());
}
为什么要绕这一圈? 因为并发删除时,两个请求可能都通过了前面的检查。带上条件后只有一个能更新成功(updated == 1),另一个拿到 updated == 0,此时它需要区分两种情况:
- 已经被别人删了 → 幂等返回当时的结果,不报错
- 已经彻底清理了 → 报 404
如果这里直接抛异常,用户会看到莫名其妙的失败;如果无脑返回成功,又会掩盖真实的并发状态。把并发的结果如实分类返回,比"报错"或"假装成功"都更正确。
五、配额放在签发阶段而不是确认阶段
配额校验出现在两个地方——票据签发时和确认时都查一遍。看起来重复,但两次的目的不同:
| 时机 | 目的 |
|---|---|
| 签发时 | 提前拦截——配额不够就别让用户白传一遍大文件 |
| 确认时 | 兜底——签发到确认之间,用户可能已经用掉了配额 |
配额本身分了三层:
private static final long DEFAULT_PERSONAL_QUOTA_BYTES = 10L * 1024L * 1024L * 1024L; // 10G
private static final long DEFAULT_GLOBAL_QUOTA_BYTES = 400L * 1024L * 1024L * 1024L; // 400G
private static final long DEFAULT_CACHE_QUOTA_BYTES = 50L * 1024L * 1024L * 1024L; // 50G
个人配额防止单个用户占满空间,全局配额是租户级的总闸,缓存配额留给临时文件。三层各管一件事,比一个总配额更可控。
六、小结
回头看,文件上传这个需求表面上很简单,但要在企业环境里做对,得回答清楚三个问题:
- 数据流经谁? 文件本体直传对象存储,应用服务器只管元数据。这是性能问题的答案。
- 怎么判断两个文件是同一个? 内容哈希(SHA256 + 大小),但查重范围要限定在用户自己的文件内,否则会泄露信息。
- 什么时候能删物理文件? 引用计数归零。逻辑引用和物理存储分开,删除走逻辑删除 + 宽限期。
如果只记一条,那就是:不要把"文件"当成一个东西。 它至少是三个:物理对象、业务引用、以及两者之间的计数关系。想清楚这三层,大部分坑就自然避开了。
默认评论
Halo系统提供的评论