皮皮舟印岁月

A JOURNAL OF TRAVEL · CODE · MARKETS
第三十七期 · 第 37 号 二〇二六年十月 · 寒露之后

阿里云 OSS 附件上传下载全链路实现

2026-10-10 5 阅读

一句话导读:附件模块代码不多,出事的地方基本都不在代码里。我们这套系统用的是浏览器直传 + 后端签发下载链接 + 数据库只存路径的组合,跑了两年多,中间因为配置漏项翻过车。这篇把控制台怎么配、前端怎么传、后端怎么签、库里怎么存讲一遍,最后重点说说把密钥放在前端之后,你实际承担了什么。文中的域名、Bucket 名和密钥都是占位符。


先说这套东西是干什么的

我们系统里有线路、客户、供应商、员工这几类业务对象,每一个都得挂附件。合同扫描件、设备现场照片、客户交上来的资质材料、员工的证件照,杂七杂八加起来几万个文件。

最省事的做法当然是都塞数据库。真塞进去你就知道了:一张业务表要开十几个附件字段,或者干脆上一个 blob 列把二进制内容存进去。结果就是备份文件一天比一天大,导出报表的时候数据库在那吭哧吭哧搬图片,查询慢得离谱。有个同事算过,我们全库备份里附件占了九成以上的体积,业务数据反而是零头。

所以文件放对象存储,数据库里只留一条索引:这个附件叫什么、在哪个目录。取的时候按索引去拿。

听起来挺简单,真写起来麻烦的地方就三个:

  • 文件别从应用服务器过,直接让浏览器传给对象存储
  • 文件是私有的,下载链接得临时签发
  • 表里到底存什么,才能保证以后换桶的时候不用改数据

前两个是技术选型,第三个是数据设计。第三个没想清楚,前两个做得再漂亮,将来迁移的时候还是得哭。


为什么没让文件走服务器

最开始我们确实想过让文件先落到应用服务器,再由服务器转手传到对象存储。链路是这样:

浏览器  ->  应用服务器(落盘)  ->  对象存储

试了一周就放弃了,三个问题凑在一起实在难受。

文件在服务器磁盘上多待一次,就得管临时文件的清理,不然 /tmp 慢慢就被撑满了。上传一个 100MB 的包,Tomcat 那条连接从头到尾被占着,用户那边进度条在走,服务器这边线程也在那干等。更麻烦的是我们后来上了两台应用服务器做负载,一个请求落在 A 机器上,下一个落在 B 机器上,临时目录还得想办法共享。

直传就把中间那一段摘掉了:

浏览器  ->  对象存储
浏览器  ->  应用服务器(只拿签名、写索引)

服务器只剩两件轻活:上传前给个通行证,传完记一条库。文件数据一个字节都不经过它。带宽省了,连接数省了,多实例也不用操心。

代价也很明确:浏览器手里必须有一把能上传的钥匙。这把钥匙怎么给、给多大权限,后面单独开一节讲。


控制台里那几项配置

建 Bucket

三个选项要留意。

地域跟 ECS、RDS 放同一个。跨地域走公网,延迟高,流量还另算钱;同地域走内网,白名单放通就行。

读写权限选私有。公开读意味着谁拿到 URL 谁就能下,业务附件显然不能这么放。

命名用纯小写加短横线。别用下划线,别用大写,更别用中文。Bucket 名会出现在域名里(https://<bucket>.oss-cn-<region>.aliyuncs.com),不规范的字符会在签名和解析上给你制造一堆麻烦,而且是那种报错信息完全看不出原因的麻烦。

申请 AccessKey

不要用主账号的密钥。主账号那把钥匙能开整个云账号下所有的门,泄露了就是账号级别的损失。

在 RAM 里建个子账号,只给它目标 Bucket 的读写权限,用这个子账号的 AccessKey。这样万一泄露,攻击者能动的也只有这一个桶。

密钥在控制台只完整显示一次,拿到之后立刻存好。我们当时图省事截了个图扔在群里,后来被安全那边点名批评了一次,这事儿别学。

配 CORS

这一步是直传能不能跑起来的前提,也是我们踩过的第一个坑。

浏览器有个跨域限制:页面在 http://your-domain,上传请求打到 https://<bucket>.oss-cn-shanghai.aliyuncs.com,这属于跨域,桶上必须显式允许,否则浏览器直接把请求拦下来,根本不发出去。

我们第一次配的时候,上传按钮点了没反应,进度条不动,控制台干干净净。查了半天以为密钥错了,又怀疑桶名写错了,最后才想起来打开浏览器开发者工具,Network 里那条请求标着 CORS error,连发都没发出去。

一条够用的规则:

配置项 值 说明
来源 Sources http://your-domain 精确到协议和端口,写 * 等于对所有网站开放
允许 Methods PUT、POST、GET、HEAD 上传用 POST,读取用 GET
允许 Headers * 直传会带 Content-Type、x-oss-* 这些头
暴露 Headers ETag、x-oss-request-id 前端要读回 ETag 做校验的话必须暴露
缓存时间 600 秒 预检请求的结果缓存多久

来源那一栏别写 *。写 * 之后,任何一个网页都能拿着这套配置往你桶里传东西。生产环境把实际来源逐个列出来,本地调试临时加一条 http://localhost:8080,调完删掉。

顺带说一句,CORS 配错了的表现和"密钥错""桶名错"很像——都是上传失败、服务端日志没动静。区别在于 CORS 是浏览器拦的,请求压根没出去,所以服务端日志里连一条记录都不会有。以后遇到上传失败,先看浏览器控制台,再看服务端日志,能少走一半弯路。

最后记下这几个参数

配完之后,前后端各要一组,别混:

  • 后端要 endpoint、AccessKeyId、AccessKeySecret、Bucket,用来签下载链接;
  • 前端要 AccessKeyId、AccessKeySecret、上传域名 host,用来在上传前算签名。

AccessKey 前后端都要,这一点就是后面所有安全问题的源头。


数据库里到底存什么

这是整套设计里我改动最多的一处,第一版写完被自己推翻了一次。

表结构最后是这样的:

CREATE TABLE tb_file (
  file_id        BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
  file_code      VARCHAR(64)           COMMENT '业务归属标识',
  file_catalogue VARCHAR(64)           COMMENT '目录,如 contract',
  file_name      VARCHAR(255)          COMMENT '文件名,如 合同_8f3ka92mx1.pdf',
  file_icon      VARCHAR(64)           COMMENT '图标类型',
  file_date      DATETIME              COMMENT '上传时间',
  PRIMARY KEY (file_id)
) COMMENT='附件索引表';

file_code 管归属,回答"这条附件属于哪个业务对象"。同一份合同可能同时挂在几个对象下,所以用它做关联,而不是按业务拆成七八张附件表。

file_catalogue 加 file_name,拼起来正好是桶里的完整 key:contract/合同_8f3ka92mx1.pdf。

file_date 就是列表展示和排序用。

第一版我加了两列,file_url 存完整地址,还有一列存桶名。后来想明白了,这两列是在给自己挖坑。

Bucket 名一旦写进表里,存储位置就焊死在数据上了。将来附件要迁到新桶,或者哪天真要换个云厂商,改代码是一行配置的事,改数据是几百万行记录批量更新,中间还不能出错,还得分批灰度。把存储位置留在代码配置里、表里只留桶内路径,迁移就变成:新桶建好,文件搬过去,配置改一行。

代价是这套逻辑一个对象存储只能对一个桶生效。如果真有多个桶并存的需求,那就补一列 bucket,给个默认值兜底。但那是需求真出现之后才做的事,不是一开始就预留的——预留的那一列,十有八九永远都是默认值。

还有个细节。上传时前端拿到的对象名是"目录/文件名"的完整形式,写库的时候拆成两段分别存:

param.fileCatalogue = filePath.split("/")[0];
param.fileName      = filePath.split("/")[1];

下载时再拼回去:

param.fileName = data.fileCatalogue + "/" + data.fileName;

拆开存的好处很实在:列表页能直接展示文件名,按目录筛选就是一个 where 条件,不用每次都对字符串做切割。


前端直传

通行证是本地算出来的

对象存储不认登录态,它只认签名。前端要上传,就得先在本地造一份凭证,跟文件一起 POST 过去。

凭证三部分:

  • policy:一段 Base64 编码的 JSON,声明这次上传的有效期和约束
  • signature:用 AccessKeySecret 对 policy 做 HMAC-SHA1 的结果
  • OSSAccessKeyId:告诉服务端是哪个 AccessKey 签的

policy 说"允许传多大、传到什么时候",signature 证明"这段 policy 确实由持密钥的人签的"。服务端自己验一遍,过了才收文件。

策略本体:

var policyText = {
  "expiration": "2030-01-01T12:00:00.000Z",   // 策略失效时间
  "conditions": [
    ["content-length-range", 0, 1048576000]   // 单文件上限,约 1GB
  ]
};

content-length-range 是这套方案里唯一能约束上传者行为的条款,务必写。不写,任何拿到密钥的人都能往你桶里灌几个 TB,账单出来的时候你才知道。

签名:

var policyBase64 = Base64.encode(JSON.stringify(policyText));
var bytes = Crypto.HMAC(Crypto.SHA1, policyBase64, accesskey, { asBytes: true });
var signature = Crypto.util.bytesToBase64(bytes);

这里的 expiration 是策略层面的失效时间,一般设得很长——我们直接写到了 2030 年。因为 policy 本身不随请求变化,前端不需要每次重算。真正让链接短期有效的是下载那边的预签名 URL,两者不是一回事,别搞混。

上传器和参数装配

上传用的 plupload,多文件、进度条、HTML5 降级都封装好了,省事。核心是 BeforeUpload 钩子里装配参数:

function set_upload_param(up, filename, ret) {
  g_object_name = g_dirname;
  if (filename != '') {
    suffix = get_suffix(filename);
    calculate_object_name(filename);
  }

  new_multipart_params = {
    'key': g_object_name,
    'policy': policyBase64,
    'OSSAccessKeyId': accessid,
    'success_action_status': '200',
    'signature': signature
  };

  up.setOption({
    'url': host,
    'multipart_params': new_multipart_params
  });

  up.start();
}

四个参数里有两个埋着雷。

success_action_status 必须写成 '200'。对象存储上传成功的默认返回码是 204,而前端判断成功的逻辑通常写的是 info.status == 200。不改这一项,文件老老实实传上去了,页面却告诉你失败,然后用户重复上传,桶里多出一堆重复文件。

key 决定文件在桶里的最终位置,在 BeforeUpload 里动态算,因为 plupload 每次处理的文件名不一样。

对象名怎么拼

规则是"目录 + 原名 + 随机串 + 后缀":

function calculate_object_name(filename) {
  suffix = get_suffix(filename);
  g_object_name = g_dirname + filename.split('.')[0] + "_" + random_string(10) + suffix;
}

那十位随机串是防覆盖的。业务场景里"合同.pdf""照片.jpg"这种名字重复率极高,两个人上传同名文件,后传的就把先传的盖掉了。而数据库里旧记录那个 key 还指向同一个对象,结果就是旧附件的内容变成了新文件——用户点开一看,是别人的合同。这种事出一次就够喝一壶的。

目录前缀不写死在代码里,从按钮的 dir 属性读:

<a id="postfiles" dir="contract" href="javascript:void(0);" class='btn'>开始上传</a>
function get_dirname() {
  dir = $("#postfiles").attr("dir");
  if (dir != '' && dir.indexOf('/') != dir.length - 1) {
    dir = dir + '/';
  }
  g_dirname = dir;
}

这样一份上传脚本能被多个页面复用,每个页面只改 dir 的值,附件就分门别类落到不同目录:

页面 dir 取值 桶内路径
客户资料 client client/营业执照_8f3ka92mx1.jpg
供应商资料 supplier supplier/合作协议_2bx91lpq7z.pdf
员工档案 staff staff/身份证_77mn0qwe5t.png
线路项目文档 project project/竣工图_1ac8vu3z4k.pdf

按业务对象分目录,比按上传时间分(2026/10/)好用得多。迁移、清理、权限收紧都能按目录整体操作。真有按时间归档的需求,dir 里写多级前缀也行。

传完之后

文件传完,前端拿到对象名,紧接着写索引:

FileUploaded: function(up, file, info) {
  if (info.status == 200) {
    insertFileDate(get_uploaded_object_name(file.name));
  }
}
function insertFileDate(filePath) {
  var param = {};
  param.fileCode      = "order" + fileCode;              // 业务归属
  param.fileCatalogue = filePath.split("/")[0];          // 目录
  param.fileName      = filePath.split("/")[1];          // 文件名
  ajaxLoad(MetgodAccount.file_add, param, function(rs) {
    if (rs.success) { table_file.ajax.reload(); }
  }, "POST", false);
}

这里的顺序值得想一想:文件已经躺在桶里了,写库却可能失败。网络抖一下、登录态过期、数据库抽风,任何一种都会留下一个孤儿对象——桶里有文件,库里没记录,用户看不见也删不掉。

反过来先写库再传文件,上传失败就会留一条指向不存在对象的死记录,用户点下载直接报错。

两种顺序都有残留,我们选的是先传后写。孤儿对象代价低,不占业务逻辑,定期扫一遍桶和库的差集就能清掉;死记录是直接暴露给用户的,体验上更难受。真要根治得上两阶段确认或者定期对账任务,那是另一个话题了。


后端的两个接口

依赖

一个包:

<dependency>
    <groupId>com.aliyun.oss</groupId>
    <artifactId>aliyun-sdk-oss</artifactId>
    <version>2.7.0</version>
</dependency>

配置集中在一个类里

域名、密钥、桶名,全部收在一个类里,别散到各个业务代码中去:

public class OssConfigUtils {

    private static String endpoint        = "https://oss-cn-shanghai.aliyuncs.com";
    private static String accessKeyId     = "LTAI********************";
    private static String accessKeySecret = "************************";

    public static String defaultBucket = "your-bucket";

    public static OSSClient getOSSClient() {
        return new OSSClient(endpoint, accessKeyId, accessKeySecret);
    }
}

集中配置的好处迁移的时候最明显。换桶改 defaultBucket 一行,换地域改 endpoint 一行。前面数据库不存桶名省下来的力气,到这一步才算真正兑现。

有一点得提醒:OSSClient 本身是线程安全的,每次请求都 new 一个再 shutdown,并发上来之后会白白多出不少连接开销。稳妥的做法是做成单例,或者交给 Spring 托管成 Bean。

预签名下载

私有桶的文件不能直接给 URL,给了就是 AccessDenied。正确做法是后端用 SDK 签一个临时链接:

@RequestMapping(value = "presign", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> presign(@RequestBody UploadFile file) throws Exception {
    OSSClient ossClient = OssConfigUtils.getOSSClient();

    // 链接有效期
    Date expiration = new Date(new Date().getTime() + 3600 * 1000);

    URL url = ossClient.generatePresignedUrl(
            OssConfigUtils.defaultBucket,
            file.getFileName(),
            expiration);

    ossClient.shutdown();
    return getSuccessMessage(url.toString());
}

generatePresignedUrl 干的事:把桶名、对象名、过期时间拼成待签字符串,用 AccessKeySecret 签一下,再把签名作为查询参数挂到 URL 上。签出来的链接大概长这样:

https://your-bucket.oss-cn-shanghai.aliyuncs.com/contract/合同_8f3ka92mx1.pdf
  ?Expires=1791623636
  &OSSAccessKeyId=LTAI********************
  &Signature=********************

拿到链接的人,在有效期内能直接下;过了时间,服务端验签失败,拒绝。

这里有个单位问题,很容易看走眼。Date 的构造参数是毫秒,3600 * 1000 才是一小时。如果写成 3600 * 10000,实际有效期会变成十小时——代码照跑,注释还写着"1 小时",谁也不觉得有问题。等到某天一条链接被转发到外部,才发现暴露窗口比预想的大了一个数量级。这种偏差不会报错,只能靠人看出来。

有效期定多长是个取舍。太短,用户点了下载还没存完链接就失效了;太长,链接一旦泄露,暴露时间就长。业务附件我们用的是一小时。

为什么不用后端转发文件流:转发要让文件穿过应用服务器,大文件长时间占着 Tomcat 连接,多实例部署时带宽还要重复消耗一份。预签名把文件传输留在浏览器和对象存储之间,服务器只做一次几毫秒的签名计算。


下载

前端三步:取索引、换链接、存文件。

$('#dataTables-file').on('click', 'a#download', function() {
  var data = $('#dataTables-file').DataTable().row($(this).parents('tr')).data();

  // 1. 两段 key 拼回完整对象名
  var param = {};
  param.fileName = data.fileCatalogue + "/" + data.fileName;

  // 2. 换预签名链接
  ajaxLoad(MetgodAccount.downFile, param, function(rs) {
    if (rs.success) {
      const url = rs.msg.replace(/\\/g, '/');

      // 3. 取二进制内容再保存
      const xhr = new XMLHttpRequest();
      xhr.open('GET', url, true);
      xhr.responseType = 'blob';
      xhr.onload = () => {
        if (xhr.status === 200) {
          saveAs(xhr.response, data.fileName);
        }
      };
      xhr.send();
    }
  }, "POST", false);
});

为什么不用 window.open(url) 直接开?

因为文件名。直接开链接,浏览器保存的时候用的是对象名,也就是 合同_8f3ka92mx1.pdf 这种带随机串的名字。用户拿到手完全不知道这是啥。转成 blob 之后前端能自己指定保存名,把随机串去掉,还原成用户当初上传的名字。多这几行代码,换来的是用户不用去猜哪个文件是哪个。

rs.msg.replace(/\\/g, '/') 这句也别省。后端返回的 URL 在某些序列化路径下会把正斜杠转义成反斜杠,直接拿去请求会得到一个无效地址,而且报错信息看不出所以然。


换桶的时候要动哪些地方

位置 改什么 数量
后端配置类 defaultBucket 1 处
后端配置类 跨地域的话还有 endpoint 1 处
前端上传脚本 host(上传域名) 每个引用该脚本的文件 1 处
前端上传脚本 g_dirname 初始值 同上
控制台 新桶的 CORS 规则 1 条
数据库 不动 0

数据库一列都不用改,这就是"只存桶内路径"换来的。

前端那两处要当心。如果上传脚本在多个页面里各存了一份副本,就得逐个改到位。漏掉一个页面,那个页面的上传会安安静静地写进旧桶,谁也不会发现,直到某天有人问"这个附件怎么找不到了"。

新桶的 CORS 一定提前配好。忘了这一步,表现就是上传请求被浏览器拦下,控制台有跨域报错,服务端日志一片空白。前面说过,这个现象和密钥错误、桶名错误很像,排查顺序记住是"先浏览器、后服务端"。


安全边界:把密钥放进前端之后,你实际承担了什么

前面一直绕着一个事没细说:AccessKeySecret 就明明白白写在前端 JS 里。任何人打开开发者工具,F12 一下,密钥就在那儿。

先说清楚这事的性质。这不是配置疏忽,也不是我们偷懒,而是浏览器直传这个模式的必然结果——浏览器要自己算签名,就必须拿到能算签名的密钥。你把算签名这一步放在哪,密钥就得放在哪。

那问题就变成:接受这个前提之后,风险到底有多大,能收窄到什么程度。

密钥泄露之后,对方能干什么

很多人第一反应是"大不了被人传点文件"。实际情况比这难受,对方能做四件事。

当免费图床和网盘用。 这是最常见的。密钥流出去之后,别人拿去当自己的存储用,传图片、传视频、传备份包。你这边看到的是存储量往上涨,流量费用跟着走。桶的容量没有上限,账单也没有。

传违规内容。 这个最要命。桶是你的,域名是你的,内容是从你这里出去的。真出了事,找的是桶的所有者,不是上传的人。轻则桶被限制访问,重则整个账号受影响。而且你根本不知道对方传了什么,除非去翻访问日志。

覆盖你已有的对象。 前面说过对象名里有十位随机串,撞上的概率很低,所以这条不是主要威胁。但如果某些历史文件是按旧规则命名的、没有随机串,那就等于敞开让人改。附件被换成别的内容,业务上可能很久都发现不了。

列举并下载全部附件。 这条取决于权限给得多宽。如果子账号的权限里带了 ListObjects,对方就能把整个桶的对象列出来,再一个个下走。所以给 RAM 子账号授权的时候,ListObjects 能不给就不给。

要强调的是,这几件事的前提都只是"拿到密钥",不需要登录你的系统,也不需要知道任何业务逻辑。

policy 能拦住什么

policy 是前端唯一能自主控制的东西,但它的能力边界很窄,得认清。

能做的:

  • content-length-range 限单文件大小,防止有人拿你桶当视频站
  • conditions 里可以加 key 前缀约束,把上传范围锁死在某个目录
  • 可以限制 content-type,只允许特定类型
  • 可以显式禁止上传者改 ACL,避免文件被设成公开读

不能做的:限制上传次数、识别上传者是谁、判断内容合不合规。policy 是一张写死的通行证,谁拿着都一样管用。

加前缀约束的写法:

var policyText = {
  "expiration": "2030-01-01T12:00:00.000Z",
  "conditions": [
    ["content-length-range", 0, 104857600],       // 上限压到 100MB
    ["starts-with", "$key", "contract/"],         // 只能传到 contract/ 下
    ["eq", "$success_action_status", "200"]
  ]
};

前缀约束只能拦住"手滑"和"顺手乱传",拦不住真想搞事的人——对方换个目录名照样传。它的价值在于把损失范围缩小,不在于防御。

三种收敛方案

按成本从低到高排。

第一种,只收紧配置。 密钥限权到最小、CORS 来源写具体域名、policy 加上前缀和大小限制。改动全在控制台,代码一行不动。这能挡掉大部分顺手拿密钥的人,成本最低,建议先做。

第二种,把签名挪回后端。 前端不再持有 Secret,改成上传前向后端要一份 policy 和 signature,拿到之后照样直传。

这是我最推荐的一步。改动不大,但把密钥从浏览器里彻底拿掉了。

后端加一个签发接口:

@RequestMapping(value = "sign", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> sign(@RequestBody SignRequest req) throws Exception {

    long expireEndTime = System.currentTimeMillis() + 60 * 1000;   // 60 秒内有效
    Date expiration = new Date(expireEndTime);

    PolicyConditions policyConds = new PolicyConditions();
    policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600);
    policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, req.getDir());

    String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
    byte[] binaryData = postPolicy.getBytes("utf-8");
    String encodedPolicy = BinaryUtil.toBase64String(binaryData);
    String postSignature = ossClient.calculatePostSignature(postPolicy);

    Map<String, String> resp = new HashMap<>();
    resp.put("policy", encodedPolicy);
    resp.put("signature", postSignature);
    resp.put("accessKeyId", OssConfigUtils.accessKeyId);
    resp.put("dir", req.getDir());
    resp.put("host", OssConfigUtils.host);
    return getSuccessMessage(resp);
}

前端相应地把本地算签名的代码删掉,改成先请求这个接口,把返回的 policy、signature 塞进 multipart_params。注意后端返回的 policy 是 Base64 之后的字符串,不是原始 JSON。

这样改完,policy 的失效时间可以压到 60 秒——它只在"拿到签名到发出上传请求"这段时间里有效。就算有人截获了这份签名,窗口也只有一分钟。而且签名是后端按目录生成的,对方没法把文件传到别的目录去。

代价是上传前多一次请求。相比把密钥暴露在浏览器里,这个代价可以忽略。

第三种,用 STS 临时凭证。 后端申请一个有效期很短的临时凭证(比如 15 分钟),带上权限策略下发到前端,前端用它去上传。凭证过期自动失效,权限范围也在策略里写死。

这是最彻底的做法,适合安全要求高的场景。复杂度也最高,前端要改用 STS 凭证算签名,后端要处理凭证申请和缓存,SDK 版本也得跟上。附件模块一般用不上,但值得知道有这么一条路。

下载这边也有口子

预签名链接有个特点:有效期内对任何人都有效,不需要登录。

意味着一条链接被转发到群里,这个文件在剩下的有效期内就是公开的。链接越长,窗口越大。

我们一开始把有效期设成十小时(就是前面那个 3600 * 10000 的单位问题),后来发现这个窗口实在太宽了,改成了……其实现在还是一小时,因为改短了用户投诉"点慢了就下不了"。这是个真实的取舍,不是纯技术问题。

几个能做的:

  • 有效期尽量短。5 到 15 分钟对绝大多数场景够用,用户体验和安全性都能接受。
  • 桶保持私有,绝不开放 ListObjects。链接泄露只能泄露单个文件,泄露不了整个桶。
  • 敏感附件单独走一套逻辑,有效期压到几分钟,前端拿到链接立刻触发下载。
  • 真要防转发,可以在下载接口里记一条日志:谁在什么时候取了哪个文件。事后追查有依据。

Referer 防盗链对这套方案帮助有限,因为预签名链接是直接给浏览器的,来源信息在跨域场景下并不可靠。别把防盗链当成主要防线。

兜底的两件事

开访问日志。 桶的访问日志打开,定期看两样东西:请求来源 IP 有没有陌生的,请求量有没有突然抬头。前面说的"被人当网盘用",在日志里看得很清楚——一个 IP 一天传了几万个对象,那不可能是正常业务。

开版本控制。 成本不高,但能救命。对象被覆盖或者误删之后还能找回上一个版本。我们有过一次运维误操作删了一批附件,靠版本控制捞回来的,那次之后这个开关就没关过。

费用告警也建议配上。存储量和流量设个阈值,超了就发通知。真被人拿去当网盘,早一天知道,少付一天的钱。

这一节的结论

浏览器直传省下来的服务器资源是真的,密钥暴露在前端也是真的,这两件事绑在一起,拆不开。

能做的是三件事:把密钥的权限收到最小,把签名的位置从浏览器挪回服务器,把下载链接的有效期压短。前两件加起来大概半天的改动量,做完之后这套方案的风险就从"任何会用 F12 的人都能往你桶里塞东西",降到"需要拿到一份 60 秒内有效的签名"。

值不值,自己判断。但如果你的桶里放的是客户资料和合同,我建议做。


收尾

回过头看,这套实现真正花心思的地方就三个。

直传决定了文件不经过服务器,把带宽和连接数从服务器上卸了下来。预签名决定了私有文件能安全地临时分享,服务器只签名不转发。数据库只存桶内路径,决定了存储位置和业务数据解耦,以后换桶改的是配置不是数据。

三件事配合起来,前端一份上传脚本、后端两个接口、库里一张四字段的表,就撑起了一个能挂多业务对象、能横向扩容、能平滑迁移的附件模块。

至于密钥这回事,写这篇文章的时候我又去看了一遍现在的实现,还是老样子。前端持有密钥这件事,属于"知道有问题,但一直排在优先级列表后面"。如果你正在做类似的模块,建议一开始就把签名放在后端——事后补比一开始就做,麻烦得多。

分享此文

评论 (0)

还没有评论,来抢沙发。

发表评论