Java借据锁:状态机+数字签名+乐观锁,三重防篡改设计实践
做民间借贷信息登记服务的朋友找到我说他那套流程里最头疼的不是算利息而是借据本身。纸质借据怕涂改电子借据怕被PS更怕同一笔借款生成好几份不同版本的文档结清之后连哪份算数都说不清。我用Java帮他把这套流程做成了一个小工具背后核心就一个词借据锁。它锁的不是文件本身而是业务状态、文档内容、存储版本三个层面让借据从生成到结清都只有一条不可篡改的路径。这篇文章把设计思路、核心代码和踩过的坑完整梳理一遍给做文档类业务系统的Java开发者做参考。1. 立项前先搞清楚这个锁到底锁的是什么1.1 借据业务里的三个脆弱点借据和普通合同不一样它金额明确、日期明确、还款责任清晰属于一旦有争议就能直接决定输赢的单据。纸质时代最大的风险是涂改和伪造但至少原件在谁手里谁就占主动。电子化之后问题变得更隐蔽一份Word文档可以被无限次复制、修改、另存甚至同一笔借款可以生成两份内容完全不同的借据到还款时各执一词。真正做业务流转的人会告诉你借据管理远不止把内容存进数据库这么简单。第一个脆弱点是状态失控借据有没有生效有没有结清是不是已经作废如果没有明确状态机任何人都可以随意把一份草稿标记成已结清。第二个脆弱点是内容失控文档生成后填好的金额、利率、借款日期可能被人静默修改。第三个脆弱点是版本失控同一笔借款生成多版本文档后来的人根本分不清哪一版是最终版。借据锁要解决的就是这三点。状态用状态机加乐观锁管住文档内容用哈希加数字签名锁死版本用数据库约束兜底。一个看起来简单的工具拆开之后就变成了状态机设计、加密签名、事务一致性这些Java后端的基础能力组合。1.2 技术选型为什么选Java而不是脚本语言选Java不是因为它流行而是因为这套系统的使用场景太适合Java生态了。借据系统要部署在客户内网或者一个小型机房服务器上JVM跨平台跑得稳不会今天缺个Python依赖明天少个Node模块。Spring Boot的管理后台和API接口生态成熟接权限、接审计日志、接数据库都顺手。更关键的是Apache POI在Java里操作Word和Excel的能力非常完善动态生成一份带表格的借据文档远比用Python-docx省心。还有一个被很多人忽略的点借据涉及金额字段Java的BigDecimal天然适合做精确十进制运算不会出现浮点数精度问题。如果用某些弱类型脚本语言金额计算稍不注意就会埋雷。当然Python也不是不能做只是对于这种需要长期维护、要对接企业权限体系、还要处理高并发状态变更的业务Java在稳定性和可维护性上有实打实的优势。面向对象的结构化建模方式也能让借据出借人借款人这些业务概念直观变成代码里的领域对象。2. 核心设计拆解状态锁、内容锁、存储锁2.1 业务状态锁借据的状态机设计业务上最不能忍的就是借据状态乱跳。我把借据生命周期收敛成四个状态DRAFT草稿、EFFECTIVE生效锁定、SETTLED已结清、CANCELLED已作废。每个状态之间的流转不是想改就能改的必须要走对应的业务动作。当前状态允许流转目标触发动作DRAFTEFFECTIVE、CANCELLED确认无误后锁定或作废EFFECTIVESETTLED、CANCELLED结清借款或经审批作废SETTLED无终态CANCELLED无终态设计上有个细节即使处于DRAFT状态一旦生成借据文档并完成了签名原则上也不允许再改内容如果确实要改必须作废当前版本重新生成。这样就不用纠结草稿能不能编辑这种边界问题管理上更简单。状态流转的并发控制要特别注意。假设两个人同时打开同一张DRAFT借据一个人点锁定一个人点作废如果没有控制最终状态可能变成后提交者的状态但前一个人的操作结果被悄悄覆盖。我在数据库层面加了version字段做乐观锁更新时强制校验version防止这种丢失更新。核心SQL思路是UPDATE iou SET status ?, version version 1 WHERE id ? AND version ?影响行数为0就说明版本冲突直接提示用户刷新重试。2.2 内容锁把借据焊死在文档里状态锁管住了借据的身份内容锁管住了借据的内容。我用的是网上最常用的组合SHA-256生成文件指纹再配合RSA数字签名做身份确认。借据文档生成的瞬间程序会读取文档字节流计算出一个固定长度的哈希值这个哈希值再用系统私钥签名得到一串只有本系统才能产生的签名串。这里为什么不用MD5或者单纯对称加密值得说清楚。MD5已经被证明存在碰撞风险对于借据这种法律效力敏感的文件不能用有理论风险的算法。对称加密比如AES解决的问题是别人看不懂但借据本身要给借款双方看不需要保密需要的是别人改不了也就是可验证性。哈希加签名这套组合能让任何人拿着借据文件、公钥和原始签名快速校验这个文件从生成之后有没有被动过哪怕一个字节。签名动作本身只针对文档字节流做不针对数据库里的业务字段做。所以哪怕有人直接改数据库里的借款人姓名文档打开还是旧的验签也通不过系统的行为是明确且一致的。密钥管理上私钥放在生成借据的后端服务里公钥对外提供验签接口不接触任何数据库只接受文件字节、签名和公钥参数。2.3 存储锁与数据一致性聊到锁和防篡改就绕不开数据库这层。Java后端保证数据一致性靠的不只是代码逻辑还有数据库约束和事务机制。我在借据表上建了loan_no借款编号的唯一索引配合业务代码判断确保同一笔借款只会有一份有效借据存在状态上再做约束避免重复生效。存储层另一个关键是事务边界。生成借据、计算摘要、签名、落库这几个动作必须在一个事务里完成要么全部成功要么全部失败。我踩过一次坑先调用签名服务保存签名再保存借据记录结果借据保存时报错签名却已经入库了留了一堆脏数据。后来把顺序调整成生成文档字节流 - 计算摘要 - 签名 - 保存借据记录含摘要和签名签名和摘要作为借据记录的附属字段一起写入从根上消除了不一致窗口。3. 实操过程从Maven依赖到借据落库3.1 工程结构与Maven依赖项目本身不复杂就是标准的Spring Boot工程但目录划分上我把业务实体模板生成签名服务分得很清楚避免后期所有逻辑堆到一个Service里。大致结构如下iou-lock ├── pom.xml └── src/main/java/com/example/iou/ ├── domain/ │ ├── Iou.java │ ├── IouStatus.java │ └── IouRepository.java ├── service/ │ ├── IouService.java │ ├── IouTemplateService.java │ └── DocumentLockService.java └── web/ └── IouController.javaMaven依赖重点关注四个Spring Boot Web提供接口能力Data JPA处理持久化POI-OOXML负责生成Word文档commons-codec处理Base64编码。版本上我用Spring Boot 3.x加JDK 17POI用5.x这个组合目前比较稳定。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency dependency groupIdcommons-codec/groupId artifactIdcommons-codec/artifactId version1.16.0/version /dependency /dependencies实体类Iou上我使用JPA的Version注解做乐观锁。这个注解非常实用更新实体时框架会自动带上version条件并递增版本号比自己手写SQL更省心。loan_no字段上加了唯一索引双保险防重复。3.2 生成借据文档的核心代码借据文档我建议用模板填充而不是动态创建全文。做法是准备一份Word模板里面用${borrowerName}、${lenderName}、${amount}、${borrowDate}这种占位符标记程序读取模板后批量替换再输出成最终借据。好处是样式统一后续调整排版只需要改模板不用改代码。POI操作Word有个非常容易踩的坑Word在底层XML里会把一段文字拆成多个Run直接用paragraph.getText()拿到完整文本再run.setText()替换经常出现替换后样式错乱甚至内容重复的问题。我封装了一个替换方法思路是先判断段落是否含占位符如果含占位符就把段落里所有Run清掉然后在段落里新建一个Run写入替换后的完整文本。private void replaceInParagraph(XWPFParagraph paragraph, MapString, String values) { String text paragraph.getText(); if (text null || !text.contains(${)) { return; } for (int i paragraph.getRuns().size() - 1; i 0; i--) { paragraph.removeRun(i); } XWPFRun newRun paragraph.createRun(); newRun.setText(replacePlaceholders(text, values)); }表格单元格里的占位符同样要处理遍历所有表格的所有单元格对每个段落调用上面的方法。需要注意清空Run重建会丢失原Run的字体样式我的解决办法是在清空前先记录第一个Run的字体属性字体名、字号、是否加粗新建Run时把这些属性重新set回去实测效果和原模板基本一致。借据生成之后立刻做签名。DocumentLockService里两个核心方法一个生成SHA-256摘要一个用RSA私钥签名。签名时不是直接签原文而是先对原文做摘要再对摘要签名验签时同样先算摘要再验性能更好也是行业里的标准做法。public String sign(byte[] data) throws Exception { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] digest md.digest(data); Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(digest); return Base64.getEncoder().encodeToString(signature.sign()); } public boolean verify(byte[] data, String base64Signature, PublicKey publicKey) throws Exception { MessageDigest md MessageDigest.getInstance(SHA-256); byte[] digest md.digest(data); Signature signature Signature.getInstance(SHA256withRSA); signature.initVerify(publicKey); signature.update(digest); return signature.verify(Base64.getDecoder().decode(base64Signature)); }这里的Base64编码是为了把二进制签名转成字符串方便存数据库和对外传输。验签接口接收的参数是文件字节流、签名串和公钥完全无状态可以独立部署成校验服务。3.3 状态流转与并发控制落地用户点击确认并锁定按钮后接口做的事可以简化成三步校验当前状态是DRAFT把文档字节流传给签名服务生成摘要和签名然后保存借据并把状态置为EFFECTIVE。由于实体上有VersionJPA会在提交事务时自动带上version条件如果这期间别人已经改过这条记录当前事务会抛出乐观锁冲突异常前端捕获后提示用户重新加载。如果用MyBatis或手写SQL逻辑就更直观。我在抽测时用原生SQL验证过并发UPDATE iou SET status EFFECTIVE, version version 1 WHERE id #{id} AND version #{version} AND status DRAFTUPDATE返回0代表没有更新到任何记录也就是对方已经抢先改了状态。此时必须中断操作而不是继续往下走。对比悲观锁SELECT ... FOR UPDATE乐观锁更适合这种低频、冲突概率不高的场景性能好也不会因为长时间持锁影响其他查询。4. 踩坑记录POI、签名、并发那些常见问题4.1 POI操作Word的五个坑第一个坑就是占位符被拆成多个Run。我遇到过模板里明明白白写着${borrowerName}程序替换后打开文档却发现前半段还在、后半段没了的情况。排查下来是Word自动把同一个段落拆成了多个Run而这个占位符正好横跨两个Run。所以替换逻辑绝不能依赖段落里只有一个Run一定要走重建Run的方案。第二个坑是新增表格行时样式丢失。借据里要按还款计划动态增加行数直接table.addRow()出来的新行往往没有边框和字体。正确做法是先getCTRow()复制一个已有行的底层XML对象再基于复制创建新行然后修改单元格文字。第三个坑是中文字体。POI默认生成的Run不指定字体时Word打开可能是默认西文字体WPS甚至会出现乱码。只要涉及中文内容都要显式设置字体比如newRun.setFontFamily(宋体)同时设置setEastAsia相关属性。第四个坑是内存。XWPFDocument加载的是一个完整文档对象模型几百份小文件无所谓但如果是批量生成几千份每份不关闭内存会肉眼可见地涨。生成流程必须用try-with-resources包住处理完立刻close。第五个坑是模板本身的问题。模板里不要放复杂域、书签、宏POI对这类元素的兼容性不稳定。我最初拿一个有批注的Word做模板生成的文档每次打开都要修复后来把模板重新做了一份纯文本表格版问题消失。4.2 防篡改校验的常见坑防篡改逻辑看起来简单真正跑起来坑也不少。第一个坑是重新读入再签名导致指纹对不上。最开始我是先生成Word文件存到磁盘再从磁盘读字节流计算哈希结果发现每次生成的文档因为包含动态时间戳之类的元数据字节流不稳定。后来改成生成字节流后立刻计算摘要和签名同一个字节数组直接落库或存文件整个过程不二次读取。第二个坑是编码不一致。签名和摘要操作的是字节但业务字段在Java里是字符串。如果生成文档时字符串用了UTF-8验签时又用默认平台编码同一个内容会变成不同的字节序列。解决方法是所有涉及字符串转字节的地方统一指定StandardCharsets.UTF_8。第三个坑是签名算法选择。一开始图省事用MD5withRSA后来安全检查发现MD5作为签名摘要已经不够安全立刻切换到SHA256withRSA。这种细节在评审时经常被揪出来建议直接一步到位。第四个坑是公钥和私钥的分离。私钥一旦泄露整套防篡改体系就是摆设。我在实际部署中把私钥放在独立服务里签名接口只对内开放公钥放在验签服务配置项里任何人都可以调用验签但拿不到签名权限。4.3 并发更新的排查实录有一次测试环境出现一个诡异现象同一张借据A用户点击锁定成功B用户点击作废也提示成功但最终数据库状态是EFFECTIVE和B看到的CANCELLED对不上。查日志发现B的事务执行了UPDATE但影响行数是0代码里没检查返回值就直接返回成功。类似这种问题属于典型的丢失更新也是Java怎么保证数据一致性最常见的考点。后来我在所有状态流转方法里加了一个统一拦截器UPDATE返回0时抛出OptimisticLockExceptionController统一捕获后返回操作冲突请刷新后重试。同时把事务边界缩小生成文档、计算摘要这些耗时不长但容易出问题的步骤移到事务外执行事务内只保留状态更新和签名数据写入这样锁持有的时间更短并发冲突概率也更低。5. 这套方案还能怎么扩展5.1 从借据到合同模板POI还能做什么这套生成文档-签名-锁定的模式完全可以直接平移到其他合同场景租房合同、合作协议、授权书底层逻辑都一样。Apache POI不只支持Word表格和段落还可以在Word里嵌入图片、生成图表。有人问Java POI Word能生成图表吗答案是能通过XWPFChart可以插入柱状图、饼图但底层XML操作比较复杂如果只是给借据配一份还款计划表直接生成Word表格再加一段Excel图表成本低得多。PDF输出这块我建议用LibreOffice headless模式把Word转PDF稳定性比Java纯代码渲染高很多唯一要注意的是目标服务器必须安装中文字体包否则PDF里中文全是方块。转换之后再对PDF做一次哈希和签名整个借据从Word到PDF都有防篡改覆盖。5.2 把锁升级为可验证存证单机签名最大的局限是公信力只限在系统内部。如果借款双方对簿公堂对方完全可以质疑这套系统是你们自己搭的。更稳妥的做法是把借据摘要同步提交到第三方存证平台或者司法存证链上拿到一个存证编号。业务上只需要在生成借据锁定时多调用一次存证接口将摘要和业务凭据一同提交后续查验时同时校验本地签名和第三方存证记录。这个方法成本低但信任等级完全不一样。还可以给借据生成一个二维码印在PDF角落。用户扫码后进入一个验真页面上传借据PDF或者手动输入借据编号系统调库返回存证状态和摘要是否一致。整个锁定-存证-查验闭环就完整了。5.3 Java开发者还能继续挖的底层知识做借据锁这个项目的过程中几乎每个环节都能往Java底层深挖。优雅实现乐观锁的核心是CAS思想这和Java并发包里的AtomicInteger、并发锁的底层机制一脉相承。AQSAbstractQueuedSynchronizer通过状态位和等待队列管理线程竞争和数据库乐观锁通过版本号管理事务竞争思路本质上是一样的只是粒度不同。如果顺着这个项目学Java我建议别急着背八股文先把状态机、事务、签名验签、POI模板替换这几条线画清楚然后去看Java并发包的源码、Spring事务传播机制、SHA系列算法原理再用一些小项目验证。现在网上Java免费入门网站和刷题平台很多从项目倒逼学习比拿着庞大的Java学习路线图硬啃效率高得多。最后说点实际体会。做借据锁最大的收获不是学会了怎么操作POI或者怎么签名而是明白了锁只是手段业务规则清晰才是根本。状态流转不定义清楚锁再多也会乱套文档生成和签名顺序不固定加密再强也会有脏数据。很多看起来复杂的业务系统拆到最底层考验的还是工程师对状态、并发、数据一致性的基本功。这套方案我们内部跑了大半年借据没出过一次篡改纠纷。如果你也在做类似文档管理、合同流转的系统先把状态流画清楚再一层层加锁一步步来会比盲目堆技术稳妥得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →