
先交代背景我最近交付了一个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 核心业务链路从发起签署到归档电子合同系统看起来只是“盖章”但完整的业务链路比想象中长很多。我把核心流程梳理成八个环节任何一个环节出现问题都可能导致合同无效合同创建用户选择模板、填写甲乙双方信息、合同金额、租期/服务期等动态字段。模板渲染系统把动态字段填充到HTML或Word模板中渲染成标准PDF文件。合同审批企业内部可根据流程设置一级或多级审批审批通过后合同才可发起。签署发起合同发起人选择签署方设置签署顺序顺序签/并签生成签署任务。身份认证签署人首次使用需要完成实名认证个人认证常用手机号人脸识别企业认证常用三要素或四要素校验。意愿确认签署人查看合同内容通过短信验证码、手写原笔迹、人脸识别等方式表达签署意愿。数字签名服务端调用证书私钥对合同摘要进行签名同时从时间戳服务器获取时间戳形成完整签名信息。归档存证签署完成后合同状态变为已完成系统保存签署人、签署时间、IP、证书编号、摘要值等证据信息供后续查阅和验签。这个链路里最容易出问题的是第5到第7步。身份认证不到位后面签名再漂亮也容易被判定无效数字签名没有结合时间戳签署时间不能自证存证信息不全出现纠纷时连基本的“谁在什么时候签的”都说不清。1.3 多端架构取舍一套后端四个前端标题里提到的“小程序公众号APPH5”不是四套独立的系统而是一套后端API四个展示端。这是我最初设计时定下的核心原则。后端只做业务逻辑和数据存储对外暴露统一的RESTful接口。四个端各自负责登录适配、页面渲染和交互但底层调用的都是同一条签署链路微信小程序用户在小程序里查看合同列表、发起签署、手写签名适合C端高频使用场景。公众号H5通过微信浏览器访问调用网页授权获取用户身份适合企业员工内部签署或分享签署链接。APP员工端或管理端通过WebView加载H5签署页配合原生拍照、人脸识别能力完成认证。独立H5PC浏览器或手机浏览器直接访问方便客户在电脑上上传PDF、预览合同、下载归档文件。这种架构的好处很明显业务逻辑只维护一份签署状态机、PDF生成、签名算法不会出现四个端行为不一致的情况。前端各自适配以后每次后端功能升级四个端自动同步生效不用重复开发。1.4 合规基础可靠电子签名到底要满足什么谈电子合同不能不谈合规。国内做电子签名绕不开可靠电子签名的四个核心要求真实身份签署人必须是经过实名认证的真实主体企业主体需要认证营业执照信息。真实意愿签署动作必须由本人主动发起需要短信验证码、人脸识别、手写笔迹等意愿确认机制。签名未被篡改签名后的任何改动都会导致验签失败。原文未被篡改合同内容在签署前后不能被修改PDF摘要值必须保持一致。这四个要求直接决定了系统的技术设计。我见过有些团队把“电子签名”做成了简单的图片按上去那只能叫“电子盖章”没有法律效力出了纠纷什么都保护不了。可靠电子签名通常依赖数字证书。企业可以申请第三方CA机构签发的数字证书签名时用证书中的私钥对合同摘要做加密验签时用对应的公钥验证。证书的有效期、吊销状态、时间戳都是关键信息实现时可以对接权威CA机构也可以先用测试证书跑通流程。为了方便演示和二次开发源码里我默认集成了一个测试证书生成模块生产环境替换成第三方CA证书即可。2. 后端核心模块与签署链路实现2.1 合同模板引擎从HTML到PDF合同模板是电子合同系统的门面。我选择用HTML模板 Freemarker渲染而不是直接操作Word原因是HTML对动态数据的控制力最强样式统一转PDF也更稳定。整个模板渲染流程是这样定义合同模板用${variable}占位符标记动态字段比如甲方名称、乙方手机号、合同金额、租赁周期。后端准备数据Map字段名与模板占位符一一对应。Freemarker将模板和数据合并生成带完整内容的HTML。使用OpenHTMLToPDF或iText的HTML转PDF能力把HTML转成PDF。在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在签名时把待签名摘要发给TSATSA返回一个带时间的时间戳令牌令牌中包含可信时间。之后把这个时间戳令牌一起嵌入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_pathcontract_template模板表id, template_name, template_content, font_settingcontract_signer签署人表id, contract_id, user_id, sign_order, sign_statussign_log签署日志表id, contract_id, signer_id, digest, timestamp_token, cert_snseal_info印章表id, enterprise_id, seal_name, seal_image, seal_typeuser_cert用户证书表id, user_id, cert_alias, cert_expire_time签署状态机我定义为0草稿1审批中2待签署3部分签署4已完成5已撤销6已拒签。状态流转必须通过状态机方法控制不能用随便update语句修改否则很容易出现跳状态问题。2.5 签名接口设计与防重复提交签署接口是最高频、最关键的接口我给它加了三层保护第一层是签名参数校验。客户端调用接口时除了token还要带一个签名串signHMAC-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-typegetPhoneNumber按钮前端拿到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其实就是微信内置浏览器中访问的网页登录流程走微信网页授权。两种scopesnsapi_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端有两种做法一种是纯原生实现一种是WebViewH5混合。我选了后者因为后端的签署流程已经通过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?txxxxx } }列表接口使用分页参数page和pageSize返回total、pages、records标准结构。安全方面统一添加两个过滤器登录态过滤器解析JWT token校验用户ID设置当前用户上下文。签名参数过滤器校验请求头中的timestamp、nonce、signtimestamp超过5分钟拒绝nonce在Redis中判断是否重复使用防止篡改和重放。这里再强调一次小程序侧的登录态不能直接用openid作为用户标识传到后端否则openid泄露后任何人都能冒用身份。正确做法是自己维护一份user表openid只用于首次注册时的映射之后所有请求都用自签发的JWT token做身份认证。4. 部署方案、安全加固与性能优化4.1 部署拓扑与基础设施配置系统从零到上线我建议分两步走。第一步单服务器部署。小型企业或者日签署量不超过1000份的场景一台4核8G的云主机足够。部署结构是NginxHTTPS 静态资源 - 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操作日志、接口调用日志都要有独立的日志文件并且定期归档。很多客户遇到纠纷时会来要数据你日志全就有底气日志缺失系统做得再好看也白搭。