4.2 图片直传:COS 临时密钥与前端上传

4.1 把文章记录的写入跑通了,但封面字段还是空的——用户选了图,总不能让图片跟着 JSON 一起 base64 塞进数据库。这一节聊发布链路的第二段:图片怎么从前端直达对象存储。核心是临时密钥方案:既不让前端拿到永久密钥,也不让图片绕道云函数多跑一趟。

场景:一张封面图的去向

需求很普通:用户在编辑器里选一张图当封面,上传完拿到地址,填进 4.1 的 cover 字段再点发布。约束有两条:图片平均一两兆,大的时候十几兆;我们的预算很薄,不想为流量反复付费。

最早想的是最省事的方案:前端用云开发 SDK 的 uploadFile,三行代码搞定,鉴权全托管:

// 最省事的 SDK 托管上传
const res = await app.uploadFile({
  cloudPath: `covers/${Date.now()}.jpg`,
  filePath: file,
})
const cover = res.fileID // fileID 直接存进 cover 字段

跑通是跑通了,但很快就不满足:我想按用户隔离目录、给不同目录配不同权限、还要在 COS 侧直接用图片处理参数生成缩略图,托管模式都做不到。于是换成了临时密钥直传,这条路是云开发存储的底层玩法,也最能讲清楚权限边界。

先给三套方案算笔账:

方案 上传链路 优点 缺点
SDK 托管上传 前端到云开发存储 三行代码,零配置 目录权限不能精细控制
云函数中转 前端到函数再到 COS 能压缩加水印 双倍流量,大文件易超时
临时密钥直传 前端直连 COS 流量省、速度快、权限可收窄 签名逻辑要自己写

云函数中转第一个被毙掉:十几兆的图走函数,函数超时风险大不说,上行下行各算一次流量,穷项目受不了。托管上传留在小图场景兜底,主链路走临时密钥。

方案:云函数发临时密钥,前端直传 COS

思路是云开发存储底层就是 COS,云函数里用 STS 服务签一张临时凭证,凭证只允许往某个目录写文件,有效期半小时。前端拿到凭证直接和 COS 通信,云开发只过一道签名,不碰文件流。

先定目录规则,权限设计全靠它:

目录 用途 写权限 读权限
articles/{openid}/ 文章配图 仅本人 公开
avatar/{openid}/ 头像 仅本人 公开
covers/ 官方封面 仅云函数 公开

发签名的云函数:

// cloudfunctions/getUploadCredentials/index.js
const STS = require('qcloud-cos-sts')
const cloud = require('wx-server-sdk')
cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV })

const cos = {
  secretId: process.env.TENCENTCLOUD_SECRETID,
  secretKey: process.env.TENCENTCLOUD_SECRETKEY,
  bucket: 'my-app-12345678',
  region: 'ap-shanghai',
  durationSeconds: 1800,
}

exports.main = async () => {
  const { OPENID } = cloud.getWXContext()
  const key = `articles/${OPENID}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`
  const policy = {
    version: '2.0',
    statement: [{
      action: ['name/cos:PutObject', 'name/cos:PostObject'],
      effect: 'allow',
      resource: [`qcs::cos:${cos.region}:uid/${cos.uid}:${cos.bucket}/articles/${OPENID}/*`],
    }],
  }
  try {
    const credential = await STS.getCredential({ ...cos, policy })
    // cos.uid 是主账号 ID,从控制台拿,写在环境变量里
    return { code: 0, credential, key, url: `https://${cos.bucket}.cos.${cos.region}.myqcloud.com/${key}` }
  } catch (e) {
    console.error('签发临时密钥失败', e)
    return { code: 500, msg: '上传服务繁忙' }
  }
}

前端用 cos-js-sdk-v5 直传,注意拿凭证是异步的:

import Cos from 'cos-js-sdk-v5'

const cos = new Cos({
  getAuthorization: async (options, callback) => {
    const res = await app.callFunction({ name: 'getUploadCredentials' })
    const c = res.result.credential
    callback({
      TmpSecretId: c.tmpSecretId,
      TmpSecretKey: c.tmpSecretKey,
      SecurityToken: c.sessionToken,
      StartTime: c.startTime,
      ExpiredTime: c.expiredTime,
    })
  },
})

async function uploadCover(file, onProgress) {
  const res = await app.callFunction({ name: 'getUploadCredentials' })
  const { key, url } = res.result
  await cos.putObject({
    Bucket: 'my-app-12345678',
    Region: 'ap-shanghai',
    Key: key,
    Body: file,
    onProgress: (p) => onProgress(Math.round(p.percent * 100)),
  })
  return url // 填进 4.1 的 cover 字段
}

上传完拿到的就是一个普通 CDN 地址,想要缩略图在 URL 后面拼参数即可:?imageView2/2/w/400,列表页用小图,详情页用原图,流量又省一半。

踩坑与排查

第一个是密钥泄露。上线前 review 的时候发现一版历史代码把 SecretKey 写进了前端配置文件,理由是反正打包进去了没人看见——这是典型的负向安全感。临时密钥方案上线后,即使前端被扒,拿到的凭证也只能写自己 openid 目录下的文件,有效期半小时,危害可控。

第二个是 resource 路径写错导致整条链路 403。STS 的 resource 字段里 bucket 要带 appid 后缀,region 要和 bucket 实际地域一致,少一个冒号就全部签名失败。调试技巧是开 STS 的 debug 日志,拿返回的签名直接用 curl 验一遍,别在前端来回试。

第三个是大文件上传到一半凭证过期。前端十几兆的图在弱网下可能传十几分钟,而凭证只有半小时。后来超过 10MB 的文件改用 cos.uploadFile 的分片上传,并且每次分片前检查剩余有效期,不足五分钟就重新签一张凭证续上。

第四个是 CORS。前端直连 COS 必须在存储桶配置里加上 Web 域名来源,允许 PUT 和 POST 方法,否则浏览器直接拦截,而且报错信息非常迷惑,看起来像网络断了。本地联调时 localhost 也要加进去。

小结

直传这套方案的收益是实实在在的:函数调用次数少了、大文件不受函数超时限制、流量只算一遍。代价是多写一个签名函数和一套 CORS、分片续签的细节。这套存储与签权的组合在本书目录的存储部分有统一说明,另一个项目的对象存储实操可以看灯塔站点实战。下一节解决图片和文字进去之后的合规问题,见内容审核一节,那是应用上线前最后一道硬门槛。

延伸阅读