项目标题拿到的第一反应,就是“这又是一个在真实生产环境里把人逼到墙角的问题”。能源化工行业的生产监控视频,不是你在办公室用Wi-Fi传个电影那么简单。厂区监控点位分散,车间和机房之间隔着好几条管线走廊,光纤链路动不动就被施工挖断,车间里的Wi-Fi在金属罐区衰减得惨不忍睹,更别说还有等保、内控审计对传输加密的硬性要求。把动辄1GB以上的监控录像从现场传到调度中心,网络一抖就前功尽弃,这种痛感没做过工业信息化项目的人很难体会。
我做的方案核心就一件事:用JS改造WebUploader这个老牌上传组件,给它加上加密分片上传和断点续传能力。WebUploader是百度FEX团队开源的组件,虽然官方停更了很久,但企业内网上大量系统早就在用它,API稳定、队列和进度管理成熟,与其推倒重来,不如在它身上做外科手术。下面这篇文章就完整记录我改造的思路、关键代码和踩过的坑,给同样在工业场景里和“大文件+烂网络”死磕的朋友一点参考。
1. 项目背景与需求拆解
1.1 能源化工场景下的上传难点
能源化工行业的监控视频和互联网行业的上传需求差距非常大,我觉得先得把这些差异摊开看,不然做出来的方案就是空中楼阁。
第一是文件体积大得离谱。普通办公室传个PPT几十MB已经嫌大了,生产监控录像可是按小时分段的。以一台1080P摄像头为例,码率做到2Mbps,一小时录像就是900MB;厂区关键装置区通常开4Mbps甚至更高码率,一个小时视频段轻松超过1.5GB。即便按30分钟一段切分,单文件也有400~700MB。这种文件用普通的FormData整体上传,网络稍微波动就是“至暗时刻”。
第二是网络环境不可控。厂区里的光纤链路经常因为施工、动物啃咬、弯折过度而断续;车间内部的无线桥接在大型金属储罐附近信号衰减非常严重;调度中心和厂区之间的网络还可能跨多个子网、过防火墙。我遇到过一条链路的丢包率在高峰时段接近3%,这种环境下传大文件,断线是常态而不是异常。
第三是安全合规不能妥协。监控视频涉及生产安全、安防布防甚至人员行为,明文HTTP传输在等保评审和内部审计阶段根本过不了。要求是传输过程必须加密,而且最好能做到分片级别的加密,这样即使某个分片在网络中被截获,也无法还原有效内容。
第四是前端运行环境老旧。厂站控制室里的PC很多还是Win7、Win10老版本,浏览器停留在Chrome 40+甚至IE11。市面上的现代上传方案虽然好用,但兼容性直接劝退。WebUploader正是这种环境下的老朋友,虽然它的传输层不满足我们的安全要求,但壳子(文件选择、队列、进度、状态管理)是真能用。
1.2 为什么选WebUploader而不是新方案
很多朋友听到“改造WebUploader”第一反应是:都2020年代了,为什么不用现成的分片上传库或者云厂商的SDK?
原因很现实。一是系统里已经跑着基于WebUploader的旧逻辑,改造成本最低的路径就是保留壳、替换传输层;二是WebUploader的分片机制、队列管理、进度回调都已经验证过多年,稳定性有保证;三是很多能源化工项目在部署时不能随便引入依赖一堆云服务的SDK。我自己也试过用axios重新搭一套上传体系,最后发现要处理的事情比想象中多太多——文件选取、并发控制、队列顺序、失败重试、进度计算,全得自己写。而WebUploader把这些都给我准备好了,我只需要干掉它的默认请求,换成“自定义加密分片请求”。
所以本次改造的核心思路,不是推翻重来,而是让WebUploader只负责它擅长的:文件选择、文件元数据管理、进度事件。真正干活的加密、续传、状态同步,全部由我写的JS函数接管。
2. 加密续传的总体设计
2.1 分片上传的本质
分片上传的原理其实不复杂:把大文件按固定大小切成N块,每块独立上传,服务端收到所有分片后按顺序合并。这样做的好处,一是上传单元小了,网络抖动时只需要重传失败的那一片,不用整个文件重来;二是可以并发上传,利用带宽;三是在弱网环境下进度是平滑增长的,不会一直卡在某个百分比不动。
断点续传则是分片上传的必然延伸。既然文件被切成块,那么只要服务端记录了“哪些块已经成功收到”,前端在下次上传时就可以跳过这些块,只传缺失的块,最后再触发合并。这里的核心是“状态一致性”:服务端记录的已传分片必须和前端即将上传的分片一一对应,否则合并出的文件会坏。
2.2 加密放在哪一层
加密策略我选择了“先切片、再分片加密”。注意,千万不要先对整个文件加密再切片。如果先整体加密,你会得到一个无法随机读取的密文流,切片后每一片都无法独立解密,断点续传和分片校验都无从谈起。
正确的流程是:
- 前端计算整个文件的MD5(用作文件指纹)。
- 按固定大小切片(比如每片5MB)。
- 对每个分片单独做AES加密(实际是每个分片的原始字节 -> AES-CBC加密)。
- 上传加密后的分片二进制,同时携带分片索引和文件MD5。
- 服务端收到某个分片后,用协商好的密钥解密,将原文写入磁盘临时文件。
- 所有分片传齐后,服务端按索引顺序合并,并校验合并后文件的MD5是否等于前端上报的MD5。
这样即使某个密文分片在半路被截获,攻击者也拿不到有效视频数据。同时由于每个分片独立加密,续传时可以只传缺失分片,完全不影响加密体系。
有朋友可能会问:AES密钥放在前端,不就等于没有加密吗?确实,前端密钥的安全性有限,它只能防止弱网链路上的被动窃听,防不了恶意用户逆向。但在能源化工场景里,我们真正要防的是“数据在网络传输过程中被旁路抓包”,而不是防本厂员工。所以可行的做法是:上传前通过服务器的登录会话接口获取一个临时密钥,前端只在内存中保存,不落盘。如果要更严格,可以走HTTPS + 密钥协商,但内网项目做到“会话密钥 + 分片密文”已经能满足多数审计要求了。
2.3 断点续传的状态记录
断点续传要想做到位,服务端必须提供两个配套接口:
- 状态查询接口:根据文件的MD5,返回该文件已经成功收到的分片索引集合。
- 分片上传接口:接收一个分片,校验后落盘,并记录索引。
前端在上传前先请求状态接口,如果返回已存在部分分片,就跳过这些分片,只上传缺失的。这里有个关键点:续传必须保证分片大小和总片数没有变化。如果前端修改了分片大小(比如从5MB改成10MB),那同样的分片索引对应的字节范围就变了,服务端之前存的5MB分片就全部失效。所以我在状态查询时会让服务端同时返回“分片大小、总片数、上次上传时间”,前端判断这三个参数都一致才走续传,否则清空已传分片重新传。
2.4 为什么用MD5而不是文件大小
文件大小这种指纹太弱。视频文件经常有转码重封装的场景,同一个监控视频可能通过不同工具导出的文件大小完全相同,但内容顺序完全不同。只用大小做指纹,一旦两个不同的文件在主键上碰撞,服务端的临时目录就会混乱,合并出来的视频花屏、时间轴断裂,排查起来非常痛苦。
用MD5之后还能顺手实现“秒传”效果:如果同一个文件之前已经完整上传过,状态接口返回“已全部接收”,前端压根不需要再传分片,直接调用合并接口,服务端根据已有的分片合并一份新副本即可。我在实际项目里发现,厂区早晚高峰视频文件高度相似,秒传命中率还挺高,大大减轻了网络压力。
3. 核心实现:改造WebUploader支持加密分片与续传
3.1 技术选型与依赖
我最终使用的依赖也就四个:
- webuploader(1.x版本即可,主要用它的UI和队列)
- jQuery(WebUploader的依赖,老项目里通常已经有了)
- crypto-js(用于AES加密)
- spark-md5(用于增量计算大文件的MD5)
如果要兼容老浏览器,还需要babel来转译ES6语法。不过我建议只在支持HTML5的浏览器上启用,禁用Flash fallback。能源化工项目里不可能为了Flash去装插件,这既安全又省心。
3.2 WebUploader当“壳”,自定义传输层接管上传
这是整个改造最核心的设计决策。WebUploader默认的上传逻辑是:把分片Blob直接作为multipart字段发送到server配置的地址。我们不能用这个默认逻辑,因为那里没法插入“加密分片”这一步。
我的做法是:
- 初始化WebUploader时,不配置server,或者配置一个占位地址但永远不会真正让它发请求。
- 通过监听
filesAdded拿到文件列表,然后调用自己写的uploadFileWithEncryption(file)函数。 - 在这个函数里,自己决定什么时候切片、怎么加密、怎么上传。
- 上传过程中通过
uploader.trigger(file, 'uploadProgress', percent)把进度反馈给组件。 - 全部完成后触发
uploadSuccess;任何失败触发uploadError或uploadFailure。
这样WebUploader成了一个纯粹的文件选择器和进度展示框,传输细节由自己掌控。看起来绕了一圈,但实际上最稳定,因为不涉及对WebUploader源码的深度hack,升级或排查问题都容易。
3.3 分片、加密、续传的关键代码
先看初始化部分:
var uploader = WebUploader.create({ pick: { id: '#picker', multiple: false }, accept: { title: '视频文件', extensions: 'mp4,avi,flv,mkv,wmv', mimeTypes: 'video/*' }, // server不设真实地址,因为由我们自定义传输 swf: 'webuploader/Uploader.swf', auto: false, disableGlobalDnd: true, dnd: '#dndArea' });accept里的扩展名按实际需要配。注意监控导出的文件可能是.dat或私有格式,这种情况下得在服务端做白名单,前端不能只靠扩展名封死。
filesAdded事件后,启动自定义上传:
uploader.on('filesAdded', function(files) { var file = files[0]; // file.source 是WebUploader内部的File对象,也就是原始浏览器File startEncryptedUpload(file); });startEncryptedUpload函数里,我分成三步走。
第一步:计算整个文件的MD5,并查询服务端的续传状态。
function calcMD5(file) { return new Promise(function(resolve, reject) { var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunkSize = 8 * 1024 * 1024; var spark = new SparkMD5.ArrayBuffer(); var reader = new FileReader(); var current = 0; var total = Math.ceil(file.size / chunkSize); reader.onload = function(e) { spark.append(e.target.result); current++; if (current < total) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror = reject; function loadNext() { var start = current * chunkSize; var end = Math.min(start + chunkSize, file.size); reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } loadNext(); }); } function getUploadedChunks(md5) { return $.ajax({ url: '/api/video/upload/status', method: 'GET', data: { md5: md5 } }); }注意这里用FileReader而不是file.arrayBuffer(),就是为了兼容IE11和老的Edge。SparkMD5增量计算避免一口气读入几个GB的内存,这是大文件计算指纹的基本操作。
第二步:切片并加密每个分片。这里要处理ArrayBuffer、WordArray和Blob的来回转换。CryptoJS直接加密的是WordArray,而我们要操作的是二进制数据,最容易踩的坑就是字节序和填充。
function arrayBufferToWordArray(ab) { var uint8 = new Uint8Array(ab); var words = new Array(Math.ceil(uint8.length / 4)); for (var i = 0; i < words.length; i++) { words[i] = (uint8[i * 4] << 24) | (uint8[i * 4 + 1] << 16) | (uint8[i * 4 + 2] << 8) | uint8[i * 4 + 3]; } return CryptoJS.lib.WordArray.create(words, uint8.length); } function wordArrayToBlob(wordArray) { var len = wordArray.sigBytes; var uint8 = new Uint8Array(len); var words = wordArray.words; for (var i = 0; i < len; i++) { uint8[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; } return new Blob([uint8]); }加密分片:
function encryptChunk(arrayBuffer, sessionKey) { var plainWordArray = arrayBufferToWordArray(arrayBuffer); var key = CryptoJS.enc.Hex.parse(sessionKey); // iv可以使用文件md5的前16字节做种子,但生产环境更建议每次会话生成 var iv = CryptoJS.lib.WordArray.create([0x00000000, 0x00000000, 0x00000000, 0x00000000]); var encrypted = CryptoJS.AES.encrypt(plainWordArray, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.ciphertext; // WordArray }这里要特别说明:IV如果全部置0,在同一个密钥下相同明文分片会得到相同密文,存在泄露模式的风险。实际项目里我会用“文件MD5取前16字节Hex + 分片索引构造block”来生成动态IV。但CryptoJS的IV要求是16字节WordArray,我通常会写成这样的工具函数:
function makeIv(md5, chunkIndex) { var hex = md5.substr(0, 16) + padStart(chunkIndex.toString(16), 16, '0'); return CryptoJS.enc.Hex.parse(hex); }第三步:上传加密分片,并处理重试和进度。
function uploadChunk(file, md5, chunkIndex, chunkTotal, sessionKey) { return new Promise(function(resolve, reject) { var chunkSize = 5 * 1024 * 1024; var start = chunkIndex * chunkSize; var end = Math.min(start + chunkSize, file.size); var blob = file.source.slice(start, end); var reader = new FileReader(); reader.onload = function(e) { var plainBuffer = e.target.result; var encryptedWordArray = encryptChunk(plainBuffer, sessionKey); var encryptedBlob = wordArrayToBlob(encryptedWordArray); var fd = new FormData(); fd.append('md5', md5); fd.append('chunkIndex', chunkIndex); fd.append('chunkTotal', chunkTotal); fd.append('originalSize', end - start); fd.append('file', encryptedBlob, 'chunk_' + chunkIndex + '.enc'); var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/video/upload/chunk'); xhr.timeout = 120000; // 2分钟超时 xhr.onload = function() { if (xhr.status === 200) { resolve(xhr.responseText); } else { reject(new Error('HTTP ' + xhr.status)); } }; xhr.onerror = function() { reject(new Error('network error')); }; xhr.ontimeout = function() { reject(new Error('timeout')); }; xhr.send(fd); }; reader.onerror = function() { reject(new Error('file read error')); }; reader.readAsArrayBuffer(blob); }); }这里的关键逻辑是:用FileReader先读分片,然后加密,然后把加密后的Blob放进FormData。注意FormData里的file字段名,服务端就按这个字段去接。同时携带md5、chunkIndex等元数据,服务端才能定位这个分片属于哪个文件。
uploadFileWithEncryption函数的整体流程我放在一个async函数里:
async function startEncryptedUpload(file) { var md5 = await calcMD5(file); var sessionKey = await fetchSessionKey(); // 从登录会话获取AES密钥 var statusRes = await getUploadedChunks(md5); var uploaded = statusRes.data.uploadedIndexes || []; var chunkSize = 5 * 1024 * 1024; var chunkTotal = Math.ceil(file.size / chunkSize); // 如果全部已传,直接合并 if (uploaded.length === chunkTotal) { await mergeFile(md5, chunkTotal); uploader.trigger('uploadSuccess', file); return; } var failedChunks = []; for (var i = 0; i < chunkTotal; i++) { if (uploaded.indexOf(i) >= 0) { continue; } var success = false; for (var attempt = 0; attempt < 3 && !success; attempt++) { try { await uploadChunk(file, md5, i, chunkTotal, sessionKey); success = true; } catch (e) { failedChunks.push({ index: i, attempt: attempt + 1, error: e.message }); if (attempt === 2) { uploader.trigger('uploadError', file, new Error('分片 ' + i + ' 超过重试次数')); uploader.stop(true); return; } // 等待1秒、2秒、3秒退避 await sleep((attempt + 1) * 1000); } } var percent = Math.round((i + 1) / chunkTotal * 100); uploader.trigger('uploadProgress', file, percent); } var mergeRes = await mergeFile(md5, chunkTotal); uploader.trigger('uploadSuccess', file, mergeRes); }这段代码把重试逻辑写死了,最多重试3次、1秒退避。真实生产我建议做成可配置项,弱网环境下可以把重试次数提到5次左右,间隔指数递增到10秒。
3.4 进度与错误处理
既然接管了传输层,WebUploader的进度条就不会自动更新了,必须手动把进度trigger给它。
uploader.trigger('uploadProgress', file, percent);注意percent是0~100的整数。WebUploader内部会根据这个值刷新UI。如果某个分片因为网络失败被重传,进度条可能会回跳,这是正常的,用户看到进度整体向前就能接受。
另外,WebUploader本身有个uploadProgress事件,我们可以监听它来做全局的进度展示:
uploader.on('uploadProgress', function(file, percentage) { $('#progress-' + file.id).text(Math.round(percentage) + '%'); });错误处理上,我建议在自定义上传函数里使用单独的错误事件名,比如customUploadError,避免和WebUploader的uploadError混淆。UI上要明确告诉你“哪个分片失败了,正在第几次重试”,而不是笼统一句“上传失败”。
3.5 服务端配套接口设计
前端改造完成,服务端也得跟上。这里我用Node.js+Express做了个简单的参考实现,但换成Java、Go、Python同理。
分片接收接口:
const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const UPLOAD_DIR = '/data/production_videos/tmp'; const SESSION_KEY = process.env.VIDEO_UPLOAD_KEY; // 部署时注入 const upload = multer({ limits: { fileSize: 6 * 1024 * 1024 } }); app.post('/api/video/upload/chunk', upload.single('file'), (req, res) => { const { md5, chunkIndex, chunkTotal, originalSize } = req.body; const chunkIndexInt = parseInt(chunkIndex); const chunkDir = path.join(UPLOAD_DIR, md5); if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } // 解密分片 const encryptedBuffer = fs.readFileSync(req.file.path); const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(SESSION_KEY, 'hex'), Buffer.from(md5.substr(0, 32), 'hex').subarray(0, 16)); const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]); // 校验长度 if (decrypted.length !== parseInt(originalSize)) { fs.unlinkSync(req.file.path); return res.status(400).json({ code: 1, msg: '分片解密后长度不符' }); } // 写入分片文件 const partFile = path.join(chunkDir, `${chunkIndexInt}.part`); fs.writeFileSync(partFile, decrypted); fs.unlinkSync(req.file.path); // 记录状态 const stateFile = path.join(chunkDir, 'state.json'); let state = { md5, chunkSize: 5 * 1024 * 1024, total: parseInt(chunkTotal), done: [] }; if (fs.existsSync(stateFile)) { state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); } if (!state.done.includes(chunkIndexInt)) { state.done.push(chunkIndexInt); } fs.writeFileSync(stateFile, JSON.stringify(state)); res.json({ code: 0, data: { chunkIndex: chunkIndexInt } }); });状态查询接口:
app.get('/api/video/upload/status', (req, res) => { const md5 = req.query.md5; const stateFile = path.join(UPLOAD_DIR, md5, 'state.json'); if (!fs.existsSync(stateFile)) { return res.json({ code: 0, data: { uploadedIndexes: [] } }); } const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); res.json({ code: 0, data: { uploadedIndexes: state.done, chunkSize: state.chunkSize, total: state.total } }); });合并接口:
app.post('/api/video/upload/merge', (req, res) => { const { md5, chunkTotal } = req.body; const chunkDir = path.join(UPLOAD_DIR, md5); const stateFile = path.join(chunkDir, 'state.json'); if (!fs.existsSync(stateFile)) { return res.status(400).json({ code: 1, msg: '状态不存在' }); } const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); if (state.done.length !== chunkTotal) { return res.status(400).json({ code: 2, msg: `分片不全,当前${state.done.length}/${chunkTotal}` }); } const filePath = path.join(UPLOAD_DIR, md5, `${md5}.mp4`); const writeStream = fs.createWriteStream(filePath); for (let i = 0; i < chunkTotal; i++) { const partFile = path.join(chunkDir, `${i}.part`); if (!fs.existsSync(partFile)) { return res.status(400).json({ code: 3, msg: `缺少分片${i}` }); } const data = fs.readFileSync(partFile); writeStream.write(data); } writeStream.end(); // 这里应该校验合并后的MD5,如果与服务端记录的MD5不一致,则回滚下次重新上传 res.json({ code: 0, data: { url: `/video/${md5}.mp4` } }); });服务端还有个重要工作:定时清理临时目录。如果某个文件传了一半就再也不传了,几GB的临时分片会一直占着磁盘,时间长了能把磁盘写满。我在项目里写了个定时任务,每天凌晨扫描所有state.json的更新时间,超过48小时未更新的目录直接删除。
4. 生产环境下的优化与坑
4.1 分片大小和并发数的取舍
分片大小我建议设为5MB左右。太小(比如1MB)会导致请求数量剧增,一个1GB文件要发1000多个请求,服务端的日志、握手开销、网络往返都在拖后腿;太大(比如32MB)一旦网络抖动,单个分片重传成本很高。5MB是一个比较均衡的值。
并发数方面,我实测在厂区普通2Mbps上行链路上,3个并发就已经能把带宽跑满,再多并发反而会引起TCP竞争,导致每个请求都超时。如果现场网络更弱,建议降到2。不要迷信“并发越高越好”,工业内网和运营商宽带不一样,瓶颈往往在链路本身。
4.2 弱网断线重试与断电续传
弱网环境下的核心诉求是“不能因为一次网络抖动就把整个上传打死”。前端要针对每个分片独立重试,重试次数和退避策略要可调。我用的退避策略是:第1次失败后等1秒,第2次等3秒,第3次等7秒,指数增长。如果连续失败超过5次,才判定这条链路有问题,提示用户检查网络。
断电续传的体验是:用户在A车间上传了500MB,断电了,等网络恢复后再次选择同一个视频文件,前端计算MD5后命中服务端状态记录,直接跳过已传分片,只传剩下的部分。这个体验的关键在于状态查询必须在分片上传之前完成,而且分片大小和总数必须校验,否则续传出来的文件必坏。
4.3 MD5计算优化
大文件计算MD5是个耗时操作。1GB文件用SparkMD5增量计算,在老PC上可能要几十秒。用户选择文件后,界面不能卡死,我用了一个小trick:先读取文件头部、中部、尾部几个64KB片段,用分块取样的“快速指纹”快速判断“大概率是同一文件”,命中后再做完整MD5。不过这只是工程上的优化,安全校验最终还是以完整MD5为准。如果不追求秒传,直接全量算MD5也能接受,只是要在UI上显示“正在校验文件完整性”的进度。
4.4 老浏览器兼容
能源化工现场的老机器和老浏览器是绕不开的。WebUploader的HTML5模式在Chrome 40+能用,但FileReader的readAsArrayBuffer没问题,注意不要用Blob.arrayBuffer()这种较新的API。CryptoJS和SparkMD5都是纯JS,兼容性没问题。ES6的async/await在IE11下不能用,我在生产环境用babel转译成ES5,或者干脆用Promise和generator的写法。另外,WebUploader依赖jQuery,建议用1.12版本,避免jQuery 3.x在IE上的坑。
5. 常见问题与排查实录
5.1 常见问题速查表
| 症状 | 可能原因 | 排查与解决 |
|---|---|---|
| 上传一直停在0% | Nginxclient_max_body_size太小,或者自定义XHR被跨域拦了 | 检查Nginx配置,放行client_max_body_size 100m;,跨域需要服务端加CORS头 |
| 断点续传到了99%后报错 | 某一分片被跳过但服务端其实没收到,前端状态判断和数据不符 | 核对state里的done数组与分片请求是否一一对应,前端续传时打印每个分片的索引看是否越过缺失分片 |
| 解密后文件播放花屏 | AES的IV或密钥不一致,或者合并顺序乱 | 检查前后端密钥生成逻辑,分片合并时用chunkIndex排序,不要用文件名排序 |
| 上传成功但合并出来的视频比原文件大或小 | 服务端把PKCS7填充也算进去了 | 解密后必须去掉padding,CryptoJS的decrypt会自动处理,但服务端用Node的crypto时要用autoPadding: true,同时校验原始大小 |
| 同一个文件换台电脑无法续传 | 状态查询接口只以MD5为主键,但不同电脑生成的MD5不一致(计算错误) | 确保MD5计算基于文件完整二进制,不要用file.name或其他元数据参与计算 |
| 上传到一半页面刷新,重选文件后显示“文件不存在” | 服务端临时目录被定时清理了 | 延长清理周期,或者在上传过程中心跳续期state文件的更新时间 |
| IE时代浏览器下卡死 | 使用了Promise.finally或async/await但没转译 | 统一用babel-polyfill,或者换ES5写法 |
5.2 实战中的一些心得
这套方案我在线上跑了大半年,最大的体会是:加密本身其实不难,难的是把加密和续传的边界理清楚。
有一次测试人员在弱网环境下连续断电,恢复后上传进度从100%跳到“正在合并”,合并完成后视频却是坏的。后来查下来,问题出在服务端合并时用了文件名排序,而文件名是1.part、10.part、2.part这样的字符串,字典序成了1,10,2,最终合并顺序全乱了。把合并逻辑改成按数字索引排序之后就正常了。这种细节,只靠看代码很难发现,必须在真实弱网环境里反复断电断网测试。
另一个心得是:前端千万不要把密钥写死在JS里,哪怕你加了混淆,也经不起逆向。我给客户演示的时候,他们安全部门直接扔了个抓包工具给我,看到密钥在请求里出现过一次,立刻打回。后来改成登录后从接口临时获取,内存中保存,请求期间不再传输密钥,才过了审计。
最后再分享一个小技巧:状态接口一定要返回chunkSize和total,前端续传前严格比对这些参数,否则版本升级时改了分片大小,老版本没传完的文件会和新版本的分片对不上,轻则重新上传,重则服务端把不同版本的分片混在一起合并,视频直接损坏。加上这个校验后,我再也没遇到过“续传续出坏文件”的投诉。