
去年接手一个老系统的改造需求写得很朴素就一句话在 IE浏览器 里展示 PDF同时禁止下载、禁止打印、禁止另存、禁止复制。听起来像是加几行 JS 就能搞定的事实际动手之后才发现这里面的水比想象中深得多——不同客户端装的渲染插件不一样、IE 自带查看器的工具栏不是 DOM 元素、CtrlS 和 PrintScreen 根本不是同一类问题。我把整个过程整理下来包括最后真正落地的那套方案、参数怎么定、坑在哪里给同样被这个需求折腾过的同行省点时间。这篇内容偏企业内网文档场景适合还在维护 IE 兼容系统、或者要给老旧 OA、合同、报表系统做浏览管控的开发者看前端和后端都会涉及小白跟着做也能跑通。1. 先说结论纯前端那套禁止下载注定被绕过我一开始也走过弯路。最先想到的方案是在页面里塞一个 iframe指向 PDF 地址然后加oncontextmenureturn false再屏蔽 CtrlS、CtrlP最后套一个透明的 div 遮罩。这四招加起来在演示的时候看着挺唬人交给测试同事十分钟就被拆干净了。1.1 那份 PDF 到底是谁在渲染想搞明白为什么拦不住得先知道 IE 里那个 PDF 是谁画的。IE11 自己没有 PDF 解析内核它靠外部组件。客户端如果装了 Adobe Acrobat Reader 并且加载项启用那么页面上的embed或object会调用 AcroPDF 的 ActiveX 控件来渲染如果没装Windows 8 之后 IE11 会走系统内置的 PDF 查看器那个查看器自带一条工具栏上面明晃晃就是下载、打印、旋转这几个按钮。问题就在这儿那条工具栏是宿主的原生 UI不在你的 DOM 树里display:none对它没有任何作用z-index也压不住。你能控制的只有那块矩形区域的外框里面的内容、右键菜单、工具栏统统是别人的地盘。所以只要 PDF 是以文档的形式交给浏览器前端再怎么折腾都是在别人的地盘上贴便签。判断当前客户端走的是哪条路径可以用一段很短的探测代码在 IE 里跑一下就有结论// 探测客户端 PDF 渲染能力结果打到 console 或隐藏域里上报 function detectPdfPlugin() { var result { acrobat: false, mime: false, version: }; try { var acro new ActiveXObject(AcroPDF.PDF); result.acrobat true; result.version acro.GetVersions ? acro.GetVersions() : ; } catch (e) { result.acrobat false; } try { result.mime !!(navigator.mimeTypes navigator.mimeTypes[application/pdf]); } catch (e) {} return result; }这段代码别小看它的实际用途是灰度分流。探测到 Acrobat 插件的机器走一条策略走内置查看器的机器走另一条策略因为两种环境下你能做的事情完全不同用一套代码硬扛只会两边都不讨好。1.2 三张防线里只有一张是真的把禁止下载、禁止打印、禁止另存、禁止复制拆开看其实对应三种完全不同层次的防护手段它们的强度差着数量级。第一种是前端交互拦截也就是屏蔽右键、屏蔽快捷键、盖遮罩层。这一层的作用是防手滑防的是普通办公人员顺手点一下对稍微懂点技术的人形同虚设。按 F12 打开开发者工具网络面板里请求地址清清楚楚直接右键复制链接就完了甚至打开新窗口就能另存。这一层必须做因为它成本极低但它不是安全边界。第二种是浏览器与插件配置比如通过组策略关掉 Adobe Reader 插件的工具栏、关掉打印菜单、禁用浏览器的下载功能。这一层强度中等但依赖客户端环境统一一旦有台机器没进域、或者用户自己装了个绿色版阅读器策略就废了。而且现在的浏览器版本迭代很快很多老配置项早就没了维护成本极高。第三种是服务端不给你原始文件只给你渲染后的位图。这是唯一真正能站得住的一层。因为客户端从头到尾就没拿到 PDF 的字节流它拿到的是一张张图片图片上没有文字层、没有可提取的文本、没有矢量路径想另存得到的也只是一张图。这才是方案的地基前面两层都是在这个地基上做的加固和骚扰。1.3 和管理层对齐能做到什么程度这里必须说一句实话不然项目验收的时候一定扯皮没有任何技术手段能做到绝对禁止。屏幕可以用手机拍可以用录屏软件可以在虚拟机里跑可以抓包重放请求。技术方案的目标从来不是零泄露而是把泄露成本抬高、把泄露行为变得可追溯、把大规模批量导出变成不可能。我在项目启动会上就把这句话摆在桌面上我们能保证的是——用户无法通过常规的下载、打印、另存、复制四个动作把文件带走我们能保证的是——任何一张被截屏或拍照流出的图片上都有他的姓名、工号和时间戳我们做不到的是——阻止一个人用手机对着屏幕拍照但拍照出来的东西带着他的名字。把预期对齐之后验收标准就从完全禁止变成了常规路径全部封死 水印可追溯 异常行为可告警这个目标是可实现的也是可验证的。后面所有的技术选型都是围绕这三条来做的。2. 拆解 IE 加载 PDF 的完整链路找出可干预的三个切入点确定了服务端切图这个地基之后剩下的工作是把它接进现有的 IE 系统。这一步的关键是先看清楚整条链路知道哪里可以插刀不然后端改了渲染前端还是老写法两边对不上。2.1 插件路径与内置查看器路径的分叉在典型的 IE11 环境里用户点开一个文档链接链路大致是这样的点击 → 网关鉴权 → 服务端返回 Content-Type 为application/pdf的响应 → IE 根据这个 MIME 类型决定怎么处理 → 如果注册了 Adobe 插件就交给插件否则交给内置查看器 → 用户在查看器里看到内容。注意中间有一个非常关键的判断点IE 是根据响应的 Content-Type 来分派的。这个点就是我们第一个切入点。如果我们返回的不是application/pdf而是image/jpeg那 IE 就不会去调任何 PDF 组件它就当成一张普通图片处理。整个 PDF 插件、工具栏、右键菜单的问题一次性全部消失。顺着这个思路往下推第二个切入点就出来了既然要返回图片那图片从哪来必须在服务端把 PDF 预先渲染成图片。第三个切入点是图片的 URL 怎么保护不能让用户拿到 URL 之后随便刷。三个切入点串起来就是完整的方案骨架。2.2 切入点一内容协商与响应头第一个切入点的落地核心是让服务端永远不返回 PDF 字节流。这听起来简单实际做的时候有两个容易被忽略的地方。第一个地方是响应头。很多人习惯给 PDF 加Content-Disposition: inline目的是让浏览器内嵌显示而不是弹下载框。这在纯 PDF 方案里是标准做法但在我们的方案里这个头根本用不上因为我们压根不返回 PDF。图片资源返回时反而要加上X-Content-Type-Options: nosniff防止某些版本的浏览器做 MIME 嗅探把图片当成别的东西解析。第二个地方是缓存。IE 的缓存策略出了名的顽固这个后面第 7 节会详细讲。这里先记住一点图片资源的响应头要明确禁止缓存否则用户删了文档本地缓存里那张图还在间接等于长期留存。配置示例Nginx 侧的一个 alias 片段location ^~ /render-img/ { alias /data/render/; add_header Cache-Control no-store, no-cache, must-revalidate, max-age0; add_header Pragma no-cache; add_header Expires 0; add_header X-Content-Type-Options nosniff; # 只允许 GET禁止目录列表 autoindex off; # 令牌校验交给后端这里做一层快速拒绝 if ($arg_tk ) { return 403; } }2.3 切入点二把渲染载体从文档换成位图第二个切入点是整套方案的核心工作量所在在服务端把 PDF 每一页渲染成位图。这一步做完禁止另存和禁止复制这两条基本自动成立因为位图上没有可复制的文本对象也没有可供另存为 PDF的源文件。渲染引擎的选择有讲究直接在服务器上装 Adobe 商业套件不现实常规做法是用 Ghostscript、MuPDF 或者 Poppler 这三套命令行工具它们的差异在第 3 节有详细对比。这里先说结论Ghostscript 在中文文档的兼容性和稳定性上表现最好MuPDF 速度最快但个别中文嵌入字体会出问题Poppler 居中。我们的方案最终选的是 Ghostscript。渲染的关键不只是能出图还要考虑输出图的尺寸、清晰度、体积三者的平衡以及渲染过程中的资源占用。一份 300 页的标书如果按 300 DPI 全量渲染成 PNG出来可能是好几个 G服务器磁盘和内存都扛不住。这不只是参数问题是整个流水线的设计问题。2.4 切入点三请求级鉴权与一次性令牌第三个切入点是给图片请求加访问控制。如果图片 URL 是/render/abc123/page1.jpg这种可预测的固定路径懂行的人写个脚本就能把整份文档拖下来前面所有努力白费。我们采用的做法是短期一次性令牌前端请求某一页图片之前先向后端要一个令牌后端把文档ID | 页码 | 用户名 | 过期时间用 HMAC 签名后返回前端拿着这个令牌去请求图片图片接口校验签名和过期时间通过才返回。// Node 侧生成令牌的示意 const crypto require(crypto); const SECRET process.env.RENDER_SECRET; function makeToken(docId, page, user, ttlSeconds) { const expire Math.floor(Date.now() / 1000) (ttlSeconds || 60); const raw [docId, page, user, expire].join(|); const sig crypto.createHmac(sha256, SECRET).update(raw).digest(hex); return Buffer.from([raw, sig].join(#)).toString(base64); } function verifyToken(token) { const decoded Buffer.from(token, base64).toString(utf8); const idx decoded.lastIndexOf(#); if (idx 0) return null; const raw decoded.slice(0, idx); const sig decoded.slice(idx 1); const expect crypto.createHmac(sha256, SECRET).update(raw).digest(hex); if (sig ! expect) return null; // 签名不符 const [docId, page, user, expire] raw.split(|); if (Number(expire) Math.floor(Date.now() / 1000)) return null; // 已过期 return { docId, page, user }; }令牌有效期给 60 秒足够了因为用户看一页图也就几秒钟过期了前端再要一个就是。这个设计带来的额外好处是如果有人拿着一个令牌去批量刷日志里会非常明显——同一个用户在一分钟内请求了 200 个不同的页码这显然不是正常阅读行为。3. 服务端切图流水线从 PDF 到死图片的完整参数这一节讲干活的部分。渲染流水线看着就是调一条命令实际参数怎么定、缓存怎么设计、并发怎么控直接决定了这套方案是能用还是不能用。我把调试过程中记录下来的数据整理在这里。3.1 三套渲染引擎的实际表现对比我们拿了一份 120 页的招标文件做测试里面有中文字体、有表格、有扫描件插图在同样的 4 核 8G 虚拟机上跑结果大致如下引擎命令120页耗时中文兼容内存峰值备注Ghostscript 9.50gs约 96 秒好约 380 MB参数丰富输出稳定Poppler 0.86pdftoppm约 74 秒较好约 300 MB速度快字体依赖系统MuPDF 1.16mutool draw约 52 秒一般约 260 MB个别嵌入子集字体缺字速度上 MuPDF 明显占优但我们最终没有选它原因在于那份测试文件里有几个用特殊子集方式嵌入的中文字体MuPDF 渲染出来有几个字变成了方框而 Ghostscript 正常。企业文档的来源五花八门各种早期排版软件导出的 PDF 都有宁可慢一点也不能出乱码这是踩过坑之后的取舍。Ghostscript 的调用命令最终定型成这样gs -dSAFER -dBATCH -dNOPAUSE -dQUIET \ -sDEVICEjpeg \ -dJPEGQ82 \ -r150 \ -dTextAlphaBits4 \ -dGraphicsAlphaBits4 \ -dUseCropBox \ -dFirstPage1 -dLastPage1 \ -sOutputFile/data/render/abc123/p0001.jpg \ /data/source/abc123.pdf逐个参数说清楚为什么要这么配。-dSAFER是安全选项限制 PostScript 里的文件操作服务器上跑渲染必须开。-dQUIET把输出噪音关掉不然日志会被刷爆。-dJPEGQ82是 JPEG 质量这个值我试过 70 到 9582 是肉眼几乎看不出损失、体积又能接受的临界点。-dTextAlphaBits4和-dGraphicsAlphaBits4是抗锯齿开了之后小字号的中文才不至于糊成一团代价是渲染时间增加大约 15%。-dUseCropBox很关键很多 PDF 的 MediaBox 比实际内容大一圈不加这个参数渲染出来边上会有大片白边。3.2 DPI、体积与清晰度的平衡怎么算DPI 是这里最需要动脑子算的一个参数。原则是渲染 DPI 应该覆盖用户可能看到的放大倍数但不应该让你为永远用不到的清晰度付存储和带宽成本。我们的查看器允许用户放大到原尺寸的 2 倍。按这个需求反推150 DPI 渲染出来的图在屏幕上 1:1 显示时大约对应 150% 的缩放比例因为屏幕一般按 96 DPI 计算逻辑像素放大 2 倍就是 300 DPI 的效果。所以 150 DPI 是一个合理起点。实测数据A4 页面纯文字内容渲染 DPIJPEG质量单页体积屏幕观感磁盘占用1000页9682约 90 KB略糊小字发虚约 90 MB15082约 220 KB清晰约 220 MB20082约 380 KB很清晰约 380 MB30082约 850 KB过剩约 850 MB表格里的体积是纯文字页的数据。如果页面里嵌了扫描图或者照片体积大概是这个数字的 3 到 5 倍。所以真正上线的时候我在配置文件里留了一个按文档类型覆盖的开关合同、公文这类纯文字的走 150 DPI图纸、扫描件这类走 200 DPI 并适当降低 JPEG 质量到 75用质量换体积。提示不要迷信高 DPI 更安全。DPI 再高另存出来的也是一张图防护强度和 96 DPI 是完全一样的。DPI 只影响观感不影响安全性所以按观感需求定就行。3.3 渲染缓存与并发队列的工程实现上线之后马上会遇到一个现实问题同一份文档会被很多人反复打开如果每次都重新渲染CPU 直接被打满。所以必须做缓存而且缓存的粒度要跟着文档内容 渲染参数走。缓存的键设计成这样文档内容哈希 DPI JPEG质量 水印模板版本。注意一定要包含水印模板版本这个我踩过坑——有次改了水印的透明度和字号结果老缓存命中页面上还是旧水印排查了半天才想起来缓存键没变。缓存的目录结构按文档哈希分片避免单个目录下文件过多导致ls卡死/data/render/ a3/ a3f8c1d9e0b2.../ # 文档内容哈希前若干位做二级目录 meta.json # 页数、尺寸、渲染参数、生成时间 p0001.jpg p0002.jpg ...并发控制上我用的是一个简单的信号量 任务队列。四种资源要限制同时渲染的文档数我们设成 2因为虚拟机只有 4 核、单个文档的渲染内存上限、队列最大长度设成 200超了直接返回系统繁忙请稍后而不是无限堆积、单次渲染超时设成 300 秒超时杀进程并标记失败。// 极简的串行队列控制渲染并发 const MAX_CONCURRENT 2; let running 0; const queue []; function enqueue(task) { return new Promise((resolve, reject) { queue.push({ task, resolve, reject }); pump(); }); } function pump() { while (running MAX_CONCURRENT queue.length) { const item queue.shift(); running; item.task() .then(item.resolve, item.reject) .then(() { running--; pump(); }); } }还有一个容易漏掉的点预渲染策略。文档上传入库的时候如果文件不大比如 50 页以内可以直接在后台异步预渲染完用户点开就是秒开。超过阈值的走按需渲染用户看到第一页的同时后台已经在渲染第二、第三页了。这个边看边渲的体验比转圈等 20 秒好太多。3.4 中文文档的字体内嵌陷阱这一块单独拎出来说因为它坑了我整整两天。Ghostscript 渲染中文依赖操作系统里有没有对应字体。如果 PDF 里字体是完整嵌入的没问题但很多国产办公软件导出的 PDF 只嵌入了字体子集或者干脆只写了字体名让阅读器自己去系统里找。服务器如果是个精简安装的 Linux没装中文字体渲染出来就是一堆方框——而且这个错误在预览日志里完全不报就是静静地出方框图。解决办法是在服务器上预装一套中文字体常见的黑体、宋体、仿宋各来一份然后确认 Ghostscript 能找到。可以用fc-list :langzh看一下系统识别到了哪些中文字体。另外还可以通过 Ghostscript 的-sFONTPATH参数指定字体目录避免依赖系统配置。备选方案是用-dSubsetFontstrue -dEmbedAllFontstrue但这两个参数主要影响的是输出 PDF 时的行为对渲染成位图帮助有限。所以归根到底还是那句服务器上把中文字体装全。这一条看起来土但最管用。4. 切片、乱序与鉴权让另存为无从下手图片渲染出来只是第一步怎么把这些图片交给前端是另一个需要设计的地方。整页图直接扔给img标签右键图片另存为还是能存下来。所以还要在交付层做点文章。4.1 整页图还是瓦片切片两种做法我都试过各有适用场景。整页图一页 PDF 渲染成一张大图前端一个img显示翻页就是换src。优点是实现简单、请求数少、滚动流畅缺点是右键另存能直接拿到整页而且大页面内存占用高。IE11 在处理宽度超过 2000px 的图片时缩放会明显卡顿。瓦片切片把一页渲染成一张大图之后再用工具切成 512×512 的小块前端用 CSS 的background-position或者绝对定位把这几十块拼起来。优点是右键永远只能存到一小块拿全图需要拼接显著提高成本缺点是请求数暴涨一页 A4 150DPI 大概 1200×1700切成 512 的块要 12 到 15 个请求IE 单域名并发只有 6 个卡顿感明显。最终的折中方案是按纵向条带切。一页图沿垂直方向切成 3 到 4 条每条高度 400 到 600px前端用四个绝对定位的 img 拼起来。这样单页请求数控制在 4 个以内IE 跑得动右键另存只能拿到一条而且这条是截断的信息不完整。!-- 纵向条带拼装示意容器高度固定内部绝对定位 -- div classpdf-page styleposition:relative;width:820px;height:1160px;overflow:hidden; img classstrip>// 页码范围校验非数字或者超范围一律拒绝 function safePage(input, maxPage) { if (!/^\d{1,6}$/.test(String(input))) return null; const n parseInt(input, 10); if (n 1 || n maxPage) return null; return n; }4.3 Canvas 绘制与 blob URL 的取舍还有一个更硬一点的做法不用img src...直接引用 URL而是用 XHR 把图片以 blob 形式拉下来用URL.createObjectURL生成一个内存地址或者干脆画到 canvas 上。var xhr new XMLHttpRequest(); xhr.open(GET, imgUrl tk token, true); xhr.responseType blob; xhr.onload function () { if (xhr.status ! 200) return; var img new Image(); img.onload function () { var cv document.getElementById(pageCanvas); var ctx cv.getContext(2d); cv.width img.width; cv.height img.height; ctx.drawImage(img, 0, 0); // 叠加一层 canvas 级水印不进入图片文件本身 drawCanvasWatermark(ctx, cv.width, cv.height, userName); URL.revokeObjectURL(img.src); // 及时释放 }; img.src URL.createObjectURL(xhr.response); }; xhr.send();这个做法有几个实实在在的好处。一是图片 URL 不会出现在 DOM 的src属性里右键图片另存为直接失效因为 canvas 没有 src。二是可以在 canvas 上再叠一层动态水印水印跟着用户名走不进入图片文件本身即使有人拿到了 blob 也没有这层水印——这反而成了追溯依据一份没有 canvas 水印的截图说明是通过非常规手段拿到的。三是 blob 地址是内存级的页面关掉就没了不会在磁盘缓存里留下痕迹。代价也有。IE11 对 canvas 的尺寸有限制宽高超过 8192px 或者面积超过某个阈值会直接绘制失败所以大页面必须先切片再画。另外 blob 拉取绕过了浏览器的图片缓存翻回上一页要重新请求我们用一层内存 Map 缓存住已经画好的 canvas 内容来缓解。5. 浏览器侧的四道闸门右键、快捷键、打印、复制服务端把文件变成了图片前端这部分的工作就是尽量把入口堵死同时把开门记录记下来。这部分代码不复杂但细节很碎。5.1 右键菜单与拖拽拦截右键菜单有两处来源要分别处理。页面空白区域的右键是 DOM 事件可以在容器上挂oncontextmenu返回 false 拦掉。图片区域右键是浏览器对图片元素的默认行为同样能被oncontextmenu影响只要事件冒泡到绑定的容器上就行。var viewer document.getElementById(pdfViewer); viewer.oncontextmenu function (e) { e e || window.event; // 记录一次右键尝试用于行为分析 reportBehavior(contextmenu); if (e.preventDefault) e.preventDefault(); e.returnValue false; return false; };拖拽也要一起拦。图片默认是可以被拖到桌面或者另一个窗口的Ctrl拖动直接就是在复制文件。处理方式是给图片元素加draggablefalse和ondragstart返回 false。这个细节很容易漏因为拖拽操作在测试时不太会被想起来。还有selectstart事件它能阻止鼠标框选。虽然图片本身没什么可选的但如果页面上还有文字层的水印或者页码框选后复制会带上这些内容一起拦掉更干净。5.2 快捷键拦截的真实命中率快捷键这部分我给一个实测的对照表免得有人以为挂个onkeydown就万事大吉了。操作组合键能否拦截说明保存网页CtrlS能keydown 里 preventDefault 即可打印CtrlP能同上但用户仍可从菜单点打印复制CtrlC能拦掉后无法用键盘复制全选CtrlA能同上查看源码CtrlU能拦掉但 F12 拦不住开发者工具F12部分keydown 能拦但菜单和外部调用拦不住截图PrintScreen不能系统级按键只能污染剪贴板录屏任意录屏软件不能完全无法拦截关于 PrintScreenIE 里有一个专有的window.clipboardData对象可以在按键触发时把剪贴板内容清掉让截图变成空白。这个做法只对 IE 有效正好符合我们的场景但它需要用户信任该站点第一次会弹提示而且不同 Windows 版本行为不一致所以只能作为辅助手段不要写进验收标准里。document.onkeydown function (e) { e e || window.event; var k e.keyCode; // 44 是 PrintScreen 在 IE 里的键码 if (k 44) { try { window.clipboardData.setData(Text, ); } catch (ex) {} reportBehavior(printscreen); return false; } // 屏蔽 CtrlS / CtrlP / CtrlC / CtrlA / CtrlU if ((e.ctrlKey || e.metaKey) [83, 80, 67, 65, 85].indexOf(k) -1) { reportBehavior(hotkey_ k); if (e.preventDefault) e.preventDefault(); e.returnValue false; return false; } };注意拦截快捷键本身只是骚扰手段真正重要的是上报。每一次右键、每一次 PrintScreen、每一次 CtrlS 尝试都应该记一条日志带上用户、文档、时间。单次行为不处理短时间内多次触发就值得关注。这才是这套代码最大的价值。5.3 打印抑制的边界在哪里打印这块必须说清楚能做到什么程度。纯前端能做的只有一件事通过 CSS 的打印样式让打印出来的内容是空白或者一块提示信息。media print { #pdfViewer { display: none !important; visibility: hidden !important; } #printMask { display: block !important; height: 100%; } }这段 CSS 在用户直接 CtrlP 打印整个网页这个路径上是有效的打印预览里 viewer 区域是空的替换成一段提示文字。但它挡不住另外几条路用户可以点浏览器菜单里的打印菜单打印走的还是同一套样式所以这条路其实也挡得住用户可以按 PrintScreen 然后粘贴到画图里打印挡不住用户可以对着屏幕拍照然后打印那张照片挡不住用户可以用系统级的打印到 PDF虚拟打印机这个本质上也是走打印样式所以也挡得住还有最关键的——微软在系统层面提供的打印到 PDF功能或者第三方的 PDF 虚拟打印机它们捕获的是屏幕渲染结果有些实现方式会绕过 CSS 打印样式。所以打印这条线我们的策略是CSS 打印样式一定要加因为它能挡住绝大多数普通用户同时在文档容器上记录一次打印事件尝试通过 CSS 里的一个特殊背景图或字体请求来间接判断打印预览是否被打开这个技巧不太可靠仅作参考然后在界面上做一个显式提醒——查看页面的角落固定显示当前用户姓名让他知道记录是存在的。5.4 复制这件事为什么在图片方案下自然消失这一条其实是整个方案里最省心的。PDF 能复制是因为它有文本层。一份 PDF 内部的文字是以字符编码加位置信息存储的阅读器读出来自然就能选中、复制、搜索。这也是为什么有些公司会把 PDF 转曲把文字转成矢量路径——转曲之后文字就变成了图形复制不了但文件体积会变大而且无法搜索。我们的方案比转曲更彻底直接从 PDF 渲染成 JPEG 位图。位图里根本没有字符对象连矢量路径都没有全是一个个像素点。用户框选不出任何东西搜索框里搜不到任何内容复制粘贴出来的是空。所以禁止复制这一条在方案层面就已经解决了前端不需要额外做任何事。反过来说这也带来一个必须提前告知业务方的副作用全文检索功能没了。如果原来的系统有在文档中查找关键字的需求那图片方案下就必须另想办法比如在服务端保留一份文本索引前端提供独立的搜索框搜索结果返回页码用户跳到那一页自己看。这个功能要不要保留、工作量多少一定要在方案评审阶段就定下来不然做到一半冒出来整个前端组件都要改。6. 动态水印把禁止换成可追溯前面几节讲的是堵这一节讲的是留痕。既然堵不死那就让每一次泄露都带上身份信息这是整个方案里我最看重的一环。6.1 水印上应该带什么信息水印内容的设计有个原则信息量要够定位到人但不要多到影响阅读也不要多到涉及个人信息合规问题。我们最终定的内容是三项姓名、工号、访问时间精确到分钟。姓名和工号用于定位时间用于判断是哪一次访问泄露的如果同一个人多次打开同一份文档靠时间戳就能区分。这三项都是企业内部标识不涉及个人隐私数据合规上没有风险。水印的视觉参数也调了好几轮。透明度一开始设成 0.25结果有业务同事投诉说看不清正文后来降到 0.10又有人截图之后水印看不清追溯不了。最终的平衡点是浅色背景页面用透明度 0.15深色内容页面用 0.20字号 28px倾斜 -30 度平铺间距 400x300。这个配置在各种截图压缩之后仍然可辨识。还有个问题是水印是加在服务端图片里还是前端用 DOM 覆盖层加。两种都做了服务端硬水印在渲染出来的 JPEG 上合成永久存在跟着图片走。缺点是同一份文档不同用户看到的水印不同导致缓存命中率下降不能按文档缓存整页图必须按文档 用户维度缓存。前端软水印用绝对定位的 div 阵列铺一层文字pointer-events: none不挡操作。优点是零成本、不用重新渲染缺点是一旦用户拿到原始图片比如通过非常规手段这层水印就没了。我们的做法是两者结合服务端免费加一层固定水印比如公司名 文档编号前端叠加用户维度的动态水印。这样即使有人绕过了前端拿到原图图上有公司标识和文档编号如果是从页面截的图还多一层用户信息。两层水印的存在让这张图是从哪条路径流出的变得可以判断。6.2 用 ImageMagick 补一层服务端水印服务端加水印最直接的工具是 ImageMagick。先做一个平铺用的水印底图然后在渲染完之后合成上去。# 第一步生成一张水印平铺底图透明背景 convert -size 400x300 xc:none \ -font /usr/share/fonts/simsun.ttc \ -pointsize 28 \ -fill rgba(128,128,128,0.15) \ -gravity center \ -annotate -30 张三 10086 2024-06-11 10:22 \ watermark_tile.png # 第二步把水印平铺合成到渲染出来的页面上 composite -tile -dissolve 100 watermark_abc123.png p0001.jpg p0001_wm.jpg这里有几个必须知道的细节。-dissolve 100看着是完全不透明但因为底图本身就是 rgba 半透明的所以最终效果是底图的透明度如果底图是全色的-dissolve 100就会盖死。-tile会自动把水印图按原尺寸平铺满整张底图所以水印底图的尺寸要控制好太小会密密麻麻太大会有大片空白。还有一个性能问题每个用户的水印都不一样意味着同一页要合成多次。这会让渲染时间翻倍。我们的优化是分两层底层图片无水印只渲染一次全局缓存用户水印层做成一张小的平铺 PNG也用缓存按用户 日期维度存合成这一步骤很快一张 150DPI 的 A4 图合成耗时大概 80 到 120 毫秒可以接受。6.3 中文字体与倾斜平铺这两个坑中文水印的字体问题比正文渲染更折腾因为正文至少还能在 PDF 里找到字体信息水印是我们自己往图上画的字体全靠服务器上的配置。第一个坑是-font参数。ImageMagick 里指定字体有两种写法一种是直接给字体文件路径/usr/share/fonts/simsun.ttc另一种是给字体名SimSun。后者依赖 fontconfig 的配置不同机器上识别结果可能不一样所以生产环境一律用完整文件路径不用字体名。第二个坑是 ttc 字体集。宋体这类字体常常以 ttc字体集合形式发布一个文件里包含多个字重。ImageMagick 处理 ttc 有时候会取错加个索引后缀simsun.ttc[0]明确指定第几个能解决。第三个坑是倾斜角度与平铺的配合。-annotate -30是让文字旋转 -30 度但旋转后的文字外接矩形变大了如果水印底图尺寸没留够文字会被裁掉一截。我一开始用 200x200 的底图结果四个角上的字都被切了。换成 400x300 并且把-gravity center用好之后才正常。第四个坑是渲染后的图片尺寸与前端容器尺寸对不上。水印是合成在服务端图片上的前端如果对图片做了缩放比如适应容器宽度水印也会跟着缩小28px 的字缩到 0.6 倍就只有 17px 了截图之后根本看不清。解决办法是让前端尽量按 1:1 显示缩放比例不要低于 0.8。7. 真实项目里踩过的七个坑这一节是我在调试过程中记下来的问题清单每一条都真实发生过也都花了不少时间。写出来是希望有人能跳过这些弯路。7.1 IE 缓存把新图顶了回去现象是用户重新上传了一份修改后的文档打开看到的还是旧内容。排查时发现数据库里的文件哈希已经变了服务端返回的也是新图但浏览器显示的是旧图。原因是 IE 对图片资源的缓存判定和现代浏览器不同。它不太看Cache-Control里的no-store更依赖 URL 是否变化。同样的 URL 再次请求它直接读本地缓存。解决办法是在图片 URL 上追加一个版本参数这个参数取自文档内容的哈希内容变了 URL 就变了IE 自然会重新请求。同时响应头里的no-store还是要加两手都要。// 用内容哈希做版本参数IE 下必须这样做 var imgUrl /render-img/ docHash /p0001.jpg?v docHash tk token;7.2 五百页文档一次性渲染把 CPU 打满有一份 500 多页的技术手册上传后用户点开服务端开始异步预渲染全部页面结果 4 核机器直接被占满其他用户的请求全部超时。这个问题暴露的是没有做规模判断。修复方案分三层第一层是阈值页数超过 80 页的一律不做全量预渲染只渲染前 5 页第二层是渲染进程优先级用nice或者 cgroup 限制渲染进程的 CPU 占用不跟 Web 服务抢资源第三层是限流同一个文档的渲染任务在队列里只保留一份不重复排队。# 用 nice 降低渲染进程优先级避免抢占 Web 服务资源 nice -n 15 gs -dSAFER -dBATCH -dNOPAUSE -dQUIET -sDEVICEjpeg \ -dJPEGQ82 -r150 -sOutputFile/data/render/xxx/p%04d.jpg input.pdf7.3 blob URL 在页面跳转后变空白用 canvas 方案的时候用户从文档 A 跳到文档 B再退回文档 A发现图片区域是空白的。原因是URL.revokeObjectURL调得太早了或者页面切换时浏览器回收了 blob 资源而我的内存缓存里的 canvas 内容也跟着被清了。修复方式是把已渲染的 canvas 内容留一份用document.createElement(canvas)生成一个离屏 canvas 存起来返回这一页时直接从离屏 canvas 拷回来不重新请求。7.4 打印出来的图糊成一片被投诉上线后收到反馈有用户打印资料的时候打印出来的文字看不清边缘模糊。原因是 150 DPI 的 JPEG 在屏幕上看没问题但打印机是 300 DPI 以上的物理输出把 150 DPI 的图放大一倍打印自然糊。这个问题的解决方案是给打印场景单独出一份高 DPI 的图但我们的打印抑制策略又是要挡住打印的这两件事本身矛盾。最后的处理是打印抑制保留但对于确实有打印需求的场景走另一条业务通道——在页面上提供一个申请打印按钮审批通过后生成一份带水印的、限制次数的纸质文件下载全程有记录。这比技术上死堵更符合业务实际。7.5 Adobe 插件消失导致老方案失效有一批客户端的 Adobe Acrobat Reader 升级到了新版本新版不再提供浏览器内嵌插件。原来依赖插件execCommand做的一些禁用操作全部失效页面上的 PDF 直接变成了下载提示。这个坑让我彻底放弃了依赖插件能力的思路。只要方案里有一点依赖于客户端的第三方插件就一定会在某次升级中失效。唯一可靠的路径是自己控制渲染和交付也就是现在这套图片方案。7.6 新版浏览器和移动端的回退虽然需求写的是 IE但实际用户群里有人用 Chrome有人用手机。图片方案的好处是天然跨浏览器img和 canvas 在哪都能跑这一块反倒是最省心的。需要注意的是移动端的触摸手势双指缩放、长按保存图片这两个动作要单独处理。长按保存图片在移动端浏览器里很难完全禁止我们的做法是靠 canvas 方案让图片没有 src长按不出保存菜单同时服务端水印保证即使被保存了也能追溯。7.7 日志里发现的批量抓取上线一个月后翻日志发现有个账号在十分钟内请求了 300 多个不同的页码而且顺序是严格递增的。这就是前面说的令牌机制带来的好处——正常阅读不会按页码顺序匀速跑。我们在行为告警里加了这条规则单用户 5 分钟内请求同一文档超过 100 个不同页码触发告警。这条规则后续真的抓到了两次异常行为一次是有人在用工具批量下载一次是有人把账号借给了外部人员。这两次告警的价值比所有的技术拦截都大因为它们是主动发现而不是被动防守。8. 平滑迁移分流开关、接口契约与上线观测方案定了最后一个问题是老系统怎么迁过去。老系统的 PDF 查看页面可能分散在十几个模块里不可能一次性全改。8.1 用分流开关控制灰度我在后端加了一个分流配置按用户、按模块、按文档类型三个维度控制走新方案还是老方案。配置存在数据库里改完即时生效不用重启。// 分流判定用户维度优先其次模块维度 function useImageViewer(userId, moduleCode, docType) { // 白名单用户强制走新方案 if (WHITELIST_USERS.indexOf(userId) -1) return true; // 模块级别开关 if (MODULE_SWITCH[moduleCode] true) return true; // 按文档类型灰度比如先让公文类走新方案 if (DOC_TYPE_SWITCH[docType] true) return true; return false; }灰度顺序是先内部测试账号 → 再一个使用频率低的小模块 → 再一个中等模块 → 最后铺开。每一步观察一周看告警和投诉。这个节奏看起来慢但比一起上线然后回滚要快得多。8.2 前后端接口契约要一次定死新方案的前后端接口只有三个定清楚之后就不用再动了GET /api/doc/{docId}/meta返回文档页数、每页宽高、是否可打印等元信息。GET /api/doc/{docId}/page/{pageNo}?tk{token}返回图片二进制。POST /api/doc/{docId}/token用当前会话换一个短期令牌。这三个接口里meta接口的返回结构要一次想清楚不然后面加字段会导致前端组件改版。我们在meta里预留了watermarkPolicy、printPolicy、maxScale三个字段后来确实都用上了省了一次接口变更。8.3 上线之后盯紧这几个指标上线后我在监控面板上挂了五个指标每个都能直接反映方案是否健康指标正常范围异常含义图片接口 P95 响应时间 400ms超过说明缓存命中率下降或磁盘 IO 瓶颈渲染队列长度 20持续偏高说明并发不够或有文档卡住缓存命中率 85%偏低说明缓存键设计有问题或用户过于分散行为告警次数每日 5突增说明有人在批量抓取图片接口 403 比例 2%偏高说明令牌过期时间太短或客户端时钟不同步最后那条客户端时钟不同步是我踩过的坑。令牌里有过期时间如果客户端和服务器的时钟差得比较多Windows 内网机器有时候就是这样会出现令牌刚生成就被判定过期的诡异现象。解决办法是令牌签发时多给 120 秒的宽容时间而不是让客户端上传时钟。这一整套东西做下来从方案评审到全量上线大概用了七周。真正花时间的地方不是写代码而是弄清楚每一条限制的真实边界在哪——哪些能挡住哪些只能骚扰哪些必须在业务层面解决。把边界摸清楚之后代码反而写得很快。如果让我给还在纠结这个需求的同行一句建议那就是先别急着写拦截代码先把服务端不返回原始文件这一条落地后面所有的事情都会变得顺理成章。