# 文件存储

# 概述

采用前端直传方案:后端签发 STS 临时密钥,前端用 COS JS SDK 把文件直接上传到存储桶,再把对象 URL 存进商品等业务字段。文件不流经应用服务器,不占后端带宽,后端也不再接收二进制流。

# 上传链路

┌──────────┐  ① GET /api/admin/upload/sts   ┌────────────┐
│  前端    │ ──────────────────────────────▶ │  后端       │
│ (Vue)    │ ◀────────────────────────────── │ (签发STS)   │
└──────────┘  ② 返回临时密钥 + 桶信息         └────────────┘
     │
     │ ③ 用临时密钥 + cos-js-sdk-v5 putObject 直接上传
     ▼
┌──────────┐
│ COS 存储桶 │
└──────────┘

① 前端向后端申请临时密钥;② 后端用永久密钥向 CAM 换临时密钥并返回;③ 前端直传 COS;④ 前端用返回的 objectKey 拼完整 URL 存库。


# 一、腾讯云控制台准备(需要你做的)

按顺序操作,前端直传必须配 CORS(第 4 步),最容易漏。

# 1. 开通 COS 服务

腾讯云控制台 → 搜索「对象存储 COS」→ 开通。

# 2. 创建存储桶(Bucket)

选项 建议值 说明
名称 mall-1250000000 全局唯一,格式 {自定义名}-{APPID},建好后不可改
地域 ap-shanghai 就近选,上海/广州/北京均可
访问权限 公有读私有写 上传走 STS 临时密钥(写),<img> 直接读公开 URL(读)。想更安全走「私有读写 + CDN 回源鉴权」见风险一节
存储类型 标准存储 常规读写场景

# 3. 子账号永久密钥(只存在后端)

永久密钥只用于后端签发临时密钥,永不发给前端。

  1. 访问管理 CAM → 用户 → 新建用户 → 自定义创建 → 访问方式勾选「编程访问」。
  2. 权限策略:勾选 QcloudCOSDataFullControl(对象存储全读写);更严格可按策略语法自建,限定单个 bucket:
    {
      "version": "2.0",
      "statement": [{
        "effect": "allow",
        "action": ["cos:PutObject", "cos:GetObject"],
        "resource": "qcs::cos:ap-shanghai:uid/1250000000:mall-1250000000/*"
      }]
    }
    
  3. 创建完成后保存 SecretId / SecretKey(只显示一次)。

# 4. 配置存储桶 CORS(前端直传的关键)

前端是浏览器跨域直传,桶必须允许前端域名跨域。控制台 → 存储桶 → 权限管理 → 跨域访问 CORS 规则 → 添加规则:

字段 建议值
来源 Origin http://localhost:5173(开发)+ 生产域名(如 https://admin.mall.com,可多条)
操作 AllowedMethod PUT、POST、GET、HEAD
AllowedHeader *(或 Content-Type、x-cos-security-token、Origin)
ExposeHeader ETag、x-cos-request-id
MaxAgeSeconds 600

不配 CORS,上传会直接报跨域错误(No 'Access-Control-Allow-Origin')。

# 5. 记录关键信息

配置 例子 存放位置
COS_SECRET_ID AKIDxxxxxxxxxxxxxxxxxx 后端环境变量
COS_SECRET_KEY xxxxxxxxxxxxxxxxxxxx 后端环境变量
COS_REGION ap-shanghai 后端 + 前端各一份
COS_BUCKET mall-1250000000 后端 + 前端各一份
COS_BASE_URL(可选) https://cdn.mall.com 前端 env;不配则默认 https://{bucket}.cos.{region}.myqcloud.com

密钥安全

永久密钥是最高敏感信息,绝不进 git、绝不进前端。后端用环境变量注入,前端只能拿到后端签发的临时密钥(30 分钟内过期)。


# 二、后端改造

# 1. 新增依赖

pom.xml 加入腾讯云 STS 临时密钥 SDK(用于后端向 CAM 申请临时密钥,不需要 cos_api——后端不再传文件):

<dependency>
    <groupId>com.qcloud</groupId>
    <artifactId>cos-sts_api</artifactId>
    <version>3.1.1</version>
</dependency>

# 2. 新增配置项

mall:
  cos:
    secret-id: ${COS_SECRET_ID:}
    secret-key: ${COS_SECRET_KEY:}
    region: ${COS_REGION:ap-shanghai}
    bucket: ${COS_BUCKET:}
    # 对象 key 前缀(与历史目录结构一致,便于迁移/排查)
    prefix: uploads
    # 临时密钥有效期(秒),默认 30 分钟
    sts-duration: ${COS_STS_DURATION:1800}

# 3. 签发临时密钥 —— CosStsService

@Service
public class CosStsService {

    @Value("${mall.cos.secret-id}") private String secretId;
    @Value("${mall.cos.secret-key}") private String secretKey;
    @Value("${mall.cos.region}") private String region;
    @Value("${mall.cos.bucket}") private String bucket;
    @Value("${mall.cos.prefix:uploads}") private String prefix;
    @Value("${mall.cos.sts-duration:1800}") private int durationSeconds;

    /** 用永久密钥向 CAM 申请临时密钥,返回给前端 */
    public CosStsResult issue() {
        TreeMap<String, Object> config = new TreeMap<>();
        config.put("secretId", secretId);
        config.put("secretKey", secretKey);
        config.put("durationSeconds", durationSeconds);
        config.put("bucket", bucket);
        config.put("region", region);
        // 只允许上传到 prefix 下的对象,防目录穿越
        config.put("allowPrefixes", new String[]{prefix + "/*"});
        // 只放行「上传」相关操作,不给读/删
        config.put("allowActions", new String[]{
            "name/cos:PutObject", "name/cos:PostObject",
            "name/cos:InitiateMultipartUpload", "name/cos:ListMultipartUploads",
            "name/cos:ListParts", "name/cos:UploadPart", "name/cos:CompleteMultipartUpload"
        });

        Response res = CosStsClient.getCredential(config);
        return CosStsResult.builder()
            .tmpSecretId(res.credentials.tmpSecretId)
            .tmpSecretKey(res.credentials.tmpSecretKey)
            .sessionToken(res.credentials.sessionToken)
            .startTime(res.startTime)
            .expiredTime(res.expiredTime)
            .bucket(bucket)
            .region(region)
            .keyPrefix(prefix + "/")
            .build();
    }
}

CosStsResult 是一个 record/DTO,序列化为:

{
  "tmpSecretId": "AKIDxxx",
  "tmpSecretKey": "xxx",
  "sessionToken": "xxx",
  "startTime": 1750000000,
  "expiredTime": 1750001800,
  "bucket": "mall-1250000000",
  "region": "ap-shanghai",
  "keyPrefix": "uploads/"
}

# 4. 新增接口 —— UploadController

@PreAuthorize("hasAuthority('common:upload')")
@GetMapping("/api/admin/upload/sts")
public Result<CosStsResult> sts() {
    return Result.success(cosStsService.issue());
}
  • 权限码 common:upload 沿用,权限目录(V1 SQL)不用动。
  • STS 接口必须走登录 + 权限码,否则任何人可白嫖你的密钥额度。

# 5. 说明:服务端校验去哪了

原来后端在收到文件时校验大小/扩展名。改成前端直传后,后端看不到文件本体,该校验转移到:

  • 前端 before-upload(现有 ProductEdit 里已有,继续保留);
  • 桶侧:COS 生命周期策略可定时清理过期对象(可选)。

spring.servlet.multipart 的上传大小限制对本流程不再生效,可保留亦可移除。


# 三、前端改造

# 1. 安装依赖 + 环境变量

npm install cos-js-sdk-v5

新增前端 env(VITE_ 前缀会被 import.meta.env 读取):

# .env.development / .env.production
VITE_COS_REGION=ap-shanghai
VITE_COS_BUCKET=mall-1250000000
# 自定义域名可选;不配则前端用 https://{bucket}.cos.{region}.myqcloud.com 兜底
# VITE_COS_BASE_URL=https://cdn.mall.com

# 2. api/upload.ts:请求 STS

import request from '@/utils/request'

/** STS 临时密钥 + 桶信息(POST /api/admin/upload 服务端中转已废弃) */
export interface UploadSts {
  tmpSecretId: string
  tmpSecretKey: string
  sessionToken: string
  startTime: number
  expiredTime: number
  bucket: string
  region: string
  keyPrefix: string
}

export function getUploadSts() {
  return request.get<any, UploadSts>('/admin/upload/sts')
}

删除原来的 uploadFile()(不再走后端中转)。

# 3. utils/cos.ts:封装直传

核心职责:缓存临时密钥,过期前自动续签;生成 objectKey;putObject 直传;返回完整 URL。

import COS from 'cos-js-sdk-v5'
import { getUploadSts } from '@/api/upload'

let client: COS | null = null
let sts: UploadSts | null = null

/** 取可用客户端:凭证将过期时重新向后端签发 */
async function getClient(): Promise<COS> {
  const needRefresh = !client || !sts || Date.now() >= (sts.expiredTime - 60) * 1000
  if (needRefresh) {
    sts = await getUploadSts()
    client = new COS({
      getAuthorization: (_opts, callback) => {
        callback({
          TmpSecretId: sts!.tmpSecretId,
          TmpSecretKey: sts!.tmpSecretKey,
          SecurityToken: sts!.sessionToken,
          StartTime: sts!.startTime,
          ExpiredTime: sts!.expiredTime,
        })
      },
    })
  }
  return client!
}

/** 生成对象 key:uploads/yyyy/MM/{uuid}.{ext}(前缀受后端 STS 策略约束) */
function buildKey(prefix: string, file: File): string {
  const now = new Date()
  const y = now.getFullYear()
  const m = String(now.getMonth() + 1).padStart(2, '0')
  const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin'
  const uuid = crypto.randomUUID().replace(/-/g, '')
  return `${prefix}${y}/${m}/${uuid}.${ext}`
}

function baseUrl(): string {
  const custom = import.meta.env.VITE_COS_BASE_URL as string | undefined
  if (custom) return custom.replace(/\/$/, '')
  return `https://${import.meta.env.VITE_COS_BUCKET}.cos.${import.meta.env.VITE_COS_REGION}.myqcloud.com`
}

/** 上传文件到 COS,返回完整访问 URL */
export async function uploadToCos(file: File): Promise<string> {
  const c = await getClient()
  const key = buildKey(sts!.keyPrefix, file)
  await c.putObject({
    Bucket: sts!.bucket,
    Region: sts!.region,
    Key: key,
    Body: file,
    ContentType: file.type,
  })
  return `${baseUrl()}/${key}`
}

# 4. 各上传组件切换 http-request

商品页有 4 处上传,把原来的 uploadFile(file) 换成 uploadToCos(file),其余逻辑(回填 URL、loading、图片预览)不变:

位置 处理函数 改动
封面 handleCoverUpload uploadToCos → 存 form.coverImage
多图 handleGalleryUpload uploadToCos → 挂到 galleryFiles 项 url
视频 handleVideoUpload uploadToCos → 存 form.video
SKU 封面 skuCoverUploadHandler uploadToCos → 写对应行 cover

统一把 uploadFile 的调用替换掉即可,beforeImageUpload / beforeVideoUpload 的大小/类型校验保留,成为前端直传的前置校验。

商品页以外若还有上传入口(后续轮播图、公告等),直接复用 uploadToCos。


# 四、验证清单

  1. 后端 GET /api/admin/upload/sts 返回 6 个字段(含 bucket/region/keyPrefix);无 common:upload 权限返回 403。
  2. 前端未配置 CORS 时上传报跨域错 → 配好桶 CORS 后消失。
  3. 商品页封面/多图/视频/SKU 封面上传成功,返回 https://... 完整 URL。
  4. 浏览器直接访问该 URL 正常显示(公有读桶)。
  5. 数据库商品字段存的是完整 URL,列表/详情页图片正常。
  6. 临时密钥过期后继续上传会自动重新签发(getClient 提前 60s 续签)。
  7. 前端代码里搜不到永久密钥;STS 只授予 uploads/* 前缀的上传权限(试一下直传桶根目录或其他前缀应失败)。

# 五、风险与取舍

  • 密钥安全:永久密钥只存后端;前端只有临时密钥,过期自动失效。若泄露,可到 CAM 吊销并重签。
  • 服务端看不到文件:大小/类型校验退化为前端前置校验,恶意客户端可绕过——学习项目可接受;严格场景可后续接「后端验签名 + COS 事件触发校验/审核」。
  • 私有桶的图片展示:若桶设私有读,<img> 直接访问会 403,需配 CDN 回源鉴权或生成预签名 URL。简单场景直接用「公有读私有写」。
  • 防盗链:公有读桶可配「防盗链 Referer 白名单」,防止图片被第三方站点盗用(可能误伤部分合法场景,需测试)。
  • 对象 key 可读性:用 UUID 命名保证唯一;需排查时 objectKey 与本地历史结构一致(uploads/yyyy/MM/uuid.ext),历史数据迁移只需替换域名前缀、无需改库。

# 参考链接