OSS 预签名 URL 存库 = 定时炸弹,批量 403 的根因与根治
某天中午,运营突然反馈某后台管理系统里的图片全部挂了。打开列表页,所有封面图清一色 403。更诡异的是:我在本机 Chrome 里看完全正常,同事的电脑一打开就红叉。排查一圈以为是 CDN 缓存问题,折腾了三轮之后才找到真相——问题根本不在 CDN,而在两个月前的一行上传代码。
现象
- 图片 URL 直接
curl→AccessDenied: Request has expired - 同一条 URL 在自己浏览器里正常显示(同事电脑不行)
- 所有失效图片大致在同一时刻(中午 12:46)开始出错,不是逐渐劣化
- 数据库里捞出来的字段值长这样:
https://xxx.oss-cn-shanghai.aliyuncs.com/img/abc.jpg?Expires=1745812000&OSSAccessKeyId=LTAI...&Signature=xxx
根因
OSS / S3 的预签名 URL(presigned URL)本质是「带过期时间的一次性授权」,URL 里的 Expires 是一个 Unix 时间戳,服务端到期即拒。
问题出在上传接口直接把预签名 URL 返回给前端,前端表单回显时又把这个 URL 原样提交回写 API,写 API 不做任何处理就入库了。于是数据库里所有新增的记录,保存的都是带签名 query 的 URL。它们的签名 TTL 统一是 7200 秒——两个小时后同一批图片一起 403,规模越大,炸得越整齐。
很多人误以为桶设成公共读就没事了:「反正匿名访问也能看,签名过期了不影响」。
实测并非如此。 只要 URL 里带了签名 query(?Expires=...&OSSAccessKeyId=...&Signature=...),OSS 就会校验这个签名。校验失败即 403,不管桶 ACL 是什么。「公共读」只意味着「没有签名 query 的匿名请求可以通过」,不意味着「失效的签名被忽略」。
图片一旦被浏览器缓存(ETag/Last-Modified),后续请求直接走 (disk cache),根本不碰 OSS。所以「我这边看着没问题」完全不能作为有效信号。
诊断方法:Chrome DevTools → Network → 找图片请求 → 看 Size 列:
- 显示具体字节数(如
64.1 kB)→ 真实请求,Status 可信 - 显示
(disk cache)/(memory cache)→ 浏览器没发出请求,不算数
最快验证:把 URL 粘进无痕窗口地址栏,或勾选 DevTools 里的 Disable cache 再刷新。
正解
需要四层同时上:
Layer 1:写路径拦截(堵源头)
所有接收前端 URL 字段的写接口,入库前先 strip 掉签名 query:
function alioss_strip_sign_query(string $url): string {
if (empty($url) || stripos($url, '.oss-cn-') === false) return $url;
$pattern = '/[?&]Expires=\d+&OSSAccessKeyId=[^&]+&Signature=[^&\s",]+/i';
return preg_replace($pattern, '', $url);
}
// 写接口调用处
$coverImage = alioss_strip_sign_query($request->post('cover_image', ''));Java 版:
public static String stripSignQuery(String url) {
if (url == null || !url.contains(".oss-cn-")) return url;
return SIGN_PATTERN.matcher(url).replaceAll(""); // regex同上
}Layer 2:读路径兜底(处理历史脏数据流出)
写路径修好之前,DB 里已有的脏数据仍会通过 GET 接口流出。用 ResponseBodyAdvice 全局拦截,strip 后对私有桶重新签名,公共读桶直接返回裸 URL。
Layer 3:数据库清库
不要手工枚举表——用 information_schema 扫出所有可能存图片 URL 的字段,再用 WHERE col LIKE '%OSSAccessKeyId=%' 二次过滤确认脏行:
SELECT TABLE_NAME, COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND DATA_TYPE IN ('varchar','text','mediumtext')
AND (COLUMN_NAME LIKE '%image%' OR COLUMN_NAME LIKE '%picture%'
OR COLUMN_NAME LIKE '%avatar%' OR COLUMN_NAME LIKE '%cover%'
OR COLUMN_NAME LIKE '%poster%' OR COLUMN_NAME LIKE '%banner%');实测:手工列出的 19 张表只覆盖了约 50% 的脏数据,information_schema 多挖出 4 张表、6 个字段。
正则注意:如果字段存的是逗号分隔的多 URL,字符类必须排除 , 和 ",否则会跨边界匹配破坏整行:
[?&]Expires=\d+&OSSAccessKeyId=[^&]+&Signature=[^&\s",]+
^^^Layer 4:上传接口契约(治本)
上传接口只返回裸 object URL,不返回预签名 URL:
// ✅ 正确:只返回裸 URL
String url = "https://" + bucket + "." + endpoint + "/" + objectKey;
return ResponseUtil.ok(url);
// ❌ 错误:调用方会存进 DB
return ResponseUtil.ok(ossClient.generatePresignedUrl(bucket, key, expiry).toString());如果前端预览需要签名,单独提供一个「仅用于预览」的接口,并在接口名 / 注释里明确标注「不可持久化」。
数据库字段永远存裸 object URL(不含签名 query)。签名逻辑集中在读路径的 service 或 ResponseBodyAdvice 里,每次读取时按需生成短 TTL(1-2h)的签名。这样 DB 里的数据永不过期,签名 TTL 设多短都无所谓。
一句话外卖
预签名 URL 是「临时通行证」,通行证不能存档案;OSS object key(裸 URL)才是档案,签名只在取用时现签。