头像换了三次还是旧图:秒传 + 预签名 URL + 私有文件权限,四层缓存叠出一个 Bug

头像换了三次还是旧图:秒传 + 预签名 URL + 私有文件权限,四层缓存叠出一个 Bug

上一篇拆开放网关的时候说过,下一篇扒 forge-starter-file。本来打算按老套路从接口讲起,结果今天正好修了一个线上反馈的 Bug------头像上传提示成功,页面上就是不变。顺着这个 Bug 往下挖,文件存储模块里最容易翻车的几个点(秒传、预签名 URL、私有文件权限、前端缓存)一次全踩到了。干脆就拿它当主线。


起因:"头像更新成功",然后什么都没变

用户反馈很简单:个人中心换头像,裁剪、上传,右上角弹出"头像更新成功",头像还是原来那张。刷新一下,有时候变成空白。

第一反应是前端没刷新。打开 Network 一看,上传接口 200,返回的 fileId 也有。但返回的 fileId 跟上一次一模一样。

同一张图反复裁剪上传,拿到的永远是同一条旧记录------这是秒传。问题从这里开始一层层往下掉。

【此处放截图:个人中心头像上传弹窗 + "头像更新成功"提示,但头像未变化】


TL;DR:先看结论

  1. 签名 URL 不要存库当永久地址 。对象存储返回的 accessUrl 是带过期时间的临时签名,秒传复用旧记录时必须重新签发。
  2. 秒传要按业务场景关掉。头像这种"同图反复裁剪上传"的场景,复用旧记录只会带回旧状态。
  3. 私有文件的读权限依赖"上传者 ID",上传时拿不到用户 ID,文件就连上传者本人都读不了。
  4. 前端有两层缓存 (签名 URL 缓存 + blob URL 缓存),fileId 不变时它们会一直命中。
  5. 还有两个按源码推演、尚未修复 的坑:秒传的跨用户 403、MIME 校验依赖客户端 Content-Type。

全景:forge-starter-file 里有什么

yaml 复制代码
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:

java 复制代码
// 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 字段一起落库了。

修复前的秒传逻辑:

java 复制代码
FileMetadata existing = metadataPersistence.getByMd5(md5);
if (existing != null && 业务类型/业务ID一致) {
    assertReadPermission(existing.getFileId(), existing);
    return existing;   // ← 库里那个 1 小时前签发的 URL 原样返回
}

为什么

accessUrl 在本地存储里是 /api/file/download/{fileId} 这种永久路径,存库没问题;但在 COS/RustFS 里它是临时凭证。同一个字段,两种语义,写代码的时候很容易只按本地存储去想。

怎么改

秒传命中时重新签发:

java 复制代码
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 里加了一行注释,说得很直白:

js 复制代码
// 业务侧只持久化 fileId;accessUrl 是 COS 临时签名,不能当头像永久地址存
const avatar = res.data.fileId || res.data.filePath || res.data.id

第 2 层:头像根本就不该秒传

现象

就算 URL 重新签了,用户裁剪的是同一张原图、裁出来的结果一样,MD5 一样,拿到的还是旧的 fileId 。fileId 不变,下游所有按 fileId 做的缓存都不会失效(见第 4 层)。

怎么改

直接对 avatar 关闭秒传:

java 复制代码
// 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:

java 复制代码
@RequestParam(value = "isPrivate", required = false, defaultValue = "true") Boolean isPrivate

私有文件的读权限最终落到 isAdminOrUploader:管理员,或者 uploaderId 等于当前用户。而 uploaderId 是上传时 SessionHelper.getUserId() 填的。修复前:

java 复制代码
public static Long getUserId() {
    LoginUser loginUser = getLoginUser();
    return loginUser != null ? loginUser.getUserId() : null;
}

Token 已经登录、但 Session 里还没写入 loginUser 的时候,这里返回 null → uploaderId 落库为 null → 之后谁都匹配不上,包括上传者本人。

另外头像还有一个天然矛盾:它要在用户列表、审批记录里给别人看,但默认是私有的,别人本来就读不了。

怎么改

两处:

java 复制代码
// SessionHelper:Session 没有 loginUser 时回退到 Sa-Token 的 loginId
if (StpUtil.isLogin()) {
    return StpUtil.getLoginIdAsLong();
}
java 复制代码
// 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 缓存。
js 复制代码
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 不变",就一直返回旧内容。

修法是头像变更时强制刷新,并且刷新失败不清空刚拿到的临时地址:

js 复制代码
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 条诀窍

  1. 存储层返回的 URL 分两类:永久路径可以存,临时签名只能用 。数据库里只存 fileId / 对象 key。
  2. 秒传是否开启应该按业务类型配置,头像、签名、证件照这类默认关掉。
  3. 私有文件权限依赖 uploaderId,上传链路里取不到用户 ID 要么回退、要么直接拒绝 ,别让 null 落库。
  4. 前端缓存有效期要比签名短 ,并且给"内容变了但 key 没变"的场景留一个 forceRefresh 出口。
  5. MIME 校验至少要做扩展名白名单 + 危险类型黑名单;真要防伪装文件,得上魔数检测。

总结

一个"头像不生效",根因是四件事叠在一起:签名 URL 被当永久地址存库、头像走了秒传、私有文件的上传者 ID 为空、前端按 fileId 缓存。单看每一层都不算错,叠起来就是用户眼里的"点了没反应"。

修复在 v1.1.3 之后的 main 分支(提交 b9256c67b),顺带补了两个单测:skipsInstantUploadForAvatar 和 instantUploadRefreshesAccessUrl。

如果这篇对你有用,点个赞、收藏一下吧。 想想你项目里的文件表:access_url 那一列存的是永久地址还是临时签名?评论区说说------我赌至少一半项目存的是签名 URL,只是还没人等够一个小时去点它。


项目地址(文中所有代码都来自真实仓库):

Forge Admin 是一个 Java 17 + Spring Boot 3.5 + Vue 3 的开源后台框架,自带 AI 智能体 / MCP 能力,可以当作"自带 AI 的若依替代品"来用。觉得有意思的话,点个 Star 就是对我最大的支持。

相关推荐
深入云栈1 小时前
Netty 4.2.x 源码深度解析 (十五):io_uring 传输 —— RingBuffer 与零拷贝 IO
java·后端
wei_shuo1 小时前
KES 数据保护体系构建:备份策略设计、恢复流程优化与时间点恢复实践
后端
MacroZheng1 小时前
装上这款全能增强插件,DeepSeek Harness瞬间高大上了!
java·人工智能·后端
imDwAaY1 小时前
6篇文章讲清楚Git:Git 实用技巧:暂存现场、挑选提交与定位 Bug (5/6)
git·后端
小卿噢1 小时前
那个时间不存在——时区代码里六个会真的出事的坑
后端
long、涯1 小时前
win10安装Maven3.9(图文版)
java·maven
小园子的小菜1 小时前
深度图解 Python 进程、线程与 GIL:彻底吃透并发核心原理
后端
霸道流氓气质1 小时前
LangGraph式图编排引擎Java实现:状态机模型、Checkpoint恢复与生产级落地实战
java·开发语言·数据库
用户7713970207061 小时前
Trae 深度使用指南:从配置到实战,一个 AI 原生 IDE 的完整工作流
后端