1. 场景还原OA里能预览却下载不到的PDF附件先说一个我前段时间遇到的场景。单位用的致远互联SeeyonOA做协同办公很多合同、公文、审批附件都是以PDF形式挂在协同和流程里。日常查阅倒是方便点开附件会进入一个基于SuwellLightRead技术封装的预览页面文档能翻页、能缩放、能打印体验和浏览器原生看PDF差不多。但真到了需要把原始PDF文件拿出来的时候问题就来了——预览页面上没有下载原文件的按钮。右键另存为保存下来的要么是网页要么是图片格式要么干脆是空白的HTML。我当时要做的是把近三年的电子档案全量备份迁移出来涉及上千份PDF附件总不能一个个截图再转PDF吧后来我仔细把SuwellLightRead预览页面的请求链路抓了一遍才发现它的工作原理也顺着这条链路找到了提取原始PDF的可靠方法。这篇文章的价值就在这里如果你也在做致远OA的二次开发、数据迁移、档案电子化归档或者只是想把预览页里某份PDF原文件扒出来做留存那这篇文章能帮你省下大量的排查时间。我想提前说明一点下面讲的提取方法前提是你在OA系统中有合法的访问权限比如你是系统管理员、开发人员或者你本人就是这份PDF的经办/审批人。提取自己的业务数据做归档、检索、备份属于正常的运维和开发工作范畴。2. SuwellLightRead的预览链路PDF是怎么在浏览器里呈现的2.1 前端控件与后端服务的分工SuwellLightRead这个控件本质上是数维Suwell提供的文档阅读组件致远OA把它封装到了协同办公、公文管理、知识管理等模块里。它做的事情看起来简单把服务端的文档流拉到前端渲染成一个看起来像普通阅读器的界面。但背后其实是两个环节在配合第一个环节是后端存储。致远OA的附件文件本体存在应用服务器磁盘上一般位于安装目录的attachs或类似目录下按年月或者业务类型分子目录存放。数据库里有一张附件元数据表里面记录了每个附件的信息包括attachmentId、文件名、存储路径、所属业务单据等。预览服务说白了就是根据附件ID去数据库查路径再从磁盘把文件读出来返回给前端。第二个环节是前端渲染。SuwellLightRead控件拿到服务端返回的文档流后会根据文件类型决定渲染方式。对PDF而言如果返回的是标准PDF二进制流控件就调用自身的阅读器组件做渲染如果版本侧重在线安全阅读也可能把PDF转成图片后再展示但这种情况在网络请求里能看到明显区别。2.2 一次完整预览请求的时间线为了把原理讲透我以一次实际预览动作为例拆解请求的完整链路用户在业务界面点击阅读或预览浏览器向OA服务端发起一个GET或POST请求URL里通常带附件ID、业务类型这些参数。服务端拿到参数后先去权限模块校验当前登录人是否有权访问该附件。校验通过后根据附件ID查询附件元数据表定位到磁盘上的物理文件。服务端把文件以流的方式写回HTTP响应响应头里会有Content-Type: application/pdf或者application/octet-stream。如果走的是控件封装协议响应体可能不是纯PDF而是带有特定封装结构的流控件自行解封装后渲染。关键点在第4步和第5步如果响应头是application/pdf那这个预览请求本质上就是一次PDF下载请求只是前端没有暴露保存按钮而已。这种情况下我们提取原始PDF的思路就非常清晰了。2.3 为什么预览页面不能直接另存为很多人会问既然服务端都返回PDF流了为什么浏览器不直接弹下载这里有几个设计原因一是权限控制。OA的附件默认只允许有权限的人在线查看如果都直接下载那点击预览按钮就够了还需要下载按钮做什么所以预览接口和下载接口往往做了分离预览接口返回的是可渲染的流但客户端不会触发下载行为。二是界面统一。为了在不同浏览器、不同终端上保持一致体验致远OA把所有文档预览都收敛到SuwellLightRead这一套界面里。你看到的是一个阅读器而不是浏览器原生的PDF Viewer。三是审计需求。预览行为可以记录日志谁看了哪份附件、看了多长时间方便审计追踪。如果直接下载到本地审计链路就断了。理解了这层设计下一步就清楚我们要做的不是破解什么安全机制而是找到预览链路里返回原始PDF的那个HTTP请求把它的响应体以文件形式保存下来。如果服务端已经返回标准PDF流那这就是一条合法且简洁的路径。3. 提取原始PDF的三条路径对比在实操之前我先把我试过的三种路径摆出来帮大家减少弯路。三种路径的适用条件、操作门槛、批量友好程度都不一样。路径操作方式适用场景门槛浏览器开发者工具抓预览请求前端抓包复制请求重放保存单份或少量提取低无需服务器权限服务端附件存储目录检索登录OA服务器按目录和数据库关联定位文件批量迁移、服务器级操作高需要服务器权限脚本调用预览服务批量拉取通过HTTP接口循环请求预览接口业务系统集成、大批量归档中需要处理会话和参数这三条路径各有用武之地实际操作中往往是组合使用。我先说结论如果只是临时提取一份两份PDF路径一最快全程在浏览器里搞定几分钟就能学会如果是要把整个OA里的某个模块附件全部导出来路径二是最稳的因为直接拿磁盘文件不需要考虑接口参数和会话有效期路径三则是把路径一自动化适合做进了业务功能、要长期运行的情况。下面我把每条路径的详细过程和原理拆开讲。4. 实操流程从预览页反查原始PDF的完整过程4.1 打开预览页面提前准备好抓包环境我用的是Chrome系浏览器操作步骤如下先登录OA系统打开任意一条带PDF附件的业务单据找到预览入口但先不点击。保持登录状态后按F12打开开发者工具切到Network网络面板勾选Preserve log保留日志避免页面跳转后请求记录被清空。在Network面板里建议先按CtrlL清空当前日志然后点击预览按钮。等阅读器加载完PDF后回到Network面板能看到几十条请求包括静态资源、接口调用、数据拉取。这时在过滤器里输入pdf或者直接按照Type类型筛选Fetch/XHR基本上能锁定目标请求。4.2 找到返回PDF流的那个关键请求逐个点开请求看响应内容找响应头里Content-Type为application/pdf的那一条。它可能长这样GET /seeyon/common/attach/readFileById.do?id123456entrytrue HTTP/1.1 Host: oa.example.com Cookie: JSESSIONID9A3F...; login_localezh_CN这个请求的响应体就是PDF二进制流。为了确认可以在Network面板的Preview页签里看是否显示了PDF内容或者在Response页签里看到%PDF-开头的内容。%PDF-是PDF文件的标准文件头看到这个基本上可以确定就是原始文件。还有一点值得注意如果SuwellLightRead封装得比较深可能需要多点几次翻页或者缩放后才能出现PDF流请求。因为控件的加载策略是边下载边渲染它可能在初始化时只拉一部分数据翻页时才拉后续内容。但绝大多数版本在首次加载时就会获取完整PDF流或大部分内容。4.3 二种路径复制cURL直接重放下载找到目标请求后在请求条目上右键选择Copy as cURL (bash)。把复制到的命令粘贴到终端里执行后面加一个-o output.pdf参数就能把PDF保存下来。我实际执行的命令类似这样curl -s https://oa.example.com/seeyon/common/attach/readFileById.do?id123456 \ -H Cookie: JSESSIONID9A3F... \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://oa.example.com/ \ -o attachment.pdf执行完以后验证一下文件file attachment.pdf看到输出是PDF document就说明提取成功。这种方式适合单份提取但如果要提取几百份手动复制cURL就没效率了得走脚本化路线。4.4 路径三写脚本批量拉取预览服务批量提取的核心就一句话循环调用预览接口每一次调用对应一份附件。但前提是要拿到一批附件ID列表。附件ID从哪来两种途径第一种是从数据库查。如果你有数据库查询权限可以直接去OA的附件元数据表里查某个业务模块的附件记录。致远OA不同版本的表结构有差异常见的有seeyon_attachment、seeyon_v5_attachment之类字段一般包含ID、FILENAME、CREATE_TIME、MODULE_TYPE等。注意不同版本的数据库表名和设计不同实际使用时建议先和开发商确认或通过数据库逆向查看表结构。第二种是从业务接口查。通过OA自身的接口或者页面上的列表请求拿到附件ID列表。但这种方式受限于前端分页和业务单据效率不如直接从数据库一次性取数。拿到附件ID列表后用脚本模拟登录会话循环请求预览接口。我用Python写过一版核心逻辑是这样的import requests session requests.Session() # 第一步模拟登录拿到会话Cookie login_url https://oa.example.com/seeyon/rest/authentication/authentication login_data { userName: your_username, password: your_password } resp session.post(login_url, jsonlogin_data) # 不同版本登录方式不同有的需要先访问一次登录页获取token # 第二步循环请求预览接口 download_url https://oa.example.com/seeyon/common/attach/readFileById.do file_ids [123456, 123457, 123458] # 从数据库或接口获得 for file_id in file_ids: resp session.get(download_url, params{id: file_id}) if resp.status_code 200 and resp.headers.get(Content-Type, ) application/pdf: with open(f{file_id}.pdf, wb) as f: f.write(resp.content) print(f附件 {file_id} 提取成功) else: print(f附件 {file_id} 提取失败状态码: {resp.status_code})这个逻辑简单但生产环境里还要考虑会话保持、错误重试、频率控制。OA系统一般都有防暴力请求的机制大批量高频请求很可能触发账号锁定或者服务端限流。我实践下来合理的做法是在循环里加延迟比如每请求一次sleep 0.5秒还可以引入重试机制和失败日志。4.5 路径二服务端存储目录直接检索如果你有OA服务器的操作权限反而没必要走HTTP接口。直接登录服务器进入附件存储根目录按时间、按目录层级找到对应文件。但要解决一个问题磁盘上的文件名往往不是业务里的可读文件名而是类似123456_20240101120000.pdf的自动命名。怎么映射回业务数据这时候还是得借助数据库。查附件元数据表找到附件ID对应的物理存储路径字段拿到路径后直接从磁盘拷贝。我遇到的情况是服务器磁盘目录下有按日期嵌套的子目录单纯靠人眼看很难定位但通过SQL可以精确查出来SELECT ID, FILENAME, FILE_PATH, CREATE_TIME FROM SEEYON_ATTACHMENT WHERE CREATE_TIME BETWEEN 2024-01-01 AND 2024-12-31 AND FILENAME LIKE %.pdf;查出结果后在服务器上按FILE_PATH字段找到对应目录批量复制、重命名即可。这种方式对千份以上的批量导出最稳定不依赖OA的HTTP服务也不怕会话过期但前提是你对数据库结构有一定的了解。5. 踩坑录文件名丢失、鉴权过期与服务端临时文件陷阱5.1 预览返回的文件名是随机ID第一次用cURL重放下载时我什么都没加直接保存结果文件名是123456这种看不出业务含义的编号。文件内容没问题但归档时需要知道它是哪份合同、哪个流程里的附件。后来我明白了预览接口出于通用性考虑响应头里不一定会带Content-Disposition: attachment; filename真实名称.pdf。所以要在提取时把业务ID和附件ID关联起来命名规则设计成附件ID_业务编号_原始文件名.pdf或者提取完后再从数据库反查文件名去补全。我的建议是无论走哪条路径先导出一份附件ID与业务信息的对照表再根据这份表给保存下来的PDF重命名。这样等批量提取完成时文件夹里的名字都是可读的后续交接和归档都省事。5.2 会话过期导致批量任务中断批量提取过程中临时会话不会永远有效。OA的权限校验依赖Cookie里的JSESSIONID或者自定义token一般有固定的失效时间。如果批量任务跑了几百份后会话过期了后续请求返回的会是登录页或者401/403状态码脚本不会自动感知。解决方案多样且我实际验证过的是每跑完一批文件检查一个固定请求的响应状态一旦发现异常就重新走一遍登录逻辑拿到新会话再继续。get请求的目标可以是当前用户信息接口。还需要在代码里做明确的错误判断不要把所有非200状态都当成功。5.3 服务端临时缓存文件被定期清理有些版本的SuwellLightRead在服务端会生成一份临时缓存文件把原始PDF复制到临时目录再给预览请求返回缓存里的那份。这种做法可以改善并发读取性能也便于加权限水印。但如果提取时只盯着预览接口拿到的是缓存副本倒也无妨内容一致。真正的坑在于如果提取动作耗时很长恰好跨过了服务端的定时清理任务临时文件被删预览接口就会返回404或空内容。应对方式还是回到附件元数据表的物理路径字段直接从正式存储区读取。预览缓存是临时工正式的附件存储才是正主。5.4 有些版本把PDF渲染成图片返回这个坑最隐蔽。部分注重防泄密的OA部署中SuwellLightRead预览不会返回PDF流而是由服务端把PDF转成一张张PNG/JPEG图片通过接口返回图片底座和像素信息控件再拼成翻页效果。这种情况下Network里找不到application/pdf的响应看到的全是图片请求。判断方法很直观Network面板里如果都是image/png响应而不是application/pdf说明走的是图片化渲染。这时候继续抓预览接口就提取不到PDF了得另找附件下载接口一般致远OA在附件详情页或者后端管理端会有下载原文件接口只是业务前端未暴露。如果实在找不到就回到路径二从服务器存储目录直接拷贝这条路永远不会被渲染策略影响。5.5 大文件、多页文档的请求拆分迷惑性还有一个值得注意的现象预览控件往往会把PDF拆成多个片段请求特别是超过几十页的大文件。第一次只拉前几页翻页时再拉后续内容。这时如果只抓到一个application/pdf的响应保存下来的可能是不完整的内容。要确认是否完整可以用PDF阅读器打开保存后的文件看页数和原文件是否一致也可以在Network面板里继续快速翻到最后一页观察是否出现了新的PDF分段请求再把这些分段合并。不过我实测下来大多数致远OA封装版本在第一次加载时就会把整个PDF推送给控件只是渲染上做了分页动画。为了稳妥保存完文件后都应验证页数和容量。6. 提取之后的事归档、检索与合规边界PDF文件提取成功只是第一步。做数据迁移和档案电子化管理时后续这些事才是真正花时间的目录结构上我习惯按业务类型/年份/月份/三层目录存放文件名统一为业务编号_附件ID_原始文件名。这样既能追溯到OA里的源单据也符合常用的归档规范。检索层面如果文件量大建议建一个简单的索引把附件ID、业务编号、文件路径、提取时间记录到一张表里后续要查某一单的附件时不用再去目录里翻。我直接用的SQLite轻量又够用。内容层面如果需要全文检索PDF内容可以做一些简单的文本抽取对扫描件还需要OCR。这一步是否要做取决于业务需求但至少先把原始PDF完整保存下来。最后谈谈合规边界。我上面写的方法全部建立在你对该系统拥有合法访问权限这个前提上。如果你只是某份PDF的收件人或审批人提取自己参与业务的那一份留档没有问题如果你是系统管理员或开发人员在做数据迁移、备份恢复、归档时提取附件也在职责范围内。但不要把这个方法用在越权访问他人附件、绕过系统留痕机制等方向上那样性质就变了。我个人的体会是SuwellLightRead这类封装控件的预览设计初衷是安全和体验统一不是要刻意拦住运维人员做数据治理。理解了它的请求链路后提取原始PDF其实就是一个找到返回PDF流的合法请求并保存响应的过程。希望这篇文章能帮你在做致远OA数据相关工作时少走点弯路。