通常我们在开发项目的过程中,文件上传基本是绕不开的需求。最常用的方法是前端把文件传给后端,后端再转发到 OSS——但这样文件数据要跑两趟网络,既浪费带宽又拖慢速度。前端直传的思路很简单:让浏览器直接把文件传到 OSS,后端只负责生成一个签名凭证就行了,上传流量完全不经过自己的服务器。这篇文章会从原理到代码,把整个方案讲清楚。
一、为什么需要前端直传?
先看两种方式的对比:
传统方式:
浏览器 ──文件──> 后端服务器 ──文件──> OSS
(两次传输,后端是瓶颈)
前端直传:
浏览器 ──请求凭证──> 后端服务器
──文件直传──> OSS
(文件只传一次,后端零压力)
| 对比项 | 传统上传 | 前端直传 |
|---|---|---|
| 文件传输次数 | 2 次 | 1 次 |
| 后端带宽压力 | 大(所有文件经过) | 零(只传凭证) |
| 后端内存压力 | 大(需缓存文件) | 零 |
| 上传速度 | 受后端带宽限制 | 直连 OSS,速度更快 |
| 安全性 | 后端控制 | 签名策略控制,同样安全 |
简单说,前端直传的核心好处就是:后端不再做流量的搬运工,只做签名的签发者。
二、阿里云 OSS PostObject 原理
前端直传用的是 OSS 的 PostObject 接口,通过 FormData 表单把文件上传到 OSS。参考以下官方文档
2.1 PostObject 请求长什么样?
POST / HTTP/1.1
Host: your-bucket.oss-cn-hangzhou.aliyuncs.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="key"
video/movie/123/uuid.mp4
------WebKitFormBoundary
Content-Disposition: form-data; name="policy"
eyJleHBpcmF0aW9uIjoiMjAyNS0wMS0wMVQxMjowMDowMFoiLCAiY29uZGl0aW9ucyI6W119
------WebKitFormBoundary
Content-Disposition: form-data; name="x-oss-signature-version"
OSS4-HMAC-SHA256
------WebKitFormBoundary
Content-Disposition: form-data; name="x-oss-credential"
LTAI5t.../20250101/cn-hangzhou/oss/aliyun_v4_request
------WebKitFormBoundary
Content-Disposition: form-data; name="x-oss-date"
20250101T120000Z
------WebKitFormBoundary
Content-Disposition: form-data; name="x-oss-signature"
a3f5b8c2d1e4...
------WebKitFormBoundary
Content-Disposition: form-data; name="success_action_status"
200
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="video.mp4"
Content-Type: video/mp4
<文件二进制数据>
------WebKitFormBoundary--
可以看到,除了 file 是文件本身,其他字段都是签名相关的信息。
2.2 V4 签名流程
阿里云 OSS V4 签名(OSS4-HMAC-SHA256)的生成过程:
1. 构造 Credential:{accessKeyId}/{date}/{region}/oss/aliyun_v4_request
2. 构造 Policy JSON:包含过期时间、文件大小限制、目录限制等条件
3. Base64 编码 Policy
4. 派生签名密钥(4步):
kSecret = "aliyun_v4" + AccessKeySecret
kDate = HMAC-SHA256(kSecret, date)
kRegion = HMAC-SHA256(kDate, region)
kService = HMAC-SHA256(kRegion, "oss")
kSigning = HMAC-SHA256(kService, "aliyun_v4_request")
5. 计算签名:HMAC-SHA256(kSigning, Base64(policy))
⚠️ 关键点:V4 签名必须由后端生成,因为 AccessKeySecret 绝对不能暴露给前端。后端生成签名后,前端只需把签名和策略一起 POST 到 OSS 即可。
三、后端要点:生成上传凭证
后端的核心职责就是生成签名凭证,这里不贴完整代码了,只讲几个必须注意的要点,踩中任何一个都会导致前端上传失败。
3.1 返回字段必须与前端 FormData 一一对应
这是最容易踩坑的地方。后端返回的凭证字段,前端在 FormData 中必须原样带上,字段名和值都不能差一个字符:
| 后端返回字段 | 前端 FormData 字段名 | 说明 |
|---|---|---|
policy | policy | Base64 编码的策略,必须一致 |
signature | x-oss-signature | ⚠️ 注意字段名不同!后端叫 signature,前端叫 x-oss-signature |
xossSignatureVersion | x-oss-signature-version | ⚠️ 同样,前端用连字符格式 |
xossCredential | x-oss-credential | ⚠️ 同上 |
xossDate | x-oss-date | ⚠️ 同上 |
dir + fileName | key | 文件在 OSS 中的完整路径 |
💡 经验教训:我们项目一开始后端返回的字段名用的是驼峰(
xossSignatureVersion),前端 FormData 需要用连字符(x-oss-signature-version),这个映射关系如果搞错,OSS 就会返回SignatureDoesNotMatch。建议在后端接口文档中明确标注前端 FormData 对应的字段名。
3.2 Policy conditions 必须覆盖前端所有字段
Policy 中的 conditions 是安全的核心,它定义了前端上传时必须遵守的规则。如果 Policy 中声明了某个条件,但前端 FormData 中没有对应的字段(或值不匹配),OSS 就会拒绝请求:
| 条件 | 含义 | 作用 |
|---|---|---|
content-length-range | 限制文件大小范围 | 防止上传超大文件 |
starts-with $Content-Type | 限制文件类型前缀 | 只允许上传视频文件 |
eq $x-oss-signature-version | 签名版本精确匹配 | 防止签名降级攻击 |
eq $x-oss-credential | 凭证精确匹配 | 防止凭证替换 |
eq $x-oss-date | 日期精确匹配 | 防止重放攻击 |
starts-with $key | 文件路径前缀匹配 | 限制上传目录 |
⚠️ 特别注意:如果后端 Policy 中写了
eq $x-oss-signature-version "OSS4-HMAC-SHA256",前端 FormData 中就必须带上x-oss-signature-version: OSS4-HMAC-SHA256,少一个字段都会报AccessDenied。
3.3 V4 签名密钥派生步骤
签名密钥的派生是固定的 4 步,不能跳步、不能换顺序:
kSecret → HMAC-SHA256("aliyun_v4" + AccessKeySecret)
kDate → HMAC-SHA256(kSecret, "20250101") // yyyyMMdd 格式
kRegion → HMAC-SHA256(kDate, "cn-hangzhou") // OSS region
kService → HMAC-SHA256(kRegion, "oss")
kSigning → HMAC-SHA256(kService, "aliyun_v4_request")
最终签名 = hex(HMAC-SHA256(kSigning, Base64(policy)))。
3.4 文件名用 UUID 生成
后端应该用 UUID 生成文件名,而不是使用用户上传的原始文件名,原因:
- 避免文件名冲突(两个用户上传同名文件)
- 避免路径遍历攻击(文件名中包含
../) - 避免特殊字符导致的 OSS 错误
3.5 凭证有效期
建议设置 30 分钟有效期(1800 秒),太短用户上传大文件时可能过期,太长则安全风险增加。
四、前端实现:直传上传
4.1 API 层定义
// src/api/oss.ts
/**
* OSS上传凭证响应
*/
export interface OssUploadPolicyVO {
accessId: string;
policy: string;
signature: string;
dir: string;
fileName: string;
host: string;
expire: number;
xossSignatureVersion: string;
xossCredential: string;
xossDate: string;
}
/**
* OSS上传凭证请求
*/
export interface OssUploadPolicyRequest {
movieId: number;
fileSize: number;
fileExt: string;
}
export const ossApi = {
/** 获取OSS前端直传凭证 */
getUploadPolicy: async (
data: OssUploadPolicyRequest
): Promise<OssUploadPolicyVO> => {
return request.post<OssUploadPolicyVO>("/oss/upload/policy", data);
},
};
4.2 核心上传方法
这是前端直传最关键的代码,用 FormData 把签名信息和文件一起 POST 到 OSS:
// 引入项目已封装的 axios 实例
import axios from "@/utils/request";
/**
* 简单上传到 OSS(小文件使用)
* 使用 OSS PostObject 签名策略一次性上传整个文件
*
* @param policy - 后端返回的上传凭证
* @param file - 要上传的文件
* @param onProgress - 上传进度回调
*/
const uploadVideoToOssSimple = async (
policy: OssUploadPolicyVO,
file: File,
onProgress?: (percent: number) => void
): Promise<void> => {
const formData = new FormData();
// ⚠️ 字段名必须与后端 Policy conditions 完全对应!
formData.append("key", policy.dir + policy.fileName); // 文件在 OSS 中的完整路径
formData.append("x-oss-credential", policy.xossCredential); // V4 签名凭证
formData.append("x-oss-date", policy.xossDate); // 签名日期
formData.append("x-oss-signature-version", policy.xossSignatureVersion); // 签名版本
formData.append("x-oss-signature", policy.signature); // 签名值
formData.append("policy", policy.policy); // Base64 编码的策略
formData.append("success_action_status", "200"); // 成功返回 200
formData.append("file", file); // ⚠️ 文件必须最后追加!
// 开发环境走 Vite 代理,生产环境直连 OSS
const uploadUrl = import.meta.env.DEV ? "/oss-upload" : policy.host;
// 使用项目已封装的 axios 实例上传文件
// axios 底层基于 XMLHttpRequest,原生支持上传进度监听
await axios.post(uploadUrl, formData, {
// 上传文件时不需要设置 Content-Type,让浏览器自动处理 FormData 的 boundary
headers: { "Content-Type": "multipart/form-data" },
// 监听上传进度
onUploadProgress: (e) => {
if (e.total) {
const percent = (e.loaded / e.total) * 100;
onProgress?.(percent);
}
},
// 5分钟超时
timeout: 5 * 60 * 1000,
});
};
4.3 完整业务流程
实际业务中,直传通常分三步走:获取凭证 → 创建业务记录 → 上传文件:
/**
* 简单上传完整流程
* 获取凭证 → 创建业务记录 → 上传文件到 OSS
*/
const handleSimpleUpload = async () => {
const fileToUpload = compressedFile.value || videoFile.value;
if (!fileToUpload) return;
try {
simpleUploading.value = true;
simpleUploadProgress.value = 0;
// ====== 第一步:获取上传凭证 ======
simpleUploadStatusText.value = "正在获取上传凭证...";
const fileExt = fileToUpload.name
.substring(fileToUpload.name.lastIndexOf(".") + 1)
.toLowerCase();
const ossPolicy = await ossApi.getUploadPolicy({
movieId: form.movieId || 0,
fileSize: fileToUpload.size,
fileExt: fileExt,
});
simpleUploadProgress.value = 10;
// ====== 第二步:创建业务记录 ======
simpleUploadStatusText.value = "正在创建记录...";
// ⚠️ ossPath 只存业务路径,不含 host 前缀
// 例如:video/movie/123/uuid.mp4
const videoId = await videoApi.create({
filename: form.filename,
ossPath: ossPolicy.dir + ossPolicy.fileName,
fileSize: fileToUpload.size,
duration: videoDuration.value,
movieId: form.movieId,
videoType: form.videoType,
episode: form.episode,
});
simpleUploadProgress.value = 20;
// ====== 第三步:上传文件到 OSS ======
simpleUploadStatusText.value = "正在上传视频...";
await uploadVideoToOssSimple(ossPolicy, fileToUpload, (percent) => {
simpleUploadProgress.value = 20 + percent * 0.8;
});
simpleUploadProgress.value = 100;
message.success("上传成功");
} catch (error: any) {
message.error(error.message || "上传失败");
} finally {
simpleUploading.value = false;
}
};
五、开发环境代理配置
本地开发时,浏览器直接请求 OSS 会遇到跨域问题。最简单的方案是通过 Vite 代理转发:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
"/oss-upload": {
target: "https://your-bucket.oss-cn-hangzhou.aliyuncs.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^/oss-upload/, ""),
},
},
},
});
前端代码中根据环境变量切换上传地址:
const uploadUrl = import.meta.env.DEV ? "/oss-upload" : policy.host;
六、OSS 跨域(CORS)配置
前端直传要求 OSS Bucket 配置正确的 CORS 规则,否则浏览器会直接拦截请求。
阿里云 OSS 控制台 → Bucket → 权限管理 → 跨域设置:
| 配置项 | 值 |
|---|---|
| 允许的来源 | * 或你的域名 |
| 允许的方法 | POST, GET, PUT |
| 允许的 Headers | * |
| 暴露的 Headers | ETag, x-oss-request-id |
| 缓存时间 | 600 |
⚠️ 暴露 ETag 头这步别漏了!如果不配置,分片上传时通过 axios 的
response.headers["ETag"]会返回null,导致无法完成合并。虽然直传场景下不需要 ETag,但建议统一配置,省得后续切分片上传时又踩坑。
七、常见踩坑与解决方案
7.1 SignatureDoesNotMatch 签名不匹配
这是直传中最常见的错误,90% 的原因是前后端字段对不上。
排查清单:
- ✅
x-oss-signature-version必须是OSS4-HMAC-SHA256 - ✅
x-oss-credential必须与后端生成的一字不差 - ✅
x-oss-date必须与后端生成的一致 - ✅
key的前缀必须与 Policy 中的starts-with $key匹配 - ✅
file字段必须最后 append(这是 OSS PostObject 的硬性要求)
7.2 AccessDenied 拒绝访问
通常是 Policy 过期或条件不满足:
- ✅ 检查凭证是否过期(默认 30 分钟有效期)
- ✅ 检查文件大小是否超过 Policy 中的
content-length-range - ✅ 检查文件类型是否匹配
starts-with $Content-Type - ✅ 检查上传路径是否匹配
starts-with $key
7.3 开发环境跨域问题
现象:浏览器控制台报 CORS policy: No 'Access-Control-Allow-Origin' 错误。
解决方案:
- 配置 Vite 代理(推荐,最省事)
- 或在 OSS 控制台配置 CORS 规则
7.4 file 字段必须最后追加
这是 OSS PostObject 的硬性要求。如果 file 不是最后一个字段,OSS 会返回 MalformedPOSTRequest 错误:
// ✅ 正确:file 最后追加
formData.append("key", ...);
formData.append("policy", ...);
formData.append("file", file); // 最后
// ❌ 错误:file 不是最后
formData.append("key", ...);
formData.append("file", file); // 不是最后
formData.append("policy", ...);
7.5 axios 上传时的注意事项
使用 axios 进行直传时,需要注意以下配置:
await axios.post(uploadUrl, formData, {
// ✅ 上传文件时让 axios 自动设置 Content-Type(包含 boundary)
// 不要手动覆盖为其他值,否则 OSS 无法解析
headers: { "Content-Type": "multipart/form-data" },
// ✅ 使用 onUploadProgress 监听上传进度
onUploadProgress: (e) => {
if (e.total) {
const percent = (e.loaded / e.total) * 100;
onProgress?.(percent);
}
},
// ✅ 设置合理的超时时间(大文件建议 5 分钟以上)
timeout: 5 * 60 * 1000,
});
八、安全设计要点
8.1 为什么不把 AccessKeySecret 放前端?
AccessKeySecret 是阿里云账号的根密钥,泄露后攻击者可以完全控制你的 OSS Bucket。V4 签名的设计就是为了让前端只拿到"一次性凭证",而不是密钥本身。
8.2 Policy 策略的安全作用
Policy 中的 conditions 限制了前端能做什么:
- 目录限制:只能上传到指定目录,不能覆盖其他文件
- 大小限制:不能上传超大文件
- 类型限制:只能上传指定类型的文件
- 时间限制:凭证 30 分钟后过期,不能长期使用
8.3 UUID 文件名
后端使用 UUID 生成文件名,而不是使用用户上传的原始文件名:
- 避免文件名冲突
- 避免路径遍历攻击
- 避免特殊字符导致的 OSS 错误
九、完整流程图
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 浏览器 │ │ 后端 │ │ OSS │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ 1. 请求上传凭证 │ │
│ {movieId, │ │
│ fileSize, │ │
│ fileExt} │ │
│───────────────────>│ │
│ │ │
│ │ 2. 生成 V4 签名 │
│ │ 构造 Policy │
│ │ 派生签名密钥 │
│ │ 计算签名 │
│ │ │
│ 3. 返回凭证 │ │
│ {policy, │ │
│ signature, │ │
│ dir, fileName, │ │
│ host, ...} │ │
│<───────────────────│ │
│ │ │
│ 4. POST 文件到 OSS │ │
│ FormData: │ │
│ key, policy, │ │
│ signature, │ │
│ x-oss-*, file │ │
│────────────────────────────────────────>│
│ │ │
│ 5. 200 OK │ │
│<────────────────────────────────────────│
│ │ │
│ 6. 创建业务记录 │ │
│───────────────────>│ │
│ │ │
十、总结
这篇文章把阿里云 OSS 前端直传的方案完整过了一遍,核心要点就这几条:
- 后端只负责签名:AccessKeySecret 永远不离开后端,前端只拿到一次性凭证
- 前后端字段必须对应:后端 Policy 中声明了哪些条件,前端 FormData 就必须带上对应的字段,差一个都会报签名错误
- Policy 控制安全:通过 conditions 限制上传目录、文件大小、文件类型
- FormData 字段顺序:
file必须最后追加,其他字段必须与 Policy conditions 匹配 - 使用 axios 统一上传:项目已封装 axios 实例,使用
onUploadProgress原生支持进度监听,代码更简洁,与项目其他接口保持一致 - 开发/生产环境切换:开发环境通过 Vite 代理解决跨域,生产环境直连 OSS
- axios 配置注意:上传文件时让 axios 自动处理 Content-Type(包含 boundary),不要手动覆盖
这套方案适用于 5MB 以下的小文件上传场景。对于大文件,需要使用分片上传,我会在下一篇文章中详细介绍。