做了大半年的电子合同项目,最大的感受是:这个系统表面上就是“把纸质合同搬到手机上传一下签个字”,真做起来才发现一整条链路全是细节。实名认证、证书签发、哈希校验、防篡改、多端集成、权限隔离,每一块拆开都够写好几篇文章。今天分享的这个Java技术栈的电子合同电子签名系统源码,后端是Spring Boot + MyBatis-Plus,前端用uni-app一套代码打包微信小程序、公众号H5、原生APP和普通H5四个端,直接部署就能跑通业务闭环。适合正在做合同管理、法务数字化,或者想快速搭建电子签约平台的同学参考。
1. 系统定位与架构拆解
1.1 电子合同到底解决了什么痛点
先回忆一下传统纸质合同的签署流程:起草合同、打印、走内部审批、邮寄给对方、对方盖章或签字、再寄回来、最后人力归档。顺利的话三五天,碰上跨地区或者对方审批流程长,一个月都不奇怪。电子合同系统做的事情就是把这条链路整体搬到线上:创建合同、设置签署方和签署顺序、通过短信或微信通知各方、各方在线完成实名认证后手写签名或上传印章、系统固化文件存档。整个流程从“天”缩短到“分钟”。
单看功能清单并不复杂,但合同签署是一个强信任场景。系统要确保三件事:签署人确实是本人,签署内容在签署后没有被改过,签署行为不可否认。这三点落到技术实现上,分别对应实名认证、哈希校验与数字签名、操作日志与证据链留痕。这也是我认为这类系统真正值钱的地方,而不是几张页面。
1.2 核心技术选型与原因
后端选用Java不是因为它有什么很华丽的特性,而是生态成熟、招人容易、出了问题网上能找到大量资料。项目里常用的组合是Spring Boot负责接口和业务编排,MyBatis-Plus做持久层,Redis处理缓存和分布式锁,MySQL存业务数据,MinIO或阿里云OSS存合同文件和签章图片。
MyBatis-Plus在这类业务里的优势很明显。合同、签署方、签章记录这些表的CRUD操作高度重复,用它的BaseMapper和IService可以直接省掉大量样板代码。再加上分页插件、代码生成器,团队可以把手写Mapper的工作量压到最低。前端选uni-app是另一个务实的决定。合同签署是社会化协作场景,你没法要求客户都装同一个APP,微信小程序覆盖熟人社交场景,公众号适合嵌入企业内部OA流程,原生APP服务大客户,H5方便对接第三方平台。用uni-app一套Vue代码编译四个端,业务逻辑共享,平台差异用条件编译单独处理,维护成本比维护四套原生工程低一个数量级。
1.3 系统能力边界与适用场景
这套源码覆盖的签署场景主要是三类:个人签署,例如劳动合同、保密协议、借款协议;企业间签署,例如采购合同、对账单、合作协议;以及平台托管签署,例如租赁平台、招聘平台代发合同。从源码角度看,它适合用来学习以下内容:Spring Boot的业务建模方式、MyBatis-Plus的持久层设计、状态机在业务流转中的应用、uni-app多端工程的组织方式、微信小程序登录与手机号获取的完整链路,以及电子签名背后哈希与加密算法的工程化落地。
2. 电子签名的技术原理与安全体系
2.1 数字签名:给合同内容加一把专属锁
电子签名不是让你在屏幕上画个名字然后把图片贴上去,核心是数字签名。我给团队讲这个概念的时候用的类比是“指纹加锁”:合同原文先做哈希运算,得到一个固定长度的摘要,这个摘要是合同内容的“指纹”,哪怕只改一个标点,指纹也会完全变化。然后再用签名者的私钥对这个摘要加密,得到签名值。合同、签名值、签名者的公钥三者一起发布。
验证的时候只需要做两件事:用公钥解密签名值得到原始摘要,再对当前合同内容重新计算摘要,两个摘要一致就说明合同没有被篡改,签名者的身份也没问题。整套体系的根基是私钥必须只有签名者自己持有,这叫“不可否认性”。项目里我一般用RSA 2048及以上或者ECDSA,私钥不落库,用KMS或者独立密钥服务管理,避免数据库泄露导致所有历史签署全部失效。
2.2 防篡改与时间戳固化
很多初学者以为合同签完存个PDF就完事了,这远远不够。PDF文件本身是可以被编辑的,如果只存文件,将来有人改了合同内容,双方各执一词,系统根本没法自证清白。所以签署完成的合同必须做哈希固化,也就是把最终PDF的SHA-256值存进数据库,后续任何字节层面的改动都会导致哈希不一致。
更严谨的做法是引入可信时间戳服务。把“文件哈希 + 当前时间”打包发给时间戳机构,由机构用它的私钥签名后返回一个时间戳证书。这样即使系统自己的服务器时间被篡改,时间戳证书仍然能证明合同在某个时刻已经存在且内容未被改动。我们在项目里对严肃性要求高的合同都会启用时间戳,普通的B端内部协议至少也会做服务端哈希留痕。需要提醒的是,如果客户要求做司法举证,只有时间戳+完整操作日志+实名认证记录才能组成一条有说服力的证据链,三者缺一不可。
2.3 实名认证与签署意愿确认
实名认证是整个系统的信任入口。个人实名一般做三要素校验:姓名、身份证号、人脸识别,判断是不是本人操作;企业实名要校验营业执照信息,再通过对公打款随机金额或法人人脸来确认企业意志。这里我的建议是直接对接第三方实名认证服务,不要自建人脸识别。活体检测模型训练、对抗攻击防御、身份证识别,每一项都是重投入。
实名认证完成后,系统为用户生成一对签名密钥,并把“用户信息、密钥公钥、实名状态”绑定在一起。每次签署动作都要重新校验用户登录状态,并对“我已阅读并同意”这种动作单独留日志。签署意愿这一点经常被忽略,很多系统把“登录即代表同意”当成默认行为,这是不对的。真正的电子合同系统里,用户必须在进入签署页时明确点击确认,并且这个点击行为要记录设备信息、IP、时间。后期一旦有纠纷,这些都是证明“本人自愿签署”的重要材料。
2.4 国密算法的取舍
国内部分行业项目对密码算法有国密要求,例如SM2、SM3、SM4。但这并不意味着所有商业项目都必须上国密,实际项目里大部分SaaS类的电子合同系统用的还是RSA/SHA-256体系,性能和兼容性更好。我的建议是把加密、摘要、签名这些底层操作封装成统一接口,例如定义一个CryptoService,里面放generateKeyPair、sign、verify方法,底层用RSA还是SM2可以配置切换。这样项目初期跑得快,后期客户提出国密合规要求,只需要替换底层实现,不需要改上层业务代码。
3. 后端核心模块与数据库落地
3.1 核心表设计与自动建表方案
持久层还是先看表结构。这里以合同主表为例:
CREATE TABLE contract ( id BIGINT PRIMARY KEY COMMENT '主键', contract_no VARCHAR(64) NOT NULL COMMENT '合同编号', title VARCHAR(255) NOT NULL COMMENT '合同标题', file_url VARCHAR(500) COMMENT 'PDF文件存储地址', file_hash VARCHAR(128) COMMENT '合同文件SHA-256哈希', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待签署 2部分签署 3已完成 4已撤销', creator_id BIGINT NOT NULL COMMENT '发起人用户ID', expire_time DATETIME COMMENT '签署截止时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_creator (creator_id), INDEX idx_status (status) ) COMMENT '合同主表';配套的表还包括contract_signatory即签署方表,存合同ID、用户ID、签署顺序、签署状态;contract_sign_record签署记录表,存每次签章的图片、哈希、请求IP、设备信息、时间戳。用户表和证书表按常规设计即可。
关于“用MyBatis-Plus实体类自动生成建表SQL”这个点,网上有很多方案,我实践下来比较稳的做法是写一个启动时的建表组件:扫描指定包下的实体类,读取@TableName注解拿到表名,读取字段上的@TableField注解和Java类型映射到MySQL类型,拼接CREATE TABLE IF NOT EXISTS语句并执行。这个方案对早期开发和演示环境很方便,但生产环境我建议换用Flyway或者Liquibase做数据库版本管理。自动建表虽然省事,一旦字段变更没有脚本记录,生产环境升级时会很痛苦。
3.2 合同模板与动态PDF渲染
合同的条文结构通常是固定的,差异点集中在甲方乙方名称、金额、日期、产品条款编号这些变量上。我用FreeMarker做模板引擎,合同原文保存成模板文件,变量位置用占位符代替,后端组装JSON数据渲染出完整HTML,再通过OpenPDF生成PDF。
这里有一个必须提前解决的坑:PDF中文字体。OpenPDF默认字体不支持中文,生成出来的PDF中文全是方块。解决方法是把宋体或思源宋体的TTF文件放到classpath下,在生成PDF时注册:
BaseFont bf = BaseFont.createFont( "/fonts/simsun.ttc,0", BaseFont.IDENTITY_H, BaseFont.EMBEDDED ); Font font = new Font(bf, 12, Font.NORMAL);字体文件不建议从系统目录动态读取,打包部署后路径不可控,直接打进jar包是最省心的做法。
3.3 签署流程状态机设计
合同签署不是简单的“草稿变已签”,中间有并行和顺序两种模式,还涉及撤回和拒绝。我在代码里用枚举定义状态和允许的动作,避免业务代码里到处写if else:
- 草稿:创建人可以编辑、删除、发送
- 待签署:至少一方未签署,所有人均可查看
- 部分签署:存在按顺序签署时,前序签署方已完成,等待后续签署方
- 已完成:所有签署方均已签署
- 已撤销:创建人撤回或超过截止时间
状态机的核心逻辑是每次变更前先校验当前状态是否允许该动作。比如撤回只能发生在没有任何签署方完成签署之前,一旦有人签过字,合同只能走线下协商修改流程,再以新合同重新发起。这个限制很多人会忽略,导致线上合同被无限制撤回,存在严重安全隐患。我把状态和动作的映射关系放在一个Map里集中管理,再用单测把所有流转路径覆盖一遍,后续维护省心很多。
3.4 行级数据权限与隔离
合同数据天然敏感,普通用户不可能也不应该看到公司内所有合同。行级权限我推荐两种实现方式。第一种是查询条件主动带上权限约束,例如在SQL里拼接exists子查询,判断当前用户是否存在于这张合同的签署方表中。优势是直观、容易排查,缺点是每个查询都要记得写,容易漏。第二种是MyBatis拦截器方案,自定义一个@DataScope注解,标注在Mapper方法上,拦截器在SQL执行前解析方法参数中的当前用户ID,自动注入权限片段。这种方案业务代码最干净,但排查问题时对拦截器逻辑的把关非常考验团队水平。
对于这套具有参考价值的源码,我更推荐先用第一种方式把逻辑跑通,等理解了权限拼接的语义,再考虑做拦截器抽象。权限永远先保证正确,再追求优雅。
4. 多端集成与微信生态踩坑记录
4.1 uni-app工程结构与条件编译
前端工程按uni-app标准组织,pages目录放业务页面,api目录统一封装请求,store管理登录态,utils放公共工具。所有接口路径和后端约定好统一前缀,请求封装里根据运行平台自动处理请求头差异。
多端差异用条件编译处理。例如小程序端修改导航栏标题:
// #ifdef MP-WEIXIN uni.setNavigationBarTitle({ title: '合同签署' }); // #endif // #ifdef H5 document.title = '合同签署'; // #endif这类平台差异化代码尽量收敛在少数工具文件里,不要散落在业务组件中。否则一旦新端上线,排查成本会很高。亲测在四个端跑下来,除了iOS的WebView对某些CSS属性渲染有差异,大部分业务代码完全可以共用。
4.2 小程序登录与手机号获取完整流程
微信小程序的用户体系接入大概是这个项目里和新手同学交流最多的话题。流程分两步:第一步wx.login拿到临时code,传给后端,后端用code加AppID、AppSecret调用微信接口换取openid和session_key,同时生成系统自己的token返回给前端。后续所有接口都带这个token。
获取手机号是另一个高频踩坑点。2023年以后微信调整了规则,不能再通过用户信息授权接口拿到手机号,唯一的正规方式是页面放一个button,设置open-type为getPhoneNumber,用户点击后通过bindgetphonenumber事件的回调拿到动态code,再把这个code发给后端,后端用code换手机号信息。注意,这个能力按次收费,开发调试强烈建议用真机,开发者工具模拟器基本拿不到真实返回。这一步也是电子合同签署流程中实名信息填充的重要来源。
4.3 手写签名面板实现要点
手写签名用canvas实现,核心逻辑并不复杂,但有两个细节非常影响体验。一是笔画平滑度,直接用touchmove坐标连线会有折角感,我用二阶贝塞尔曲线对中间点做平滑处理,效果会好很多。二是导出图片的清晰度,canvas默认按CSS像素渲染,小屏设备上导出图片会非常糊。必须将canvas宽高乘以devicePixelRatio:
const dpr = uni.getSystemInfoSync().pixelRatio; canvas.width = canvasWidth * dpr; canvas.height = canvasHeight * dpr; ctx.scale(dpr, dpr);签名完成后调用toDataURL导出透明背景PNG,base64传到后端存起来。真机上还要给签名区域设置touch-action: none,否则页面滚动和画线手势会互相冲突,导致笔画断断续续。这个坑我在真机测试时才意识到,模拟器上完全复现不了。
4.4 公众号、H5与APP的接入方式
公众号内嵌H5和普通H5的基础差异在登录体系。公众号页面需要走微信OAuth授权,用户在微信浏览器里打开时跳转授权页,拿code后后端换取openid。H5部署在自有站点时,账号密码登录即可,或者是短信验证码登录。APP端我采用的做法是保留原生壳,负责推送、扫码和外部唤起,核心签署页面全部用WebView内嵌H5。这样小程序、H5、APP三端都复用同一套签署流程页面,唯一的代价是APP离线状态无法签署,但合同签署本身就是强网络依赖场景,这个取舍很合理。
深度链接这块,APP需要在前端工程里配置URL Scheme,H5页面在APP内做支付或签署跳转时,通过Scheme唤起对应原生页面。如果公司同时有iOS和Android的包,这里还需要额外处理应用未安装时的Fallback逻辑。
5. 落地过程中的常见问题排查
5.1 PDF签名后打不开或乱码
OpenPDF生成PDF时尽量用1.3.30以上版本,旧版本和部分打印机的PDF兼容性有问题。另外中文乱码问题,核心就是字体没有注册,解决方案参考3.2节。我再补充一个点:如果模板里有特殊符号比如不换行空格、特殊引号,部分字体渲染会报错,建议上线前用批量合同样本做一轮PDF渲染验证。
5.2 签名坐标偏移
PDF页面坐标系的原点在左下角,前端canvas坐标原点是左上角,这两个坐标系不能直接对应。叠加签章图片时要做换算:pdfY = pageHeight - imageY - imageHeight。很多新手做出来的签章位置总是偏上一截或者偏下一截,基本都是忽略了坐标系转换。
5.3 并发签署导致状态错乱
并行签署时,两个人可能同时提交签署,如果合同主表状态更新不加锁,可能出现状态覆盖,从部分签署直接跳回待签署。我在签署接口里加了Redis分布式锁,key设计为contract:sign:{contractId},加锁后再校验状态、写入签署记录、更新合同主状态。锁粒度只到合同维度,不阻塞其他合同操作。
5.4 高频问题速查表
| 问题 | 常见原因 | 处理方式 |
|---|---|---|
| 小程序请求失败 | request域名未加入白名单 | 开发工具勾选“不校验合法域名”,生产环境配置合法域名 |
| 手机号获取不到 | 使用了模拟器或旧接口 | 换真机调试,使用button组件的getPhoneNumber |
| PDF中文全部变方块 | 缺少中文字体注册 | Classpath中加入TTF字体并注册 |
| 手写签名导出白屏 | 绘图未完成就导出 | draw回调结束后再调用toDataURL |
| 签章位置偏移 | 坐标系未转换 | 按pageHeight - imageY - imageHeight计算 |
| 重复点击签署生成了多条记录 | 未做幂等 | 前端按钮防重复提交,后端加业务幂等键 |
最后说一点我做这类项目的体会。电子合同系统最容易被人低估的是底层可信数据的设计,界面做得再花哨,都不如把证据链做扎实重要。真正考验功力的地方在于,一旦双方对一份合同产生争议,系统能不能拿出完整的证据链:谁在什么时间查看了合同、做了实名认证、确认了哪些条款、签了什么内容、当时的文件哈希是多少。这套源码把基础骨架都搭好了,后续可以沿着电子签名证据链的方向继续打磨,比如把签署结果摘要做外部存证、对接企业微信审批流、增加多语言合同能力。先把闭环跑通,再逐步优化信任体系,是我一贯的做法。