阿里云 OSS 文件前端直传实战:从原理到完整实现

6 阅读11分钟

通常我们在开发项目的过程中,文件上传基本是绕不开的需求。最常用的方法是前端把文件传给后端,后端再转发到 OSS——但这样文件数据要跑两趟网络,既浪费带宽又拖慢速度。前端直传的思路很简单:让浏览器直接把文件传到 OSS,后端只负责生成一个签名凭证就行了,上传流量完全不经过自己的服务器。这篇文章会从原理到代码,把整个方案讲清楚。


一、为什么需要前端直传?

先看两种方式的对比:

传统方式:
浏览器 ──文件──> 后端服务器 ──文件──> OSS
         (两次传输,后端是瓶颈)

前端直传:
浏览器 ──请求凭证──> 后端服务器
       ──文件直传──> OSS
         (文件只传一次,后端零压力)
对比项传统上传前端直传
文件传输次数2 次1 次
后端带宽压力大(所有文件经过)零(只传凭证)
后端内存压力大(需缓存文件)
上传速度受后端带宽限制直连 OSS,速度更快
安全性后端控制签名策略控制,同样安全

简单说,前端直传的核心好处就是:后端不再做流量的搬运工,只做签名的签发者


二、阿里云 OSS PostObject 原理

前端直传用的是 OSS 的 PostObject 接口,通过 FormData 表单把文件上传到 OSS。参考以下官方文档

阿里PostObject 接口

服务端签名直传

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 字段名说明
policypolicyBase64 编码的策略,必须一致
signaturex-oss-signature⚠️ 注意字段名不同!后端叫 signature,前端叫 x-oss-signature
xossSignatureVersionx-oss-signature-version⚠️ 同样,前端用连字符格式
xossCredentialx-oss-credential⚠️ 同上
xossDatex-oss-date⚠️ 同上
dir + fileNamekey文件在 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*
暴露的 HeadersETag, 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' 错误。

解决方案

  1. 配置 Vite 代理(推荐,最省事)
  2. 或在 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 前端直传的方案完整过了一遍,核心要点就这几条:

  1. 后端只负责签名:AccessKeySecret 永远不离开后端,前端只拿到一次性凭证
  2. 前后端字段必须对应:后端 Policy 中声明了哪些条件,前端 FormData 就必须带上对应的字段,差一个都会报签名错误
  3. Policy 控制安全:通过 conditions 限制上传目录、文件大小、文件类型
  4. FormData 字段顺序file 必须最后追加,其他字段必须与 Policy conditions 匹配
  5. 使用 axios 统一上传:项目已封装 axios 实例,使用 onUploadProgress 原生支持进度监听,代码更简洁,与项目其他接口保持一致
  6. 开发/生产环境切换:开发环境通过 Vite 代理解决跨域,生产环境直连 OSS
  7. axios 配置注意:上传文件时让 axios 自动处理 Content-Type(包含 boundary),不要手动覆盖

这套方案适用于 5MB 以下的小文件上传场景。对于大文件,需要使用分片上传,我会在下一篇文章中详细介绍。