开局先聊点实际的:国产化终端上,最容易翻车的不是业务逻辑,而是文件上传下载。
很多团队在移植Web系统时,功能测试都过了,一放到国产化环境就直接卡壳——要么传不上去,要么下载下来是乱码,要么浏览器直接弹个不支持的插件提示。文件上传下载看着简单,真正适配国产化系统时,你会碰到浏览器内核差异、本地文件权限限制、后端接口兼容性,还有ActiveX控件依赖等一系列问题。这篇文章我就把我在信创项目里实际用过的三种方案完整拆开讲,从原理、代码到踩坑记录,一次说清楚。
三种方案分别是:原生表单上传(input + FormData + AJAX)、前端组件库封装方案(以Element Upload为主)、以及浏览器原生文件系统API(File System Access API)。每条路线都有它存在的理由和适用的场景,我一个个说。
1. 内容整体设计与思路拆解:为什么国产化环境要单独考虑文件上传下载?
1.1 国产化系统的真实技术背景
先说清楚“国产化系统”到底是啥。我们日常说的国产化,通常指基于Linux内核深度定制的操作系统,比如麒麟(Kylin)、统信UOS(UOS)。浏览器方面,常见的包括360安全浏览器(国产化版本)、奇安信浏览器、红莲花浏览器、火狐浏览器等,其中大部分走的是Chromium内核,少部分保留了IE兼容模式。
在做技术选型的时候,你要记住一个关键事实:国产化浏览器主流的还是Chromium内核,但版本普遍落后,Chromium 80到100之间的版本都非常常见。这意味着什么呢?意味着很多新特性你不能用,比如最新的File System Access API在旧内核上根本不识别;也意味着很多旧的坑你得踩,比如HTTP/2兼容、TLS版本、MIME类型识别等等。
这一章我想重点讲的是:为什么别的功能移植到国产化环境只要改改样式,而文件上传下载却要全盘重新设计?
- 第一,文件上传下载天然涉及本地文件系统权限。Windows上的IE内核可以通过ActiveX绕过浏览器沙箱直接操作本地文件,这套逻辑在国产化Linux系统上完全行不通——没有ActiveX,也没有IE内核。
- 第二,国产化浏览器默认安全策略更严格。很多国产浏览器默认禁止混合内容(HTTP页面里调用HTTPS接口),会对非标准端口的请求做拦截,跨域校验也更死板。
- 第三,文件编码问题。国产化系统默认文件编码通常是UTF-8,如果你后端代码写死了中文编码或者Windows-1252,下载的文件名直接就是乱码。
1.2 三条技术路线的选型决策逻辑
做技术方案,最怕的就是“一把梭”。我根据自己的经验,把三种方案的适用边界梳理了一遍:
| 方案 | 核心实现方式 | 适用场景 | 主要风险 |
|---|---|---|---|
| 方案一 | input + FormData + XMLHttpRequest/fetch | 通用性强,兼容所有浏览器 | 大文件上传体验差,无进度控制细节 |
| 方案二 | 前端组件库封装(Element/AntD Upload) | 中后台管理系统,开发效率优先 | 组件自带逻辑有时与国产化浏览器有兼容冲突 |
| 方案三 | File System Access API + 分片上传 | 需要本地目录读写、大文件传输、离线断点续传 | 旧版本Chromium不支持,兼容性门槛高 |
选型逻辑其实就一句话:能用方案一解决的绝不上方案三,需要快速交付的用方案二,真有硬性需求的才考虑方案三。我在实际项目里,大部分情况下用的是方案二封装 + 方案一兜底,只有在档案系统、成果附件上传这类大文件场景才启用方案三。
提示:不论选哪种方案,开发阶段就要在国产化操作系统的浏览器上反复测试,不要到最后联调才暴露问题。一旦功能模块被封版,再改上传逻辑就是伤筋动骨。
2. 方案一:原生表单上传——最基础也最稳妥的兜底方案
2.1 为什么这个方案永远不能被抛弃
原生表单上传是Web的“祖宗方案”,任何浏览器、任何系统、任何版本都支持,没有例外。你不需要引入任何第三方依赖,不需要担心组件库和浏览器内核冲突,它就是最简单可靠的选择。
之前项目里有个坑我印象深刻:用了某个UI组件库的Upload组件后,国产化浏览器直接报错“无法获取未定义或 null 引用的属性”,那个组件内部用了较新的JavaScript语法,旧内核不支持。当时我们紧急用原生input方案替换,半小时就解决了问题。
原生方案还能保证一个很重要的特性:完全掌控请求过程。你可以自由控制请求头、超时时间、并发数量,甚至在后端接口发生变化时快速调整,完全不用和组件库的内部逻辑纠缠。
2.2 完整代码实现:从input到服务端接收
实现思路分三步:用户选择文件 → 组装FormData → AJAX异步发送。
HTML部分就是一个简单的input,核心点在于accept属性和multiple属性:
<input type="file" id="fileInput" accept=".xlsx,.xls,.doc,.docx,.pdf,.jpg,.png" multiple /> <button id="uploadBtn">开始上传</button>JavaScript部分,我推荐用原生XMLHttpRequest而不是fetch,因为XHR天然支持上传进度监听:
document.getElementById('uploadBtn').addEventListener('click', function() { const fileInput = document.getElementById('fileInput'); if (fileInput.files.length === 0) { alert('请先选择文件'); return; } const formData = new FormData(); // 支持多文件,遍历加入FormData for (let i = 0; i < fileInput.files.length; i++) { // 关键:这里的key可以重复,后端用同一个字段名接收多文件 formData.append('files', fileInput.files[i]); } const xhr = new XMLHttpRequest(); // 上传进度事件,大文件场景尤其重要 xhr.upload.addEventListener('progress', function(e) { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); // 把percent渲染到UI,比如显示“已上传45%” console.log('上传进度:' + percent + '%'); } }); xhr.addEventListener('load', function() { if (xhr.status === 200) { let response = JSON.parse(xhr.responseText); // 根据后端返回结构判断是否成功 console.log('上传成功', response); } else { console.error('上传失败,HTTP状态码:' + xhr.status); } }); xhr.addEventListener('error', function() { console.error('网络异常,上传中断'); }); xhr.open('POST', '/api/upload', true); // 不要手写 Content-Type,让浏览器自动生成boundary // xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // 千万别写这行! // 如果需要身份验证,加token头 xhr.setRequestHeader('Authorization', 'Bearer ' + localStorage.getItem('token')); xhr.send(formData); });这里要重点提醒一个新手必踩的坑:不要手动设置XHR的Content-Type为multipart/form-data。浏览器会自动在Content-Type后面追加一个boundary参数用于分隔不同字段数据,如果你手动设置了Content-Type,上传的文件后端几乎100%解析不出来,报“未上传文件”或者“文件损坏”。
后端接收这块不多展开,但给个Java Spring Boot的例子方便对照:
@PostMapping("/api/upload") public R upload(@RequestParam("files") MultipartFile[] files) { for (MultipartFile file : files) { // 判断文件大小、类型,落盘或上传OSS } return R.ok(); }2.3 下载功能的原生实现:老浏览器也能跑
上传说完,下载同样有讲究。很多人的第一反应是window.open(url),这在国产化环境里会有两个问题:
- 如果URL需要携带token,URL会超长,浏览器GET请求可能被拒绝。
window.open直接打开文件流时,某些浏览器会把二进制内容显示成乱码页面,而不是触发下载。
我推荐的做法是隐藏iframe或者动态a标签加blob方式。比较通用的实现是用a标签加download属性加blob:
function downloadFile(url, fileName) { const xhr = new XMLHttpRequest(); xhr.open('GET', url, true); xhr.responseType = 'blob'; // 关键:声明响应为二进制的blob xhr.setRequestHeader('Authorization', 'Bearer ' + localStorage.getItem('token')); xhr.addEventListener('load', function() { if (xhr.status === 200) { const blob = xhr.response; // 兼容极旧浏览器的下载方式 if (window.navigator.msSaveOrOpenBlob) { // 部分国产浏览器旧版本走这个分支 window.navigator.msSaveOrOpenBlob(blob, fileName); } else { const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 释放内存 window.URL.revokeObjectURL(downloadUrl); } } else { console.error('下载失败,状态码:' + xhr.status); } }); xhr.send(); }为啥用blob而不是直接用原URL?核心原因是可以带上鉴权信息。很多文件URL是有权限控制的,直接GET URL不带token会404;另一个原因是blob能正确保留文件流格式,避免浏览器尝试解析二进制文件为网页。
注意:动态a标签的download属性对同源URL有效,跨域下载时download属性在某些浏览器里会被忽略。如果真的跨域下载,建议后端做成同源代理接口,“后端取文件再流式返回给前端”,这是最稳的。
3. 方案二:组件库封装方案——中后台系统提效的利器
3.1 为什么还要用它,组件库到底封装了什么
方案一虽然通用,但真正做项目时你会觉得很繁琐——进度条要自己画,错误提示要自己弹,成功回调要自己处理,多文件列表管理要自己写。这时候组件库封装的Upload组件就能帮上大忙。
以国内最常见的Element UI(Vue 2)和Element Plus(Vue 3)为例,Upload组件把文件选择、上传队列、进度展示、成功失败状态管理、文件列表展示全部封装好了,你只需要配置几个回调函数即可。
移动端还不明显,但在中后台管理系统里,这类组件最大的价值是:它能让你少写大约300行与文件上传相关的UI代码,项目工期紧的时候这就是生命线。
3.2 el-upload核心参数配置实战
下面是一个基于Vue 3 + Element Plus的文件上传配置模板,我在多个国产化项目中验证过:
<template> <el-upload ref="uploadRef" action="/api/upload" :auto-upload="true" :file-list="fileList" :accept="'.xlsx,.xls,.doc,.docx,.pdf,.jpg,.png'" :headers="uploadHeaders" :data="uploadData" :limit="5" :on-exceed="handleExceed" :before-upload="beforeUpload" :on-success="handleSuccess" :on-error="handleError" :on-progress="handleProgress" :on-remove="handleRemove" :disabled="uploading" > <el-button :loading="uploading">点击上传</el-button> <template #tip> <div class="el-upload__tip">支持xlsx/xls/doc/docx/pdf/jpg/png格式,单个文件不超过20MB</div> </template> </el-upload> </template>对应Script部分的实现逻辑:
import { reactive, computed } from 'vue'; export default { setup() { const uploadData = reactive({ source: 'portal', businessId: '123456' }); // 注意:直接用computed确保每次上传都取到最新token const uploadHeaders = computed(() => { return { Authorization: 'Bearer ' + localStorage.getItem('token') }; }); // 上传前的校验,返回false则中断上传 function beforeUpload(file) { const allowTypes = ['xlsx', 'xls', 'doc', 'docx', 'pdf', 'jpg', 'png']; const extension = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase(); if (!allowTypes.includes(extension)) { ElMessage.error('不支持该文件格式'); return false; } if (file.size > 20 * 1024 * 1024) { ElMessage.error('文件大小超过20MB限制'); return false; } return true; } function handleSuccess(response, file, fileList) { // 如果后端返回业务错误码,也要在这里手动ElMessage提示 if (response.code === 200) { ElMessage.success('文件上传成功'); } else { ElMessage.error(response.msg || '上传失败'); } } function handleError(err) { ElMessage.error('上传网络异常'); console.error('upload error:', err); } function handleProgress(event, file, fileList) { // 可以在这里实现自定义进度效果,默认组件自带进度条 } function handleExceed(files, fileList) { ElMessage.warning('最多上传5个文件,已超出数量限制'); } return { uploadData, uploadHeaders, beforeUpload, handleSuccess, handleError, handleProgress, handleExceed }; } };这里我想提醒一个很重要的国产化兼容性细节:Element Plus的Upload组件内部默认用axios的XHR机制,走的也是FormData格式,这本身没有问题,但在国产化浏览器上,如果后端接口返回的数据不是标准JSON格式,或者响应头的Content-Type没有正确设置为application/json,组件的onSuccess回调可能不会被触发,而是走onError。我们排查过类似问题,最后发现是后端设置响应头的时候漏了一段跨域配置。
3.3 下载场景的封装思路:统一处理大文件与签名请求
组件库的Upload只管上传,下载还是要自己封装。我习惯在项目里做一个统一的download.js工具类,把上一章的blob下载逻辑封装成公共方法:
import axios from 'axios'; import { ElMessage } from 'element-plus'; export async function downloadFileWithAuth(url, params, fileName, method = 'get') { try { const response = await axios({ url, method, params, responseType: 'blob', timeout: 60000, headers: { Authorization: 'Bearer ' + localStorage.getItem('token') } }); // 非常重要:如果后端返回的是JSON错误信息,responseType=blob会把它变成了blob, // 需要手动判断blob类型再决定是提示错误还是触发下载 const blob = response.data; if (blob instanceof Blob) { const fileNameMatch = blob.type.includes('application/json'); if (fileNameMatch) { // 后端可能返回了错误信息,解析出错误提示 const reader = new FileReader(); reader.onload = (e) => { const errorMsg = JSON.parse(e.target.result).msg || '下载失败'; ElMessage.error(errorMsg); }; reader.readAsText(blob); return; } } // 真正的文件名优先级:传入文件名 > 响应头中的文件名 > URL末尾 let finalName = fileName; const disposition = response.headers['content-disposition']; if (!finalName && disposition) { const regex = /filename\*=utf-8''([^;]+)/; const match = disposition.match(regex); if (match) { finalName = decodeURIComponent(match[1]); } } const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = finalName || 'download_' + Date.now(); document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); } catch (error) { ElMessage.error('下载请求异常'); console.error('download error:', error); } }这个工具类在你的组件里可以直接复用。这里建议你从头到尾封装好Error Blob检测,否则会碰到一个经典问题:后端明明鉴权失败了,返回的是JSON的401,前端却下载了一个名为后端接口路径的乱码文件。那个坑排查起来真的费时间。
4. 方案三:File System Access API与分片上传——面向大文件和深度集成的进阶方案
4.1 File System Access API到底是什么、能用在哪
方案三是最“现代”的一条路线。File System Access API是W3C推出的浏览器原生文件系统访问接口,能让网页直接读取本地目录、创建文件、写入文件,相当于把之前只有Electron这类桌面应用才能干的事搬到了网页上。
API主要有三个方法:
window.showOpenFilePicker()—— 唤起系统的文件选择器,返回FileSystemFileHandle对象,可以读取文件内容window.showSaveFilePicker()—— 唤起系统的保存对话框,可直接将内容写入用户指定的本地文件window.showDirectoryPicker()—— 选择整个目录,返回所有文件和子目录的句柄
在国产化浏览器里,这个API的兼容情况比较特殊。Chromium内核的360浏览器、奇安信浏览器等,只要内核版本到一定程度就能用(一般在Chromium 86以上支持)。火狐浏览器目前不支持,IE内核就别提了。所以这个方案能不能用,取决于你目标环境里浏览器内核的分布情况。
4.2 深度集成案例:目录批量上传与本地文件读取
给大家展示一个比较实用的场景——批量上传整个目录下的文件:
async function uploadDirectory() { if (!window.showDirectoryPicker) { alert('当前浏览器不支持目录选择功能,请升级浏览器或使用Chromium内核浏览器'); return; } try { // 让用户选择目录,这里的mode:'read'表示只读模式 const dirHandle = await window.showDirectoryPicker({ mode: 'read' }); // recursivelyGetFiles是自定义递归函数,遍历整个目录树 const files = await recursivelyGetFiles(dirHandle); // 组装FormData并逐一上传 for (const file of files) { const formData = new FormData(); formData.append('file', file.file); formData.append('relativePath', file.relativePath); // 逐文件上传,可加并发控制,避免同时开太多请求 await fetch('/api/upload/withPath', { method: 'POST', body: formData, headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); } alert('目录上传完成,共上传' + files.length + '个文件'); } catch (err) { // 用户取消选择目录,或权限被拒绝 if (err.name === 'AbortError') { console.log('用户取消了选择'); } else { console.error('目录上传失败', err); } } } async function recursivelyGetFiles(dirHandle, basePath = '') { const allFiles = []; for await (const [name, handle] of dirHandle.entries()) { const fullPath = basePath ? basePath + '/' + name : name; if (handle.kind === 'directory') { const subFiles = await recursivelyGetFiles(handle, fullPath); allFiles.push(...subFiles); } else if (handle.kind === 'file') { const file = await handle.getFile(); allFiles.push({ file, relativePath: fullPath }); } } return allFiles; }这个目录上传的能力很实用——档案数字化、批量附件挂载,一次选择整个文件夹就全搞定了。但你要注意,首次调用showDirectoryPicker时,浏览器会强制要求用户交互(点击事件里调用),不能异步链中调用,否则会被安全策略拦截。
4.3 大文件分片上传:切、传、并的完整链路
分片上传是为了解决两个核心痛点:单文件超时、断点续传。把一个大文件切成若干小片,每片单独上传,某一片失败了只重传这一片,对用户体验的提升是革命性的。
分片上传通常分成三步:前端切片 → 并发上传 → 服务端合并。前端代码如下:
const CHUNK_SIZE = 5 * 1024 * 1024; // 每片5MB,可以根据网络环境调整 function createChunks(file) { const chunks = []; let start = 0; let index = 0; while (start < file.size) { const chunk = file.slice(start, start + CHUNK_SIZE); chunks.push({ chunk, index, start, end: Math.min(start + CHUNK_SIZE, file.size) }); start += CHUNK_SIZE; index++; } return chunks; } async function uploadLargeFile(file) { const fileHash = await calculateFileHash(file); // 用SparkMD5等库计算文件md5,用于秒传和断点判断 const chunks = createChunks(file); const total = chunks.length; // 先询问服务器,该文件是否已上传过部分分片 const checkResponse = await fetch('/api/upload/fileStatus', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileHash }) }); const checkResult = await checkResponse.json(); // 假设返回 { uploaded: [0,1,2,5] } 已上传的分片索引列表 const uploadedIndexes = new Set(checkResult.uploaded || []); // 并发控制,建议限制在3-5个并发 const concurrency = 3; const failedChunks = []; // 用简单的并发队列实现 const queue = chunks.filter(chunk => !uploadedIndexes.has(chunk.index)); async function worker() { while (queue.length > 0) { const chunk = queue.shift(); const uploadForm = new FormData(); uploadForm.append('fileHash', fileHash); uploadForm.append('chunkIndex', chunk.index); uploadForm.append('totalChunks', total); uploadForm.append('file', new File([chunk.chunk], file.name)); try { const resp = await fetch('/api/upload/chunk', { method: 'POST', body: uploadForm, headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); const result = await resp.json(); if (result.code !== 200) { failedChunks.push(chunk.index); } else { // 更新UI进度,已上传片数/total } } catch (e) { failedChunks.push(chunk.index); } } } await Promise.all(Array.from({ length: concurrency }, worker)); // 所有分片上传完成后,通知后端合并文件 if (failedChunks.length > 0) { // 可以提示用户“有x个分片失败,自动重试” for (const idx of failedChunks) { // 可以自行重试逻辑,这里省略 } } else { const mergeResponse = await fetch('/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileHash, fileName: file.name, totalChunks: total }) }); const mergeResult = await mergeResponse.json(); if (mergeResult.code === 200) { alert('大文件上传成功'); } } } // 计算文件md5,注意大文件不要一次性读入内存 async function calculateFileHash(file) { const buffer = await file.arrayBuffer(); // 这里是示意,实际推荐用SparkMD5的增量计算方式,避免大文件OOM return 'dummyHash_' + file.size + '_' + file.name; }服务端合并就不展开了,核心思路是:每个分片到达后先落盘到临时目录,合并模块按文件名加hash索引顺序读取所有分片,按index顺序拼接成完整文件,最后校验文件大小和md5与前端传的一致就说明成功。
下载端的大文件也没那么复杂,常见策略是后端对文件做流式输出,前端用fetch读取流后逐步写入文件系统:
async function downloadLargeFileStream(url, fileName) { const response = await fetch(url, { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); if (!response.ok) { alert('下载请求失败'); return; } const reader = response.body.getReader(); // 用流式读取,边读边拼装,避免超大文件占用大量内存 const chunks = []; let receivedLength = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedLength += value.length; // 可以在这里更新下载进度 } const blob = new Blob(chunks); // 后续的a标签下载同上 }注意:File System Access API中的
showSaveFilePicker在国产化系统中,可能会因为系统主题字体渲染导致对话框界面错位,建议在保存大文件时用“浏览器默认下载”的兜底逻辑,不强制依赖该API。
5. 从浏览器到服务器的完整链路配置:跨域、超时与安全头
5.1 Nginx网关层的关键配置项
文件上传下载的大部分的问题,前端调好只是第一步,链路中间任何一个环节配置不对,照样歇菜。最常见的是Nginx做了反向代理,但配置文件里没有调节超时和上传大小限制,文件传一半直接被网关掐断了。
下面是一份我在实际项目里用过的Nginx配置片段,专门针对文件上传场景调整:
server { listen 80; server_name yourdomain.com; # 上传大小限制,默认Nginx是1m,必须改大,单位m,根据业务需求调整 client_max_body_size 1024m; # 上传/下载超时时间:连接、读取、发送都适当放大 proxy_connect_timeout 600s; proxy_read_timeout 600s; proxy_send_timeout 600s; location /api/ { proxy_pass http://backend_servers; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 大文件下载时,关掉缓冲,让数据流直达客户端 location /api/download/ { proxy_pass http://backend_servers; proxy_buffering off; proxy_request_buffering off; } }几个参数重点解释下:
client_max_body_size:请求体上限。默认只有1m,也就是超过1MB的文件一律413错误。做文件系统必须改大,几百MB的文件建议直接设置1024m。proxy_read_timeout:Nginx等待后端服务器响应的时间。如果后端处理一个大文件合并耗时很久,不调大这个参数,网关会提前断开连接。proxy_request_buffering off:关闭请求体缓冲,让前端上传的数据流实时转发到后端,避免Nginx先把整个文件缓存到磁盘再转发,减少磁盘I/O和延迟。proxy_buffering off:下载时同理,关闭响应缓冲,文件流直接从后端发往前端,特别适合大文件下载。
如果你用的是Kong或APISIX这类网关服务,注意它们的超时配置可能不是默认的60秒,需要明确调大,尤其是APISIX默认的proxy-read-timeout和proxy-send-timeout对文件传输来说根本不够用。
5.2 跨域、Authorization头与预检请求
国产化系统的前端和后端分离部署很常见,跨域是绕不开的坎。一旦涉及跨域,文件上传的POST请求通常不是简单请求,会先触发OPTIONS预检请求。如果后端没有正确响应预检请求,前端请求根本发不出去。
以Spring Boot为例,下面是跨域配置的Baseline:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); // 允许的源,生产环境建议写成具体域名,不要用* config.addAllowedOriginPattern("*"); // 允许携带凭证,注意:allowedOriginPattern("*")和allowCredentials(true)可以同时用 config.setAllowCredentials(true); // 允许的请求头,特别注意要放行Authorization config.addAllowedHeader("*"); config.addAllowedHeader("Authorization"); // 允许的方法 config.addAllowedMethod("*"); // 预检请求的缓存时间,单位秒,减少OPTIONS请求次数 config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }跨域这块有一个细节:如果后端同时配置了CorsFilter和Spring Security,要注意过滤器顺序,CorsFilter必须在Security过滤器之前执行,否则预检请求会被Security拦截,表现为前端POST请求能走到OPTIONS但到达不了Controller。
5.3 HTTPS是所有高级方案的前提
这里写个必看提示:Chromium 86+内核的浏览器,File System Access API等文件系统API只在安全上下文(Secure Context)中才可用。所谓安全上下文,简单说就是你访问页面的协议必须是HTTPS,或者是内网IP的HTTP(部分浏览器允许localhost和127.0.0.1)。
我在实施时就被坑过:内网测试环境用IP访问页面,结果showOpenFilePicker直接提示“此API不可用”,我以为是浏览器内核版本问题,排查了半天,最后加上HTTPS才解决。
碰到这种场景,不要试图绕过安全策略,老老实实给内网服务器配上HTTPS证书,自签证书也行,部署后让用户在浏览器里手动信任证书即可。
5.4 网页授权回调域名与域名白名单配置
国产化系统的一些中间件(如统一认证、单点登录)在文件上传下载场景里,经常涉及“网页授权回调域名”的配置。你下载文件时带上的Cookie或token回调到认证中心,认证中心要能识别当前域名,否则回调直接被判定为非法来源。
做信创项目时,一般统一认证平台的回调域名白名单是在部署的时候手动维护的。有次我们改了门户域名,忘了同步认证中心的授权回调域名配置,导致所有用户在下载大文件时,被强制踢回登录页,白排查了两个小时。所以改动域名时,先把统一认证的回调白名单更新掉再调试,这是优先级最高的操作之一。
6. 实操中踩过的坑与排查技巧实录
6.1 一张速查表快速定位文件上传下载问题
我把多年踩坑汇总成一张速查表,每次线上反馈文件传不上,我都是按这个顺序排查的:
| 现象 | 可能原因 | 检查与解决路径 |
|---|---|---|
| 上传报413错误 | Nginx的client_max_body_size过小 | 检查网关层配置,对照5.1节调整 |
| 上传报CORS错误 | 后端跨域配置缺失或顺序错误 | 检查CorsFilter与Security过滤器顺序 |
| 上传进度条不走 | 请求被服务端快速返回错误,或浏览器沙箱拦截 | Console看具体错误,确认网络请求状态 |
| 下载文件名乱码 | 后端响应头Content-Disposition未按RFC 5987编码 | 后端改为filename*=UTF-8'' + URLEncoder.encode(fileName) |
| 下载显示为乱码页面 | 直接用window.open打开二进制流 | 改用blob方式(见2.3节代码) |
| 上传时登录状态丢失 | 前端headers未带Authorization,或携带了跨域Cookie但后端不允许 | 检查请求头、携带的鉴权信息,跨域场景用显式token头 |
| 大文件上传超时 | 网关或后端默认超时时间太短 | 调整Nginx proxy_read_timeout、后端服务超时配置 |
| 组件Upload点击没反应 | 浏览器内核版本过旧,组件内部依赖新语法 | Console查TypeError,考虑原生input方案兜底 |
| HTTPS站点内HTTP接口无法访问 | 浏览器混合内容拦截 | 统一改为HTTPS,或使用代理转发 |
6.2 实测中最容易忽略的3个细节
第一,文件编码问题最容易出现在PDF或TXT文件的下载改名场景中。后端返回的Content-Disposition里如果直接写中文文件名且没有做URL编码,前端解析时就会乱码。请务必定统一规范:后端一律走filename*=UTF-8''格式,前端解析时用decodeURIComponent解码。
第二,国产化系统里,多浏览器并行测试是必须的,但测试重点不同。360和奇安信这类Chromium内核浏览器侧重点在File System Access API是否触发安全上下文限制;火狐浏览器侧重点在Blob下载和FormData上传是否兼容。我的习惯是准备一个三行清单:Chromium系跑通全流程、火狐跑通上传下载基础流程、旧内核跑通input背景方案,三条全部通过后才算合格。
第三,上传后的临时文件清理是个隐患。如果后端采用分片上传落盘逻辑,记得在合并成功后清理临时分片目录,否则一次大文件操作会在服务器上留下几十个小文件,日积月累能把磁盘塞满。这个不出故障但出了就是大事。
6.3 如何在实机环境中快速验证方案是否可用
分享一个我自用的快速验证脚本思路:
<!DOCTYPE html> <html lang="zh-CN"> <body> <h3>文件上传下载能力快速检测</h3> <div id="result"></div> <script> const result = []; // 检测1:File System Access API result.push('showOpenFilePicker: ' + (typeof window.showOpenFilePicker !== 'undefined' ? '支持' : '不支持')); result.push('showDirectoryPicker: ' + (typeof window.showDirectoryPicker !== 'undefined' ? '支持' : '不支持')); // 检测2:Blob下载 result.push('createObjectURL: ' + (typeof window.URL.createObjectURL !== 'undefined' ? '支持' : '不支持')); result.push('msSaveOrOpenBlob: ' + (typeof window.navigator.msSaveOrOpenBlob !== 'undefined' ? '支持(旧版)' : '不支持')); // 检测3:fetch流读取 result.push('ReadableStream: ' + (typeof window.ReadableStream !== 'undefined' ? '支持' : '不支持')); // 检测4:安全上下文 result.push('isSecureContext: ' + (window.isSecureContext ? '是' : '否')); document.getElementById('result').innerHTML = result.join('<br>'); </script> </body> </html>在目标国产化系统的浏览器上打开这个页面,一分钟就能确认当前环境支持哪些能力,再选择相应方案。这个方法我每次接手新项目都会先跑一遍,比查文档快得多。
7. 根据经验说点心里话
做了这么多年国产化适配,我最大的体会是:不要预判国产化浏览器的能力边界,一切以实测为准。有的系统Chromium内核版本很新,跑File System Access API毫无压力;有的系统明面上说是国产化浏览器,其实是套了国产外壳的IE内核,啥新特性都用不了。所以方案选择的关键不是“哪个先进”,而是“当前环境哪个跑得起来”。
给刚开始做这类项目的朋友一个实用建议:代码里写一个浏览器能力检测模块,自动判断当前浏览器支持哪些API,然后动态切换上传策略。这个模块也就几十行代码,但能省掉你和用户沟通时90%的“为什么我不能传”的问题。
文件上传下载听起来是个基础功能,但在国产化环境里,它比业务逻辑更考验兼容性功底。我写这套方案,就是希望你在遇到“明明代码没问题,但就是在国产化系统上不好使”的时候,能快速地找到问题所在,而不是从头排查到凌晨。