拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Vue 大文件上传断点续传:分片、哈希与并发优化实践

做了几年前端浏览器里上传几个GB的大文件这件事真不是 把 input 的 type 改成 file 就完事 那么简单。网速一抖、服务器一重启、用户不小心关了页面几百MB甚至几个G的文件就得从头再来换谁心态都得崩。这篇文章就是我实际在 Vue 项目里落地断点续传的完整记录从分片思路、哈希计算到并发控制和 Web Worker 优化每一步都会讲清楚为什么这么干以及我踩过哪些坑。1. 大文件上传的痛点和断点续传的核心思路1.1 为什么大文件直接上传会失败很多人在小文件上传时没遇到过问题就觉得大文件只是慢一点。真不是这样。HTTP 协议本身并没有限制请求体大小但实际链路里到处都有瓶颈Nginx 默认的client_max_body_size一般是 1m后端框架也有自己的请求体上限随便哪一个卡住上传就直接给你返回 413 或者连接中断。更麻烦的是网络的不确定性。普通宽带上行带宽本来就不大一个 2G 的文件可能要传一二十分钟甚至更久。这段时间里只要 TCP 连接抖动一次、用户把笔记本合上、或者 WiFi 换了个网络请求就断了浏览器不会帮你自动恢复一切归零。这种全量重传的体验在实际业务中是绝对不可接受的。我见过一个真实案例某个后台管理系统里用户要上传一个 3.8G 的数据库备份文件用的是最原始的form-data直接提交等了一个多小时结果在 97% 的时候网络断开前端报了个 Network Error文件彻底没传上去。那个用户当场就炸了。1.2 断点续传的基本原理把大文件切成小块断点续传的思路说起来特别朴素既然整个文件一次传不可靠那我们就把它切成很多个小块一块一块传每一块都是独立的请求。传完的块记录下来下次再传的时候只把没传过的块补上就行。这就像搬家搬一整个大衣柜你不可能一次把衣柜从五楼抬到一楼摔一跤就得从头再来。但如果你把它拆成三块柜门、抽屉、柜体即使某一块在中途被磕坏了你只需要回去重新扛那一块之前搬下去的两块不用再动。这个朴素思路落到技术上需要解决几个问题怎么切浏览器里用Blob.prototype.slice()方法可以把一个文件对象切成任意大小的分片。怎么知道哪些分片传过前端在上传前先向服务端发起一个查询请求服务端返回这个文件已经存在哪些分片。怎么证明分片属于同一个文件这是最核心的需要一个唯一标识来关联所有分片业内标准做法是计算整个文件的哈希值比如 MD5。1.3 整体技术方案选型在动手写代码之前我把技术栈定了一下前端框架Vue 3 Vite组件用组合式 API 组织逻辑状态管理用 Pinia。哈希算法spark-md5纯 JS 实现使用广泛兼容性没问题。分片大小默认 10MB 一片文件小于 10MB 就不分片直接整传。上传方式XMLHttpRequest封装成 Promise FormData因为浏览器原生进度事件upload.onprogress在原生 XHR 上最稳定。用 axios 也可以但发现 axios 在并发多个大请求的时候资源占用略高后面我详细说。后端接口设计我只说前端视角的约定接口作用请求参数返回内容POST /upload/check查询文件是否已存在、已传哪些分片fileHash、fileName{ uploaded: boolean, uploadedChunks: number[] }POST /upload/chunk上传单个分片fileHash、chunkIndex、chunkTotal、chunk文件流成功后返回该分片的存储结果POST /upload/merge所有分片传完后通知服务端合并fileHash、fileName、chunkTotal合并后的文件信息这个方案不是唯一的但在中小型项目中是最成熟、最稳妥的一套。后面我会把每个环节的关键代码和为什么这么写的逻辑都展开讲。2. 前端分片与文件指纹计算整个方案的地基2.1 分片方案怎么定才合理分片大小的选择非常关键不是越大越好也不是越小越好。分片太大就失去了断点续传的意义一个分片传一半断了照样得重传分片太小又会导致请求数量爆炸比如一个 2G 文件切成 1MB 一片就是 2048 个请求HTTP 连接建立的耗时和请求头开销会严重拖慢上传速度。我自己实测下来的经验值是这样的文件大小建议分片大小分片数量0 - 100MB不切分或 2MB1 - 50 片100MB - 1GB5MB20 - 200 片1GB - 10GB10MB100 - 1000 片10GB 以上20MB500 片左右这里有一个需要平衡的点分片越多细粒度续传越灵活但总请求数和资源开销越大。我最终在项目里定的是 10MB 一刀切对于大多数业务场景几百MB到几GB的文件这个值很顺手。分片代码很简单利用File.prototype.slice// 文件名 分片大小返回分片数组 function createChunks(file, chunkSize 10 * 1024 * 1024) { const chunks []; let cur 0; while (cur file.size) { chunks.push(file.slice(cur, cur chunkSize)); cur chunkSize; } return chunks; }slice()不会真的把文件内容复制一份它只是创建了一个指向原文件特定字节范围的引用底层内存是共享的。所以即使文件很大分片操作本身不会导致内存暴涨可以放心用。2.2 用 spark-md5 计算文件唯一标识分片切好了但服务端怎么知道这些分片是同一个文件的我用的办法是在整个文件上传之前先计算整个文件的 MD5 哈希值用这个哈希值作为文件唯一标识。spark-md5 支持增量计算可以一段一段地把数据喂进去不需要把整个文件一次性读进内存。我封装了一个计算文件 MD5 的函数import SparkMD5 from spark-md5; function calcFileHash(file) { return new Promise((resolve, reject) { const chunkSize 2 * 1024 * 1024; // 每次读 2MB const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let currentChunk 0; const totalChunks Math.ceil(file.size / chunkSize); reader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk totalChunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror (e) reject(e); function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }这里注意一个小细节FileReader的readAsArrayBuffer每次读取 2MB 是为了避免一次读入整个大文件导致内存撑爆。2MB 这个值也是经验值太大页面会卡太小读文件的循环次数太多反而慢。提示MD5 本身不是为安全设计的哈希算法存在碰撞可能但在文件上传这种场景里它只是用来做断点续传的标识不是做安全校验所以完全够用。2.3 哈希计算的性能瓶颈大文件卡死在算指纹这一步当我第一次把上面这段代码跑起来的时候发现一个大问题一个 3GB 的文件在主线程计算 MD5页面直接卡死用户动不了鼠标进度条也不动看起来就像是浏览器崩溃了。为什么会这样因为spark.append()要处理的数据量等于整个文件的大小这是一个非常重的 CPU 密集任务全部跑在浏览器的主线程UI 线程上主线程一被占满页面渲染、事件响应全部暂停。最直接的优化方案是改用requestAnimationFrame或者setTimeout把一个长任务拆成多个小任务让出主线程给 UI 渲染。但更好的方案是用 Web Worker把计算量从主线程彻底搬走。这一点我在第 4 部分会详细讲因为它值得单独的篇幅。另外还有一个备选方案是抽样哈希不计算整个文件的 MD5而是只取文件开头、中间、结尾各几 MB 拼起来算一个哈希。这种方案速度快得多但存在极小概率的哈希碰撞而且如果两个文件的头中尾恰好相同就会误判为同一文件。我自己的态度是业务上追求可靠就全量计算追求速度且文件极大比如 10GB 以上再考虑抽样方案。3. Vue 中断点续传的核心实现从 0 到 1 的完整流程3.1 项目结构和状态设计我先说项目里的目录结构和状态设计因为这一块很多人一开始就乱掉后面越写越难维护。src/ components/ FileUploader.vue // 上传组件 composables/ useUploader.ts // 上传核心逻辑 useChunkUpload.ts // 分片上传逻辑 worker/ hashWorker.ts // 哈希计算 worker stores/ uploadStore.ts // 上传状态管理Pinia我强烈建议把上传逻辑抽到组合式函数composable里而不是全部写在 Vue 组件中。原因很简单上传逻辑包含状态流转、并发控制、错误重试代码量轻松上千行全部塞进组件会变成灾难。抽成 composable 以后组件里只保留 UI 绑定比如进度条、按钮状态、文件列表。核心状态我用 Pinia 管理// stores/uploadStore.ts export const useUploadStore defineStore(upload, { state: () ({ fileList: [], // 文件列表 uploadStatus: idle, // idle | hashing | uploading | paused | done | error hashProgress: 0, // 哈希计算进度 uploadedMap: {}, // 每个文件的已上传分片记录key 是 fileHash uploadProgress: 0, // 当前文件的整体上传进度 }), });这里的uploadedMap是整个断点续传方案的关键它记录的是这个文件已经传了哪些分片。页面刷新后这个数据会丢失所以还要靠服务端返回。3.2 分片上传主流程先查后传再合并我把整个上传流程设计成了三个明确的阶段查询 - 上传缺失分片 - 请求合并。第一步用户选择文件后组件拿到 File 对象先校验大小超过配置阈值就走分片上传然后计算文件哈希。哈希算出来之后立刻调用POST /upload/check接口async function checkUpload(fileHash, fileName) { const res await request({ url: /upload/check, method: post, data: { fileHash, fileName }, }); // 返回 { uploaded: true/false, uploadedChunks: [0, 2, 3, ...] } return res.data; }这一步解决了两个问题秒传如果服务端返回uploaded: true说明这个文件之前已经完整传过了前端不需要再上传任何分片直接显示上传成功。这个功能在很多网盘里都有体验极佳。断点续传如果服务端返回了uploadedChunks数组比如[0, 2, 3]就说明第 0、2、3 号分片已经存在前端只需要把剩余的 1、4、5... 号分片传上去。第二步非常关键看一段源码async function uploadFile(file) { const chunks createChunks(file, CHUNK_SIZE); const fileHash await calcFileHash(file); // 如果传了 Hash 回调可以让外部展示哈希计算进度 const { uploaded, uploadedChunks } await checkUpload(fileHash, file.name); // 计算出需要上传的分片索引 const remainIndexes chunks .map((_, index) index) .filter((index) !uploadedChunks.includes(index)); const chunkRequests remainIndexes.map((index) { const chunk chunks[index]; const formData new FormData(); formData.append(file, chunk); formData.append(fileHash, fileHash); formData.append(chunkIndex, index); formData.append(chunkTotal, chunks.length); return request({ url: /upload/chunk, method: post, data: formData, headers: { Content-Type: multipart/form-data }, }); }); // 并发控制见第 4 部分 await runWithConcurrency(chunkRequests, 3); // 所有分片上传完成通知服务端合并 await request({ url: /upload/merge, method: post, data: { fileHash, fileName: file.name, chunkTotal: chunks.length }, }); }第三步的合并接口是服务端在所有分片都到齐后把它们按照chunkIndex顺序拼接成完整文件。这一步对前端来说就是发个请求但我要提醒一个细节合并接口要设计成幂等的。如果合并请求因为网络原因发出去两次服务端不能报错第二次请求应该直接返回成功或者先检查文件是否已存在否则会出现上传成功但实际合并失败的诡异问题。3.3 断点续传的恢复逻辑页面刷新也不怕断点续传最核心的价值就是当上传中断后用户再次选择同一个文件能接着传而不是重头传。实际恢复逻辑是这样的用户再次选择同一个文件后前端重新计算文件哈希。文件名可以变但内容不变哈希就不变。调用checkUpload服务端根据哈希查数据库返回已上传的分片索引。前端过滤掉已上传的分片只传剩余部分。全部传完后再次调用合并接口。这里有一个重要的工程细节服务端需要一个文件上传任务表来记录分片上传状态。每一片上传成功后服务端要把记录落库或者写到 Redis这样才能在任意时刻知道这个文件传到了哪里。踩过一个坑有一版实现里服务端是把分片文件直接存在临时目录里用文件是否存在来判断这个分片传过了。表面看没问题但服务端一旦重启或者做了磁盘清理临时文件没了前端就会把已经传过的分片全部重传一遍。后来我明确要求后端同学把分片状态记录到数据库以记录而不是文件存在性作为续传依据这个问题才彻底解决。你控制不了服务端的重启策略所以不要让临时文件存在性成为唯一判断依据。3.4 进度管理不仅要算得准还要显示得明白在上传过程中前端需要展示两类进度单个分片的进度用 XHR 的upload.onprogress事件获取每个分片都有。整个文件的进度等于已成功上传的分片数 / 总分片数加权的公式要考虑分片大小但在固定分片大小下直接按分片数量算就行。还有一个细节上传和哈希计算的进度是两回事。哈希计算阶段显示计算文件指纹 xx%上传阶段显示上传中 xx%这两段进度我分开展示否则用户会看到进度卡在某个位置不动误以为程序挂了。整体进度的核心代码function getOverallProgress(chunkTotal, uploadedChunkCount, currentChunkIndex, currentChunkProgress) { // 已经完成的占比 当前正在上传分片的占比 return ( (uploadedChunkCount currentChunkProgress / 100) / chunkTotal ) * 100; }这个公式看着简单但实际开发中很多人的进度会跳变明明传了一半进度突然从 40% 跳回 20%。我排查过原因是并发上传时多个分片同时回调进度而状态更新时没有对已上传分片数做去重导致重复计数然后又被下一次更新覆盖。解决方法是进度更新使用一个固定的、幂等的状态记录方式已成功分片用 Set 存储set.add(index)天然去重。4. 并发控制与 Web Worker 优化把上传速度和安全都拉满4.1 为什么要控制并发数量如果拿到了所有需要上传的分片就一股脑全部同时发起请求比如一个文件切了 300 片同时发 300 个请求会发生什么首先浏览器对同一域名的并发连接数是有限制的HTTP/1.1 下一般是 6 个左右剩下的请求全部排队并没有真正同时上传。其次同时创建大量 XHR 请求会占用大量内存和网络带宽上传速度不仅不会变快反而会因为连接竞争而变慢。更危险的是大量并发请求会让服务端压力陡增分片写入时如果涉及磁盘 IO很容易出现超时或写入失败。所以需要实现一个并发池同时最多跑 n 个上传请求一个完成从任务队列里拉下一个。async function runWithConcurrency(tasks, limit 3) { const results []; const executing new Set(); for (const task of tasks) { const promise Promise.resolve().then(() task()); results.push(promise); executing.add(promise); const clean () executing.delete(promise); promise.then(clean).catch(clean); if (executing.size limit) { await Promise.race(executing); } } return Promise.all(results); }limit取多少合适我一般推荐 3-5。太低浪费带宽太高会给服务端造成压力。我在一个实际项目中测过3 并发和 6 并发总耗时几乎没有差别但服务端 CPU 和磁盘 IO 占用明显不同。并发不是越高越好稳定才是第一位的。4.2 并发条件下的失败重试分片并发上传后另一个新问题就出现了某个分片因为网络抖动失败了怎么办我的策略是失败自动重试每个分片最多重试 3 次。重试的时候不需要手动重新排队只需要把失败的任务重新放入并发池即可。async function uploadChunkWithRetry(chunkUploadFn, retries 3) { try { return await chunkUploadFn(); } catch (err) { if (retries 0) { throw err; } // 退避策略每次重试等待时间递增 await sleep(1000 * (4 - retries)); return uploadChunkWithRetry(chunkUploadFn, retries - 1); } }这里的退避等待时间也很讲究。如果所有失败的分片同时立刻重试极大概率会再次同时失败因为服务端可能还在高负载中。我采用简单的线性退避第 1 次重试等 1 秒第 2 次等 2 秒第 3 次等 3 秒。重试 3 次仍然失败就停止该文件的整体上传并明确告诉用户失败原因。4.3 用 Web Worker 把哈希计算搬出主线程前面提到过整个文件计算 MD5 是一个十分耗时的操作3GB 的文件在普通笔记本上可能要算 10-20 秒这期间主线程被占住页面完全无法操作。Web Worker 的本质是另开一个线程来跑 JS主线程和 Worker 之间通过消息通信。我把哈希计算逻辑封装成一个 Worker// worker/hashWorker.ts import SparkMD5 from spark-md5; self.onmessage (e) { const { file, chunkSize } e.data; const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let currentChunk 0; const totalChunks Math.ceil(file.size / chunkSize); reader.onload (ev) { spark.append(ev.target.result); currentChunk; self.postMessage({ type: progress, percent: Math.round((currentChunk / totalChunks) * 100), }); if (currentChunk totalChunks) { loadNext(); } else { self.postMessage({ type: done, hash: spark.end() }); } }; reader.onerror () { self.postMessage({ type: error, message: 读取文件失败 }); }; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); };主线程里创建和使用 Workerfunction calcFileHashInWorker(file) { return new Promise((resolve, reject) { const worker new Worker(new URL(../worker/hashWorker.ts, import.meta.url), { type: module, }); worker.postMessage({ file, chunkSize: 2 * 1024 * 1024 }); worker.onmessage (e) { if (e.data.type done) { worker.terminate(); resolve(e.data.hash); } else if (e.data.type error) { worker.terminate(); reject(new Error(e.data.message)); } }; }); }这里有个兼容性问题需要注意File对象通过postMessage传给 Worker 时是结构化克隆structured clone不是简单引用。本质上相当于把文件的引用传给了 Worker文件数据并不需要被复制所以内存开销是可控的。验证完 Web Worker 后我实测了一个 2.5GB 的文件主线程计算时页面帧率掉到个位数鼠标拖动都卡搬到 Worker 后页面其他操作完全流畅只有进度条在正常增长。这个优化对用户体验的提升是肉眼可见的强烈建议直接采用。注意部分老旧的浏览器比如 IE 和早期 Edge不支持 Web Worker如果你的项目要兼容这类浏览器需要保留一个主线程计算的降级方案。4.4 上传组件里的 UI 反馈优化上传过程中除了数值进度我还加了两个细节提升体验第一取消按钮。用户点取消后前端的并发池要立即停止发出新请求已经发出的请求通过AbortController中断。同时把当前的任务状态保存到服务端这样下次再传时可以接着来。const controller new AbortController(); // 在 request 封装里传入 signal request({ url: /upload/chunk, method: post, data: formData, signal: controller.signal }); // 用户点击暂停 controller.abort();第二分片级别的可视化。我在上传列表里用一个小表格展示每个分片的状态未开始、上传中、已成功、失败。这个功能在生产环境非常有用用户能直观地看到卡在哪一片客服排查问题时也能准确定位。5. 常见问题与排查技巧实录断点续传落地过程中我遇到了不少坑有些是前端的问题有些是前后端配合的问题我把它们整理成一份速查表方便你遇到类似问题直接对号入座。现象可能原因排查方向与解决方案页面卡死点击无响应哈希计算占满主线程改用 Web Worker 计算哈希上传到一半进度归零服务端临时文件被清理或校验逻辑失效服务端用数据库记录分片状态而不是依赖临时文件存在性并发上传时进度来回跳已上传分片重复计数用 Set 存储已成功分片索引幂等去重部分分片请求 413Nginx 的client_max_body_size设置太小调大服务端和反向代理的请求体限制所有分片传完但文件无法打开合并时顺序错乱确认后端按chunkIndex严格排序后合并刷新页面后无法继续前端没有重新查询已上传分片上传前必须调用 check 接口拿到uploadedChunks再过滤服务端收到大量一模一样的文件存储合并接口没有做幂等处理合并前先检查文件是否已存在存在则直接返回成功上传速度远低于带宽上限并发数太低或分片太小适当调大并发数和分片大小找到平衡点5.1 哈希计算卡顿问题再补充这个问题太常见我再多说几句。如果你确实因为兼容性原因不能使用 Web Worker还有一个降级方案把计算任务拆进requestAnimationFrame每次只算一小段然后让出主线程给浏览器渲染。function calcHashWithRaf(file, callback) { const chunkSize 1 * 1024 * 1024; const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let currentChunk 0; const totalChunks Math.ceil(file.size / chunkSize); function processNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); reader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk totalChunks) { requestAnimationFrame(processNext); } else { callback(spark.end()); } }; reader.readAsArrayBuffer(file.slice(start, end)); } processNext(); }这个方案比一次性计算好很多因为requestAnimationFrame会在每次浏览器绘制前执行一次页面至少不会完全冻结。但要注意它整体耗时比 Worker 方案要长一些所以能上 Worker 还是优先 Worker。5.2 后端 merge 接口的幂等处理合并接口是我和前端口水战最多的一个接口。前端的诉求是我多调用几次不能报错后端一开始写的是文件存在就返回 500。最后我们达成的约定是合并前检查分片是否齐全不齐全返回明确错误码附上缺失分片的索引前端可以据此补传。合并后检查最终文件是否存在存在则直接返回成功不重复合并。合并期间如果出现磁盘写入错误返回可重试的错误码前端可以稍后重试。前端代码里还要对合并失败做处理如果合并失败不是整个文件重传而是重新走一次 checkUpload把缺失的分片补传再重新合并。这个逻辑是断点续传方案的最后一块拼图也是最容易被忽略的边界情况。5.3 测试断点续传的实用技巧给断点续传写测试时不要真的去把一个 2G 文件传一大半然后拔网线那样效率太低。我常用的办法是在开发模式下给并发上传函数加一个失败注入参数模拟第 N 个分片强制失败function shouldInjectFailure(index) { const injectAt Number(localStorage.getItem(injectFailureAt)); return injectAt index; } // 上传分片时 if (shouldInjectFailure(chunkIndex)) { throw new Error(模拟网络中断); }设置injectFailureAt5后第 5 片必然失败前端应该自动重试重试仍失败则记录状态。刷新页面后再次选择文件第 5 片应该被过滤掉因为它已经传成功了只传后续分片。这个测试方法很高效能覆盖大部分断点续传的核心逻辑。5.4 大文件上传的安全与校验最后提一下校验问题。分片上传过程中其实很难保证每个分片在网络上传输时完全没有损坏。可靠的做法是在分片上传成功后返回该分片在服务端存储的 MD5前端对比是否与本地分片的 MD5 一致不一致就认为该分片损坏需要重传。这个方案的成本略高因为每个分片都要额外算一次 MD5。但对数据完整性要求很高的业务比如医学影像、银行回单这个开销是值得的。常规业务可以使用简化方案在最终合并后服务端计算整个文件 MD5前端用本地计算的文件 MD5 与它对比不一致则提示合并后校验失败并触发重传。这种兜底校验成本低也能挡住绝大多数异常。6. 从我的项目里总结出的优化经验代码写完不是终点,上线之后还会遇到很多现实问题。我的优化经验可以总结为三条线第一条线是速度。分片大小、并发数、哈希方式是影响上传速度的三个核心变量。我建议你做一个简单的扫描测试用同一个 500MB 文件分别用 5MB/10MB/20MB 分片 2/3/5 并发组合跑一遍记录总耗时和服务端资源占用选一组最优的而不是拍脑袋定。不同网络环境和服务端配置下最优组合可能不同。第二条线是稳定。分片上传最大的优势不是快而是稳定。断点续传、失败重试、幂等合并、进度去重这些机制保证的是无论网络多差只要最终所有分片都传完了文件就是完整的。我在生产环境中观察到加了重试和续传机制后大文件上传成功率从大概 70% 提升到了 99.5% 以上这个提升比任何速度优化都值钱。第三条线是体验。用户看不到分片只能看到进度条和状态提示。一个靠谱的上传组件需要有明确的阶段划分计算指纹中、上传中、合并中需要有暂停/取消功能失败时要给明确的错误提示而不是一个冰冷的红色 toast。我还做了刷新后自动恢复上传任务的功能进入上传页时读取本地存储的 fileHash主动 check 一次如果有未完成的任务就弹窗询问用户检测到未完成的上传是否继续。这个功能上线后用户满意度提升非常明显。最后再说一个容易被坑的点分片上传功能的开发要前后端联调落地前端千万不要一个人闷头写完就交付。我曾经把 check、chunk、merge 三个接口的协议和联调文档写得明明白白后端同学实现时还是把 chunk 的字段名chunkIndex写成了index联调时排查了很久。建议时间允许的话把接口协议用 OpenAPI/YApi 之类的工具固化下来再用前端写一个冒烟测试脚本自动跑一遍核心流程能把这类低级问题挡在开发阶段。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门