---
url: /docs/guide/integration/cos-sts-direct-upload.md
---
# COS STS 临时密钥直传 — 前端接入指南

> 上线：2026-10-09 · 端点：`POST /api/v1/storage/{sts,complete}` · 错误码段 40419xxx
> 场景：C 端**用户数据包大文件**直传腾讯云 COS（分片 / 断点续传 / 进度回调），不经后端中转
> 契约状态：**STS 零入参**（R10.6）· **body 任意形态兜底**（R10.6+，2026-10-09）
> 运维部署实录（后端配置 / 踩坑 / 修复）：[STS 直传生产部署实录](../../internal-wiki/operations/2026-10-09-sts-cos-direct-upload-deployment)

## 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\_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 直传生产部署实录](../../internal-wiki/operations/2026-10-09-sts-cos-direct-upload-deployment)
* 后端代码：`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）
