文件名没后缀就打不开:前端文件预览的内容嗅探改造

脱敏说明:本文内部项目以化名出现------lab-web(前端,UmiJS + React)、lab-aidoc(文档智能后端)。Crawlab、PDF、React 等开源/通用技术名词保留原称。文中提到的真实测试文件已化名为"某规格书"。

一个"爬虫文件没带 .pdf 后缀就识别成二进制、无法查看"的问题,乍看像是后端没给对 Content-Type,追完整条链路才发现根因在前端。这篇记的不是改了哪个函数,而是为什么这个 bug 该在前端修、而不是后端

一、问题现象

爬虫抓回来的文件,在产品来源文件弹窗里预览时,如果文件名没带 .pdf 后缀,就会报"该文件为二进制文件,暂不支持文本预览"。

第一反应是后端的锅------是不是后端把 Content-Type 传错了?但这个直觉是错的。

二、追链路:前后端各看一眼

我没有一上来就动手改,而是把整条预览链路从后端入库到前端渲染都追了一遍。这一追省了后面所有的返工。
#mermaid-svg-7oKGU2mQTTALLgNR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7oKGU2mQTTALLgNR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7oKGU2mQTTALLgNR .error-icon{fill:#552222;}#mermaid-svg-7oKGU2mQTTALLgNR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7oKGU2mQTTALLgNR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7oKGU2mQTTALLgNR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7oKGU2mQTTALLgNR .marker.cross{stroke:#333333;}#mermaid-svg-7oKGU2mQTTALLgNR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7oKGU2mQTTALLgNR p{margin:0;}#mermaid-svg-7oKGU2mQTTALLgNR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7oKGU2mQTTALLgNR .cluster-label text{fill:#333;}#mermaid-svg-7oKGU2mQTTALLgNR .cluster-label span{color:#333;}#mermaid-svg-7oKGU2mQTTALLgNR .cluster-label span p{background-color:transparent;}#mermaid-svg-7oKGU2mQTTALLgNR .label text,#mermaid-svg-7oKGU2mQTTALLgNR span{fill:#333;color:#333;}#mermaid-svg-7oKGU2mQTTALLgNR .node rect,#mermaid-svg-7oKGU2mQTTALLgNR .node circle,#mermaid-svg-7oKGU2mQTTALLgNR .node ellipse,#mermaid-svg-7oKGU2mQTTALLgNR .node polygon,#mermaid-svg-7oKGU2mQTTALLgNR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7oKGU2mQTTALLgNR .rough-node .label text,#mermaid-svg-7oKGU2mQTTALLgNR .node .label text,#mermaid-svg-7oKGU2mQTTALLgNR .image-shape .label,#mermaid-svg-7oKGU2mQTTALLgNR .icon-shape .label{text-anchor:middle;}#mermaid-svg-7oKGU2mQTTALLgNR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7oKGU2mQTTALLgNR .rough-node .label,#mermaid-svg-7oKGU2mQTTALLgNR .node .label,#mermaid-svg-7oKGU2mQTTALLgNR .image-shape .label,#mermaid-svg-7oKGU2mQTTALLgNR .icon-shape .label{text-align:center;}#mermaid-svg-7oKGU2mQTTALLgNR .node.clickable{cursor:pointer;}#mermaid-svg-7oKGU2mQTTALLgNR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7oKGU2mQTTALLgNR .arrowheadPath{fill:#333333;}#mermaid-svg-7oKGU2mQTTALLgNR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7oKGU2mQTTALLgNR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7oKGU2mQTTALLgNR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7oKGU2mQTTALLgNR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7oKGU2mQTTALLgNR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7oKGU2mQTTALLgNR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7oKGU2mQTTALLgNR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7oKGU2mQTTALLgNR .cluster text{fill:#333;}#mermaid-svg-7oKGU2mQTTALLgNR .cluster span{color:#333;}#mermaid-svg-7oKGU2mQTTALLgNR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7oKGU2mQTTALLgNR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7oKGU2mQTTALLgNR rect.text{fill:none;stroke-width:0;}#mermaid-svg-7oKGU2mQTTALLgNR .icon-shape,#mermaid-svg-7oKGU2mQTTALLgNR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7oKGU2mQTTALLgNR .icon-shape p,#mermaid-svg-7oKGU2mQTTALLgNR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7oKGU2mQTTALLgNR .icon-shape .label rect,#mermaid-svg-7oKGU2mQTTALLgNR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7oKGU2mQTTALLgNR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7oKGU2mQTTALLgNR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7oKGU2mQTTALLgNR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前端预览
后端入库

如 规格书V1.0 的 .0
只看文件名扩展名
爬虫下载文件
filepath.Ext == 空?
补 .pdf
不补

文件名残留
文件表 Type=application/pdf

入库
/api/file/:uid 返回

Content-Type: application/pdf
FilePreview 组件
按什么判类型?
无 .pdf → 当文本预览
拿到 PDF 字节

looksBinary 命中
报:二进制文件

不支持文本预览

关键发现有两个:

  1. 后端入库时补 .pdf 的逻辑有漏洞 :只在 filepath.Ext(filename) == "" 时才补。但爬虫的 title 只要带个点就会漏网------比如 规格书V1.0filepath.Ext 认为扩展名是 .0,于是不再补 .pdf。历史数据、其他入库路径也可能没后缀。
  2. 但后端返回的 Content-Type 其实是对的 :文件表里 Type 字段就是 application/pdf,还有内容嗅探兜底,/api/file/:uid 实际返回的就是 application/pdf

所以根因不在后端,在前端 :前端 FilePreview 组件的类型判定只看传入的 name 扩展名 (调用方还没传 mimeType),从头到尾没看一眼响应头。名字里没有 .pdf → 落到默认的 code 文本预览 → 拿到 PDF 字节后 looksBinary 判定为二进制 → 报错。

"识别为二进制"不是后端传错了 Content-Type,而是前端在取到内容之前就按扩展名把预览方式定死了

三、关键决策:在前端修,还是后端修

理清根因后,方案选择就成了一个判断题。我的结论是:主要前端修,后端补一个兜底就够

方案 覆盖范围 取舍
只改后端(强制补 .pdf 只管新数据 存量坏记录依然打不开
只改前端(按内容识别) 新旧数据全覆盖 后端本来 Content-Type 就对,前端不看是前端的问题
前端为主 + 后端兜底 全覆盖 + 下载名也正常 推荐

如果只改后端不改前端,存量数据依然打不开 ;只改前端不改后端,功能上已经完全可用(后端本来就返回了正确的 Content-Type,只是前端之前不看)。所以这次的重心放在前端,后端的强制补 .pdf 只是顺带让 Content-Disposition 的下载名也正常。

四、方案:内容魔数 → 响应头 → 扩展名

需求从"修 PDF 这一种"扩到了"对所有文件都这样处理"。于是把预览流程从"先按扩展名决定取文本还是取 blob"改成:

统一拉取文件字节,按 内容魔数(二进制)→ 响应 Content-Type → 纯文本魔数 → 显式 mimeType → 扩展名 判定真实类型,再走对应渲染。

为什么是这个优先级顺序?

  • 魔数优先于扩展名 :文件名可以乱起(内容是 PDF 却叫 .doc),但文件头的魔数不会撒谎。对于"无后缀/乱命名"这类问题,魔数是最可靠的真相来源。
  • 响应头优先于扩展名、但让位给魔数 :响应头理论上权威,但它可能缺失或被代理误标(比如统一压成 application/octet-stream),所以不能盲信,得让魔数兜底。
  • 纯文本不轻易认作二进制 :只有内容确实含二进制特征时才走魔数嗅探。纯文本即使以 BMPK 等开头也不会被误认成图片/压缩包------这个防误判很重要,否则会把含特殊字符的文本文件全干掉。

魔数识别覆盖:PDF、PNG/JPEG/GIF/WebP/BMP/ICO/SVG、MP4/AVI/WebM/MKV、WAV/MP3/FLAC/OGG、docx/xlsx/pptx(zip 内部分辨)、旧版 Office(OLE2)、zip/rar/7z/gzip/bz2/xz 等。

五、测试时顺带抓到一个真 bug

项目里没有现成的测试工具(jest/vitest 都没装),我直接用真实源码里的识别函数搭了个临时 node 验证脚本,跑了 31 个字节样例。

第一次跑,两个失败。第一反应是核对:是测试数据问题还是代码问题?一查,抓到一个真 bug:

RAR 签名 Rar!\x1a\x07 实际是 6 字节 ,代码里却按 7 字节比较,所以永远匹配不上。

这是个藏在原代码里、靠人工预览根本发现不了的 bug(谁会专门传个 rar 进来预览)。另一个"失败"是测试数据太短(MP4 样例不足 12 字节)。修掉 RAR 的长度后,31 项全过。

这是个挺有代表性的体会:没有测试基建的项目,靠人肉验证永远只能覆盖常用路径。临时搭个脚本跑真实字节样例,成本不高,却能捞出 RAR 这种沉睡 bug。脚本跑完即删,不污染仓库。

六、一个差点淹没改动的小插曲:prettier 噪音

写完一跑 prettier,整个文件被重排了------1605 行全量 diff。原因是这个文件历史上一直是无分号风格,从没被 prettier 格式化过,而项目默认 prettier 配置是带分号的。

这时候有个选择:留着格式化(符合项目配置),还是恢复原风格?我查了仓库其他文件,发现风格是混用的 (部分带分号、部分不带),而这个文件历史上一直无分号。最后决定恢复原风格,只保留功能性改动------避免把一次"+270/-80"的功能改动淹没在 1600 行格式噪音里,否则 reviewer 根本看不出我到底改了什么。

具体做法:用 git show HEAD: 重建原始版本到临时文件,再在上面手工叠加我的三处功能改动,最后覆盖回工作区。diff 重新收敛到只有功能改动。

这是个值得记住的原则:格式化和功能改动要分开提交。如果你的格式化会污染整个文件,那就把它单独拆一次 commit(或者干脆不改格式),别让 reviewer 在噪音里捞针。

七、真实文件实测

光跑字节样例不够,得拿真实文件验。仓库里正好有一个典型场景的文件------某规格书(化名):真实类型是 PDF 1.6(文件头 %PDF-1.6,1.2MB),但文件名没带 .pdf 后缀。

对比新旧逻辑:

  • 旧逻辑detectType 按扩展名判为 code,内容又被 looksBinary 判为二进制 → 报错,和用户描述一致。
  • 新逻辑 :无论响应 Content-Type 是 application/pdfapplication/octet-stream 还是缺失,都正确识别为 pdf 并走 PDF 预览。

这个文件正好命中了"文件名带点(导致后端不补后缀)+ 真实内容是 PDF"的双重场景,是这次修复最理想的验证样本。

八、结果与复盘

改动只在一个文件(FilePreview/index.tsx),最后 commit message:

复制代码
fix: 文件预览按内容识别类型,修复无后缀/乱命名文件无法查看

之后按发版流程(master → release-arm64)合并、推送、监控 CI 构建到成功。

几条复盘:

  1. "识别为二进制"往往不是二进制的问题,是类型判定的顺序问题。 这次根因不是 Content-Type 传错,而是前端在取内容前就按扩展名定死了预览方式。追完整条链路再动手,能避免"改错端"。
  2. 最可靠的真相来源是内容本身。 文件名可乱起、响应头可缺失,只有魔数不撒谎。把判定优先级设成"魔数 → 响应头 → 扩展名",是对抗各种历史脏数据的最稳姿势。
  3. 防误判比多识别更重要。 纯文本不轻易认作二进制------多识别一种格式顶多多支持一个文件,误判一次却会让正常的文本文件全打不开。宁可保守。
  4. 没有测试基建时,临时脚本也能捞 bug。 RAR 签名那个长度错误,靠人工预览永远发现不了。搭个真实字节样例脚本,跑完即删,性价比极高。
  5. 别让格式噪音淹没功能改动。 1600 行 prettier diff 会毁掉一次 review 体验。格式化和功能改动分开,是 reviewer 友好性的基本盘。

一个延伸思考:后端的 filepath.Ext == "" 判断本就是个脆弱前提(V1.0.0 就能骗过它)。更稳的做法是入库时就用魔数判定真实类型,而不是依赖文件名。这次前端的内容嗅探其实已经把"类型识别"这件事做对了,后端只是历史包袱。以后如果要让下载名也正确,再回头收紧后端即可。

相关推荐
Euato_Key1 小时前
全栈视野学习Cookie——理解 Cookie 的本质
前端
前端开发呀1 小时前
我的 AI 终端配置清单
前端
敲代码的玉米C2 小时前
测试一直在写你的真实数据根
前端·人工智能·架构
执子念的飞鱼2 小时前
File Viewer 进入 2K 节点后,我更在意这 3 条回归
前端·typescript
richdata2 小时前
动态OTB vs 静态OTB:鞋服零售该选哪种管理模式?
前端·html·零售
Jodie同志2 小时前
第16~23天:持久化、HITL、流式、MCP与安全
前端·后端·agent
Jodie同志2 小时前
第1~15天:原生Agent、RAG与LangGraph基础(完整代码实操)
前端·后端·agent
sunly_2 小时前
React useActionState 的用法详解
前端·javascript·react.js
今日无bug2 小时前
RESTful + Interface:从 todos 项目理解后端接口设计
前端·restful