ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于SpringBoot+thymeleaf的轻量级电子签合同系统设计与实现

基于SpringBoot+thymeleaf的轻量级电子签合同系统设计与实现 简介这是一套基于Spring Boot与Thymeleaf模板引擎开发的Java项目源码定位为电子文件签字与合同盖章管理系统的实现范例。系统围绕文件/通知发布场景借助数字签名技术完成签署人身份验证逻辑与线下纸质合同签署一致适合正在学习企业级Java开发、需要参考完整签章流程的开发者也可直接作为电子合同项目的毕设或二次开发底子。压缩包共485个文件大小约63.97MB主要包括Java源文件、XML配置、Thymeleaf模板页面HTML/CSS/JS、图片与图标资源以及可供移动端安装的APK文件整体结构清晰能直观看到PC端与移动端模块如何组织。目前已有1313人学习下载内容预览显示项目中包含PC控制器、移动控制器、二维码工具类、PageOffice集成等相关实现能够帮助读者理解电子签名、身份验证、二维码生成、文档在线预览等功能的落地方式。通过本地部署运行可获得一套可操作的真实签章系统既锻炼Spring Boot整合能力也积累合同管理、权限校验、移动端交互等实战经验。 说实话电子签合同这个需求在中小型企业内部系统里出现的频率远超想象。以前大家总觉得要上法大大、上e签宝这种专业SaaS平台但其实很多场景只是内部审批确认、供应商对账确认、或简单的双方框架协议签署业务流程并不复杂。这时候自己用Java技术栈基于SpringBootthymeleaf撸一套轻量级的电子文件签字合同管理系统反而更灵活、更可控还能省下不少按份收费的SaaS服务费。这套系统的核心价值很清楚把线下打印→盖章→扫描→归档的流程变成线上的上传文件→定位签名位置→在线签字→生成签署记录。我实际做下来整个核心链路并不复杂关键是几个技术点的取舍。这篇文章我会把项目的整体设计思路、电子签名实现原理、完整实操流程以及我踩过的几个比较典型的坑一次性讲清楚。无论是准备做毕业设计还是公司内部需要一套简易签署工具都可以直接参考。1. 项目整体设计与技术选型思路1.1 为什么选SpringBootthymeleaf而不是前后端分离这大概是很多人在立项时会纠结的第一个问题。现在SpringBoot Vue前后端分离已经是主流但我在这个项目里刻意选择了thymeleaf服务端渲染核心原因有三点。第一系统的使用场景是内部OA或轻量级业务系统配套用户量不大并发极低对页面交互的复杂度要求也不高。这种情况下服务端渲染的开发效率极高一个Controller返回一个视图名thymeleaf自动把数据渲染进HTML完全不需要处理跨域、不需要封装Axios请求、不需要维护接口文档。对中小型团队或单人开发来说能省掉至少三分之一的工作量。第二电子签合同这个场景对页面状态和操作链路的追踪要求很直接。比如合同当前处于什么状态、当前登录用户有没有签署权限、待签位置有哪些这些在服务端渲染模式下后端在返回页面前就可以把状态判断做掉直接在模板里按条件渲染按钮。虽然有状态的前后端分离也能做但服务端渲染在代码可读性和出错排查上会轻松很多。第三thymeleaf本身就是Spring Boot官方推荐的模板引擎两者集成几乎没有成本spring-boot-starter-thymeleaf 引入后基本零配置就能用。对于这个体量的项目没必要为了技术潮流引入Nginx 前端构建链路那一整套东西。1.2 核心功能模块划分整个系统的功能模块我拆成了五个部分用户与权限管理基于Spring Security或简单的拦截器做登录认证区分管理员和普通签署人两种角色。合同模板管理上传Word或PDF模板定义合同的基本信息和变量占位符。合同发起与流转创建合同实例指定签署方生成签署任务通知。在线签字核心在PDF指定坐标位置绘制签名、日期支持笔迹签名和图片签名两种模式。签署记录与归档保存签署痕迹、操作日志、最终合同文件支持下载归档。这五个模块里最核心、也最容易被问到底怎么实现的就是在线签字。这里我先带你过一遍核心原理后面实操章节再给关键代码。1.3 数据库表设计要点数据库设计不复杂但有几张表的字段需要特别注意。合同主表contract_infoCREATE TABLE contract_info ( id bigint(20) NOT NULL AUTO_INCREMENT, contract_no varchar(64) NOT NULL COMMENT 合同编号, title varchar(200) NOT NULL COMMENT 合同标题, file_path varchar(500) DEFAULT NULL COMMENT 原始文件存储路径, final_file_path varchar(500) DEFAULT NULL COMMENT 签署完成后文件路径, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-草稿 1-签署中 2-已完成 3-已作废, creator_id bigint(20) NOT NULL COMMENT 创建人ID, created_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_contract_no (contract_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;签署记录表contract_sign_recordCREATE TABLE contract_sign_record ( id bigint(20) NOT NULL AUTO_INCREMENT, contract_id bigint(20) NOT NULL, user_id bigint(20) NOT NULL, sign_type tinyint(4) NOT NULL COMMENT 1-笔迹签名 2-图片签名, sign_image_path varchar(500) NOT NULL COMMENT 签名图片保存路径, page_num int(11) NOT NULL COMMENT 签署页码, pos_x decimal(10,2) NOT NULL COMMENT 签名X坐标(相对于PDF页面), pos_y decimal(10,2) NOT NULL COMMENT 签名Y坐标(相对于PDF页面), sign_hash varchar(64) NOT NULL COMMENT 签名内容哈希,用于防篡改校验, sign_time datetime NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里两个字段容易被忽略一个是 sign_hash签名之后把用户ID合同ID签署时间签名图片字节做一个SHA-256存进库里后续做完整性校验时能派上大用场另一个是 pos_x/pos_y因为PDF坐标系和Web页面坐标系不一样如果前端传的是页面像素坐标后端必须做换算这一步不做后面签名位置一定偏移算是这个项目里最大的坑之一。2. 电子签名核心技术实现从Canvas手写到PDF坐标定位2.1 数字签名不是画个图而是一套校验逻辑很多人一听电子签字第一反应是这不就是前端搞一个Canvas让用户写名字然后转成图片贴进PDF嘛。对也不对。纯画图的方式确实是最直观的电子签名但如果系统里的合同未来需要具备基本的防抵赖能力就必须在签名图片之外附加一层凭证信息。我这里的实现方案是每次签名都会生成一段不可见的哈希串记录签署人、签署时间、合同编号、签名图片数据并将这段哈希嵌入PDF文件的元数据里。后续只要重新计算PDF内容哈希并与元数据对比就能判断文件是否被改动过。当然要说明白这跟基于CA证书的数字签名在法律效力级别上还有差距但对于企业内部流程留痕、供应商确认这类场景完整性校验已经足够。如果你确实需要CA级电子签名后期可以对接第三方签名服务商的API架构上预留一个接口就行。2.2 PDF签名坐标的换算逻辑这是整个项目里我调试时间最长的地方必须单独拎出来讲。在Web页面上用户看到的PDF是通过PDF.js渲染在Canvas上的用户点击的位置是相对这个Canvas的像素坐标。假设页面显示宽度是1200像素而PDF原始页面的宽度是595点A4纸那么用户点击的X像素坐标转成PDF点的公式是pdfX pageX * (pdfPageWidth / canvasDisplayWidth)Y坐标更坑。Web页面的坐标原点在左上角向下为正PDF坐标原点在左下角向上为正。所以页面的Y像素转PDF坐标时要做一次翻转pdfY pdfPageHeight - (pageY * (pdfPageHeight / canvasDisplayHeight))如果不做这个换算签名会出现在完全错误的位置而且页面缩放级别一变签名位置就漂移。因此后端的签署接口必须接收这四个参数pageNum、clickX、clickY、scale或canvas的宽高由后端统一做换算别指望前端把坐标算好再传上来前端算完还得再传一遍原始参数核对麻烦且容易出错。2.3 笔迹签名图片生成的两套方案笔迹签名的图片生成我实验过两种方案各有适用场景。第一种是前端Canvas直接导出PNG。用户在Canvas上通过mouse事件或touch事件收集轨迹点然后用canvas.toDataURL(image/png) 导出图片转成Base64传给后端。这种方案的优点是实现快交互流畅度高所见即所得。缺点是如果用户是在电脑上拿鼠标写字笔迹会比较粗糙很难看而且容易写歪。第二种是前端收集轨迹点数组后端用Java重新绘制。这种方式代码量会多不少Java里要用Graphics2D一个个画点和连线但好处是可以在后端对笔迹做平滑处理比如对轨迹点做贝塞尔曲线插值或者调整线宽、颜色。而且后端一旦拿到原始轨迹点可以顺便把整个书写过程的时长、压力值如果有的话一起记录下来为后续笔迹特征方向的扩展留了余地。我在实际项目里用的是第二种虽然工作量大了点但最终生成的签名效果比前端直接导出的好看一个档次而且签名图片的尺寸统一、背景透明贴到PDF里不会出现白底方块观感非常好。具体做法是前端在Canvas上记录下笔迹的points数组含x、y、time字段签名完成后把points 笔宽 颜色一起POST到后端后端再用Graphics2D画出签名图片。下面是后端生成签名图片的核心代码亲测可跑Service public class SignatureImageService { // 根据笔迹坐标点生成透明背景的PNG签名图片 public BufferedImage generateSignatureImage(ListPoint points, int width, int height) { // 创建透明背景画布 BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d image.createGraphics(); // 开启抗锯齿,让笔画边缘更平滑 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setColor(new Color(0, 0, 0)); // 黑色笔迹 BasicStroke stroke new BasicStroke(2.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); g2d.setStroke(stroke); // 把原始坐标点映射到目标图片尺寸 // 这里做归一化缩放,签名占图片宽度的90%,垂直居中 ListPoint normalized normalizePoints(points, width, height); for (int i 0; i normalized.size() - 1; i) { Point p1 normalized.get(i); Point p2 normalized.get(i 1); // 两点间距过大的说明是断笔,不用连线 if (distance(p1, p2) 50) { g2d.drawLine(p1.x, p1.y, p2.x, p2.y); } } g2d.dispose(); return image; } private ListPoint normalizePoints(ListPoint points, int targetWidth, int targetHeight) { // 计算原始笔迹的外接矩形 int minX points.stream().min(Comparator.comparingInt(p - p.x)).get().x; int maxX points.stream().max(Comparator.comparingInt(p - p.x)).get().x; int minY points.stream().min(Comparator.comparingInt(p - p.y)).get().y; int maxY points.stream().max(Comparator.comparingInt(p - p.y)).get().y; int srcWidth maxX - minX; int srcHeight maxY - minY; if (srcWidth 0) srcWidth 1; if (srcHeight 0) srcHeight 1; int targetW (int)(targetWidth * 0.9); int targetH (int)(targetHeight * 0.9); double scale Math.min((double)targetW / srcWidth, (double)targetH / srcHeight); ListPoint result new ArrayList(); for (Point p : points) { int newX (int)((p.x - minX) * scale) (targetWidth - (int)(srcWidth * scale)) / 2; int newY (int)((p.y - minY) * scale) (targetHeight - (int)(srcHeight * scale)) / 2; result.add(new Point(newX, newY)); } return result; } }这套代码在生产环境跑了半年多签名图片的稳定性和美观度都经受住了考验。核心就一句话别在前端直接导图把原始坐标点传给后端画质量和可扩展性都会好很多。3. 合同签署全流程实操从上传到归档的完整链路3.1 合同发起与签署任务分配合同签署的第一步是发起方创建合同。这个环节的操作路径是上传PDF文件 → 填写合同标题和编号 → 指定签署方账户 → 系统生成签署任务。上传这块我建议直接使用Spring Boot自带的MultipartFile把原始PDF存到服务器指定目录同时把文件访问的绝对路径存进数据库。注意不要直接把文件存进数据库的Blob字段虽然操作简单但后续合同数量上来之后会把数据库撑爆而且文件预览、下载都要走一遍I/O转换性能极差。文件存储目录我通常会按月份分目录比如 /data/contract/202506/文件名用合同编号_原始.pdf统一命名。这样做归档和备份都很方便直接同步整个/data/contract目录就够了。创建合同后系统自动给签署人生成一条待办任务。这里的任务通知不需要上MQ直接在内页的消息中心插一条记录用户登录后在待我签署列表里就能看到简单够用。3.2 PDF坐标定位签署位置的实操细节签署人打开待签合同页面加载远程PDF用PDF.js渲染显示。然后用户点击页面上添加签名按钮在PDF预览区域的任意位置点击一下系统弹出签名框用户手写完成后点击确认前端把数据POST到后端签署接口PostMapping(/api/sign) ResponseBody public Result doSign(RequestBody SignRequest request) throws Exception { // 1. 校验用户是否有签署该合同的权限 ContractInfo contract contractService.getById(request.getContractId()); if (contract null || contract.getStatus() ! 1) { return Result.error(合同不存在或不在签署状态); } // 2. 根据前端传递的页面坐标换算成PDF坐标系中的坐标 PdfDocument pdf PdfReader.open(contract.getFilePath()); PdfPage page pdf.getPage(request.getPageNum()); float pdfWidth page.getWidth(); float pdfHeight page.getHeight(); float pdfX request.getClickX() * (pdfWidth / request.getCanvasWidth()); float pdfY pdfHeight - request.getClickY() * (pdfHeight / request.getCanvasHeight()); // 3. 生成签名图片(来自笔迹坐标点) BufferedImage signImage signatureImageService.generateSignatureImage( request.getPoints(), 300, 150); String signImgPath saveSignatureImage(signImage, request.getContractId()); // 4. 将签名图片绘制到PDF指定位置 String signedFilePath signPdfService.signPdf( contract.getFilePath(), contract.getFinalFilePath(), request.getPageNum(), pdfX, pdfY, signImgPath); // 5. 生成签名哈希,记录签署日志 String signHash DigestUtils.sha256Hex( request.getContractId() request.getUserId() signTime signImgPath); contractSignRecordService.saveSignRecord( request, signImgPath, pageNum, pdfX, pdfY, signHash); // 6. 检查所有签署方是否已完成签署,完成则更新合同状态 contractService.checkAndCompleteSign(request.getContractId()); return Result.success(); }这个接口是整个系统的核心链路每步都不难但每步都可能出问题。尤其第4步绘制签名到PDF的库选择我建议用Apitron PDF Kit或OpenPDF而不是在网上随便找的PDF操作工具类。因为这个环节稍有不慎就是内存溢出或中文乱码我在第5节会详细说。3.3 合同状态流转草稿、签署中、已完成合同状态机的设计直接影响系统使用的顺畅度。我设计的流程是这样的0-草稿创建人上传文件后未指定签署方或者指定了还没发送此时可以编辑、删除。1-签署中签署任务已发送给所有签署方任何一方都不能再修改合同原始文件。这个状态下要特别注意上传的原始文件必须锁定防止被覆盖或删改。2-已完成所有签署方都完成签字系统自动把最终PDF归档到 finalFilePath同时追加一份签署记录摘要包含各方签名图片、签名时间、坐标、哈希值。3-已作废合同发起方可以在签署中状态下作废合同但必须填写作废原因并留痕。这个状态机的核心原则是原始文件一旦进入签署中状态就不允许再被修改。所有签署动作只作用于最终文件的一个副本上。每次签署后签过的PDF会另存为新的文件路径这样即使后续签名出错还有上一版文件可以回滚。3.4 thymeleaf模板在合同管理页面的应用thymeleaf在这个项目里主要承担管理后台页面的渲染。比如合同列表页用th:each循环渲染每行合同数据用th:if根据status字段动态显示签署中、已完成标签这些都是thymeleaf非常典型且高效的用法。有个小技巧合同详情页需要同时展示PDF预览区、签署记录列表、操作按钮区。如果用Vue你需要准备三套数据接口但用thymeleaf可以直接在Controller里一次性查询出所有数据塞进Model页面模板一次性渲染完成。对于这种数据聚合度很高的场景服务端渲染真的比前后端分离舒服太多。4. 常见问题与排查实录这一节建议直接收藏4.1 签名位置偏移和坐标不准问题症状签名图片显示在PDF页面上一个完全随机的位置或者每次放的位置都不一样。排查过程先确认前端传的clickX/clickY是不是相对PDF Canvas容器的坐标而不是相对页面的坐标。很多前端小白的坑就在这里——事件监听绑在了document上拿到的坐标是相对浏览器窗口的根本没减去PDF容器的offset。确认方法很简单把前端传的坐标原样打印出来在页面上点同一个位置对比几次看看数值是否稳定。JS代码里用 e.offsetX 和 e.offsetY 通常就没问题但如果你在Canvas外层包了一层divoffsetX/offsetY可能还是相对外层div的必须用 getBoundingClientRect() 手动计算相对Canvas的坐标。其次是Y坐标翻转问题原理我在前面已经讲过这里不再赘述。总之记住后端收到的所有坐标先做像素→PDF点换算再做Y轴翻转一步都不能少。4.2 PDF处理库的内存泄漏问题症状合同签署功能正常跑但连续签署十几份文件后应用内存占用持续上涨最终报 java.lang.OutOfMemoryError: Insufficient memory。原因分析这个报错在Java里通常是因为PDF处理库没有正确释放资源。有些PDF操作库的文档会告诉你调用close()方法但如果你用的是旧版本或者代码里存在异常分支没走finally块底层native内存很容易泄漏。解决思路升级到最新版库。在finally块中确保所有PDF文档对象、字体对象、图片流全部close。为PDF签署服务单独配置一个线程池限制并发数量避免高并发下同时打开大量PDF句柄。JVM参数里适当调大堆内存同时预留一部分非堆内存给PDF处理使用-Xmx512m -XX:MaxMetaspaceSize256m我实际踩过坑后把PDF签署逻辑全部改成了每次签署都重新打开文件、用完立即关闭的模式内存曲线基本就平了。4.3 中文乱码问题症状PDF里写入的文字是???,或者名字完全显示不出来。原因PDF处理的字体库没有注册中文字体。Java环境下默认字体不支持中文很正常需要在代码里显式加载系统中文字体文件比如Windows下的 simsun.ttc宋体或 C:/Windows/Fonts/msyh.ttc微软雅黑。核心代码片段// 注册中文字体 FontFactory.register(C:/Windows/Fonts/simsun.ttc, SimSun); Font chineseFont FontFactory.getFont(SimSun, BaseFont.IDENTITY_H, 12f);注意一个字坑如果你的服务器是Linux比如CentOS没有Windows字体文件必须先把字体文件复制到服务器 /usr/share/fonts/ 目录下否则代码里写死Windows路径上生产环境照样乱码。更省心一点的做法是直接把字体文件打进项目resources目录运行时从classpath加载。4.4 SpringBoot版本导致的启动报错症状项目在本地JDK 1.8跑得好好的换台机器或者用新脚手架生成项目后启动直接报错提示类找不到或编译错误。这类问题在SpringBoot项目里一天能遇到八百回核心原因就是SpringBoot版本和JDK版本不匹配。比如Spring Boot 3.x 强制要求JDK 17及以上如果你还在用JDK 1.8就必须把SpringBoot版本锁定在 2.7.x。别自己瞎猜直接在pom.xml把版本写死parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent另外如果你的IDEA或Eclipse里存在多个JDK版本还要注意检查项目编译级别Project Structure里Language Level是不是1.8很多代码没问题但启动报错的情况其实是IDE把源码编译成了一个不兼容的字节码版本。4.5 签名图片变黑底或透明背景丢失症状签名图片在网页里看着是透明的放到PDF里却变成了黑底色或白底方块特别丑。这个问题的根源在于图片编码格式。如果前端Canvas导出时用了JPEG格式JPEG本身不支持透明通道背景会被强制填成白色或黑色。解决方式是统一使用PNG格式导出后端在绘制时也要确保使用 BufferedImage.TYPE_INT_ARGB带Alpha通道。我在前面的代码示例里特意用了这个类型就是因为这个坑踩过。另外前端处理的时候如果Canvas本身不是透明的比如先填充了白色背景后端怎么努力也救不回来。正确做法是Canvas创建时不填充背景色用 document.createElement(canvas) 新的画布默认为透明。5. 实操心得与扩展建议这套系统我从零到一完整开发下来前后大概花了两周业余时间。最大的体会是电子签这个领域技术上不存在不可逾越的壁垒真正的复杂度全在流程细节和不能出错的地方。比如签名的防篡改记录、合同状态的严格流转、坐标换算的准确性这些地方稍微放水后面上线就会被业务方反复找麻烦。几个扩展方向如果你准备在这个项目上继续深挖可以优先考虑签名笔迹特征采集在保存轨迹点的同时记录每段的书写速度、压力、停留时间为后续笔迹鉴定或更高级的认证功能做数据储备。合同模板变量替换把合同做成多个模板动态填充不同签约方的名称、金额、日期等变量生成PDF后再进入签署流程这能大大提升批量签署场景的效率。短信通知签署任务签署任务创建后通过短信或微信模板消息通知签署人提升签署时效。注意对接前一定要设置好签名模板否则会被运营商驳回。对接第三方CA数字证书服务如果你确实需要国家认可的电子签名法律效力可以在签署环节加入CA证书服务商的API把活体识别数字证书时间戳整合进来。这个改造可以做成一个开关按合同类型选择是否需要CA级签名。最后再分享一个我自己用得很顺的小技巧每次签字时在PDF页面上先画一个半透明的矩形框提示用户签字放在这个区域用户点击位置如果落在框里系统自动把签名居中放置。这个小交互看起来不起眼但实际使用中大大提高了用户签名的位置规范性省去了后续大量因为签名位置太歪导致的文件重新签署的问题。做类似系统的时候值得直接抄过去用。本文还有配套的精品资源点击获取
返回列表