Appearance
COS STS 临时密钥直传 — 前端接入指南
上线:2026-10-09 · 端点:
POST /api/v1/storage/{sts,complete}· 错误码段 40419xxx 场景:C 端用户数据包大文件直传腾讯云 COS(分片 / 断点续传 / 进度回调),不经后端中转 契约状态:STS 零入参(R10.6)· body 任意形态兜底(R10.6+,2026-10-09) 运维部署实录(后端配置 / 踩坑 / 修复):STS 直传生产部署实录
1. 三步流程总览
前端 后端 腾讯云
① POST /storage/sts ────────▶ 校验 → STS AssumeRole ──▶ STS
◀── 三元组+bucket+object_dir ─┘ │
② COS SDK(三元组初始化)── 直接 PUT 分片到桶 ──────────────▶ COS(私有读写)
③ POST /storage/complete ───▶ 登记 UploadRecord ──────────▶ (RECORD_TYPES 建解析任务)- 三元组 30min 过期、只能写
uploads/{你的uid}/*、无删除/列举权限 - 主密钥永不下发;泄露面 = 短时效临时凭证
2. 端点契约
2.1 POST /api/v1/storage/sts(换临时凭证)
请求(Bearer 登录态 · body 完全不限制 —— 可以不传、传空、传任何形态):
json
// 最简形态:连 body 都不传(wx.request 不写 data 即可)
// 或 {}、[]、null、form-encoded —— 服务端一律兜底成空入参,照发凭证
{"filename": "health-data.zip", "mime": "application/zip", "size_bytes": 10485760}
// ↑ 也可带上做「预校验」:带 filename → 校验扩展名白名单;带 mime → 校验 MIME 白名单;带 size_bytes → 校验 500MB 上限⚠️ 2026-10-09 修复(R10.6+):此前空 body / 非 JSON / 缺 Content-Type 会被 FastAPI 校验层拒掉(40201001「body: Field required」)—— 现在路由层自读 body,任何形态都放行。
响应(code=0):
json
{
"code": "0",
"msg": "ok",
"data": {
"region": "ap-guangzhou",
"bucket": "qm-app-files-prod-1418512491",
"secretId": "AKIDlIuH...", ← STS 临时 Id(每次不同 · 30min 过期)
"secretKey": "JaLhgXPV...", ← STS 临时 Key
"sessionToken": "8APl6X34...", ← ⚠️ SDK 初始化必传(缺 → COS 403)
"startTime": 1791523846,
"expiredTime": 1791525646,
"object_dir": "uploads/u000023/202610",
"max_size_bytes": 524288000
}
}错误:401 未登录(40419002)/ 400 带非法字段时 mime 或扩展名不在白名单(40419004·40419001)/ 413 带 size_bytes 字段超限 >500MB(40419005)/ 429 限频 10次/min / 503 服务未配置(40419006)。
📌 只要带了合法 token,不存在 40201001(请求体校验)类错误 —— 任何 body 形态都进业务层。
2.2 POST /api/v1/storage/complete
json
{"object_key": "uploads/u000023/202610/<uuid>.zip", "size_bytes": 10485760, "type": "xiaomi_zip"}- 属主校验:key 必须
uploads/{当前用户}/开头 → 否则 403 type ∈ RECORD_TYPES(xiaomi_zip / coros_fit / coros_archive / garmin_fit / apple_health / huawei_export / sport_screenshot)→ 建 UploadRecord 进解析 pipeline;其余 type 仅确认- 响应:
{"ok": true, "record_id": "up-1"}(record_id=null = 该 type 不建记录)
2.3 POST /api/v1/storage/download-url(换下载链接 · R10.7 二期下载)
json
{"object_key": "uploads/u000023/202610/<uuid>.zip"}- 属主校验:key 必须
uploads/{当前用户}/开头 → 否则 403;非uploads/前缀 400 - 响应:
{"download_url": "https://...q-signature=...", "expire_at": 1791530889} - 预签名 GET · 30min 有效 —— 前端拿到 URL 后直接 GET 下载(不经后端中转)
- 过期后重新调用本端点换新链接(前端按
expire_at判临期)
2.4 POST /api/v1/storage/delete(删除文件 · R10.8)
json
{"object_key": "uploads/u000023/202610/<uuid>.zip"}- 属主校验同 download-url(跨用户 403 / 非
uploads/前缀 400) - 删对象 + 删同名上传记录:
{"ok": true, "record_deleted": true} - 幂等:对象不存在不报错;记录不存在
record_deleted=false—— 重复调用安全 - ⚠️ 前端临时凭证无 Delete 权限(策略设计)—— 删除一律走后端端点
3. JS / H5 / App-WebView 接入(cos-js-sdk-v5)
bash
npm install cos-js-sdk-v53.1 接口封装 api/storage.js
js
import request from '@/utils/request'; // 项目既有 request(自动带 Bearer)
// 零入参形态(推荐)—— 服务端按 token 取 uid,凭证照发
export function fetchStsCredential() {
return request.post('/storage/sts', {});
}
// 可选预校验形态 —— 带了就校验,不带就跳过
// export function fetchStsCredential({ filename, mime, size_bytes } = {}) {
// return request.post('/storage/sts', { filename, mime, size_bytes });
// }
export function reportUploadComplete({ object_key, size_bytes, type = 'misc' }) {
return request.post('/storage/complete', { object_key, size_bytes, type });
}3.2 上传器 utils/cos-uploader.js
js
import COS from 'cos-js-sdk-v5';
import { fetchStsCredential, reportUploadComplete } from '@/api/storage';
/**
* 数据包直传(sts → 分片直传 → complete 全封装)
* @param {File} file
* @param {{type?: string, onProgress?: (pct:number)=>void}} [opts]
* @returns {Promise<{objectKey: string, recordId: string|null}>}
*/
export function uploadDataPackage(file, opts = {}) {
const { type = 'misc', onProgress } = opts;
return new Promise((resolve, reject) => {
fetchStsCredential({
filename: file.name,
mime: file.type || 'application/octet-stream',
size_bytes: file.size,
}).then((sts) => {
const cos = new COS({
getAuthorization(options, callback) {
callback({
TmpSecretId: sts.secretId,
TmpSecretKey: sts.secretKey,
SecurityToken: sts.sessionToken, // ⚠️ 必传 —— 缺 Token COS 403
StartTime: sts.startTime,
ExpiredTime: sts.expiredTime, // 临期 SDK 自动重取(重新触发本回调)
});
},
});
const uuid = `${Date.now()}${Math.random().toString(16).slice(2, 8)}`;
const ext = file.name.includes('.') ? file.name.slice(file.name.lastIndexOf('.')) : '.zip';
const objectKey = `${sts.object_dir}/${uuid}${ext}`; // ⚠️ 必须落在 object_dir 下
cos.sliceUploadFile(
{
Bucket: sts.bucket,
Region: sts.region,
Key: objectKey,
FilePath: file,
PartSize: 1024 * 1024, // 1MB 分片
onProgress(info) { onProgress?.(Math.floor(info.percent * 100)); },
},
async (err) => {
if (err) return reject(err);
try {
const { record_id } = await reportUploadComplete({
object_key: objectKey, size_bytes: file.size, type,
});
resolve({ objectKey, recordId: record_id });
} catch (e) { reject(e); }
},
);
});
});
}3.3 页面调用
js
import { uploadDataPackage } from '@/utils/cos-uploader';
const result = await uploadDataPackage(file, {
type: 'xiaomi_zip', // 建解析记录的 type
onProgress: (pct) => (progress.value = pct), // 进度条
});
// result = { objectKey: 'uploads/u-xxx/202610/xxx.zip', recordId: 'up-1' }3.4 下载(download-url → 直连 GET)
js
import request from '@/utils/request';
/** 换下载链接(30min 有效) */
export function fetchDownloadUrl(objectKey) {
return request.post('/storage/download-url', { object_key: objectKey });
}
/** H5 / 浏览器:拿到链接直接开新窗口或 a 标签下载(链接自带签名,无需再带 token) */
export async function downloadFile(objectKey, fileName) {
const { download_url } = await fetchDownloadUrl(objectKey);
const a = document.createElement('a');
a.href = download_url;
a.download = fileName || '';
a.click();
}js
/** 小程序:uni.downloadFile + 保存/预览 */
async function downloadInMp(objectKey) {
const { download_url } = await fetchDownloadUrl(objectKey);
uni.downloadFile({
url: download_url, // ⚠️ 无需 Authorization 头(签名在 URL query 里)
success: (res) => {
if (res.statusCode === 200) {
uni.saveFile({ tempFilePath: res.tempFilePath }); // 或 uni.openDocument 预览
}
},
});
}⚠️ 两个注意点:
download_url指向 STS 直传桶域名(qm-app-files-prod-*.cos.ap-guangzhou.myqcloud.com)——小程序需加入 downloadFile 合法域名- 链接 30min 过期:下载前实时换链接,不要缓存 URL;
expire_at(秒级 unix)可用于判临期
4. 微信小程序差异(cos-wx-sdk-v5)
bash
npm install cos-wx-sdk-v5js
import COS from 'cos-wx-sdk-v5';
// getAuthorization 回调与上完全一致;上传改为:
cos.uploadFiles({
Bucket, Region,
Key: objectKey,
FilePath: wxFilePath, // wx.chooseMessageFile / FileSystem 拿到的路径
SliceUpload: true,
});小程序后台需在「downloadFile 合法域名」配置 COS 域名(qm-app-files-prod-1418512491.cos.ap-guangzhou.myqcloud.com)。
5. 安全模型(前端须知)
| 机制 | 效果 |
|---|---|
| 临时凭证 30min 过期 | 泄露窗口极短;过期后 COS 直接 403 |
| policy 锁路径 | 只能写 uploads/{自己uid}/*;写他人目录 COS 403 |
| 白名单无 Delete/Get | 临时凭证不能删对象、不能读他人对象;下载走后端签发(§2.3) |
| RoleSessionName 带 uid | 腾讯云 CloudAudit 审计可追溯到具体用户 |
| 主密钥永不下发 | secretId/secretKey 键名兼容旧格式,值已是临时凭证 |
6. 前端联调验证清单(下载接口)
环境:生产
https://api.qingmulife.cn· 准备一个可用的用户 access token + 一条已上传文件(object_key 从myUploads拿,格式uploads/{uid}/202610/xxx.zip)
| # | 用例 | 操作 | 期望结果 |
|---|---|---|---|
| 1 | 正常换链 | POST /storage/download-url body {object_key} | code=0;download_url 指向 qm-app-files-prod-*.cos.ap-guangzhou.myqcloud.com/...;expire_at ≈ 当前 + 1800s |
| 2 | 链接可直接下载 | 用 download_url 裸 GET(不带任何鉴权头) | 200,文件内容与上传时一致(比对大小/md5) |
| 3 | 跨用户拒绝 | 用 A 的 token 请求 B 的 object_key | HTTP 403 · code=40419002 |
| 4 | 穿越拒绝 | object_key 含 ..(如 uploads/{uidA}/202610/../../{uidB}/x.zip) | HTTP 403 |
| 5 | 非 uploads/ 前缀 | object_key = avatar/x.png | HTTP 400 · code=40419001 |
| 6 | 空 body | POST /storage/download-url 不传 body | HTTP 400 · code=40419001「object_key 必填」 |
| 7 | 未登录 | 不带 Authorization 头 | HTTP 401 · code=40419002 |
| 8 | 过期链接失效 | 用已过 30min 的旧 download_url 下载 | 403(COS 侧签名过期)→ 重新调 #1 换新链接即可 |
js
// 冒烟(浏览器控制台形态):
const t = '<access_token>';
const r = await fetch('https://api.qingmulife.cn/api/v1/storage/download-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ object_key: '<你的 object_key>' }),
});
const { data } = await r.json();
console.log(data); // { download_url, expire_at }
// 再裸 GET 验证:
const dl = await fetch(data.download_url);
console.log(dl.status, (await dl.blob()).size);7. FAQ
Q1:上传报 COS 403 AccessDenied? 依次排查:① sessionToken 没传/过期(30min 重取 —— 忘带 Token 时 COS 报的是 InvalidAccessKeyId 而非 AccessDenied,可据此区分)② Key 不在 object_dir 下 ③ 文件超 500MB ④ Key 少了文件名(把 object_dir 本身当 Key —— 实测最高频误用):
js
Key: sts.object_dir // ❌ 403 AccessDenied(目录路径不是合法 Key)
Key: `${sts.object_dir}/${fileName}` // ✅ 含文件名的完整对象路径🔎 判读技巧:COS 返回的 XML
<Resource>= 本次请求的 object 路径(部分 SDK 把它显示在Service:字段,如Service: /uploads/u000074/202610)。对照它即可定位 Key 拼错在哪。 另注意:上传链路只放开 PUT 系列 + HeadObject —— 列举/下载/删除都会 AccessDenied(这是设计,不是故障)。
Q2:/storage/sts 返回 503? 后端 STS 配置缺失(fail-closed)—— 联系后端检查 STS_* 环境变量。
Q2.5:/storage/sts 不传 body 会报错吗? 不会(2026-10-09 修复后)。空 body / 无 Content-Type / form-encoded / null / [] 全部照发凭证。此前的 40201001「body: Field required」已消除。只要 token 合法且端点未限频,任何 body 形态都返回 code=0 + 完整凭证。
Q3:能覆盖别人的文件吗? 不能。policy 只允许写自己的 uploads/{uid}/*;且 object_key 建议由前端 uuid 生成(天然防冲突)。
Q4:同一文件重复上传会怎样? Key 含 uuid 每次不同 → 桶内多副本;去重(秒传)属二期能力。
Q5:上传完成但 complete 忘了调? 文件已在桶里但无解析记录 —— 解析 pipeline 不会触发;务必在 put 成功回调里调 complete。
Q6:如何下载已上传的文件?POST /api/v1/storage/download-url(Bearer 登录态)传 {object_key} → 拿 download_url(30min 有效)直接 GET。 只能下载自己的文件(uploads/{你的uid}/ 前缀);前端临时凭证本身无下载权限(策略设计),下载一律走后端签发。
8. 相关
- 后端部署实录(配置 / 踩坑三连 / alembic 顶号 P0 / 空 body 修复):STS 直传生产部署实录
- 后端代码:
src/app/upload/(R104e9dd000→ R10.5 PG 化69f28b92→ R10.6 零入参bcb107df→ R10.6+ 空 body 兜底1bb81686→ R10.7 读路由+下载623a5a9d);迁移 0056_upload_record - 测试:
tests/unit/application/test_storage_sts.py(25)+tests/integration/test_storage_sts_e2e.py(13)