☰
Java实现电子合同签署系统:多端架构与数字签名实战解析
2026/10/7 2:44:12 网站建设 项目流程

先交代背景:我最近交付了一个Java版本的电子合同电子签名系统,源码覆盖微信小程序、公众号、APP、H5四个端,后端基于Spring Boot构建。项目做了三个月,从合同模板设计到CA数字签名接入、从PDF防篡改到多端签署页适配,踩了不少坑,也积累了一些可以复用的经验。如果你正打算自建电子合同系统,或者需要把签章能力嵌入现有的业务系统,这篇内容应该能帮你少走弯路。

我打算按自己的实现思路来拆解:先讲技术选型和整体架构,再拆后端的合同生成、数字签名、证据链保存,然后说说小程序/公众号/APP/H5四个端的落地差异,最后把部署和常见问题整理出来。文章偏工程实践,代码只有核心片段,重点是讲清楚为什么这么做,以及哪些地方容易翻车。

1. 电子合同系统整体设计与技术选型

1.1 为什么选择Java技术栈

选Java不是因为情怀,是这个场景下最务实的选择。

电子合同系统属于企业级应用,天然涉及资金、契约、身份信息,对稳定性、安全合规、并发处理的要求都很高。Java生态里Spring Boot + MyBatis Plus这套组合已经非常成熟,周边人才储备充足,后续接银行存管、CA机构、公证处接口的时候,对方SDK也大多是Java版优先。再加上Java的强类型和成熟的加密库支持,做数字签名这类对算法精度要求高的功能,比Python、Node要更安心一些。

核心依赖我列一下,都是经过验证的:

  • Spring Boot 2.7 / 3.x,负责整个Web层和依赖注入
  • MyBatis Plus,做数据持久化,分页和条件构造器很好用
  • iText 7 / PDFBox,处理PDF生成、签名、防篡改
  • BouncyCastle,提供RSA、SM2、SHA-256等加密算法实现
  • Redis,做分布式锁、缓存、防重复签署
  • MySQL / PostgreSQL,存合同元数据、签署记录、印章信息
  • MinIO / 阿里云OSS / 腾讯云COS,存合同原文和签名图片

有几个朋友问我为什么不用Node写完事,我的回答是:Node写原型确实快,但电子合同系统不只是“能用”,而是要稳定跑几年,期间还要过等保、审计、多租户扩展,Java的长期维护优势会越来越明显。

1.2 核心业务链路:从发起签署到归档

电子合同系统看起来只是“盖章”,但完整的业务链路比想象中长很多。我把核心流程梳理成八个环节,任何一个环节出现问题都可能导致合同无效:

  1. 合同创建:用户选择模板、填写甲乙双方信息、合同金额、租期/服务期等动态字段。
  2. 模板渲染:系统把动态字段填充到HTML或Word模板中,渲染成标准PDF文件。
  3. 合同审批:企业内部可根据流程设置一级或多级审批,审批通过后合同才可发起。
  4. 签署发起:合同发起人选择签署方,设置签署顺序(顺序签/并签),生成签署任务。
  5. 身份认证:签署人首次使用需要完成实名认证,个人认证常用手机号+人脸识别,企业认证常用三要素或四要素校验。
  6. 意愿确认:签署人查看合同内容,通过短信验证码、手写原笔迹、人脸识别等方式表达签署意愿。
  7. 数字签名:服务端调用证书私钥对合同摘要进行签名,同时从时间戳服务器获取时间戳,形成完整签名信息。
  8. 归档存证:签署完成后,合同状态变为已完成,系统保存签署人、签署时间、IP、证书编号、摘要值等证据信息,供后续查阅和验签。

这个链路里最容易出问题的是第5到第7步。身份认证不到位,后面签名再漂亮也容易被判定无效;数字签名没有结合时间戳,签署时间不能自证;存证信息不全,出现纠纷时连基本的“谁在什么时候签的”都说不清。

1.3 多端架构取舍:一套后端,四个前端

标题里提到的“小程序+公众号+APP+H5”不是四套独立的系统,而是一套后端API,四个展示端。这是我最初设计时定下的核心原则。

后端只做业务逻辑和数据存储,对外暴露统一的RESTful接口。四个端各自负责登录适配、页面渲染和交互,但底层调用的都是同一条签署链路:

  • 微信小程序:用户在小程序里查看合同列表、发起签署、手写签名,适合C端高频使用场景。
  • 公众号H5:通过微信浏览器访问,调用网页授权获取用户身份,适合企业员工内部签署或分享签署链接。
  • APP:员工端或管理端,通过WebView加载H5签署页,配合原生拍照、人脸识别能力完成认证。
  • 独立H5:PC浏览器或手机浏览器直接访问,方便客户在电脑上上传PDF、预览合同、下载归档文件。

这种架构的好处很明显:业务逻辑只维护一份,签署状态机、PDF生成、签名算法不会出现四个端行为不一致的情况。前端各自适配以后,每次后端功能升级,四个端自动同步生效,不用重复开发。

1.4 合规基础:可靠电子签名到底要满足什么

谈电子合同不能不谈合规。国内做电子签名,绕不开可靠电子签名的四个核心要求:

  1. 真实身份:签署人必须是经过实名认证的真实主体,企业主体需要认证营业执照信息。
  2. 真实意愿:签署动作必须由本人主动发起,需要短信验证码、人脸识别、手写笔迹等意愿确认机制。
  3. 签名未被篡改:签名后的任何改动都会导致验签失败。
  4. 原文未被篡改:合同内容在签署前后不能被修改,PDF摘要值必须保持一致。

这四个要求直接决定了系统的技术设计。我见过有些团队把“电子签名”做成了简单的图片按上去,那只能叫“电子盖章”,没有法律效力,出了纠纷什么都保护不了。

可靠电子签名通常依赖数字证书。企业可以申请第三方CA机构签发的数字证书,签名时用证书中的私钥对合同摘要做加密,验签时用对应的公钥验证。证书的有效期、吊销状态、时间戳都是关键信息,实现时可以对接权威CA机构,也可以先用测试证书跑通流程。为了方便演示和二次开发,源码里我默认集成了一个测试证书生成模块,生产环境替换成第三方CA证书即可。

2. 后端核心模块与签署链路实现

2.1 合同模板引擎:从HTML到PDF

合同模板是电子合同系统的门面。我选择用HTML模板 + Freemarker渲染,而不是直接操作Word,原因是HTML对动态数据的控制力最强,样式统一,转PDF也更稳定。

整个模板渲染流程是这样:

  1. 定义合同模板,用${variable}占位符标记动态字段,比如甲方名称、乙方手机号、合同金额、租赁周期。
  2. 后端准备数据Map,字段名与模板占位符一一对应。
  3. Freemarker将模板和数据合并,生成带完整内容的HTML。
  4. 使用OpenHTMLToPDF或iText的HTML转PDF能力,把HTML转成PDF。
  5. 在PDF中加入页码、水印、骑缝章图片等附加元素。

这一步的坑主要在字体上。服务器上如果没有安装中文字体,或者PDF渲染时没有嵌入字体,生成出来的合同中文会变成方块。建议在部署文档中明确要求安装常用中文字体,比如宋体、黑体、微软雅黑,并且确保iText配置了正确的字体路径。

生成PDF之后还需要加水印和页码,水印信息可以是合同编号、签署状态、生成时间,用于防止打印后复印件被滥用。页码用iText的PageEventHandler实现,在页面底部居中显示“第X页 / 共Y页”。

2.2 数字签名与证书接入

这一节是整系统的技术核心。我先从原理讲起,再给核心代码。

数字签名不是把签名图片贴到PDF上,而是对合同原文计算摘要,用私钥对摘要加密,再把这个加密结果保存在PDF中。任何人修改合同内容,重新计算摘要后和原始摘要不一致,验签就失败。

用公式表示就是:

  • 摘要 = SHA-256(合同原始内容)
  • 签名值 = RSA_Encrypt(私钥, 摘要)
  • 验签 = RSA_Decrypt(公钥, 签名值) == SHA-256(合同原始内容)

实现上使用iText 7的PdfSigner,它是官方推荐的PDF签名入口,支持设置签名外观、签名摘要、时间戳。下面是一段简化版的核心签名代码:

public SignResult signContract(SignRequest request) { // 1. 根据合同ID查出待签合同文件路径 Contract contract = contractMapper.selectById(request.getContractId()); String srcPdfPath = contract.getStoragePath(); String dstPdfPath = srcPdfPath.replace(".pdf", "_signed.pdf"); String certAlias = request.getCertAlias(); // 2. 读取证书库中的私钥和证书 KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(new FileInputStream(certPath), certPwd.toCharArray()); PrivateKey privateKey = (PrivateKey) ks.getKey(certAlias, certPwd.toCharArray()); Certificate[] chain = ks.getCertificateChain(certAlias); // 3. 设置签名外观,也就是PDF上展示的电子签章区域 PdfSigner signer = new PdfSigner( new PdfReader(srcPdfPath), new FileOutputStream(dstPdfPath), new StampingProperties()); PdfSignatureAppearance appearance = signer.getSignatureAppearance(); appearance.setReason(request.getSignReason()); appearance.setLocation(request.getSignLocation()); appearance.setSignDate(new GregorianCalendar()); // 签名区域,单位是PDF坐标点,注意不同页面尺码的换算 Rectangle rect = new Rectangle(300, 100, 180, 60); appearance.setPageRect(rect); appearance.setPageNumber(1); // 4. 关联手写签名图片或者印章图片 appearance.setSignatureGraphic(ImageDataFactory.create(request.getSignBase64())); appearance.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC); // 5. 创建数字签名并计算摘要 PdfSignature cryptoSignature = new PdfSignature(PdfName.Adobe_PPKLite, PdfName.Adbe_pkcs7_detached); appearance.setCrypto(cryptoSignature); // 6. 调用外部签名接口执行摘要运算并写回PDF ExternalSignature es = new BouncyCastlePrivateKeySignature(privateKey, "SHA-256", null); signer.signDetached(es, chain, null, null, null, 0); // 7. 保存签署记录,包括摘要值、证书主题、签名时间 return saveSignLog(contract, dstPdfPath); }

这段代码省略了证书读取细节,但主流程已经完整:读取合同、设置签名区域、关联签名图片、使用私钥签名、写回新PDF。生产环境注意把证书存储在硬件加密机或云KMS中,私钥不应该落到业务服务器的磁盘明文文件里。

签名图片这里有一个重要细节:签名图片的格式必须是透明背景PNG,因为印章通常是红色圆形,手写签名是黑色笔迹,如果背景是白色矩形,会遮挡合同正文,看起来非常不专业。前端Canvas导出时注意设置toDataURL('image/png'),并且不在Canvas里画白色背景。

2.3 防篡改与时间戳:让合同“签了就不能改”

数字签名保证了合同内容签名后被改动就能被发现,但还有一个问题:签名时间如何证明?本地系统时间很容易修改,所以需要第三方的可信时间戳。

实现方案是调用时间戳服务器(TSA),在签名时把待签名摘要发给TSA,TSA返回一个带时间的时间戳令牌,令牌中包含可信时间。之后把这个时间戳令牌一起嵌入PDF签名属性中。Java中可以通过BouncyCastle的TimeStampRequestGenerator实现TSA请求,核心代码如下:

MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(contractContent); // 构造时间戳请求 TimeStampRequestGenerator tsqGenerator = new TimeStampRequestGenerator(); tsqGenerator.setCertRequest(true); BigInteger nonce = BigInteger.valueOf(System.currentTimeMillis()); TimeStampRequest request = tsqGenerator.generate(TSPAlgorithms.SHA256, digest, nonce); // 向TSA服务器发送请求 HttpURLConnection conn = (HttpURLConnection) new URL(tsaUrl).openConnection(); conn.setRequestMethod("POST"); ... TimeStampResponse response = new TimeStampResponse(receivedData); TimeStampToken token = response.getTimeStampToken();

时间戳令牌需要保存在签名记录表中,后续验签时系统可以通过令牌中的时间戳公钥校验其有效性。

数据库层面也要做防篡改:合同每签署一个节点,都要把合同ID、签署人ID、摘要值、时间戳、上一节点摘要值存成一条不可变记录,形成哈希链。任何人篡改数据库某一条记录,会导致整条链断裂。这个设计虽然简单,但在民事诉讼和仲裁场景中是非常有力的证据。

2.4 印章管理与权限模型

电子合同系统通常涉及企业公章、合同专用章、法人章和个人签名,印章不能随意用,否则会出现“员工乱签合同”的风险。

印章管理我设计了三个层级:

  • 企业级印章:企业实名认证后上传公章模板,模板审核通过后才能在合同中使用。
  • 法人章:绑定法人身份信息,使用时必须通过法人或授权人验证。
  • 个人签名:用户在签署页手写形成,一人一生一组,保存在个人签名库中。

权限模型采用RBAC设计,角色分四类:

  • 超级管理员:管理整个系统,包括企业入驻、印章审核、数据看板。
  • 企业管理员:管理本企业员工、印章、合同模板。
  • 经办人:创建和发起合同,但需要管理员授权印章。
  • 签署人:只能查看和签署分配给自己的合同。

签署时的“用章权限”需要额外控制。我实现了一个简单的规则引擎:每次发起签署时,系统会检查发起人是否有该印章的使用权限,如果无权,则提示联系管理员授权。这个检查很基础,但是很可能成为需求方在验收时重点测试的环节,千万不要漏掉。

数据库表设计上,我建议至少包含这几张核心表:

表名说明关键字段
contract合同主表id, contract_no, template_id, status, storage_path
contract_template模板表id, template_name, template_content, font_setting
contract_signer签署人表id, contract_id, user_id, sign_order, sign_status
sign_log签署日志表id, contract_id, signer_id, digest, timestamp_token, cert_sn
seal_info印章表id, enterprise_id, seal_name, seal_image, seal_type
user_cert用户证书表id, user_id, cert_alias, cert_expire_time

签署状态机我定义为:0草稿,1审批中,2待签署,3部分签署,4已完成,5已撤销,6已拒签。状态流转必须通过状态机方法控制,不能用随便update语句修改,否则很容易出现跳状态问题。

2.5 签名接口设计与防重复提交

签署接口是最高频、最关键的接口,我给它加了三层保护:

第一层是签名参数校验。客户端调用接口时除了token,还要带一个签名串sign=HMAC-SHA256(appSecret, timestamp + nonce + contractId),服务端验证通过后才放行,防止请求被篡改或重放。

第二层是Redis分布式锁。每个合同在同一时间只能有一个签署操作在进行,锁的key设计成sign:contract:{contractId},获取不到锁则提示“合同签署中,请勿重复操作”。

第三层是乐观锁。签署记录表中增加version字段,更新时通过update ... where version = ?保证并发安全。

public void doSign(DoSignRequest request) { // 分布式锁 String lockKey = "sign:contract:" + request.getContractId(); RLock lock = redissonClient.getLock(lockKey); if (!lock.tryLock(10, 30, TimeUnit.SECONDS)) { throw new BusinessException(500, "合同正在签署中,请稍后再试"); } try { // 校验合同状态 Contract contract = contractMapper.selectById(request.getContractId()); if (contract.getStatus() != ContractStatus.WAIT_SIGN.getCode()) { throw new BusinessException(500, "当前合同状态不支持签署"); } // 校验签署人身份与当前登录ID是否一致 validateSigner(request); // 执行签名和存证 signService.doSign(request); } finally { lock.unlock(); } }

3. 多渠道前端实现与协作细节

3.1 微信小程序端:登录、手机号解密与签署页

小程序端是整个系统的使用高频入口,最核心的三块是登录、手机号获取、签署页集成。

登录采用wx.login获取code,后端将code发给微信接口换取openid和session_key,再绑定到系统用户表。这里有一个很多新手会踩的坑:wx.login的code只能使用一次,有效期5分钟,如果后端处理失败再重试,必须重新调wx.login拿新的code,否则会报invalid code。

手机号获取有两种方式:老版本是用户在页面点击open-type="getPhoneNumber"按钮,前端拿到encryptedData、iv和code,后端用session_key对encryptedData解密。新版本更简单,后端只需要把前端传来的code再调一次微信的getPhoneNumber接口,不需要自己解密。我建议直接用新版本方案,省去session_key缓存和加解密的麻烦。

签署页是小程序里最复杂的部分。我采用WebView套H5页面的方式:小程序首页是原生页面,点击合同进入web-view组件加载H5签署页。这样做的好处是签名逻辑只有一份,维护成本低。但要注意两点:

  • web-view只能加载业务域名下已备案的HTTPS页面,体验版和正式版都要配置域名白名单。
  • 小程序的wx.chooseImage、wx.downloadFile等API在web-view中不可用,签名图片生成后需要通过wx.miniProgram.postMessage传回小程序端,或者直接由H5上传到后端存储。

PDF预览我推荐直接用wx.openDocument打开后端下载的文件,这个API是微信原生支持,体验很流畅。注意要传fileType: 'pdf',并且先把文件下载到本地临时目录。

3.2 公众号H5端:OAuth鉴权与JSSDK

公众号H5其实就是微信内置浏览器中访问的网页,登录流程走微信网页授权。两种scope:

  • snsapi_base:静默授权,只能获取openid,适合只识别用户的场景,无需用户点击授权,体验最顺滑。
  • snsapi_userinfo:需要用户点击确认,能获取用户昵称头像,适合第一次绑定时需要展示头像的场景。

实际使用时,我第一版一直只拿openid,后来发现无法区分用户身份,被需求方打回。所以建议:首次进入系统时,如果用户未绑定,用snsapi_userinfo做一次完整授权,获取头像昵称并存入个人资料;之后每次进入都用snsapi_base静默登录,不再重复打扰用户。

公众号分享功能需要用到JSSDK。前端引入wx.config配置后,才能自定义分享标题、缩略图和链接。有一个容易忽略的坑:JSSDK的签名后端需要对当前页面URL进行计算,URL必须是location.href.split('#')[0],去掉井号后面的hash部分,否则签名校验会一直报invalid signature。

另外微信浏览器缓存特别严重,签署完合同跳回列表页,经常看不到状态更新。我在页面onShow里主动重新拉取接口,并且给接口url加一个时间戳参数绕过缓存。

3.3 APP端与H5端的融合方案

APP端有两种做法:一种是纯原生实现,一种是WebView+H5混合。我选了后者,因为后端的签署流程已经通过H5封装好了,原生App内嵌WebView加载相同URL即可。

WebView桥接方面,主要处理三类原生能力:

  • 人脸识别:H5页面调用原生桥的startFaceVerify()方法,原生拉起人脸SDK,结果通过回调返回H5。
  • 拍照上传:合同附件或营业执照上传时,原生提供拍照能力,返回图片本地路径。
  • 推送通知:合同待签署、签署完成时,需要推送,原生桥提供getPushToken()和registerPushListener()。

H5页面在PC浏览器上也要能用。响应式布局上,我用的是Vant组件库适配移动端,PC端单独使用了更宽的布局,合同预览区固定宽度为900px居中显示。签署区域的坐标计算很关键:PDF展示在页面上会等比缩放,用户在PDF上点击的位置是页面像素坐标,需要按缩放比例换算回PDF坐标点。

换算公式是:

pdfX = pageX / scaleX pdfY = (pageHeightPixels - pageY) / scaleY

这里有个反直觉的地方:PDF坐标系的原点在左下角,而Web页面的坐标原点在左上角,所以纵向必须做一次翻转。很多签章位置偏移的问题,90%是忘了这一步。

3.4 统一API设计与安全参数

四个前端共用一套API,接口风格必须统一。我对所有写操作统一返回如下结构:

{ "code": 0, "message": "success", "data": { "contractId": 123, "signUrl": "https://xxx.com/sign?t=xxxxx" } }

列表接口使用分页参数page和pageSize,返回total、pages、records标准结构。

安全方面统一添加两个过滤器:

  • 登录态过滤器:解析JWT token,校验用户ID,设置当前用户上下文。
  • 签名参数过滤器:校验请求头中的timestamp、nonce、sign,timestamp超过5分钟拒绝,nonce在Redis中判断是否重复使用,防止篡改和重放。

这里再强调一次:小程序侧的登录态不能直接用openid作为用户标识传到后端,否则openid泄露后任何人都能冒用身份。正确做法是自己维护一份user表,openid只用于首次注册时的映射,之后所有请求都用自签发的JWT token做身份认证。

4. 部署方案、安全加固与性能优化

4.1 部署拓扑与基础设施配置

系统从零到上线,我建议分两步走。

第一步,单服务器部署。小型企业或者日签署量不超过1000份的场景,一台4核8G的云主机足够。部署结构是:Nginx(HTTPS + 静态资源) -> Spring Boot单实例 -> MySQL + Redis + MinIO。Docker Compose编排所有中间件,应用服务直接用java -jar跑systemd托管,日志用logback滚动切割,防止磁盘被日志占满。

第二步,集群化扩展。当签署量上来以后,把Spring Boot横向扩容成两个实例,Nginx配置upstream负载均衡,Redis共享缓存和锁,MySQL做主从。PDF生成和验签是CPU密集操作,单台机器CPU可能满负荷,可以把合同生成放到消息队列异步执行,前端先返回“合同生成中”,等消费者处理完毕再通过WebSocket或轮询通知前端刷新。

中间件选型上,我不建议一上来就上Kafka和微服务全家桶,小团队维护成本太高。Redis + MySQL + MinIO已经覆盖了90%的业务需求,后续真有性能瓶颈再按模块拆分。

4.2 数据安全与敏感信息保护

电子合同系统存储的是敏感业务数据,安全加固必须做扎实。

传输层强制启用HTTPS,只允许TLS1.2以上协议。HTTP请求统一301跳转到HTTPS。这里是Nginx配置的一块核心:

server { listen 443 ssl; server_name contract.example.com; ssl_certificate /usr/local/nginx/cert/server.crt; ssl_certificate_key /usr/local/nginx/cert/server.key; ssl_protocols TLSv1.2 TLSv1.3; # 禁止内容被第三方网站iframe嵌套,防点击劫持 add_header X-Frame-Options SAMEORIGIN always; }

数据库层面,手机号、身份证号、银行卡号必须加密存储,我使用AES-256-GCM对敏感字段做加密,加密密钥统一放在环境变量中,不要写入代码或配置文件。合同原文件必须落盘加密,MinIO的object存储加密功能可以直接开。提到的“用户密码”则必须用BCrypt加密。

审计日志方面,对创建合同、发送签署、签署动作、撤销合同等关键操作,必须记录操作人、操作时间、IP、操作结果,保留至少三年以上。一旦出现纠纷,这些日志是还原事实的重要依据。

4.3 性能优化:签署高峰不卡顿

实际运营中,签署行为往往集中在工作日上午和大型活动期间。为了扛住高峰流量,我从三个维度做了优化。

一是PDF生成异步化。合同模板渲染和PDF签名都涉及大量CPU计算,如果在请求线程中同步执行,Tomcat线程池很快会被占满。我的做法是:发起签署请求先上锁落库,状态置为“生成中”,丢到线程池异步生成。前端轮询状态,生成完成后再显示签署按钮。

二是静态资源走CDN。合同模板、印章图片、签名库图片这些变更频率很低的资源,上传到对象存储后配置CDN加速,减少后端带宽压力。

三是数据库索引优化。合同表以status + create_time、contract_no建联合索引,签署人表以user_id + status建索引,查询SQL使用explain工具检查是否命中索引。避免签署记录表大范围全表扫描。

对于签署高峰期,Redis里预分配合同号段,而不是每次都用数据库自增ID,可以避免主键竞争,同时也能让合同编号看起来更规整。

4.4 一个细节:跨域访问与文件下载

多端调用API必然涉及跨域问题。小程序和公众号不需要配置CORS,因为请求由微信客户端发出;但H5在浏览器中访问,必须配置CORS,否则前端发起fetch/XHR会被浏览器拦截。

Nginx上配置跨域允许来源时,不要随便用*,会带来安全隐患。我按部署域名精确配置:

add_header Access-Control-Allow-Origin https://contract.example.com; add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS; add_header Access-Control-Allow-Headers Content-Type,Authorization;

文件下载路径要时效性控制。合同PDF不能直接暴露永久的静态URL,否则别人获得链接就能无限下载。下载接口统一走后端,后端校验当前用户对该合同有查看权限后,生成带签名和过期时间的临时URL,有效期5分钟,过期后重新申请。

5. 常见问题与避坑实录

5.1 小程序手机号解密的那些坑

这里单独写一节,是因为我在这上面浪费了将近两天。老版本解密的流程是:前端通过getPhoneNumber拿到encryptedData和iv,后端用微信登录获取的session_key做AES解密。但实际开发中发现,session_key经常已经过期,解密一直失败。

排查后发现原因:用户在微信登录后,如果很久没再调用wx.login,后端存的session_key是旧的,而getPhoneNumber按钮触发的code是新的。新旧session_key不匹配,导致解密失败。

正确做法是:点击获取手机号按钮时,前端同时调用wx.login获取最新code传给后端,后端用最新code重新换取session_key,再解密手机号。或者干脆使用新版getPhoneNumber接口,只传code到后端,后端直接发起微信接口调用,不用自己解密。

5.2 PDF预览黑屏与乱码处理

我遇到过两种常见情况:

第一种是PDF预览黑屏。微信内置浏览器对PDF支持不稳定,直接打开PDF链接经常黑屏。解决方案是不要直接用浏览器打开PDF,而是用wx.openDocument预览,这个API会调起微信的PDF阅读器,稳定很多。

第二种是PDF中文字乱码。模板转PDF之后,在浏览器预览正常,但签章后重新生成PDF出现乱码,核心原因是签章后的PDF重排字体,但原来的中文字体没有嵌入。解决办法是在HTML转PDF时强制嵌入字体子集,iText支持setFontProvider设置中文字体文件路径。如果还不生效,检查服务器是否安装了字体,fc-list | grep -i simhei查一下便知。

5.3 手写签名位置偏移问题

签章位置偏移是另一个高频问题。H5页面中手写签名区域在屏幕上的坐标是像素,而PDF页面坐标是点(1点 = 1/72英寸),两者体系完全不同。如果直接把前端传上来的像素坐标当PDF坐标用,签章位置一定会偏。

我采用了一个通用的换算方案:前端把PDF页面渲染成一个<canvas>,记录PDF页面宽度和canvas显示宽度的比例,并将实际点击位置的像素坐标按比例还原为PDF坐标,再由后端按PDF坐标系原点在左下角的规则进行Y轴翻转。

换算代码我封装成了一个工具类:

public class PdfCoordinateUtil { public static Point convertToPdfPoint(int pagePixelX, int pagePixelY, int pageWidthPx, int pageHeightPx, float pdfWidth, float pdfHeight) { float scaleX = pdfWidth / pageWidthPx; float scaleY = pdfHeight / pageHeightPx; float x = pagePixelX * scaleX; float y = pdfHeight - (pagePixelY * scaleY); return new Point(x, y); } }

这套换算在桌面端和移动端都适用,只需要前端把页面显示尺寸和实际点击坐标传过来。还有一点:签章区域不能太小,否则后续打印时印章显示不清楚,建议最小宽度不低于120px、高度不低于60px。

5.4 证书过期与国密算法兼容性

数字证书有有效期,一般是1年或5年。如果系统不处理证书过期,签署会直接失败,而且日志容易让人摸不着头脑。

我的做法是:新增一个定时任务,每天扫描user_cert表的证书过期时间,距过期前30天、7天、1天分别给用户和企业管理员发站内信和短信提醒,过期当天自动禁用该证书申请新签名。证书到期后用户可以走“证书续期”流程重新申请,不影响历史合同验签。

国密算法(SM2/SM3/SM4)是一个需要提前考虑的点。国内一些政府和金融机构要求使用国密算法做签名,而不是国际算法RSA。Java原生库不支持SM2,需要用BouncyCastle扩展。我的源码中预留了算法适配接口,切换算法时只需替换SignatureProvider实现类和密钥生成逻辑,业务层不变。建议你在对接CA机构前确认对方支持哪些算法,避免后期返工。

5.5 合同列表状态不同步问题

小程序/公众号端经常出现“签了之后列表还是待签署”的情况。原因有几种:

一是缓存。微信内置浏览器缓存严重,前端列表页放在onShow中重新拉取接口,并给接口URL拼接时间戳绕过缓存。

二是WebSocket通知没对接。签署状态变更后,没有主动推送通知到其他页面,导致用户停留在旧页面时看不到更新。我后来增加了一个简单的事件通知机制:签署状态流转后,向相关用户推送“合同已签署/已拒签”消息,前端收到消息后刷新列表。

三是数据库主从延迟。如果部署了主从架构,写完后立刻读从库可能读到旧状态。可以在签署成功后强制读主库,或者延迟3秒后再返回前端,确保前端状态已更新。

最后一些想说的话

做完这套系统,我最大的体会是:电子合同系统真正的难点不是代码本身,而是对业务规则和合规细节的理解。前端画一个签名Canvas、后端调用一次签名接口,这些都是表面功夫,真正值钱的是那套完整的签署链路、防篡改机制和证据链设计。

如果你准备拿这套源码做二次开发,我建议优先把CA证书和对权威CA机构的对接做好,这是商业化的硬门槛。其次是多租户的合同权限设计,企业级客户一定会考察“员工能不能越权使用公司印章”这类场景。

最后一个实用建议:一定要在前期把日志埋点做好。签署行为日志、PDF操作日志、接口调用日志都要有独立的日志文件,并且定期归档。很多客户遇到纠纷时会来要数据,你日志全,就有底气;日志缺失,系统做得再好看也白搭。

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

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

立即咨询