资讯动态

OSS图片上传完整实战:后端签名+前端直传方案详解

发布时间:2026/10/17 4:18:28 来源:尧图企业网站定制
做后台管理系统的人应该都遇到过这个需求图片上传。表面上看就是一个文件选择框加一个进度条但真正落地的时候各种细节能把人磨到没脾气。我自己在做某内部管理系统的后台时就处理过类似场景——前端用 Vue ElementUI后端是 SpringBoot存储选的是阿里云 OSS。这篇就把完整落地过程整理出来从方案选型、后端签名接口、前端上传组件配置再到我实际踩过的坑一次性讲清楚给正在做同类功能的同学一份可以直接参考的版本。这个方案解决什么问题简单说前端选择图片后不经过你自己的应用服务器而是直接传到 OSS由 OSS 负责存储后端只负责生成一份“上传凭证”控制过期时间、目录前缀、文件大小。它适合正在做后台管理、内容发布、用户头像、商品图片等功能的同学参考前端、后端、运维都能从中找到自己关心的那部分。1. 方案选型为什么选“后端签名 前端直传”1.1 三种上传方式的对比与取舍图片上传到 OSS从大的方向看有三种做法后端中转、前端裸传、后端签名加前端直传。我先把三者摆在一起对比再解释为什么最终选第三种。所谓后端中转就是前端把图片转成 Base64 或二进制流丢给后端接口后端再用 OSS SDK 去上传。这个方案实现最简单后端代码十几行就能跑通适合企业内部的小工具、低并发场景。但它有个硬伤图片数据要在应用服务器上过一遍一张 5MB 的图传上来服务器内存和带宽都会被吃掉并发稍微上来一点业务接口就跟图片上传互相抢资源体验很糟。前端裸传更直接把 AccessKey 写在前端上传组件直接指向 OSS 的地址。这种方式开发最快但安全问题非常突出——密钥在前端代码里几乎是裸奔状态任何人打开控制台就能拿到上传凭证恶意刷流量和存储费用是小事更严重的是可能被利用做非法文件分发。我个人的态度很明确不管项目多急这条路都不要走。后端签名加前端直传是业界比较成熟的做法。后端拿着 AccessKeySecret 生成一个带时效的 Policy并对它签名把签名后的参数返回给前端前端把这些参数作为表单字段直接 POST 给 OSS。密钥始终留在后端前端拿到的只是一份“限时、限目录、限大小”的凭证OSS 校验通过后才会接受文件。1.2 核心流程与数据流设计整套流程可以用一个时序来描述。页面准备上传前前端先调用后端接口/api/oss/policy。后端收到请求后生成一个 Policy 字符串——里面包含过期时间、允许的目录前缀、文件大小范围——然后用 AccessKeySecret 做 HMAC-SHA1 签名再把policy、signature、accessid、dir、host、expire这些参数返回给前端。前端拿到参数后构造一个 multipart 表单把key、policy、OSSAccessKeyId、signature、file这些字段按规则拼好直接 POST 到 OSS 的 host 地址。OSS 校验签名合法后保存文件返回200。前端再从返回信息里拼接出图片访问 URL用于回显和提交业务表单。这套流程里应用服务器只参与“发凭证”这一个轻量动作不参与文件传输带宽压力全部在 OSS 侧。这也是它能在生产环境长期稳定跑的根本原因。2. 后端实现SpringBoot 签名接口的完整落地2.1 工程配置与 OSS 客户端初始化后端我用的是 SpringBoot 2.7 Java 8OSS SDK 用的 3.x 系列稳定版。先在pom.xml里引入依赖dependency groupIdcom.aliyun.oss/groupId artifactIdaliyun-sdk-oss/artifactId version3.17.4/version /dependency版本不建议追新3.x 系列经过大量生产环境验证接口稳定相关问题的排查资料也最多。如果有更高版本需求也要先做兼容性测试。接着在配置文件里维护 OSS 的基本信息oss: access-key-id: your-access-key-id access-key-secret: your-access-key-secret endpoint: oss-cn-shenzhen.aliyuncs.com bucket: your-bucket-name这里重点说一下 endpoint 和 bucket 的关系。endpoint 是 OSS 的 API 接入地址比如oss-cn-shenzhen.aliyuncs.com表示华南深圳区域而文件访问 URL 通常是bucket名.endpoint也就是your-bucket-name.oss-cn-shenzhen.aliyuncs.com。很多初学者在这里搞混签名发到错误地址一直报 403。我习惯提供一个配置属性类来绑定这些值用ConfigurationProperties统一管理Component ConfigurationProperties(prefix oss) public class OssProperties { private String accessKeyId; private String accessKeySecret; private String endpoint; private String bucket; // 省略 getter/setter }后续生成签名、初始化客户端都用这个配置类避免在业务代码里到处写魔法值。2.2 Policy 生成与签名计算的细节上传凭证的核心是 Policy 和 Signature。Policy 是一段 JSON里面描述了这次上传的限制条件Signature 是对 Policy 内容的签名。整个过程可以拆成三步。第一步构造 Policy 的 JSON 结构。我写了一个方法传入目录前缀和过期时间private String buildPolicy(String dir, long expireSeconds) { long expireEndTime System.currentTimeMillis() expireSeconds * 1000; Date expiration new Date(expireEndTime); JSONObject policy new JSONObject(); policy.put(expiration, formatIso8601Date(expiration)); JSONArray conditions new JSONArray(); // 限制必须上传到指定 bucket JSONObject bucketCond new JSONObject(); bucketCond.put(bucket, this.ossProperties.getBucket()); conditions.add(bucketCond); // 限制 key 以指定目录开头 JSONArray keyCond new JSONArray(); keyCond.add(starts-with); keyCond.add($key); keyCond.add(dir); conditions.add(keyCond); // 限制文件大小在 0 到 5MB 之间 JSONArray sizeCond new JSONArray(); sizeCond.add(content-length-range); sizeCond.add(0); sizeCond.add(5 * 1024 * 1024); conditions.add(sizeCond); policy.put(conditions, conditions); return policy.toJSONString(); }需要注意conditions数组的元素有两种形式一种是对象比如{bucket: xxx}另一种是数组比如[starts-with, $key, dir/]。这两种混在一个数组里是允许的但顺序不要乱前面的 bucket 条件、中间的大小条件、后面的 key 前缀条件OSS 解析时都按数组下标逐个判断。条件写错最典型的报错是PolicyConditions violated后面排查章节会展开。第二步把 Policy 字符串做 Base64 编码。注意这里编码的是字符串 UTF-8 字节不是 JSON 对象本身。第三步用 AccessKeySecret 对编码后的字符串做 HMAC-SHA1 签名得到的二进制结果再做一次 Base64最终才是signature字段。完整签名方法如下private String signPolicy(String policyBase64) { try { Mac mac Mac.getInstance(HmacSHA1); SecretKeySpec keySpec new SecretKeySpec( this.ossProperties.getAccessKeySecret().getBytes(StandardCharsets.UTF_8), HmacSHA1 ); mac.init(keySpec); byte[] rawHmac mac.doFinal(policyBase64.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(rawHmac); } catch (Exception e) { throw new RuntimeException(OSS签名计算失败, e); } }全部准备好之后Controller 里把参数汇总返回给前端GetMapping(/api/oss/policy) public MapString, String policy() { String dir blog/ LocalDate.now().format(DateTimeFormatter.ofPattern(yyyy/MM/dd)); long expireSeconds 3600; String policyJson buildPolicy(dir, expireSeconds); String policyBase64 Base64.getEncoder().encodeToString( policyJson.getBytes(StandardCharsets.UTF_8) ); String signature signPolicy(policyBase64); MapString, String result new HashMap(); result.put(accessid, this.ossProperties.getAccessKeyId()); result.put(host, https:// this.ossProperties.getBucket() . this.ossProperties.getEndpoint()); result.put(policy, policyBase64); result.put(signature, signature); result.put(dir, dir); result.put(expire, String.valueOf(expireSeconds)); return result; }2.3 返回参数说明与前端使用方式这里把返回参数整理成一张表方便对照。参数含义前端用途accessidAccessKeyId表单字段 OSSAccessKeyIdhostOSS 的 POST 地址上传组件的 actionpolicyBase64 编码后的上传策略表单字段 policysignature对 policy 的签名表单字段 signaturedir允许上传的目录前缀拼接 key 用expire有效期秒数判断是否需要重新获取凭证前端拿到这些参数后构造的表单字段顺序并不强制但key、policy、OSSAccessKeyId、signature、file这五个是必须的。如果缺少任何一个OSS 都会直接拒绝上传。这里要提醒一句expire一般给 3600 秒足够不要给太长。凭证有效期越长意味着被滥用的窗口越大。用户在上传页停留时间一般不超过几分钟过期后重新调一次接口就行。2.4 安全升级STS 临时凭证替换自签名自签名方案已经能满足多数业务需求AccessKeySecret 不会暴露到前端Policy 也限制了目录和大小。但如果你的系统对安全性要求更高或者需要给多个第三方系统开放上传能力建议升级成 STS 临时凭证方案。STS 的思路是通过 RAM 角色的 AssumeRole 接口换取一组临时凭证包含临时 AccessKeyId、临时 AccessKeySecret 和 SecurityToken有效期可以设置为 15 分钟到 1 小时。前端上传时表单里除了原来的字段还要多带一个x-oss-security-token字段值就是 SecurityToken。相比自签名STS 的优点是临时凭证过期后自动失效即使被截获也无法长期使用同时 RAM 角色可以把权限精确控制到某个 Bucket、某个目录做到最小授权。代价就是后端多一步调用 AssumeRole 的代码前端表单多一个字段整体改造成本不大。生产环境我推荐优先考虑这种方案尤其是项目有等保要求或者对接外部系统时。3. 前端实现Vue ElementUI 上传组件的完整配置3.1 上传组件与必要参数前端基于 Vue 2 ElementUI上传组件用的是el-upload。这里最核心的一点是el-upload默认的action需要指向后端接口但我们的场景里 action 需要指向 OSS 的 host因此 action 属性的值必须在拿到 policy 接口返回后才能确定。模板部分我这样写el-upload :actionuploadAction :datauploadData :before-uploadbeforeUpload :on-successhandleUploadSuccess :on-errorhandleUploadError :on-progresshandleUploadProgress :show-file-listfalse acceptimage/* div classupload-trigger img v-ifform.coverUrl :srcform.coverUrl classcover-preview / i v-else classel-icon-plus/i /div /el-uploadshow-file-list设置为 false是因为我们用自定义预览图来展示已上传图片不使用 ElementUI 自带的文件列表界面更干净。accept限制为图片类型但这只是前端提示真正的类型校验还是要在before-upload里做。script 部分的核心变量data() { return { uploadAction: , uploadData: {}, form: { coverUrl: } } }3.2 文件校验与文件名重命名before-upload是 el-upload 生命周期里最早执行的钩子也是做校验和准备上传参数的理想位置。它对每个文件都会调用一次而且是在文件真正上传之前所以很适合在这里拉取最新的 policy。我这里的实现逻辑是async beforeUpload(file) { const isImage file.type.startsWith(image/) if (!isImage) { this.$message.error(只能上传图片文件) return false } const isLt5M file.size / 1024 / 1024 5 if (!isLt5M) { this.$message.error(图片大小不能超过 5MB) return false } // 生成新的文件名 const suffix file.name.substring(file.name.lastIndexOf(.)) this.generatedFileName Date.now() _ Math.random().toString(36).slice(-6) suffix // 拉取上传凭证 const res await getOssPolicy() const data res.data this.uploadAction data.host this.uploadData { key: data.dir / this.generatedFileName, policy: data.policy, OSSAccessKeyId: data.accessid, signature: data.signature, success_action_status: 200 } return true }文件名校验这里特别说一下很多人不做重命名直接拿用户原始文件名上传中文名在 OSS 里会出现 URL 编码问题同名文件还会互相覆盖。我踩过一次用户传了两张同名但不同内容的图片后者把前者覆盖了数据恢复花了不少功夫。所以无论业务多简单文件名都要重命名建议格式是时间戳 随机字符串 原扩展名保证全局唯一。扩展名白名单也不能省。虽然file.type.startsWith(image/)能拦掉大部分非图片但有些特殊扩展名类型为空所以在后缀校验时再加一道const whiteList [.jpg, .jpeg, .png, .gif, .webp] if (!whiteList.includes(suffix.toLowerCase())) { this.$message.error(仅支持 jpg/jpeg/png/gif/webp 格式) return false }3.3 上传成功后的回显与数据绑定OSS 的 POST 上传成功后如果表单里带了success_action_status 200OSS 会返回一个空的 body。前端不要依赖 response 里的内容去拿文件地址而要自己根据host / key拼接 URL。上传成功回调handleUploadSuccess(response, file) { const url this.uploadAction / this.uploadData.key this.form.coverUrl url this.$message.success(图片上传成功) }这里有个常见的坑uploadData.key在单个文件上传时没问题但如果用户在短时间内连续上传多个文件uploadData会被后续文件的before-upload覆盖掉导致第一个文件的回显地址变成第二个文件的地址。解决办法是给每个文件保存独立的 key或者在before-upload里把 key 挂到 file 对象上。我推荐后一种做法file.ossKey data.dir / this.generatedFileName然后在成功回调里直接用file.ossKey拼接handleUploadSuccess(response, file) { const url this.uploadAction / file.ossKey this.form.coverUrl url }这样多个文件同时上传时互不干扰。3.4 进度条、预览与删除ElementUI 的el-upload自带进度条但是show-file-list设为 false 后进度条也没了。我实际的做法是用on-progress回调维护一个上传中的状态按钮区域显示 loading 遮罩传完再还原。这样视觉上更统一也不会和 ElementUI 默认的进度条样式冲突。删除图片的逻辑也要想清楚。从产品角度看删除有两种含义只是把页面上的 URL 清掉还是真要从 OSS 上把文件删掉。前者很简单直接清空form.coverUrl即可后者需要后端提供一个删除接口内部调用 OSS SDK 的deleteObject方法。我建议根据业务决定如果要真删接口里要做权限校验防止任何人传个 key 就把别人的文件删了。预览功能用el-image的preview-src-list就能实现图片上传成功后把 URL 加到预览列表里点击放大查看体验很好。4. 常见问题与排查技巧实录4.1 403 Forbidden 家族问题上传图片时最常遇到的就是 403但 403 背后原因五花八门。我把实际遇到过的几种整理成表。现象原因解决办法SignatureDoesNotMatch签名算法写错或 AccessKeySecret 不一致确认签名是对 Base64 后的 policy 做 HMAC-SHA1再对结果做 Base64AccessDeniedBucket 权限是私有读写匿名 POST 被拒绝上传本身不需要 Bucket 公共读检查 policy 的 bucket 条件是否正确InvalidAccessKeyIdAccessKeyId 不存在或已禁用检查子账号状态确认 AK/SK 是同一对PolicyConditions violatedpolicy JSON 里 conditions 格式写错检查对象条件和数组条件是否按规范拼接RequestTimeTooSkewed客户端时间与服务器时间偏差过大同步服务器时间或把有效期稍微加长我印象最深的是一次 SignatureDoesNotMatch当时把 HMAC-SHA1 的结果直接 Base64 了实际上应当先对policyBase64字符串本身计算 HMAC二进制结果再 Base64。少做一次编码签名就完全对不上。排查这类问题最好的方法是写一个最小单元测试用固定密钥和固定 policy 验证每次签名结果一致再和官方文档给的示例对照。4.2 跨域 CORS 配置不当前端直传 OSS 时浏览器会发起跨域请求。如果 OSS 控制台没有正确配置 CORS 规则上传会在浏览器层就被拦截报错信息往往是Access to XMLHttpRequest has been blocked。这时从后端日志和 OSS 日志看请求根本没到达 OSS问题纯在前端。解决办法是在 OSS 控制台找到对应 Bucket 的“跨域设置”添加一条规则来源填写你的前端域名本地开发时填http://localhost:端口允许 MethodsGET、POST、PUT、DELETE、HEAD允许 Headers*暴露 HeadersETag允许 Headers 一定要有*因为上传表单里的自定义字段会被浏览器作为请求头处理漏配会导致请求被拒绝。本地开发跨域问题最多建议先在这里排查。4.3 文件上传成功但图片打不开这种问题最隐蔽因为上传流程是通的返回也是 200但图片怎么都访问不了。原因通常是两个。一是 Bucket 权限是私有读写。上传不受影响因为上传用的是签名鉴权但访问 URL 是匿名的私有 Bucket 当然拒绝。解决办法是设置 Bucket 为公共读或者后端生成带签名的 URL。二是 key 路径拼接错误。host和key拼接时如果多了一个斜杠或少了一个斜杠OSS 会把它当成一个新目录来解析自然找不到文件。我建议统一封装一个方法function buildFileUrl(host, key) { return host.replace(/\/$/, ) / key.replace(/^\//, ) }这样无论前后端怎么传拼接结果都是规范的。4.4 凭证过期、并发上传与命名冲突前端页面长时间停留后再上传policy 很可能已经过期。OSS 返回的报错是Invalid according to Policy: Policy expired。解决办法是前端在before-upload里每次重新拉取 policy而不是初始化组件时只拉一次。拉取接口本身很轻每次上传多一次请求没什么压力。并发上传多张图片时如果都用同一个uploadData.key后传的文件会把前面的覆盖掉。这就是我之前说的命名唯一性问题。解决思路很简单每个文件的 key 必须在before-upload里独立生成不能共用组件 data 里的同一个变量。4.5 前后端联调中的高频踩坑点联调阶段我还遇到过一次很典型的问题前端把el-upload的默认 action 指向了后端域名导致上传请求先到了 SpringBootSpringBoot 没有对应接口直接 404。这是因为我对el-upload的理解偏差——它的 action 是表单最终提交的地址不是“先找后端要个地址再去上传”的中间代理。正确做法是必须在拿到 policy 接口返回后把action动态设置成 OSS 的 host。另外一个容易被忽略的问题是后端接口返回结构不一致。前端用 axios 拦截器统一处理了response.data.data的结构但 policy 接口直接返回 Map导致前端取不到数据。联调时最好先约定统一的返回体比如{ code: 0, data: {...}, msg: ok }所有接口都按这个格式返回会省掉大量低级 debug 时间。5. 几件值得养成习惯的事项目上线跑了一段时间后复盘时我发现有几个点如果早期就做好能少踩很多坑。第一AccessKey 管理要严格。AK/SK 一定不要提交到 Git 仓库也不要写在配置文件里硬编码推荐用环境变量、配置中心或者 KMS 托管。建议创建单独的 RAM 子账号只授予目标 Bucket 的上传和读取权限不要直接用主账号密钥。权限粒度能做到“最小够用”就好宁可后续再加权利也不要一开始就给太宽。第二目录规划要提前想清楚。一个 Bucket 里可能会放用户头像、内容封面、商品图、日志截图等不同类型目录建议按业务和日期双层组织比如avatar/2024/06/、blog/2024/06/。这样后续做生命周期管理比如定期清理临时目录会非常方便也能避免所有文件堆在根目录里。第三数据库里存 key不要存完整 URL。完整 URL 里包含 bucket 域名一旦将来换 CDN 或者换自定义域名所有历史数据都要批量更新。只存 key 的话展示层统一拼域名换域名只改一处配置就行。第四善用 OSS 的图片处理能力。回显时可以直接在 URL 后面追加?x-oss-processimage/resize,w_400之类的参数让 OSS 返回压缩后的缩略图能显著降低列表页的加载时间。原图还在 OSS 上需要大图时去掉参数就行。最后再分享一个实操中的心得。整套方案跑通后我又回头把签名逻辑单独抽了一个工具类写了几组固定输入的单元测试。这看起来是小动作但后面前端同事调整参数或者我修改 policy 条件时回归测试直接跑一遍几秒钟就知道有没有把签名写坏。图片上传这种功能最怕的不是代码复杂而是“看起来正常但偶尔失败”的隐蔽问题。把关键环节拆出来做验证比在页面里反复人工点按要可靠得多。这套方案从上线到现在除了偶尔有人传了超大图被前端拦截之外基本没有再因为上传问题被叫去排查过。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑