头像换了三次还是旧图:秒传 + 预签名 URL + 私有文件权限,四层缓存叠出一个 Bug
上一篇拆开放网关的时候说过,下一篇扒
forge-starter-file。本来打算按老套路从接口讲起,结果今天正好修了一个线上反馈的 Bug——头像上传提示成功,页面上就是不变。顺着这个 Bug 往下挖,文件存储模块里最容易翻车的几个点(秒传、预签名 URL、私有文件权限、前端缓存)一次全踩到了。干脆就拿它当主线。
起因:"头像更新成功",然后什么都没变
用户反馈很简单:个人中心换头像,裁剪、上传,右上角弹出"头像更新成功",头像还是原来那张。刷新一下,有时候变成空白。
第一反应是前端没刷新。打开 Network 一看,上传接口 200,返回的 fileId 也有。但返回的 fileId 跟上一次一模一样。
同一张图反复裁剪上传,拿到的永远是同一条旧记录——这是秒传。问题从这里开始一层层往下掉。
【此处放截图:个人中心头像上传弹窗 + "头像更新成功"提示,但头像未变化】
TL;DR:先看结论
- 签名 URL 不要存库当永久地址。对象存储返回的
accessUrl是带过期时间的临时签名,秒传复用旧记录时必须重新签发。 - 秒传要按业务场景关掉。头像这种"同图反复裁剪上传"的场景,复用旧记录只会带回旧状态。
- 私有文件的读权限依赖"上传者 ID",上传时拿不到用户 ID,文件就连上传者本人都读不了。
- 前端有两层缓存(签名 URL 缓存 + blob URL 缓存),
fileId不变时它们会一直命中。 - 还有两个按源码推演、尚未修复的坑:秒传的跨用户 403、MIME 校验依赖客户端
Content-Type。
全景:forge-starter-file 里有什么
forge-starter-file(14 个类 / 2912 行)
├── core/FileManager # 统一入口:校验、秒传、权限、上传下载(649 行)
├── storage/FileStorage # 存储策略接口
│ └── impl/
│ ├── LocalFileStorage # 本地磁盘
│ ├── TencentCosFileStorage # 腾讯云 COS
│ └── RustfsFileStorage # RustFS(S3 协议)
├── spi/
│ ├── FileMetadataPersistence # 元数据持久化 SPI(system 插件实现,落 sys_file_metadata)
│ └── StorageConfigProvider # 存储配置 SPI(后台可配置多套存储)
├── controller/FileController # /api/file/*
└── config/FileAutoConfiguration # 收集所有 FileStorage 注册进 FileManager
设计思路很典型:starter 只管"怎么存","元数据存哪、权限怎么判"通过 SPI 交给上层插件。所以这个 Bug 横跨了三层:starter 的 FileManager、system 插件的 SystemFileMetadataPersistence、前端的 utils/file.js。
【此处放截图:后台「存储配置」页面,展示本地 / COS / RustFS 多套配置】
第 1 层:秒传把过期的签名 URL 原样还了回去
现象
同一张图第二次上传,接口秒回,返回的 accessUrl 打开是 403(签名过期)。
源码
先看 COS 实现上传完成时怎么生成 accessUrl:
// TencentCosFileStorage
private String buildAccessUrl(String key, Integer expires) {
if (config != null && StrUtil.isNotBlank(config.getDomain())) {
return StrUtil.removeSuffix(config.getDomain(), "/") + "/" + key;
}
Date expiration = new Date(System.currentTimeMillis() + (expires != null ? expires : 3600) * 1000L);
return cosClient.generatePresignedUrl(defaultBucket, key, expiration, HttpMethodName.GET).toString();
}
上传时调用的是 buildAccessUrl(key, null)——默认 1 小时的预签名 URL。然后这个元数据被 SystemFileMetadataPersistence.save() 用 BeanUtil.copyProperties 整个拷进实体,accessUrl 字段一起落库了。
修复前的秒传逻辑:
FileMetadata existing = metadataPersistence.getByMd5(md5);
if (existing != null && 业务类型/业务ID一致) {
assertReadPermission(existing.getFileId(), existing);
return existing; // ← 库里那个 1 小时前签发的 URL 原样返回
}
为什么
accessUrl 在本地存储里是 /api/file/download/{fileId} 这种永久路径,存库没问题;但在 COS/RustFS 里它是临时凭证。同一个字段,两种语义,写代码的时候很容易只按本地存储去想。
怎么改
秒传命中时重新签发:
private FileMetadata withFreshAccessUrl(FileMetadata metadata) {
...
FileStorage storage = getStorage(metadata.getStorageType());
String freshUrl = storage.getAccessUrl(metadata.getFileId(), 3600);
if (freshUrl != null && !freshUrl.isBlank()) {
metadata.setAccessUrl(freshUrl);
}
...
}
前端也同步改了约定——profile.vue 里加了一行注释,说得很直白:
// 业务侧只持久化 fileId;accessUrl 是 COS 临时签名,不能当头像永久地址存
const avatar = res.data.fileId || res.data.filePath || res.data.id
第 2 层:头像根本就不该秒传
现象
就算 URL 重新签了,用户裁剪的是同一张原图、裁出来的结果一样,MD5 一样,拿到的还是旧的 fileId。fileId 不变,下游所有按 fileId 做的缓存都不会失效(见第 4 层)。
怎么改
直接对 avatar 关闭秒传:
// avatar 不做秒传:同图反复裁剪上传应生成新记录,避免复用旧元数据里过期的 accessUrl
if (metadataPersistence != null && !isAvatarBusinessType(businessType)) {
FileMetadata existing = metadataPersistence.getByMd5(md5);
if (existing != null
&& Objects.equals(existing.getBusinessType(), businessType)
&& Objects.equals(normalizeBusinessId(existing.getBusinessId()), normalizeBusinessId(businessId))) {
...
}
}
顺手还修了个小问题:businessId 一个是 null、一个是空串 "" 时,Objects.equals 判不等,秒传该命中不命中。现在统一用 normalizeBusinessId 把空白归一成 null。
秒传省的是一次上传的带宽,代价是"新上传 = 旧记录"。 对附件库是好事,对头像、签名图这种"用户期望每次都是新的"场景就是坑。
第 3 层:私有文件,连上传者自己都读不了
现象
有时候头像不是旧图,而是空白。控制台里 /api/file/url/{fileId} 返回 403「无权读取该文件」。
源码
头像上传没传 isPrivate,而 FileController 的默认值是 true:
@RequestParam(value = "isPrivate", required = false, defaultValue = "true") Boolean isPrivate
私有文件的读权限最终落到 isAdminOrUploader:管理员,或者 uploaderId 等于当前用户。而 uploaderId 是上传时 SessionHelper.getUserId() 填的。修复前:
public static Long getUserId() {
LoginUser loginUser = getLoginUser();
return loginUser != null ? loginUser.getUserId() : null;
}
Token 已经登录、但 Session 里还没写入 loginUser 的时候,这里返回 null → uploaderId 落库为 null → 之后谁都匹配不上,包括上传者本人。
另外头像还有一个天然矛盾:它要在用户列表、审批记录里给别人看,但默认是私有的,别人本来就读不了。
怎么改
两处:
// SessionHelper:Session 没有 loginUser 时回退到 Sa-Token 的 loginId
if (StpUtil.isLogin()) {
return StpUtil.getLoginIdAsLong();
}
// SystemFileMetadataPersistence.checkPermission:头像对登录用户可读
if ("avatar".equals(entity.getBusinessType())) {
try {
return StpUtil.isLogin();
} catch (Exception ignored) {
return false;
}
}
注意 catch 里返回的是 false——拿不到登录上下文就拒绝,这是 fail-closed,方向是对的。
第 4 层:前端两层缓存,fileId 不变就一直命中
utils/file.js 里有两层缓存:
- 签名 URL 缓存:内存 Map + localStorage,默认请求
expires=43200(12 小时),本地缓存有效期比签名少 60 秒,避免拿到"刚好过期"的 URL; - blob URL 缓存:内部文件接口需要带 Token,没法直接塞给
<img>,所以先fetch成 blob 再URL.createObjectURL,按fileId缓存。
export async function resolveRenderableFileUrl(fileData, expires = FILE_URL_CACHE_EXPIRE, forceRefresh = false) {
const cacheKey = renderableCacheKey(fileData)
if (!forceRefresh && cacheKey && renderableBlobUrlCache.has(cacheKey))
return renderableBlobUrlCache.get(cacheKey)
...
}
这两层缓存本身设计得挺讲究(缓存比签名早过期、替换 blob 时 revokeObjectURL 旧的防泄漏),但遇上第 2 层的"fileId 不变",就一直返回旧内容。
修法是头像变更时强制刷新,并且刷新失败不清空刚拿到的临时地址:
watch(() => userStore.avatar, () => {
loadAvatar(true) // forceRefresh
}, { immediate: true })
【此处放截图:修复后头像上传即时生效的前后对比(或 DevTools 中 /api/file/url 请求)】
还没修的两个坑(按源码推演,欢迎指正)
坑 A:秒传可能让第二个用户上传失败。 getByMd5 是 WHERE md5 = ? LIMIT 1,不区分上传者。用户 A 上传了一份私有 PDF(默认 businessType=common),用户 B 上传同一份文件,命中秒传 → assertReadPermission 检查 A 的私有文件 → B 不是管理员也不是上传者 → B 的上传直接 403。更稳的做法是把 uploaderId 纳入秒传匹配条件,或者命中后只复用存储对象、给 B 新建一条元数据。
坑 B:MIME 校验信的是客户端。 validateMimeType 比对的是 MultipartFile.getContentType(),这个值由浏览器/调用方给出;而且传 application/octet-stream 会直接放行。pom 里 tika-core 目前是注释状态,没有做魔数检测。好在扩展名这一层是硬的,见下文。
另外两个需要知道的权衡:头像的放宽规则按 businessType 判断,而 businessType 是上传时前端传的参数——影响范围只是"自己上传的文件变成所有登录用户可读",不会越权读别人的,但要心里有数;COS 配了自定义 domain 时 buildAccessUrl 直接拼永久地址、不签名,私有文件能不能被外部访问就取决于桶的 ACL 了。
这个模块做对的地方
- 扩展名双保险:内置
DANGEROUS_EXTENSIONS(jsp/php/html/js/sh/exe/jar/sql/svg……),而且后台配置的白名单也会再过滤一遍黑名单——管理员手滑把svg加进允许列表也没用。 - 默认私有:
isPrivate默认true,上传公共素材需要*:*:*权限。 - 私有文件下载加
Cache-Control: private, no-store,/api/file/url接口同样禁止缓存。 - 下载走流式
inputStream.transferTo(outputStream),不会把整个文件读进内存。
可带走的 5 条诀窍
- 存储层返回的 URL 分两类:永久路径可以存,临时签名只能用。数据库里只存
fileId/ 对象 key。 - 秒传是否开启应该按业务类型配置,头像、签名、证件照这类默认关掉。
- 私有文件权限依赖
uploaderId,上传链路里取不到用户 ID 要么回退、要么直接拒绝,别让null落库。 - 前端缓存有效期要比签名短,并且给"内容变了但 key 没变"的场景留一个
forceRefresh出口。 - MIME 校验至少要做扩展名白名单 + 危险类型黑名单;真要防伪装文件,得上魔数检测。
总结
一个"头像不生效",根因是四件事叠在一起:签名 URL 被当永久地址存库、头像走了秒传、私有文件的上传者 ID 为空、前端按 fileId 缓存。单看每一层都不算错,叠起来就是用户眼里的"点了没反应"。
修复在 v1.1.3 之后的 main 分支(提交 b9256c67b),顺带补了两个单测:skipsInstantUploadForAvatar 和 instantUploadRefreshesAccessUrl。
如果这篇对你有用,点个赞、收藏一下吧。 想想你项目里的文件表:access_url 那一列存的是永久地址还是临时签名?评论区说说——我赌至少一半项目存的是签名 URL,只是还没人等够一个小时去点它。
项目地址(文中所有代码都来自真实仓库):
- Gitee:gitee.com/ForgeLab/fo…
- GitHub:github.com/yaomindong1…
- 文档:www.dlforgelab.com:8084/forge-docs/
- 演示:www.dlforgelab.com:8084/forge/login… / 123456)
Forge Admin 是一个 Java 17 + Spring Boot 3.5 + Vue 3 的开源后台框架,自带 AI 智能体 / MCP 能力,可以当作"自带 AI 的若依替代品"来用。觉得有意思的话,点个 Star 就是对我最大的支持。