把用户文件存到对象存储,最省事的做法是服务端收文件再转发。问题是这样一来, 用户上传一个 500MB 的视频,流量要在这台 4 核 4G 的小机器上走两遍(进来的和出去的), 带宽和内存都会被吃掉。而且服务端一旦重启,正在传的文件直接废掉。
直传能绕开这个问题:客户端拿到一个带签名的 URL,自己把文件 PUT 到对象存储,服务端一个字节都不碰。 这篇记录这条路怎么走通,以及三个和权限有关的细节。
直传的第一反应是「把 SecretId / SecretKey 打进小程序」。这条路绝对不能走:
.js 里的字符串一个都藏不住;正确做法是预签名:密钥只存在服务端,客户端每次读写先向服务端要一个签名好的 URL, 这个 URL 里带了一个短期有效的签名,能且只能操作那一个对象。
// 客户端:先换签名,再直传
const signed = await post('/api/cos/sign', { op: 'put', key }, token)
if (signed.error) return { error: signed.error }
wx.request({
url: signed.data.url,
method: 'PUT',
data: bytes,
header: { 'content-type': 'application/octet-stream' },
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) resolve({ ok: true })
else resolve({ error: `上传失败(HTTP ${res.statusCode})` })
}
})
代码里定的是上传 10 分钟、下载 15 分钟。这两个数字是按最坏情况倒推的:
反过来说,有效期也不能太短。曾经试过 60 秒,结果是弱网环境下签名先过期、文件还没传完, 用户看到的是「上传失败(HTTP 403)」,而服务端日志里一切正常——排查了很久才想到是签名超时。
所有对象键统一是 users/{uid}/{相对路径}。这个前缀是整个权限模型的地基:
服务端签发 URL 之前,校验请求的 key 是否以 users/{当前登录用户}/ 开头,
不是就拒绝。用户拿不到别人的签名,也就碰不到别人的文件。
看起来一行 startsWith 就够了。实际上不够。
假设用户 A 的 uid 是 u_a,他提交这个 key:
users/u_a/vault/../../u_b/photo.jpg
字符串确实以 users/u_a/ 开头,startsWith 返回 true。
但如果下游把这个 key 直接拼进 URL 或者交给 SDK,部分实现会做路径归一化,
最终访问到 users/u_b/photo.jpg——越权读取别人的文件。
这个 bug 是我写测试时抓出来的,而且抓到的方式很偶然:我为了让 mock 行为和真实实现一致, 没有在 mock 里自己写一份校验,而是直接复用了真实模块。测试一跑, 构造的越权用例成功了——如果 mock 是我另写一份「看起来一样」的逻辑,这个洞会一直留着。
function ownedBy(key, uid) {
if (!key || typeof key !== 'string') return false
if (key.includes('..')) return false // 直接拒绝,不做归一化
if (key.includes('\\')) return false // 反斜杠在某些实现里也是分隔符
return key.startsWith(`users/${uid}/`)
}
处理方式是拒绝而不是「归一化后再校验」。归一化本身就依赖具体实现的分隔符规则,
而拒绝只需要一条规则:对象键里不该出现 ..。合法的对象键永远不需要它。
顺手记一条经验:凡是把用户输入拼进路径的地方,都要单独测一次
..。 这条规律与语言、框架无关,二十年来没失效过。
最初的设计是「用户点删除 → 调 COS 删除接口」。跑了几天发现两个问题:
改成「移入回收站」:客户端只调一个接口,服务端在一个事务里把对象标进回收站表、 删掉文件记录、扣减配额。真正的对象删除由后台定时任务在 30 天后执行。
// 客户端只发一次请求,不碰数据库、不碰配额
post('/api/trash/move', {
items: items.map(item => ({
cloudPath: item.cloudPath,
parts: item.cloudParts || 1,
enc: item.cloudEnc === true,
size: Number(item.cloudSize) || 0,
})),
}, token)
这个改动带来一条更普遍的约束:凡是涉及多个存储系统的写操作,收敛到服务端做。 客户端只负责发起意图,不负责保证一致性。
小程序里 wx.request 的 data 只能是一次性能拿到的数据。
一个大文件如果整个读进内存,在低端机上会直接 OOM。
做法是按固定大小切块,每块单独 PUT 成一个对象,再把块的序号记在文件元信息里。 下载和播放时按序号拼接。代价是对象数量变多、管理复杂度上升,收益是任意大小的文件都能处理, 且断了可以从断点续传。
切块大小定在 4MB 左右是经验值:太小则请求数爆炸(COS 对请求数计费), 太大则单块失败的重传成本高、内存占用也上去了。
| 角色 | 知道什么 |
|---|---|
| 客户端 | 自己的登录令牌、单次签名 URL;不知道存储密钥 |
| 服务端 | 存储密钥、用户身份、对象键;转发的是签名,不是文件 |
| 对象存储 | 密文、对象键、大小、上传时间;扩展名也在对象键里 |
最后一行值得单独说:加密只保护内容,不保护元数据。对象键写成
users/u_a/vault/2026-10/IMG_0413.jpg,存储方就知道这个用户在某个月存了一张 jpg。
要藏住这一层,得把对象键也做随机化,代价是列表功能需要自己维护一份映射表。
这个取舍取决于你到底在防谁。
这条路的核心设计只有一句:客户端永远不持有长期凭证,只持有短期的、单对象的、带身份前缀的授权。
三个细节各自对应一类真实事故:密钥进包对应「整桶被拿走」,路径穿越对应「越权读别人的文件」, 客户端直删对应「数据与配额不一致」。这三个都不是理论风险,都是我在上线前实际撞到的。