商会小程序里有个高频需求:会员上传营业执照、资质证书、合同扫描件。这些文件有几个特点------单个可能几十 MB,弱网环境下容易传一半失败,而且会员不会耐心等。
一开始我们走的是"前端传应用服务器、服务器再转发 OSS"的两段式。问题很快出来了:大文件经过应用服务器,占用带宽和内存,并发一高服务器就扛不住;网络抖动时整个文件重传,会员等到失去耐心。
后来改成"前端直传 OSS + 大文件分片"。这篇把实现记下来。
先说边界
我们的场景:文件集中在图片和 PDF,单文件几 MB 到几十 MB,弱网(会员多在手机流量下操作)。基于这两点,我们排除了"直接整文件直传"------几十 MB 在 4G 下传一次失败就得从头来。也排除了"应用服务器中转"------前面说了带宽问题。
最终方案:大文件(超过 5 MB)走分片上传,小文件直传;直传和分片都走 OSS 临时凭证,不经过应用服务器。
后端:签发直传凭证
前端直传 OSS 不能用主账号 AK,要靠 STS 临时凭证,权限收敛到指定目录。
```javascript
// 服务端:签发 OSS 直传 STS 凭证(Node + ali-oss sts)
const STS = require('ali-oss').STS;
const sts = new STS({
accessKeyId: process.env.OSS_AK,
accessKeySecret: process.env.OSS_SK
});
async function getOssToken(dir, userId) {
// 把 userId 拼进上传路径,天然隔离不同会员的文件
const prefix = `{dir}/{userId}/`;
const res = await sts.assumeRole(
process.env.OSS_ROLE_ARN,
{
Version: '1',
Statement: [{
Effect: 'Allow',
Action: 'oss:PutObject',
Resource: \`acs:oss:\*:\*:${process.env.OSS_BUCKET}/${prefix}\*\`
}]
},
1800 // 凭证有效期 30 分钟
);
return {
region: process.env.OSS_REGION,
bucket: process.env.OSS_BUCKET,
prefix,
accessKeyId: res.credentials.AccessKeyId,
accessKeySecret: res.credentials.AccessKeySecret,
stsToken: res.credentials.SecurityToken
};
}
```
前端:分片上传
5 MB 以内的文件,直接用 `multipartUpload` 直传。超过的,手动切片,每片带序号上传,失败只重传当前片。
```typescript
import OSS from 'ali-oss';
import SparkMD5 from 'spark-md5';
const CHUNK = 5 * 1024 * 1024; // 5MB 一片
function sliceFile(file: File): Blob\[\] {
const chunks: Blob\[\] = \[\];
for (let i = 0; i < file.size; i += CHUNK) {
chunks.push(file.slice(i, i + CHUNK));
}
return chunks;
}
async function uploadWithRetry(
client: OSS,
key: string,
chunk: Blob,
index: number
) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
await client.put(`{key}.part{index}`, chunk);
return;
} catch (e) {
if (attempt === 2) throw e;
await new Promise((r) => setTimeout(r, 800 * (attempt + 1)));
}
}
}
async function uploadFile(file: File, token: any) {
const client = new OSS({
region: token.region,
accessKeyId: token.accessKeyId,
accessKeySecret: token.accessKeySecret,
stsToken: token.stsToken,
bucket: token.bucket
});
const ext = file.name.split('.').pop();
const hash = await fileMd5(file); // 用于断点续传去重
const key = `{token.prefix}{hash}.${ext}`;
if (file.size <= CHUNK) {
await client.put(key, file);
return key;
}
const chunks = sliceFile(file);
// 每片独立上传,失败只重传该片
await Promise.all(
chunks.map((c, i) => uploadWithRetry(client, key, c, i))
);
// 合并分片(OSS 需先 initMultipartUpload,逐 part 上传后 complete)
return key;
}
function fileMd5(file: File): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
const spark = new SparkMD5.ArrayBuffer();
reader.onload = (e) => {
spark.append(e.target!.result as ArrayBuffer);
resolve(spark.end());
};
reader.readAsArrayBuffer(file);
});
}
```
踩过的坑
第一个是断点续传。文件传到第 7 片断了,刷新页面再传,前面 6 片白传。解决办法是用文件 MD5 当 key 前缀,上传前先查 OSS 已有哪些分片(`listParts`),只补传缺失的。这个逻辑加上后,弱网下的体验好了很多。
第二个是凭证过期。直传凭证 30 分钟有效,传大文件可能超时。做法是上传前检查凭证剩余时间,不足 5 分钟就重新签发,不中断当前传。
第三个是并发数。几十片一起 `Promise.all` 上传,手机浏览器会限流。我们限到 3 片并发,既快又稳。
第四个是文件类型。上传前不只看扩展名,要读文件头判断真实类型,避免会员把 exe 改成 pdf 传上来。校验不过直接拦在客户端。
第五个是进度反馈。分片上传要实时显示总进度,不能只显示"上传中"。每片完成更新一次进度条,会员才愿意等。
什么时候不需要这套方案
如果文件都很小(全是几 MB 内的图片),整文件直传 OSS 就够了,不用分片。如果根本不需要前端直传(比如只在管理后台由干事上传),走应用服务器中转也行,逻辑简单。分片直传的价值在于"大文件 + 弱网 + 高并发"三个条件同时成立。
我们这边在用的商会管理系统叫未来漫城·商会互联平台,上面会员资料上传现在走的就是分片直传 OSS,一个几十 MB 的资质包在手机流量下也能一次传完,断了还能续。