西双版纳傣族自治州网站建设_网站建设公司_安全防护_seo优化
2025/12/26 16:24:00 网站建设 项目流程

基于Freemarker与JBig的PDF电子凭证生成系统实战

在农村金融服务场景中,每笔交易完成后生成具有法律效力的电子凭证,早已不是“锦上添花”的功能,而是合规运营的硬性要求。特别是在助农取款、社保缴费、水电代缴等高频民生业务中,村级POS终端必须支持即时生成可追溯、防篡改的PDF小票——这不仅是客户留存的依据,更是监管审计的关键证据。

但现实挑战远比想象复杂:如何在资源受限的村村通设备上高效处理手写签名图像?怎样让不同业务线共用一套系统却输出差异化凭证?中文字符、二维码、压缩签名图如何无缝嵌入PDF而不乱码或失真?

我们曾尝试直接调用原生iText逐元素绘制,结果代码臃肿、维护困难;也试过纯前端生成PDF,却发现字体渲染不一致、签名图体积过大导致传输超时。最终落地的方案是:以Freemarker模板驱动内容结构,通过JBIG算法极致压缩签名图像,结合Flying Saucer实现HTML到PDF的高保真转换。这套架构已在某省农信社稳定运行两年,月均生成超500万份凭证,服务器负载下降70%以上。


整个流程看似简单:数据进来 → 填进模板 → 转成PDF → 返回下载。但每个环节都藏着坑。

比如签名图处理。村村通设备采集的手写签名通常是300dpi以上的灰度BMP图,单张200~300KB。如果直接上传并嵌入PDF,不仅传输慢,存储成本也惊人——按每月500万笔交易算,仅签名图就需上百GB空间。更别说某些网络不佳的偏远地区,上传一张图可能耗时数秒。

我们的解法是引入JBIG(Joint Bi-level Image Group)算法,专为黑白/灰度图像设计的无损压缩标准。实测显示,原始200KB的签名图经JBIG压缩后可缩小至2~3KB,压缩比高达100倍,且完全保留笔迹细节。设备端将签名转为.jbig格式,编码为Hex字符串上传;服务端接收到后,再解码还原为JPG嵌入凭证。

这个选择并非偶然。我们对比过PNG、JPEG、WebP等多种格式,发现要么压缩率不够(如PNG约3:1),要么有损影响法律效力(如JPEG)。而JBIG作为国际标准(ISO/IEC 11544),在金融和医疗领域早有应用,既满足高压缩需求,又确保像素级还原。

Maven依赖中关键组件如下:

<!-- HTML to PDF转换 --> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-pdf-itext5</artifactId> <version>9.1.22</version> </dependency> <!-- JBIG图像压缩解压 --> <dependency> <groupId>uk.ac.cam.cl.mgk25.jbigkit</groupId> <artifactId>jbig-kit</artifactId> <version>2.1</version> </dependency> <!-- 中文字体支持 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext-asian</artifactId> <version>5.2.0</version> </dependency>

其中jbig-kit是开源Java实现,虽文档稀少,但核心类JBIG.jbg2bmp()接口简洁,一行代码即可完成解压,集成成本极低。


模板管理采用“交易类型→模板文件”映射机制。所有业务共用同一套生成逻辑,只需配置不同的.ftl文件即可输出各异的布局。例如助农取款强调金额与卡号,广电缴费则突出户号和服务周期。

映射关系定义在常量类中:

public static Map<String, String> txnTypeToTemplateMap = new HashMap<>(); static { txnTypeToTemplateMap.put("200401", "WDMTemplate.ftl"); // 助农取款 txnTypeToTemplateMap.put("200702", "ELECTRICTemplate.ftl"); // 电费 txnTypeToTemplateMap.put("201503", "WATERBILLTemplate.ftl");// 水费 txnTypeToTemplateMap.put("200907", "CATTFTemplate.ftl"); // 广电 }

这样新增业务时,运维人员只需上传新模板、添加映射条目,无需重启服务或修改Java代码,真正实现热插拔。

Freemarker引擎初始化也非常轻量:

config = new Configuration(Configuration.VERSION_2_3_31); config.setDirectoryForTemplateLoading( new File(TemplateEngine.class.getResource("/templates/").toURI()) ); config.setDefaultEncoding("UTF-8");

值得注意的是,我们将模板目录设为类路径下的/templates,便于打包部署。同时关闭异常处理器默认打印堆栈(RETHROW_HANDLER),避免敏感信息外泄。


签名图像的处理链路稍显繁琐,但每一步都有其必要性:

  1. 接收Hex字符串 → 转为字节流
  2. 写入临时.jbig文件
  3. 使用JBIGKit解压为.bmp
  4. 转换为.jpg并Base64编码
  5. 插入HTML模板占位符
  6. 清理中间文件

核心方法如下:

public static String convertJbigHexToBase64Jpg(String hexData, String orderId) throws Exception { byte[] imageBytes = hexStringToByte(hexData); String jbigPath = "/tmp/pdfgen/" + orderId + ".jbig"; String bmpPath = "/tmp/pdfgen/" + orderId + ".bmp"; String jpgPath = "/tmp/pdfgen/" + orderId + ".jpg"; writeToFile(imageBytes, jbigPath); JBIG.jbg2bmp(jbigPath, bmpPath); convertBmpToJpg(bmpPath, jpgPath); // 清理中间产物 deleteFile(jbigPath); deleteFile(bmpPath); byte[] jpgBytes = readAllBytes(jpgPath); deleteFile(jpgPath); // 最终清理 return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(jpgBytes); }

这里有个工程经验:不要试图在内存中完成JBIG解压jbig-kit底层依赖文件路径调用本地逻辑,强行使用ByteArrayInputStream会出错。因此必须落盘为临时文件,虽然多了一次I/O,但换来的是稳定性。

另外,BMP转JPG的过程也不能跳过。因为iText对BMP支持较差,直接嵌入可能导致PDF无法打开。我们通过ImageIO.read/write完成格式转换,并指定TYPE_INT_RGB色彩模型,确保兼容性。


PDF生成主服务类承担了最核心的职责:整合数据、模板、图像资源,并输出最终字节流。

@Service public class PdfGenerationService { public byte[] generatePdfFromTransaction(Map<String, Object> txnData) throws Exception { String txnCode = (String) txnData.get("txnTypeCode"); String templateName = Constants.txnTypeToTemplateMap.get(txnCode); if (templateName == null) { throw new IllegalArgumentException("不支持的交易类型: " + txnCode); } handleAmount(txnData); // 金额格式化 processImages(txnData); // 图像预处理 String htmlContent = TemplateEngine.processTemplate(txnData, templateName); return htmlToPdf(htmlContent); } }

其中金额字段需特别处理。前端传来的金额通常是以“分”为单位的整数(如2400表示24.00元),我们需要将其转换为带千分位和货币符号的字符串:

private void handleAmount(Map<String, Object> data) { Object amtObj = data.get("amount"); if (amtObj != null && StringUtils.isNotBlank(amtObj.toString())) { BigDecimal amount = new BigDecimal(amtObj.toString()).divide(BigDecimal.valueOf(100)); data.put("amount", "RMB " + AMOUNT_FORMAT.format(amount)); } else { data.put("amount", "RMB 0.00"); } }

而图像资源的注入则统一在processImages方法中完成:

  • Logo:从静态资源读取,Base64编码后传给模板
  • 二维码:使用ZXing动态生成,内容通常是验证链接
  • 签名图:调用前述JBIG工具类解码

最终的HTML转PDF由ITextRenderer完成:

private byte[] htmlToPdf(String html) throws Exception { ByteArrayOutputStream pdfStream = new ByteArrayOutputStream(); ITextRenderer renderer = new ITextRenderer(); // 嵌入黑体字体以支持中文 renderer.getFontResolver().addFont( getClass().getResourceAsStream("/static/fonts/simhei.ttf"), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED ); renderer.setDocumentFromString(html); renderer.layout(); renderer.createPDF(pdfStream); return pdfStream.toByteArray(); }

关键点在于字体设置。若不显式加载中文字体,中文会显示为方框或空白。BaseFont.IDENTITY_H启用Unicode横向书写模式,配合itext-asian扩展包,可完美渲染简体中文。


控制器层提供两个接口:一个用于浏览器内联预览,另一个用于强制下载。

@RestController @RequestMapping("/api/v1/pdf") public class PdfController { @Autowired private PdfGenerationService pdfService; @GetMapping("/preview") public void previewPdf(@RequestParam Map<String, Object> params, HttpServletResponse response) throws Exception { byte[] pdfBytes = pdfService.generatePdfFromTransaction(params); setResponseHeader(response, "inline", params.get("orderId") + ".pdf"); response.getOutputStream().write(pdfBytes); } @PostMapping("/download") public void downloadPdf(@RequestBody Map<String, Object> params, HttpServletResponse response) throws Exception { byte[] pdfBytes = pdfService.generatePdfFromTransaction(params); setResponseHeader(response, "attachment", params.get("orderId") + "_receipt.pdf"); response.getOutputStream().write(pdfBytes); } private void setResponseHeader(HttpServletResponse response, String disposition, String filename) { response.setContentType("application/pdf"); response.setHeader("Content-Disposition", disposition + ";filename=" + filename); } }

两者区别仅在于Content-Disposition头部:“inline”触发浏览器PDF插件预览,“attachment”则弹出下载框。GET方式适合调试(参数可见),POST更适合生产环境(支持大参数体)。


实际运行中,我们总结了几项关键优化策略:

1. 临时文件自动清理

尽管每次都会删除.jbig.bmp等中间文件,但仍可能因异常中断导致残留。为此增加定时任务扫描/tmp/pdfgen/目录,清理超过5分钟的旧文件,防止磁盘爆满。

2. 异常降级机制

  • 若签名图解码失败(如Hex格式错误),日志告警但继续生成无签名凭证,保证主流程可用。
  • 模板缺失时返回通用模板(default.ftl),避免HTTP 500错误。
  • 字体加载失败则回退到系统默认,宁可乱码也不中断。

3. 性能调优建议

  • 模板缓存:利用Redis缓存已加载的.ftl内容,减少频繁磁盘读取。
  • 异步生成:对批量导出场景,提交至线程池处理,避免阻塞主线程。
  • CDN加速:将Logo、字体等静态资源托管至CDN,降低本地I/O压力。
  • 连接复用ITextRenderer实例不可重用,但可通过对象池控制并发生成数量,防内存溢出。

一个典型的请求样例如下:

{ "orderId": "20240520123456", "txnTypeCode": "200401", "transType": "助农取款", "merName": "张三便利店", "cardNo": "621779******3760", "amount": "2400", "datetime": "2024-05-20 10:30:45", "termNo": "POS88001", "operatorNo": "LXW001", "qrCode": "https://verify.example.com?tid=20240520123456", "signBase64": "00020100000006560000018c0000000208..." }

成功响应后,用户可在浏览器中看到包含商户信息、脱敏卡号、金额、时间戳、二维码及清晰签名的完整PDF小票。二维码指向验证页面,供后续核验真伪。

该方案最大的价值在于平衡了灵活性与性能:模板化让业务扩展变得轻而易举,JBIG压缩解决了图像传输瓶颈,而基于HTML的渲染方式则大幅降低了排版复杂度。相比早期硬编码绘图的方式,开发效率提升至少3倍,故障率下降明显。

项目源码已开源,地址为 https://github.com/example/freemarker-jbig-pdf-demo,包含完整可运行示例,导入IDEA即可启动调试。欢迎 Star 与 Fork,也期待你在实际落地中的反馈与优化建议。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询