Skip to content

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-v5

3.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-v5
js
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_keyHTTP 403 · code=40419002
4穿越拒绝object_key 含 ..(如 uploads/{uidA}/202610/../../{uidB}/x.zip)HTTP 403
5非 uploads/ 前缀object_key = avatar/x.pngHTTP 400 · code=40419001
6空 bodyPOST /storage/download-url 不传 bodyHTTP 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/(R10 4e9dd000 → 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)