简介:围绕大文件上传这一常见后端难题,这份演示工程以 Python 配合 Flask 搭建服务端,前端借助 WebUploader 完成分片切分与并发提交,并用 Bootstrap 渲染上传进度条,适合已掌握 Python 基础、想弄清分片上传与流式下载完整链路的开发者参考。整包共 26 个文件、约 427KB,其中 2 个 py 文件承载 Flask 路由与分片合并逻辑,2 个 html 模板分别对应上传页与文件列表页,5 个 css 与 5 个 js 支撑页面样式及 WebUploader 交互,另含字体图标与 requirements 依赖清单,结构轻量、跑通门槛低。已有 653 人学习。读者可从中获得多用户并发上传互不干扰的处理思路、接收全部分片后合并成完整文件的具体实现,以及低内存占用的流式下载方案;代码与说明还点出合并分片时内存紧张的隐患,并提示不合并、下载时按序重组回传的优化方向,便于二次改造与排错。
1. 大文件上传别一把梭:分片落地要先想清楚的几件事
三年前接过一个内网批量从浏览器上传打包文件的活,前端XMLHttpRequest一次性发 2GB 压缩包,标签页直接卡死,网关 413,用户重试两次后开始怀疑人生。后来把文件切成 5MB 一片、由 WebUploader 负责排队并发、Flask 在后端按序号落盘再合并,整个交互才回到正常人的观感。upload-demo 就是按这个思路搭的一套可跑的前后端:前端 Bootstrap 页面 + WebUploader 分片投递,后端 Flask 提供分片接收和合并接口,再配一个 stream-downloader 支持 Range 回传,Fragmentation 的脏活都收在服务端。它适合两类人:一类是要在本地或内网快速把大文件上传跑通的后端和全栈,另一类是想搞清楚分片协议字段、并发参数和断点续传边界的人。下面按“先定协议、再写后端、再接前端、最后排坑”的顺序,把这套东西拆开。
2. 分片协议怎么定:chunkSize、并发数、md5 校验的取值与边界
分片上传最难的不是写代码,是先把协议砍清楚。协议不统一,前端传的和后端接的就对不上,chunk从 0 还是从 1 开始、总分片数叫chunks还是total、文件标识用 md5 还是 uuid,这些细节决定后面能不能断点续传、能不能去重。这一章就把这些字段拍死,同时给出 chunkSize 和线程数的取值区间,最后落到 WebUploader 的配置代码上。
2.1 先约定请求字段:序号、总数、文件标识
一个分片请求最少要带四样东西:文件唯一标识、原始文件名、当前分片索引、分片总数。WebUploader 在开启chunked后会自动把id、name、chunk、chunks、size、type、lastModifiedDate这几个字段塞进 formData,后端直接从request.form取就行,不需要前端额外拼。协议字段建议按下面这张表对齐,名字不要改来改去。
| 字段 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
| id | string | 是 | 文件唯一标识,WebUploader 默认生成WU_FILE_x,跨会话续传要换成指纹 |
| name | string | 是 | 原始文件名,合并时用它命名输出文件 |
| chunk | int | 是 | 分片索引,从 0 开始 |
| chunks | int | 是 | 分片总数 |
| size | int | 否 | 当前分片字节数,用于服务端二次校验 |
| file | binary | 是 | 分片二进制内容,字段名由fileVal决定 |
有两个隐藏约定必须一开始就写进注释:chunk一定从 0 开始,合并时用range(chunks)遍历;id默认带会话属性,浏览器刷新后 WebUploader 会重新生成,所以真正的断点续传不能靠它,要换成“文件名 + 大小 + 最后修改时间”或前端算出来的 md5。协议定好之后再谈参数,否则参数调半天也对不上。
2.2 chunkSize 和 threads 怎么选:不是越大越快
chunkSize 太小,分片请求数会爆炸。2GB 文件按 1MB 切是 2048 个请求,服务端小文件 IO 和合并时的open次数都会明显上去,进度条也会跳得人心里没底。chunkSize 太大,单片失败的重传代价高,弱网下经常出现“传了 80% 卡住、重传又从头开始”的翻车现场。我给一个常用区间,实际按网络和磁盘调整。
| 场景 | 建议 chunkSize | threads | 备注 |
|---|---|---|---|
| 移动端弱网 | 1–2 MB | 1–2 | 重传代价低,进度更新频繁 |
| 公网普通 | 2–5 MB | 2–3 | 最常见的组合 |
| 内网/局域网 | 5–10 MB | 3–5 | 带宽够,减少请求数 |
| 超大文件(>10GB,内网) | 10–20 MB | 4–6 | 注意磁盘顺序写和临时空间 |
threads不是越大越好,WebUploader 的并发是按分片算的,3 个线程意味着同一时刻有 3 个分片在写盘。如果后端合并逻辑里用“目录里文件数 >= 总数”判断是否合并,并发一高就会同时触发多次合并,第 5 章会专门讲这个坑。我的习惯是先把threads压到 3 跑通,再往上加。
还有两个限制要一起对齐:网关的client_max_body_size和 Flask 的MAX_CONTENT_LENGTH,两者都必须大于 chunkSize,通常留 1–2MB 余量给 multipart 边界和表单字段。比如 chunkSize 设 5MB,这两个值就别低于 7MB,否则每个分片都会被 413 拦掉,前端看到的只是“上传失败”,根本不知道是网关干的。
2.3 md5 校验放前端还是后端:别让用户干等
md5 有两个用途:完整性校验和秒传索引。放前端算,2GB 文件在浏览器里用 SparkMD5 跑一遍,快的十几秒、慢的半分钟,用户点了上传之后界面没反应,体验很差。放后端算,得等所有分片到齐、合并完成之后才知道对不对,出问题已经晚了。
常见做法是分层:前端只算一个轻量指纹(name + size + lastModified拼起来做一次字符串 hash),保证同一文件每次生成同一个id,用于服务端去重和断点续传;后端在合并完成后对整个文件算一次 md5,写进元数据,下载或后续校验时用。这段代码不复杂,关键是按块读、别把大文件整个读进内存。
import hashlib def md5_of_file(path, buf_size=4 * 1024 * 1024): h = hashlib.md5() with open(path, 'rb') as f: while True: block = f.read(buf_size) if not block: break h.update(block) return h.hexdigest()逻辑说明:按 4MB 一块循环读,update增量计算,内存占用恒定。参数buf_size取 4–8MB 比较均衡,太小系统调用多,太大对 SSD 和机械盘都没有额外收益。算完的 md5 建议同时写进uploads/<name>.meta.json,别只打印到日志里。
2.4 WebUploader 的分片参数落到代码里
协议和参数定完,前端初始化就那几行配置,但每一项都要知道它改的是什么。
var uploader = WebUploader.create({ swf: '/static/webuploader/Uploader.swf', server: '/api/upload', pick: '#picker', fileVal: 'file', // 二进制字段名,后端 request.files['file'] 对应 chunked: true, // 开启分片 chunkSize: 5 * 1024 * 1024, // 每片 5MB chunkRetry: 2, // 单片失败重试 2 次 threads: 3, // 并发分片数 duplicate: true, // 允许重复选择同一个文件 auto: false, // 手动触发 upload() resize: false, // 图片场景才需要 formData: { // 额外业务字段,比如目录 id }, accept: { title: '压缩包', extensions: 'zip,rar,7z,tar,gz' } });参数说明:fileVal决定后端从哪个字段拿二进制,默认就是file;chunkRetry是单分片重试次数,不是整个文件;auto: false配合一个“开始上传”按钮,避免用户选完文件立刻开跑;duplicate: true在续传场景下必须开,否则同一文件第二次选择会被直接过滤。accept.extensions只做前端提示,真正的后缀校验必须放后端,这一点后面踩坑章节还会提。
3. Flask 后端分片落地:接收、落盘、合并与清理的完整链路
前端把分片投过来之后,后端要做的事只有四件:校验字段、落盘、判断是否到齐、合并并清理。这四件事里最容易出问题的是“判断到齐”和“合并清理”,前者涉及并发,后者涉及原子性。这一章按目录结构、接收路由、合并函数、去重接口的顺序写,每一步都给可运行的代码。
3.1 目录结构和配置:临时区和成品区要分开
先把目录拍平,分片和成品不要放在一起,后面清理和排查都方便。
upload-demo/ ├── app.py ├── config.py ├── static/ │ └── webuploader/ ├── templates/ │ └── index.html ├── chunks/ # 临时分片目录,每个文件一个子目录 │ └── <file_id>/ │ ├── 000000.part │ └── 000001.part └── uploads/ # 合并后的成品 └── package.zip配置项单独放一个文件,方便改参数时一眼看到全部约束。
# config.py import os BASE_DIR = os.path.abspath(os.path.dirname(__file__)) CHUNK_DIR = os.path.join(BASE_DIR, 'chunks') UPLOAD_DIR = os.path.join(BASE_DIR, 'uploads') MAX_CONTENT_LENGTH = 20 * 1024 * 1024 # 单请求上限,要大于 chunkSize ALLOWED_EXTENSIONS = {'zip', 'rar', '7z', 'tar', 'gz'} os.makedirs(CHUNK_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)注意:
MAX_CONTENT_LENGTH的单位是字节,这里写 20MB 是按 chunkSize 5MB 留了 4 倍余量,方便后续往上调 chunkSize 而不用改配置。如果前面有网关,网关那层也要同步放开。
3.2 分片接收路由:字段校验优先于写盘
路由的第一步不是save,是校验。缺字段、索引非法、后缀不在白名单,这些都要在写盘之前拦掉,否则chunks/目录会越来越乱。
import os from flask import Flask, request, jsonify from werkzeug.utils import secure_filename app = Flask(__name__) app.config.from_pyfile('config.py') def chunk_dir(file_id): """按 file_id 建子目录,secure_filename 防止路径穿越""" safe_id = secure_filename(file_id) path = os.path.join(app.config['CHUNK_DIR'], safe_id) os.makedirs(path, exist_ok=True) return path @app.route('/api/upload', methods=['POST']) def upload_chunk(): file_id = request.form.get('id', '').strip() name = request.form.get('name', '').strip() try: index = int(request.form.get('chunk', -1)) total = int(request.form.get('chunks', -1)) except (TypeError, ValueError): return jsonify(code=400, msg='分片参数非法'), 400 if not file_id or index < 0 or total <= 0 or index >= total: return jsonify(code=400, msg='缺少必要参数'), 400 ext = name.rsplit('.', 1)[-1].lower() if '.' in name else '' if ext not in app.config['ALLOWED_EXTENSIONS']: return jsonify(code=400, msg='文件类型不允许'), 400 f = request.files.get('file') if f is None: return jsonify(code=400, msg='没有分片内容'), 400 target = os.path.join(chunk_dir(file_id), f'{index:06d}.part') if os.path.exists(target) and os.path.getsize(target) == f.content_length: # 分片已存在且大小一致,直接视为成功,天然支持重传 return jsonify(code=0, msg='分片已存在', done=False) f.save(target) if _chunks_ready(file_id, total): merged = merge_chunks(file_id, name, total) if merged: return jsonify(code=0, msg='合并成功', done=True, path=os.path.basename(merged)) return jsonify(code=0, msg='分片已保存', done=False)逻辑说明:secure_filename把file_id里的../之类全部干掉,防止写到chunks/外面;分片文件名用{index:06d}.part六位补零,保证字典序等于数字序;os.path.exists那段是重传去重,分片已经在且大小一致就不再写盘,直接返回成功,比前端拦截“跳过已上传分片”要稳得多。参数index和total都从表单里取,转 int 失败一律按非法处理,不要用默认值兜底。
3.3 合并函数:按序号显式拼接,别依赖目录顺序
合并这段最容易翻车的地方是“按目录返回顺序拼接”。os.listdir的顺序在不同文件系统上不一致,10.part在字典序里会排在2.part前面,拼出来的文件直接损坏。正确的做法是按range(total)显式拼路径。
def _chunks_ready(file_id, total): d = chunk_dir(file_id) for i in range(total): if not os.path.exists(os.path.join(d, f'{i:06d}.part')): return False return True def merge_chunks(file_id, name, total): src_dir = chunk_dir(file_id) out_name = secure_filename(name) or f'{file_id}.bin' out_path = os.path.join(app.config['UPLOAD_DIR'], out_name) tmp_path = out_path + '.tmp' with open(tmp_path, 'wb') as out: for i in range(total): part = os.path.join(src_dir, f'{i:06d}.part') if not os.path.exists(part): # 理论上前面的 _chunks_ready 已经拦过,这里做兜底 if os.path.exists(tmp_path): os.remove(tmp_path) return None with open(part, 'rb') as pf: while True: block = pf.read(4 * 1024 * 1024) if not block: break out.write(block) os.replace(tmp_path, out_path) # 原子替换,避免进程崩溃留下半成品 for i in range(total): p = os.path.join(src_dir, f'{i:06d}.part') if os.path.exists(p): os.remove(p) try: os.rmdir(src_dir) except OSError: pass return out_path逻辑说明:先写out_path + '.tmp',全部拼完再os.replace原子替换,中间进程挂了也不会让下载端读到半个文件;拼完才删分片,删除失败不影响主流程。参数:读取块 4MB,和 md5 计算的块大小保持一致,便于后续核对。
3.4 去重与查询接口:让前端知道这个文件是不是已经有了
除了/api/upload,还要两个轻量接口。一个是“这个文件合并过没有”,用于秒传提示;另一个是“这个文件当前传了多少片”,用于前端展示进度起点。
@app.route('/api/exists', methods=['GET']) def file_exists(): name = secure_filename(request.args.get('name', '')) if not name: return jsonify(code=400, msg='缺少文件名'), 400 path = os.path.join(app.config['UPLOAD_DIR'], name) return jsonify(code=0, exists=os.path.exists(path)) @app.route('/api/progress', methods=['GET']) def chunk_progress(): file_id = request.args.get('id', '') try: total = int(request.args.get('chunks', 0)) except ValueError: total = 0 if not file_id or total <= 0: return jsonify(code=400, msg='参数非法'), 400 d = chunk_dir(file_id) uploaded = [i for i in range(total) if os.path.exists(os.path.join(d, f'{i:06d}.part'))] return jsonify(code=0, uploaded=uploaded, count=len(uploaded), total=total)参数说明:/api/exists按文件名查,前提是合并后的成品名不重复;如果业务里同名文件会覆盖,就得在文件名后面拼file_id前六位。/api/progress返回已上传索引列表,前端可以直接用来渲染“已传 37/400 片”。注意这两个接口都要做参数校验,int()异常要接住,不然一个手工构造的 URL 就能让服务端 500。
4. WebUploader 接 Bootstrap:进度条、暂停和续传的页面结构
前端这层要做的事比后端直观,但细节更多。Bootstrap 负责把文件列表和进度条画得能看,WebUploader 负责分片和并发,中间靠事件把状态串起来。这一章按页面骨架、文件队列渲染、状态回调和秒传提示的顺序写,把“进度条卡在 99%”“每个分片都弹一次成功”这类问题提前堵掉。
4.1 页面骨架:选择、开始、列表三件套
页面尽量简单,一个选择按钮、一个开始按钮、一个文件列表就够了。Bootstrap 的 card 和 list-group 拿来就用,不需要自定义太多样式。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/webuploader/webuploader.css"> </head> <body> <div class="container py-4"> <div class="card"> <div class="card-body"> <div id="picker" class="btn btn-primary">选择文件</div> <button id="btn-start" class="btn btn-success" disabled>开始上传</button> <ul id="file-list" class="list-group mt-3"></ul> </div> </div> </div> <script src="/static/jquery.min.js"></script> <script src="/static/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/static/webuploader/webuploader.min.js"></script> <script src="/static/js/upload.js"></script> </body> </html>这段 HTML 里没有额外逻辑,#picker是 WebUploader 挂载选择按钮的容器,#btn-start在fileQueued之后才启用,#file-list是渲染队列的地方。注意 WebUploader 会接管#picker的点击事件,不要再给它绑自定义 click,否则可能一次点击弹两次选择框。
4.2 文件队列与进度条:用file.id当行标识
每个文件入队时 WebUploader 会给一个file.id,用它当li的 id,后续进度更新就能精确命中,不会串行。
uploader.on('fileQueued', function (file) { var $li = $( '<li class="list-group-item" id="' + file.id + '">' + '<div class="d-flex justify-content-between align-items-center">' + '<span class="file-name text-truncate" style="max-width:60%">' + file.name + '</span>' + '<span class="file-size text-muted small">' + (file.size / 1024 / 1024).toFixed(2) + ' MB</span>' + '</div>' + '<div class="progress mt-2" style="height:8px;">' + '<div class="progress-bar progress-bar-striped progress-bar-animated" ' + 'role="progressbar" style="width:0%"></div>' + '</div>' + '<div class="file-state small text-muted mt-1">等待上传</div>' + '</li>' ); $('#file-list').append($li); $('#btn-start').prop('disabled', false); }); uploader.on('uploadProgress', function (file, percentage) { var $bar = $('#' + file.id).find('.progress-bar'); $bar.css('width', (percentage * 100).toFixed(2) + '%'); $('#' + file.id).find('.file-state') .text('上传中 ' + (percentage * 100).toFixed(1) + '%'); });参数说明:percentage是 0–1 的浮点数,不是百分数,直接乘 100 再toFixed;file.size单位是字节,展示时除两次 1024。进度条用了 Bootstrap 的progress-bar-animated,视觉上有动感,但注意它只是动画,不代表真实速率。如果分片数很多(比如 400 片以上),uploadProgress触发频率会很高,可以在回调里做一次节流,别每个分片都重排一次布局。
4.3 成功、失败与合并完成:别把“分片成功”当“文件成功”
WebUploader 的uploadSuccess是按分片触发的,一个 400 片的文件会触发 400 次。如果在这里直接显示“上传完成”,用户会看到进度条刚过 1% 就提示成功,后面还有 399 个分片在跑。
uploader.on('uploadSuccess', function (file, response) { var $li = $('#' + file.id); if (response && response.done) { $li.find('.file-state') .removeClass('text-muted') .addClass('text-success') .text('合并完成:' + response.path); $li.find('.progress-bar') .removeClass('progress-bar-animated') .addClass('bg-success'); } else { $li.find('.file-state').text('分片已传,等待合并'); } }); uploader.on('uploadError', function (file, reason) { $('#' + file.id).find('.file-state') .removeClass('text-muted') .addClass('text-danger') .text('失败:' + reason); }); $('#btn-start').on('click', function () { uploader.upload(); $(this).prop('disabled', true).text('上传中…'); });逻辑说明:只有后端返回done: true才代表整个文件合并完成,其他情况一律视为“分片在传”。uploadError只负责展示,不自动重试,重试由chunkRetry在 WebUploader 内部处理。参数:reason是 WebUploader 给的错误描述,通常是http或server,想看具体状态码得在uploadAccept里读第二个参数。
4.4 秒传提示与上传前预检
秒传不是必须,但体验提升明显。用户选完文件,先请求一次/api/exists,命中的话直接在列表里标“已存在,无需上传”,并把该文件从队列里移除。
uploader.on('fileQueued', function (file) { $.getJSON('/api/exists', { name: file.name }, function (res) { if (res.code === 0 && res.exists) { $('#' + file.id).find('.file-state') .addClass('text-success') .text('服务端已存在,跳过上传'); uploader.removeFile(file, true); // 队列空了就把开始按钮恢复初始状态 if (uploader.getFiles().length === 0) { $('#btn-start').prop('disabled', true).text('开始上传'); } } }); });这段逻辑要注意两点:removeFile(file, true)第二个参数传true表示同时从队列和内存里移除,不传的话文件还挂在getFiles()里;移除之后如果队列空了,开始按钮要恢复禁用状态,不然用户点一下会报错。秒传本质上是用“文件名 + 大小”换一次查询,严格来说有误判风险(同名不同内容),预算够的话把前端指纹也带上做联合判断。
5. 避坑排查:分片上传里最容易翻车的 5 个点
这套东西跑通之后,我在不同环境里复现过五类问题,症状各不相同,但原因都指向协议或并发上的疏忽。下面按“现象 → 原因 → 解决”逐条写,遇到类似报错可以先对照这一节排查。
5.1 所有分片都传完了,合并出来的文件却打不开
现象:前端进度条 100%,后端日志显示合并成功,但下载下来的压缩包解压报错,或者文件大小比原文件小几十字节。
原因:合并时按os.listdir的返回顺序拼,没有按数字序号。chunks/目录里如果有10.part、2.part这种没有补零的文件名,字典序会把10排在2前面,拼出来的内容就是错位的。另一种情况是分片文件名补零位数不够,超过 999 片之后1000.part又排到了999.part前面。
解决:分片文件名统一用{index:06d}.part六位补零,合并时按range(total)显式拼路径,不依赖文件系统顺序。合并完成后再算一次 md5,跟前端传的指纹或源文件对比,不一致就删掉成品重来。
5.2 第一片就返回 413,前端只显示“上传失败”
现象:选择文件后立刻报错,浏览器 Network 面板里分片请求状态码 413,后端日志里连路由都没进。
原因:请求在网关层就被拦了。网关的client_max_body_size默认值通常只有 1MB,Flask 的MAX_CONTENT_LENGTH如果也没调,两层都会拦。前端拿到的错误信息只有http,看不出具体原因。
解决:两层都要放开,网关配置里加client_max_body_size 20m;,Flask 里设MAX_CONTENT_LENGTH = 20 * 1024 * 1024,两者都要大于 chunkSize 并留 1–2MB 余量。排查时先用curl直接打后端端口,绕开网关,能定位是哪一层拦的。
5.3 并发一高,合并函数被触发好几次
现象:日志里merge_chunks连续跑了三次,后两次报FileNotFoundError,最终成品只有一个,但中间产生了一堆.tmp残留。
原因:接收路由里用“目录里文件数 >= 总数”判断是否合并,threads=3时三个分片几乎同时到,三个请求都判断为真,同时进入合并。第一个合并完把分片删了,后两个找不到文件就抛异常。
解决:合并前抢锁。最简单的方式是用os.open(lock_path, os.O_CREAT | os.O_EXCL)创建一个标记文件,创建成功才进入合并,创建失败直接返回“分片已保存”。用完在finally里删掉标记文件。生产环境可以用文件锁fcntl.flock或直接丢给队列串行处理。
5.4 刷新页面后再传同一个文件,进度从 0 开始
现象:用户传了一半刷新浏览器,重新选择同一个文件,进度条从 0 开始,chunks/目录里出现两套分片。
原因:WebUploader 默认的id是WU_FILE_x这种会话级标识,刷新后重新生成,后端按新id建了新目录,旧分片成了孤儿。前端也没有做“已上传分片跳过”的处理。
解决:把id换成稳定指纹,name + size + lastModified拼字符串做一次 hash 即可,保证同一文件每次生成同一个值。后端接收时判断目标分片已存在且大小一致就直接返回成功,前端不需要拦截,重传会自然变成“秒传”。这套方案比在前端跳过已上传分片稳,因为 WebUploader 没有原生的“跳过分片”事件,硬拦截容易出兼容问题。
5.5chunks/目录越传越大,磁盘慢慢被吃满
现象:跑了一周之后chunks/占了几个 GB,里面全是没合完的残留分片。
原因:合并成功才删分片,但合并失败、进程重启、用户中途关页面这些情况都不会触发删除,残留就一直挂着。
解决:加一个定时清理任务,遍历chunks/下的子目录,按最后修改时间判断,超过 24 小时没更新的整目录删掉。清理任务要跳过正在合并的目录,可以在合并前写一个.merging标记文件,清理时看到标记就跳过。这个习惯我从那次磁盘告警之后就一直保留着。
6. 上传完还得能下载:stream-downloader 的 Range 支持与校验
上传跑通只是半条链路,另一半是下载。大文件下载如果一次性读进内存再返回,服务端内存会被打爆;如果客户端下到 99% 断网,没有 Range 支持就得从头再来。upload-demo 里的 stream-downloader 就是补这一块的,Flask 的send_file开conditional=True就能拿到 Range 能力,但有几个参数和验证步骤值得单独说。
6.1 send_file 的 conditional 参数和 Range 响应头
Flask 的send_file和send_from_directory都支持conditional=True,打开之后会根据请求头里的Range返回 206 状态码和对应片段。这是最省事的做法,不用自己写生成器。
from flask import send_from_directory @app.route('/api/download/<path:filename>') def download(filename): return send_from_directory( app.config['UPLOAD_DIR'], filename, as_attachment=True, conditional=True )参数说明:as_attachment=True让浏览器弹下载框而不是直接打开;conditional=True是 Range 支持的关键,不传的话永远返回 200 和完整文件;<path:filename>允许文件名里带子目录,但实际用的时候建议只允许单层,避免路径穿越。如果下载要走鉴权或限速,就不能用send_file,得换成逐块yield的生成器,同时在响应头里自己写Accept-Ranges: bytes和Content-Range。
6.2 用 curl 验证断点续传有没有真的生效
改完代码别急着在浏览器里点,先用curl看响应头,这一步能省掉很多“以为好了其实没好”的返工。
# 1. 看响应头里有没有 Accept-Ranges curl -I http://127.0.0.1:5000/api/download/test.zip # 2. 请求前 1KB,期望返回 206 和 Content-Range curl -r 0-1023 http://127.0.0.1:5000/api/download/test.zip -o part1.bin # 3. 请求最后 1KB,验证尾部也能取到 curl -r -1024 http://127.0.0.1:5000/api/download/test.zip -o part2.bin # 4. 合并后对比大小 ls -l part1.bin part2.bin判断标准很直接:第一条命令的响应头里必须有Accept-Ranges: bytes;第二条的响应码必须是206 Partial Content,且Content-Range形如bytes 0-1023/104857600;第三、四条确认首尾两段都能单独取到。少了任何一个,说明conditional没生效,或者中间有代理把 Range 头吃掉了。
6.3 我一般怎么收尾这套链路
这套东西我后来在自己机器上反复跑过很多次,总结出一个固定动作:上传完成后从uploads/里取出成品算一次 md5,再用curl走一遍 Range 下载,把下载回来的片段和原文件对应区间做一次字节比对。听起来麻烦,但它能同时验证合并顺序、Range 切片和文件完整性三件事,比等用户反馈“下到 99% 断了”要省心得多。从那以后我每次改完分片或下载相关的代码,都强制走一遍这套校验,再往上加新功能。希望帮到你。
本文还有配套的精品资源,点击获取