在现代 Web 应用中,用户上传图片、视频等文件已成为标配功能。如果所有文件都经过后端中转,不仅增加服务器带宽压力,还可能导致性能瓶颈。阿里云 OSS(Object Storage Service)提供了强大的对象存储能力,支持前端直传,真正做到“上传不走后端”。本文将带你深入实践使用 Post Policy 表单上传 实现前端直传,并对比 STS Token 模式,分析其优劣。同时,我们将展示如何通过 Nacos 动态配置管理 OSS 参数。

使用Policy 表单进行上传

优点:官方文档

  • 使用插件(如,element upload, web uploader等)上传,不用重写上传方法,通过地址提交,可以方便的使用插件提供的功能。

使用STS临时凭证进行上传

优点:官方文档

  • 配合ali-oss能方便进行大文件的分片上传等功能。

前端直传 + 后端签发 Policy

我们采用 OSS 表单上传(Post Object) 方式,流程如下:

Nacos 动态配置管理

为了使系统更加灵活和可维护,我们可以利用 Nacos 进行动态配置管理。下面是一个简单的 OSS 配置类示例,它可以从 Nacos 的 YAML 文件中注入配置,并初始化 OSS 客户端。

@Configuration
@Data
@RefreshScope // 当 Nacos 配置变化时,自动刷新此 Bean
public class OssConfig {

    @Value("${spring.cloud.alicloud.oss.endpoint}")
    private String endpoint;

    @Value("${spring.cloud.alicloud.oss.bucket}")
    private String bucket;

    @Value("${spring.cloud.alicloud.access-key}")
    private String accessId;

    @Value("${spring.cloud.alicloud.secret-key}")
    private String accessKey;

    /**
     * 创建 OSS 客户端
     */
    public OSSClient initClient() {
        OSS client = new OSSClientBuilder().build(endpoint, accessId, accessKey);
        return (OSSClient)client;
    }

    /**
     * 查询存储桶是否存在 例如:传入参数examplebucket-1250000000,返回true代表存在此桶
     */
    public boolean doesBucketExist(OSSClient client, String bucketName) {
        try {
            return client.doesBucketExist(bucketName);
        } catch (OSSException | ClientException e) {
            throw new RRException(e.getMessage());
        }
    }
}

后端:签发上传策略(Policy)

/**
 * 三方文件上传
 */
@RequestMapping("/oss")
@RestController
public class ThirdFileController {
    @Autowired
    private OssConfig ossConfig;

    // policy规定了请求表单域的合法性。不包含policy表单域的请求被认为是匿名请求,并只能访问public-read-write的Bucket。
    @PostMapping("/policy")
    public R policy(@RequestBody FileUploadForm form) {
        String bucket = ossConfig.getBucket();
        String endpoint = ossConfig.getEndpoint();

        OSSClient ossClient = ossConfig.initClient();
        Assert.isTrue(EnumUtil.contains(FileTypeEnum.class, form.getFileType()), "文件类型错误");
        Assert.isTrue(EnumUtil.contains(BizModuleNameEnum.class, form.getBizModuleName()), "业务类型错误");

        // 这里最好报错误代码
        Assert.isTrue(ossConfig.doesBucketExist(ossClient, ossConfig.getBucket()), "存储桶不存在");
        String host = "https://" + bucket + "." + endpoint; // host的格式为 bucketname.endpoint
        // 用户上传文件时指定的前缀
        String objectName = form.getBizModuleName() + "/" + form.getFileType() + "/" + new SimpleDateFormat("yyyy/MM/dd").format(new Date()) + UUID.randomUUID();

        Map<String, String> resJsonMap = null;
        long expireTime = 30;
        long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
        Date expiration = new Date(expireEndTime);
        PolicyConditions policyConds = new PolicyConditions();
        policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
        // 设置上传成功后的回调地址【测试时可内网穿透】
        // policyConds.addConditionItem(PolicyConditions.COND_SUCCESS_ACTION_REDIRECT, "xxxxx/oss/uploadCallBack");
        policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, objectName);
        // 限制上传的文件为指定的图片类型

        policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_CONTENT_TYPE, "image/");
        // 配置文件名称
        String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
        // 表单和policy必须使用UTF-8编码,且policy表单域要经过Base64编码。
        byte[] binaryData = null;
        binaryData = postPolicy.getBytes(StandardCharsets.UTF_8);
        String encodedPolicy = BinaryUtil.toBase64String(binaryData);
        // signature是指在使用PostObject上传方式时,为保证上传请求的安全性,OSS要求每个上传请求都携带一个签名(Signature),用于确保上传请求的合法性和安全性
        String postSignature = ossClient.calculatePostSignature(postPolicy);

        resJsonMap = new HashMap<>();
        resJsonMap.put("accessid", ossConfig.getAccessId());
        resJsonMap.put("policy", encodedPolicy);
        resJsonMap.put("signature", postSignature);
        resJsonMap.put("dir", objectName);
        resJsonMap.put("host", host);
        resJsonMap.put("expire", String.valueOf(expireEndTime / 1000));

        ossClient.shutdown();
        return R.ok().put("data", resJsonMap);
    }

    // 获取预签名链接
    @GetMapping("/getPreSignedUrl")
    public R getPreSignedUrl(@RequestParam String objectName) {
        OSSClient ossClient = ossConfig.initClient();
        String url = ossClient.generatePresignedUrl(ossConfig.getBucket(), objectName, new Date(System.currentTimeMillis() + 3600 * 1000)).toString();
        ossClient.shutdown();
        return R.ok().put("data", url);
    }

前端: 直传 OSS

使用: <single-upload v-model="dataForm.logo"></single-upload>

<template>
  <div>
    <el-upload
      class="upload-demo"
      action="#"
      :on-preview="handlePreview"
      :on-remove="handleRemove"
      :file-list="fileList"
      :before-upload="onBeforeUpload"
      :limit="1"
      :on-success="onSuccess"
      list-type="picture">
      <el-button size="small" type="primary">点击上传</el-button>
      <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过5M</div>
    </el-upload>
  </div>
</template>

<script>
import {policy} from "./policy";
import http from "../../utils/httpRequest.js";

export default {
  data() {
    return {
      fileList: [],
      currentFile: {},
      policyData: {},
      isValueInitialized: false // 标记 value 是否已初始化,防止重复处理
    };
  },
  props: {
    value: {}
  },
  watch: {
    value: {
      handler(newVal) {
        if (this.isValueInitialized) return;
        this.isValueInitialized = true;

        if (newVal && newVal !== '') {
          this.fetchAndSetFile(newVal);
        } else {
          this.fileList = [];
        }
      }
    }
  },
  methods: {
    handleRemove(file, fileList) {
      this.fileList.pop();
      this.$emit('input', '');
    },

    handlePreview(file) {
      // 预览逻辑(当前无实际操作)
    },

    onBeforeUpload(file) {
      const isJPG = file.type === 'image/jpeg';
      const isPNG = file.type === 'image/png';
      const isLt5M = file.size / 1024 / 1024 < 5;

      if (!isJPG && !isPNG) {
        this.$message.error('上传图片只能是 JPG 或 PNG 格式!');
        return false;
      }
      if (!isLt5M) {
        this.$message.error('上传图片大小不能超过 5MB!');
        return false;
      }

      this.currentFile = file;
      if (this.fileList.length < 5) {
        this.getPolicy(file);
      } else {
        this.$message.warning("最多可上传5张图片");
      }
    },

    onSuccess() {
      // 上传成功钩子(当前无逻辑)
    },

    // 获取预签名 URL 并更新 fileList
    fetchAndSetFile(objectName) {
      const url = http.adornUrl(`/thirdpart/oss/getPreSignedUrl?objectName=${objectName}`);
      fetch(url, {method: 'GET'})
        .then(res => res.json())
        .then(resp => {
          this.fileList.splice(0);
          this.$nextTick(() => {
            this.fileList.push({
              url: resp.data,
              name: 'logo'
            });
          });
        })
        .catch(() => {
          this.fileList = []; // 失败时清空
        });
    },

    // 获取上传策略并执行上传
    getPolicy(file) {
      policy({
        bizModuleName: "product",
        fileType: "image",
      })
        .then(res => {
          const formData = new FormData();
          const dir = res.data.dir;

          // 构造表单数据用于 OSS 上传
          formData.append('name', dir);
          formData.append('policy', res.data.policy);
          formData.append('OSSAccessKeyId', res.data.accessid);
          formData.append('success_action_status', '200');
          formData.append('signature', res.data.signature);
          formData.append('key', dir);
          formData.append('file', file);

          // 执行上传
          fetch(res.data.host, {method: 'POST', body: formData})
            .then(() => this.fetchAndSetFile(dir)) // 上传成功后获取预签名链接并更新
            .catch(() => {
              this.$emit('input', '');
            });
        })
        .catch(() => {
          this.$message.error('获取上传策略失败');
          this.$emit('input', '');
        });
    }
  }
};
</script>

💡 为什么说“前端直传不安全”是个伪命题?

很多开发者一听“前端上传”,第一反应就是:

“啊?那不是要把AccessKey写在前端?这不是白送黑客吗?”

——错!大错特错!

 误解1:必须把AK/SK放前端?

真相:你根本不需要把长期密钥暴露在前端!

阿里云OSS提供多种安全上传方式,最常用的是:

✅ 方式一:Post Policy(表单上传)
  • 后端用AK/SK生成一个带策略(Policy) 的签名(Signature)。
  • 签名中包含上传限制条件(如路径、大小、过期时间等)。
  • 前端只拿到这个签名,发起POST请求上传文件。
  • 密钥不暴露,安全性高
✅ 方式二:STS临时凭证(Security Token Service)
  • 后端调用STS服务,获取一个临时Token(含AccessKeyID、Secret、Token)。
  • 有效期可设为几分钟到几小时。
  • 前端用这个临时凭证上传,过期自动失效。
  • 即使泄露,影响极小

📌 所以,说“前端上传=密钥泄露”=完全不懂OSS机制!


误解2:前端能随意上传任意文件?

真相:有 Policy 在,前端说了不算!

Policy 是一段JSON,定义了严格的上传规则,例如:

{
  "expiration": "2025-12-31T12:00:00.000Z",
  "conditions": [
    ["eq", "$key", "uploads/2025/user123/avatar.jpg"],
    ["content-length-range", 0, 5242880],
    ["eq", "$x-oss-forbid-overwrite", "true"]
  ]
}

这意味着:

  • 只能上传到指定路径 ✅
  • 文件大小不能超过5MB ✅
  • 不允许覆盖已有文件 ✅

👉 哪怕你拿到签名,也只能在这个“沙盒”里操作,超出范围直接被OSS拒绝!


❌ 误解3:一个签名能无限次使用?

真相:通过Policy可以实现“类一次性”效果!

比如你想让用户上传头像 avatar.jpg,你可以设置:

  • key 固定为 users/${userId}/avatar.jpg
  • x-oss-forbid-overwrite: true → 禁止覆盖
  • 过期时间5分钟

结果就是:

  • 用户只能上传一次(第二次会因“禁止覆盖”失败)
  • 即使别人拿到签名,也只能上传这个文件一次
  • 5分钟后签名自动失效

🎯 这不就是“事实上的单次有效”

误解4:上传后端不知道?没法校验?

真相:OSS支持“上传回调(Callback)”!

你可以在上传请求中加入:

Callback: https://your-api.com/oss-callback
Callback-Body: filename=${object}&size=${size}&mimeType=${mimeType}

文件一上传成功,OSS就会自动调用你的接口,通知结果,无需前端再“汇报”。

🎯 场景:用户上传头像 → OSS回调你的服务 → 你异步下载并校验图片宽高 → 存入数据库 → 返回结果

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐