脱敏说明:本文内部项目以化名出现------
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 命中
报:二进制文件
不支持文本预览
关键发现有两个:
- 后端入库时补
.pdf的逻辑有漏洞 :只在filepath.Ext(filename) == ""时才补。但爬虫的title只要带个点就会漏网------比如规格书V1.0,filepath.Ext认为扩展名是.0,于是不再补.pdf。历史数据、其他入库路径也可能没后缀。 - 但后端返回的 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),所以不能盲信,得让魔数兜底。 - 纯文本不轻易认作二进制 :只有内容确实含二进制特征时才走魔数嗅探。纯文本即使以
BM、PK等开头也不会被误认成图片/压缩包------这个防误判很重要,否则会把含特殊字符的文本文件全干掉。
魔数识别覆盖: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/pdf、application/octet-stream还是缺失,都正确识别为pdf并走 PDF 预览。
这个文件正好命中了"文件名带点(导致后端不补后缀)+ 真实内容是 PDF"的双重场景,是这次修复最理想的验证样本。
八、结果与复盘
改动只在一个文件(FilePreview/index.tsx),最后 commit message:
fix: 文件预览按内容识别类型,修复无后缀/乱命名文件无法查看
之后按发版流程(master → release-arm64)合并、推送、监控 CI 构建到成功。
几条复盘:
- "识别为二进制"往往不是二进制的问题,是类型判定的顺序问题。 这次根因不是 Content-Type 传错,而是前端在取内容前就按扩展名定死了预览方式。追完整条链路再动手,能避免"改错端"。
- 最可靠的真相来源是内容本身。 文件名可乱起、响应头可缺失,只有魔数不撒谎。把判定优先级设成"魔数 → 响应头 → 扩展名",是对抗各种历史脏数据的最稳姿势。
- 防误判比多识别更重要。 纯文本不轻易认作二进制------多识别一种格式顶多多支持一个文件,误判一次却会让正常的文本文件全打不开。宁可保守。
- 没有测试基建时,临时脚本也能捞 bug。 RAR 签名那个长度错误,靠人工预览永远发现不了。搭个真实字节样例脚本,跑完即删,性价比极高。
- 别让格式噪音淹没功能改动。 1600 行 prettier diff 会毁掉一次 review 体验。格式化和功能改动分开,是 reviewer 友好性的基本盘。
一个延伸思考:后端的 filepath.Ext == "" 判断本就是个脆弱前提(V1.0 的 .0 就能骗过它)。更稳的做法是入库时就用魔数判定真实类型,而不是依赖文件名。这次前端的内容嗅探其实已经把"类型识别"这件事做对了,后端只是历史包袱。以后如果要让下载名也正确,再回头收紧后端即可。