# 文件存储
# 概述
采用前端直传方案:后端签发 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. 子账号永久密钥(只存在后端)
永久密钥只用于后端签发临时密钥,永不发给前端。
- 访问管理 CAM → 用户 → 新建用户 → 自定义创建 → 访问方式勾选「编程访问」。
- 权限策略:勾选
QcloudCOSDataFullControl(对象存储全读写);更严格可按策略语法自建,限定单个 bucket:{ "version": "2.0", "statement": [{ "effect": "allow", "action": ["cos:PutObject", "cos:GetObject"], "resource": "qcs::cos:ap-shanghai:uid/1250000000:mall-1250000000/*" }] } - 创建完成后保存 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。
# 四、验证清单
- 后端
GET /api/admin/upload/sts返回 6 个字段(含 bucket/region/keyPrefix);无common:upload权限返回 403。 - 前端未配置 CORS 时上传报跨域错 → 配好桶 CORS 后消失。
- 商品页封面/多图/视频/SKU 封面上传成功,返回
https://...完整 URL。 - 浏览器直接访问该 URL 正常显示(公有读桶)。
- 数据库商品字段存的是完整 URL,列表/详情页图片正常。
- 临时密钥过期后继续上传会自动重新签发(
getClient提前 60s 续签)。 - 前端代码里搜不到永久密钥;STS 只授予
uploads/*前缀的上传权限(试一下直传桶根目录或其他前缀应失败)。
# 五、风险与取舍
- 密钥安全:永久密钥只存后端;前端只有临时密钥,过期自动失效。若泄露,可到 CAM 吊销并重签。
- 服务端看不到文件:大小/类型校验退化为前端前置校验,恶意客户端可绕过——学习项目可接受;严格场景可后续接「后端验签名 + COS 事件触发校验/审核」。
- 私有桶的图片展示:若桶设私有读,
<img>直接访问会 403,需配 CDN 回源鉴权或生成预签名 URL。简单场景直接用「公有读私有写」。 - 防盗链:公有读桶可配「防盗链 Referer 白名单」,防止图片被第三方站点盗用(可能误伤部分合法场景,需测试)。
- 对象 key 可读性:用 UUID 命名保证唯一;需排查时 objectKey 与本地历史结构一致(
uploads/yyyy/MM/uuid.ext),历史数据迁移只需替换域名前缀、无需改库。
# 参考链接
- 腾讯云对象存储官方文档:https://cloud.tencent.com/document/product/436 (opens new window)
- 临时密钥(STS)生成与使用指引:https://cloud.tencent.com/document/product/460/104296 (opens new window)
- 前端直传 SDK
cos-js-sdk-v5:https://github.com/tencentyun/cos-js-sdk-v5 (opens new window) - CORS 跨域配置文档:https://cloud.tencent.com/document/product/436/11459 (opens new window)
← 商品管理