IE浏览器里展示PDF,同时把下载、打印、另存、复制这几条路全部堵死——这个需求我第一次接的时候以为是前端加点禁右键代码就完事,结果整整折腾了三周才敢上线。场景很典型:企业内部OA、合同审阅、财务报表、工单附件,浏览器还锁在IE11甚至IE8内核的壳里,业务方要求用户只能看、不能带走。难点从来不在"怎么显示PDF",而在"怎么让用户拿不到原始文件"。下面这套方案是我在两个项目里跑通并且稳定运行了一年多的版本,包含服务端渲染、令牌接口、IE前端拦截和终端策略四层,代码可以直接抄。
1. 先认清防护边界划在哪里
1.1 只要屏幕上看得见,就一定拿得走
先说个不太好听但必须接受的结论:任何在浏览器里渲染出来的内容,理论上都能被拿走。用户拿手机拍屏你拦不住,用采集卡抓屏你也拦不住,虚拟机里跑一套再复制走你还是拦不住。所以"禁止下载、禁止打印、禁止另存、禁止复制"这四个词,在工程上真正准确的含义是:把顺手就能拿走的成本,抬高到需要专门动手、且留下痕迹的程度。
这个认知决定了整个架构的取舍。如果业务方的预期是"绝对安全、一点都流不出去",那这个项目从一开始就不该用浏览器做,应该走专用的文档阅读客户端或者干脆只允许在受控终端上看。但如果预期是"防止普通员工随手另存一份发出去",那浏览器方案完全够用,而且成本低得多。
我在项目启动会上一定会跟业务方确认三件事:谁有权限看、外流后的追责手段是什么、终端环境能不能管控。这三件事的答案直接决定后面投入多少。如果终端能装管控软件,那前端拦截做到七成就行;如果终端完全不管,用户可以用任意浏览器打开,那前端拦截基本等于心理安慰,全部压力都得压到服务端。
1.2 三条技术路线的横向对比
在IE里展示PDF,业内能走的路其实就三条,我把它们的实际表现列一下,避免你重复踩坑:
| 路线 | 实现方式 | 禁止下载 | 禁止打印 | 禁止复制 | 实际评价 |
|---|---|---|---|---|---|
| 浏览器/插件原生渲染 | Adobe Reader ActiveX 或IE内置PDF处理 | 弱 | 弱 | 弱 | 只能藏工具栏按钮,Ctrl+P照样弹窗 |
| 前端JS渲染 | PDF.js 等库在页面里画 | 中 | 中 | 中 | IE11基本跑不动,1.x版本勉强能用但极卡 |
| 服务端转图片 | 后端把PDF烧录水印后渲染成图 | 强 | 强 | 强 | 推荐,IE兼容性最好 |
第一条约十年前是主流。做法是用<object>标签挂 Adobe 的 AcroPDF 控件,然后通过 param 参数把工具栏、侧边栏、书签栏全关掉:
<object id="pdfViewer" classid="clsid:CA8A9780-280D-11CF-A24D-444553540000" width="100%" height="100%"> <param name="src" value="/doc/preview?id=123" /> <param name="showToolbar" value="false" /> <param name="showScrollbars" value="true" /> <param name="showSidePane" value="false" /> <param name="showBookmarks" value="false" /> </object>看着挺像那么回事,工具栏确实没了。但问题是:控件的print()和printWithDialog()方法依然可以被调用,用户在页面里按 Ctrl+P,IE 会把打印请求转给插件,照样打。而且更致命的是——你传给src的那个地址,用户在F12里能直接看到,复制出来用别的工具打开就是原始PDF。我当时的结论是:插件方案只适合"防误操作",不适合"防故意"。
第二条路,PDF.js 在 IE11 上是个灾难。新版本用了大量 ES6+ 语法和 Worker,IE11 直接报语法错误。能跑的只有 1.x 老版本,但渲染一页A4要两三秒,翻页时风扇狂转,十页以上的文档基本没法用。我实测过,放弃了。
第三条路是最终选择,逻辑很朴素:既然浏览器拿到完整PDF文件就一定会泄露,那就不给它完整文件。后端把PDF每页渲染成图片,图片上烧录好水印,一页一页按需下发。前端拿到的永远是位图,没有文字层,没有文件流,Ctrl+S 存下来的是HTML,右键另存的是张图。
1.3 转图片方案顺带解决了三个问题
选这条路的收益比想象中大。第一,文本复制天然被禁掉。PDF里的文字层在渲染成像素的那一刻就没了,用户框选半天选不中任何字,除非上OCR。第二,打印变得可控。打印出来的是一张图,如果水印够密,打印件反而成了溯源证据。第三,IE兼容性最好。IE对<img>标签的支持是这个星球上最稳的东西,比什么canvas、什么WebGL都稳。
代价也有。图片比PDF大,一份20页的合同转成图片大概3到8MB;另外就是图片本身可以被右键另存,所以"禁止另存"这一环必须靠前端拦截加服务端令牌配合,后面细说。
提示:如果你的文档里有需要精确检索的正文内容,转图片方案会让搜索失效。这种情况可以考虑双轨——图片用于展示,另建一份只含标题和索引的全文检索库,检索结果只返回页码,不返回正文。
2. 服务端渲染:把PDF变成带水印的图
2.1 渲染参数怎么定,算一遍就清楚
参数这块最容易拍脑袋,我把计算过程写出来,你照着调就行。
A4纸是 210mm × 297mm,换算成英寸是 8.27in × 11.69in。渲染DPI决定像素尺寸:
- 96 DPI:794 × 1123 px,屏幕上够看,放大就糊
- 150 DPI:1240 × 1754 px,显示器上看清晰,这个是我的默认值
- 200 DPI:1654 × 2339 px,为了应对高分屏和用户放大
- 300 DPI:2480 × 3508 px,只有需要打印的场景才用,体积翻三倍
输出格式上,PNG 无损但体积大,一张150 DPI的扫描件能到 2MB 以上;JPEG 有损但压得狠,质量设 82 时,同样的页面大概 200 到 400KB,视觉上几乎看不出差别。IE不支持WebP,别想着用它省流量。
所以一份20页的文档,150 DPI + JPEG q82,总量大约 4 到 8MB。这个量级不能一次性推给浏览器,IE会直接卡死或者报内存不足。我的做法是分页懒加载,首屏只拉前两页,用户滚动到第三页附近再请求后面的。
还有并发的问题。IE11 对同一个域名默认只开 6 条并发连接(IE8以后都是这个数)。如果一页文档你切成20个图片请求,加上页面本身和其他资源,队列会排得很长,用户看到的是"图一块一块慢慢冒出来"。所以我做了个折中:一页就是一张图,不做切片,但加上请求队列控制,同时在服务器侧开启HTTP/1.1的keep-alive,让6条连接循环复用,实测比不控制队列快将近一倍。
带宽估算也得做。假设峰值100人同时看文档,每人拉3页,每页300KB,那就是90MB的瞬时流量。如果办公网出口只有百兆,这个峰值能把网络打满。所以我在网关层加了每IP每分钟的请求数限流,超了直接返回429,让前端显示"访问过于频繁,请稍后重试",不要让后端扛。
2.2 PDFBox渲染加烧录水印的完整代码
后端我用的是 Java + PDFBox 2.0.x,因为这个组合稳定、资料多、字体处理也成熟。Maven 依赖:
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.29</version> </dependency>渲染加烧录水印的核心方法:
import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.rendering.ImageType; import java.awt.*; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; /** * 渲染单页并烧录水印 * @param doc 已加载的PDF文档 * @param pageIndex 页码,从0开始 * @param markText 水印文本,建议包含用户名+工号+时间 */ public BufferedImage renderWithWatermark(PDDocument doc, int pageIndex, String markText, int dpi) throws Exception { PDFRenderer renderer = new PDFRenderer(doc); // 开启降采样,缩放时能显著提速,画质损失可控 renderer.setSubsamplingAllowed(true); BufferedImage img = renderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB); Graphics2D g = img.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); // 透明度大约 15%,太深会挡内容,太浅不起威慑作用 g.setColor(new Color(128, 128, 128, 38)); int fontSize = Math.max(14, img.getWidth() / 42); g.setFont(new Font("Microsoft YaHei", Font.PLAIN, fontSize)); AffineTransform origin = g.getTransform(); int stepX = Math.max(1, img.getWidth() / 3); int stepY = Math.max(1, img.getHeight() / 6); // 关键:从负坐标开始铺,否则旋转后边角会留空 for (int y = -img.getHeight(); y < img.getHeight() * 2; y += stepY) { for (int x = -img.getWidth(); x < img.getWidth() * 2; x += stepX) { g.setTransform(origin); g.translate(x, y); g.rotate(Math.toRadians(-30)); g.drawString(markText, 0, 0); } } g.setTransform(origin); g.dispose(); return img; }这里有个坑必须提醒:水印的坐标系陷阱。我第一次写的时候直接g.rotate(Math.toRadians(-30), cx, cy),然后把drawString的坐标算成从 0 开始,结果画出来的水印整个飞到了画布外面,一张都看不见。原因是旋转之后坐标系也跟着转了,你在旋转后的坐标系里画(0,0),实际落点已经偏出去很远。正确写法就是上面那样——保存原始变换,每次绘制前setTransform复位,再translate到目标位置,最后rotate,让文字以目标点为原点旋转。
另一个坑是中文变方框。服务器如果没有装中文字体,drawString画出来的中文全是一个个小方块。解决方案是显式指定字体,并且确保服务器装了对应字体。Linux 环境上我一般装fonts-wqy-zenhei或fonts-noto-cjk,然后在代码里用Font.createFont(Font.TRUETYPE_FONT, new File("/usr/share/fonts/..."))加载成实体字体对象,比依赖 logical font 名可靠得多。
保存成JPEG时要控制压缩质量:
import javax.imageio.*; import javax.imageio.stream.ImageOutputStream; import java.io.File; import java.util.Iterator; public void saveJpeg(BufferedImage img, File target, float quality) throws Exception { // 关掉 ImageIO 的磁盘缓存,高并发时能省掉大量磁盘IO ImageIO.setUseCache(false); Iterator<ImageWriter> it = ImageIO.getImageWritersByFormatName("jpeg"); if (!it.hasNext()) throw new IllegalStateException("no jpeg writer"); ImageWriter writer = it.next(); ImageWriteParam param = writer.getDefaultWriteParam(); param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); param.setCompressionQuality(quality); try (ImageOutputStream ios = ImageIO.createImageOutputStream(target)) { writer.setOutput(ios); writer.write(null, new IIOImage(img, null, null), param); } finally { writer.dispose(); } }ImageIO.setUseCache(false)这行别省。默认情况下 ImageIO 会用临时文件做缓存,并发渲染几十页的时候磁盘IO会突然飙起来,我有一回压测时磁盘使用率直接打满,页面全部超时,排查半天才定位到这里。
2.3 Ghostscript 和 pdfium 的备选路线
如果你的技术栈不是Java,Ghostscript 是最省事的替代方案,一条命令渲染整个文档:
gs -dSAFER -dBATCH -dNOPAUSE \ -sDEVICE=jpeg \ -dTextAlphaBits=4 -dGraphicsAlphaBits=4 \ -r150 -dJPEGQ=82 \ -sOutputFile=/data/cache/%d.jpg \ /data/source/contract.pdf-r150是DPI,-dJPEGQ=82是质量,输出文件名用%d会自动编号。速度比PDFBox快不少,但水印就得用合图层的方式后处理了——先渲染无水印图,再用图像库叠水印。这个两步流程会多一点磁盘IO,不过Ghostscript本身很快,总体还是划算的。
还有一个选择是 pdfium,它是各种浏览器的PDF渲染内核,有无水印的Java绑定和命令行工具。它的字体fallback做得比PDFBox好,遇到奇怪字体的PDF不容易出方框,缺点是接口相对底层,文档也少。我的建议是:普通业务文档用PDFBox,包含大量特殊字体的设计稿类PDF用pdfium。
注意:渲染是个CPU密集型操作。一台4核8G的服务器,用150 DPI渲染A4页面,单页大约150到300毫秒。一份20页的文档首次渲染大概要5秒。这个时间必须放进缓存预热流程,不能让用户等——我的做法是文档上传时就异步触发全量渲染,把结果落盘,用户打开时直接读缓存图。
3. 接口层才是真正的"禁止下载"
3.1 一次性令牌与签名URL的设计
前端拦截是纸糊的墙,真正的门锁在服务端。核心思路是:图片地址不可预测、不可复用、不可分享。
每个URL都带上四个参数:文档ID、页码、过期时间戳、HMAC签名。签名内容把文档ID、页码、过期时间、用户ID、客户端IP全部拼进去,用服务端密钥做一次HMAC-SHA256。这样即使有人把URL复制给别人,换个IP就打不开,过了一分钟也失效。
public String buildPageUrl(String docId, int pageNo, String userId, String clientIp) { long exp = System.currentTimeMillis() + 60_000L; // 60秒有效期 String raw = docId + "|" + pageNo + "|" + userId + "|" + clientIp + "|" + exp; String sign = HmacUtils.hmacSha256Hex(SECRET_KEY, raw); return "/doc/page/" + pageNo + "?d=" + docId + "&u=" + userId + "&e=" + exp + "&s=" + sign; }服务端接收时反向校验:
@GetMapping("/doc/page/{pageNo}") public void page(@PathVariable int pageNo, @RequestParam("d") String docId, @RequestParam("u") String userId, @RequestParam("e") long exp, @RequestParam("s") String sign, HttpServletRequest req, HttpServletResponse resp) throws IOException { String clientIp = IpUtils.clientIp(req); long now = System.currentTimeMillis(); if (now > exp) { resp.sendError(410, "expired"); return; } String raw = docId + "|" + pageNo + "|" + userId + "|" + clientIp + "|" + exp; if (!HmacUtils.hmacSha256Hex(SECRET_KEY, raw).equals(sign)) { resp.sendError(403, "forbidden"); return; } // 校验会话与文档权限 if (!permService.canView(userId, docId)) { resp.sendError(403, "no permission"); return; } resp.setContentType("image/jpeg"); resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); resp.setHeader("Pragma", "no-cache"); resp.setHeader("Expires", "0"); resp.setHeader("X-Content-Type-Options", "nosniff"); resp.setHeader("Content-Disposition", "inline"); File img = cacheService.getPageImage(docId, pageNo); if (img == null || !img.exists()) { img = renderService.renderAndCache(docId, pageNo, userId, clientIp); } try (InputStream in = new FileInputStream(img)) { IOUtils.copy(in, resp.getOutputStream()); } auditService.log(userId, docId, pageNo, clientIp); }这段代码里有几个点值得单独说。过期时间给60秒是有讲究的——太短页面加载会失败,太长用户就有足够时间把URL抄下来分享。60秒够IE把当前屏的图拉完,用户想复制出去再打开基本已经失效。签名绑IP是防分享的关键,虽然同一办公网出口NAT后IP可能一样,但至少挡住了把链接发到外网。签名绑用户ID保证了链接不能跨账号使用。
我见过有些人图省事,直接用文档ID和一个固定哈希做URL参数,结果用户改个数字就能看别人的文档。这种低级问题在安全评审里一定会被打回,别偷这个懒。
3.2 响应头与IE的缓存脾气
IE的缓存策略跟现代浏览器不太一样,它对Cache-Control: no-store的遵守程度在部分版本上很让人头疼。我实测遇到过:用户退出登录后再点浏览器的后退按钮,图片居然还能从缓存里显示出来。解决办法是三层响应头一起上:
Cache-Control: no-store, no-cache, must-revalidate, max-age=0 Pragma: no-cache Expires: 0Pragma是HTTP/1.0的老东西,但IE至今认它。三个头一起写,IE11上基本就干净了。
另外还有一个骚操作可以顺手加上:在图片URL后面追一个每次都会变的随机数参数。因为URL变了,IE就不会命中缓存。这个参数不用传到后端,后端忽略它就行。
var url = pageUrl + '&_r=' + Math.random().toString(36).substring(2);3.3 访问审计与异常行为识别
这个功能很多项目会漏掉,但它才是外流之后能追责的依据。每次图片请求都记一条日志:谁、什么时候、看了哪个文档的哪一页、客户端IP是什么。这些数据平时没人看,出事的时候价值千金。
我在日志基础上加了一个简单的异常检测规则:同一个用户在5分钟内拉取超过200页图片,或者同一个账号在3个不同IP上打开同一个文档,就触发告警。正常看文档不可能触发,但如果是脚本批量下载,一定会。
提示:审计日志里千万不要记录文档正文内容,只记录行为元数据。一方面是合规要求,另一方面日志体积也会失控。
4. IE前端这层的拦截怎么写
4.1 禁右键、禁选中、禁拖拽的ES5写法
IE11 不支持箭头函数、不支持let/const的作用域行为、不支持模板字符串。写这段代码的时候老老实实回到 ES5,别用任何新语法,否则就是在给自己挖坑。
(function () { 'use strict'; function stop(e) { e = e || window.event; if (e.preventDefault) { e.preventDefault(); } e.returnValue = false; e.cancelBubble = true; return false; } // 右键菜单 document.oncontextmenu = stop; // 文本选中 document.onselectstart = stop; // 拖拽图片 document.ondragstart = stop; // 复制剪切粘贴 document.oncopy = stop; document.oncut = stop; document.onpaste = stop; // F1 帮助 document.onhelp = stop; window.onhelp = stop; })();这里有个重要提醒:user-select: none这套CSS在IE10以上才有效,IE9及以下只能靠onselectstart拦截。如果你要兼容IE8,那就必须两个都上。
.noselect { -ms-user-select: none; /* IE10+ */ -moz-user-select: none; -webkit-user-select: none; user-select: none; -ms-touch-select: none; }图片上再补两个属性,减少被拖拽的概率:
<img src="..." draggable="false" galleryimg="no" unselectable="on" />galleryimg="no"是IE专属的,作用是去掉图片左上角那个"保存/打印/邮件"的悬浮工具条。这个工具条在IE6到IE9上很烦人,鼠标一划过去就冒出来,用户点一下就能把图存走。加上这个属性它就消失了。现代浏览器忽略这个属性,不影响其他环境。
4.2 快捷键拦截与打印拦截
键盘拦截要注意一个细节:IE的按键事件里,keyCode和which在不同版本上取值不一样,保险的写法是两个都取,哪个有值用哪个。
document.onkeydown = function (e) { e = e || window.event; var code = e.keyCode || e.which; var ctrl = e.ctrlKey || e.metaKey; if (code === 123) { return stop(e); } // F12 开发者工具 if (e.shiftKey && code === 121) { return stop(e); } // Shift+F10 右键菜单 if (e.altKey && code === 115) { return stop(e); } // Alt+F4 if (ctrl) { switch (code) { case 83: // Ctrl+S 保存 case 80: // Ctrl+P 打印 case 67: // Ctrl+C 复制 case 65: // Ctrl+A 全选 case 85: // Ctrl+U 查看源码 case 79: // Ctrl+O 打开 case 78: // Ctrl+N 新窗口 return stop(e); } } return true; };打印这块要坦白说:IE里 Ctrl+P 的拦截并不百分之百可靠。有些版本上onkeydown能拦住,有些版本浏览器会先弹打印对话框,事件才传到页面。所以还需要加打印前后的事件黑洞:
window.onbeforeprint = function () { var nodes = document.getElementsByClassName('page'); for (var i = 0; i < nodes.length; i++) { nodes[i].style.visibility = 'hidden'; } }; window.onafterprint = function () { var nodes = document.getElementsByClassName('page'); for (var i = 0; i < nodes.length; i++) { nodes[i].style.visibility = 'visible'; } };配合CSS层的打印媒体查询:
@media print { html, body { display: none !important; visibility: hidden !important; } }这样即使用户绕过快捷键拦截打出了打印预览,页面上也是一片空白。代价是浪费一张纸,但内容确实没打出来。
4.3 IE上必须知道的几个兼容坑
坑一:base64图片的尺寸限制。IE8 时代<img src="data:image/jpeg;base64,...">超过 32KB 就不显示,IE9 以后放宽了,但大字符串拼接在IE11的引擎上依然很慢——它没有现代JS引擎那种字符串优化,拼接一个1MB的base64会明显卡顿甚至假死。所以永远不要试图把图片转成base64塞进页面,老老实实用URL让浏览器自己去请求。
坑二:不要用 WebP。IE完全不支持,会显示成红叉。统一用JPEG,如果对清晰度要求极高且不在乎体积,用PNG。
坑三:X-UA-Compatible必须写。如果用户的IE开启了兼容性视图,页面会用老引擎渲染,你的CSS和JS可能全部失效。
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta charset="utf-8" />坑四:图片懒加载要自己写。IE不支持loading="lazy"这个原生属性。我的做法是给每个占位div固定高度,监听window.onscroll,滚动到可见区域时才设置img.src。注意IE的getBoundingClientRect()返回值是相对视口的,配合document.documentElement.scrollTop计算,别忘了IE的怪癖模式下scrollTop要从document.body取。
坑五:内存释放。一次加载几十张大图后,即使把img元素从DOM里移除,IE也不一定立即释放内存,浏览久了会卡。我一般控制同时存在的图片不超过10张,超出就主动把img.src置空,帮助回收。
5. 终端策略和溯源的纵深防御
5.1 IE kiosk模式与组策略
如果文档查看的场景是在受控的企业内网终端上,那可以利用IE自带的能力再加一道锁。IE有个 kiosk 全屏模式:
iexplore.exe -k "https://oa.example.com/view?docId=123"这个模式下没有菜单栏、没有地址栏、没有工具栏,用户唯一能操作的就是页面内容加一个右上角的关闭按钮。F11 不能退出,Alt+F4 可以。这对公共查询终端特别合适——比如办事大厅里那台给群众查资料的机器。
再往上就是组策略。Windows域环境下,可以在域策略里统一配置IE的菜单项和快捷键行为,把打印、另存为这些入口从根上关掉。这不是应用层能做的事,需要终端运维配合。做方案评审的时候我会把这条明确写出来,让甲方评估可行性,能配上就配上,配不上就说明风险由前端拦截承担。
5.2 明水印和暗水印,用途完全不同
水印有两个层次,很多人会混为一谈。
明水印是威慑和溯源。就是我在2.2节代码里烧录的那个,内容一般是"张三 工号10086 2024-06-12 14:30"。用户一打开就看到自己的名字铺满整页,心理上就不太敢截图外传,因为传出去一眼就知道是谁泄露的。这个必须烧录到像素里,不能是前端叠的CSS图层——CSS水印用F12删掉就没了,纯属心理安慰。
暗水印是事后追责。思路是把用户ID、文档ID编码进像素的最低位,肉眼完全看不出来,事后拿到外流的图片可以提取出这串信息。用Python实现很简单:
import numpy as np from PIL import Image def embed_lsb(src_path, bits, out_path): """bits: 0/1 组成的列表,代表要隐藏的信息""" arr = np.array(Image.open(src_path).convert('RGB')) flat = arr.flatten() n = min(len(bits), flat.size) b = np.array(bits[:n], dtype=np.uint8) flat[:n] = (flat[:n] & 0xFE) | b Image.fromarray(flat.reshape(arr.shape)).save(out_path, quality=95)但我必须说清楚它的局限:LSB水印扛不住JPEG二次压缩。用户如果把图重新存一次JPEG,最低位就全乱了。所以暗水印只在一种情况下有用——截屏工具直接保存PNG、或者打印扫描后做原图比对。它是补充手段,不是主力。明水印才是真正干活的。
真要做好溯源,还有一招更粗暴但有效:每份文档在像素级别做微小的、不影响阅读的差异,比如某几个像素点亮度差1。每个人拿到的图略有不同,外流后比对这几处就能定位到人。代价是每份文档都要单独渲染一遍,存储成本翻很多倍。只有涉密级别很高的文档才值得这么做。
6. 常见问题速查与踩坑实录
6.1 高频故障对照表
这套方案跑了两年多,遇到的问题基本都在这张表里了:
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| IE里图片显示成空框或红叉 | 响应的Content-Type不对 | 必须是image/jpeg,配合nosniff |
| 图片能拖出幽灵缩略图 | 浏览器默认允许拖拽 | ondragstart返回false,加上draggable="false" |
| 退出后后退仍能看到图 | IE缓存未彻底失效 | 三层缓存头 + URL追随机数 |
| 水印跑到画布外面 | Graphics2D旋转坐标系 | 先translate再rotate,从负坐标开始铺 |
| 中文水印变成方框 | 服务器缺中文字体 | 装思源黑体,用Font.createFont显式加载 |
| 20页以上文档IE卡死 | 一次性拉取图片过多 | 分页懒加载,同时存在的图不超过10张 |
| F12里能直接看到图片URL | 前端拦截本就是浅层 | 令牌60秒有效 + 绑定用户和IP |
| Ctrl+P 仍弹出打印窗口 | IE对keydown拦截不严格 | 配合 kiosk 模式或组策略 |
| 页面加载特别慢 | 首次渲染没预热 | 上传时异步预渲染,落盘缓存 |
| 高并发时磁盘跑满 | ImageIO默认使用磁盘缓存 | ImageIO.setUseCache(false) |
| 有的PDF渲染出来是空白 | 文档加密或有权限限制 | 加载时传空密码,捕获InvalidPasswordException |
| 图片在放大后很模糊 | DPI设置过低 | 提到200 DPI,或按缩放级别准备两套尺寸 |
6.2 几个我实打实踩过的坑
第一个坑:以为前端拦截就够用了。项目初期我们只做了禁右键、禁打印那套JS,自测的时候觉得很完美——右键点不出来、Ctrl+P不弹窗、框选也选不中。结果上线第二天,业务方就收到一份流出去的PDF。用户的操作是:在页面空白处右键 → 查看源文件 → 直接找到了PDF的原始地址。那一刻我才彻底明白前端拦截的边界在哪里,后面把架构整个改成了服务端转图。
第二个坑:图片有水印,但水印能删。我们最早的版本水印是前端用CSS叠加的一层div,看着挺漂亮。后来同事演示了怎么用F12把那个div删掉,全程不到五秒。从此所有水印一律走服务端烧录,前端做的任何"保护"我都不再当成安全措施,只当成体验优化。
第三个坑:token有效期设成了24小时。一开始觉得用户可能看得慢,给个长点的有效期比较友好。后果是这个URL变成了一个可以随便分享的长期链接,谁拿到都能看。改成60秒之后有个副作用需要注意:用户如果读一页停下来思考很久,再去拉下一页,旧的token可能已经过期了。所以我在前端加了一个保活机制,页面上每30秒静默请求一次刷新接口,重新签发一批新的分页URL并替换img.src。这样用户感觉不到,安全边界也守住了。
第四个坑:IE11的内存。有份80页的招标文件,用户直接从头滚到尾,IE内存飙到1.2G然后整个页面崩了。后来我加了严格的图片回收逻辑,滚动离开视口超过5屏的图片一律把src置空、从DOM里摘掉。用户往上滚回去的时候重新加载,多花几百毫秒,但页面不会崩。
第五个坑:字体的问题排查了很久。水印中文在测试服务器上好好的,一到生产就是方框。查了半天发现测试机是Windows Server自带微软雅黑,生产是CentOS最小化安装,一个中文字体都没有。这个坑很典型——所有涉及文字绘制的服务端功能,上线前都要在目标环境验证字体,别只在开发机上测。
第六个坑:并发下的连接池。渲染是CPU密集的,如果请求线程直接去做渲染,20个并发就能把线程池打满,后面的请求全部排队超时。我的做法是渲染任务丢到独立的固定大小线程池(核数 + 1),请求线程只去缓存里取图,取不到就返回一个"渲染中"的占位图,前端每隔一秒重试一次。这个模式很土,但特别稳。
最后再分享一个小心思:我把占位图和真实图的尺寸做成完全一致的,都是按DPI算好的固定像素。这样图片从占位换成真图的时候页面不会跳动,用户视觉上很平滑。IE对这种布局稳定性其实很敏感,如果图片尺寸不固定,加载过程中页面会来回抖,体验非常差。
关于这个方案还能往哪走,我个人的想法是在渲染环节加一层动态清晰度控制——首次加载用低DPI快速铺满,用户放大某一块的时候再局部渲染高DPI。pdfium有按区域渲染的接口,做成瓦片式的按需高清,理论上能把首次加载时间从5秒压到1秒以内。这个我还在试,等跑通了再单独写一篇。