简介:面向Java开发者的电子合同电子签章实现项目,基于Spring Boot搭建,聚焦PDF合同数字签章生成与校验场景。压缩包为zip格式,约72KB,包内文件明细暂未提供,适合希望掌握电子签章集成、PDF盖章处理及数字签名原理的中级开发者。资源围绕Spring Boot框架展开,涉及RSA/DSA非对称加密、JCA/JCE加密扩展、Bouncy Castle加密库及iText/PDFBox等PDF操作库,帮助理解从接口接收到证书/印章管理、签章数据结构嵌入与验证的完整链路。项目从Maven配置到源码结构均有说明,包含电子签章服务类、资源目录配置(证书、密钥、电子章图片)等设计思路,并提示了数据安全、合规与用户体验的落地考量。目前已有1248人学习下载,适合需要快速搭建电子签章原型或进行合同数字化改造的技术人员参考。
1. Java 电子签章:不是画个红章图片那么简单
做合同系统的同学多半遇到过这个需求:采购合同、对账单、人事文件,甲方要求线上盖章。有人图省事,直接把红色公章从 Word 里抠出来贴到 PDF 上,对接方一看就拒了——PS 出来的图片章没有任何法律效力,也验不了真伪。真正的电子签章 = 印章图片(负责“看得像”)+ 数字签名(负责“验得真”)。这套 Spring Boot 源码包覆盖了从密钥对生成、自签名证书、印章图片绘制到 PDF 签名落章、验签的完整链路,装进现有 Java 后端即可用。适合正在给 OA、ERP、合同管理模块加签章功能的开发,也适合需要在一周内拿出可演示原型的场景。
2. 电子签章的原理与选型:先想清楚签什么、拿什么签、盖在哪
2.1 电子签章的两层结构:印章是“皮”,数字签名是“骨”
先做一个区分。电子印章是视觉层,通常是一张带单位名称、专用字样的 PNG 图片,可能是红色圆章、椭圆章或方章;电子签名是数据层,它是用私钥对文档内容计算出的摘要做加密,形成一段不可伪造的数据。两者组合起来才是合规的电子签章。如果你只在 PDF 上贴图片,这不叫签章,叫插图。验签时拿不到任何证书信息,更无法证明文档在盖章后没有被改过。
数字签名的核心动作可以拆成四步。第一步,计算文档摘要,常见算法是 SHA-256;第二步,用签名者的私钥对这个摘要加密,得到签名值;第三步,把签名值、签名者证书、公钥一道嵌入 PDF 的签名域;第四步,验证方读取签名值,用公钥解密,再对当前文档重新计算摘要,两者一致才说明文档未被篡改。你看,这里面最关键的并不是“章长什么样”,而是私钥有没有被妥善保管、证书有没有过期、签名后文档有没有被二次编辑。
自签名证书和 CA 签发证书的区别也值得先讲清楚。自签名证书是自己给自己发证,验证时它的根就是它自己,浏览器或 PDF 阅读器会提示“无法验证颁发者”。内部系统用完全没问题,拿来对接外部司法机构或大型企业,需要走 CA 证书。源码包里默认用 BouncyCastle 生成自签名证书,方便本地调试,生产环境替换成从 CA 申请的证书即可。
2.2 技术选型:密钥库、绘图、签名库怎么配
选型这件事,我一般按三层来定。
第一层是密钥与证书。Java 生态里 KeyStore 是标准答案,常见格式有 JKS 和 PKCS12。新项目建议直接用 PKCS12,跨平台、可被 openssl 读取、官方也推荐。证书生成可以用 JDK 自带 keytool,也可以代码里用 BouncyCastle 的 X509v3CertificateBuilder 动态生成。动态生成的好处是签章系统可以做成多租户——每家企业一个密钥对,注册时自动发证书。密钥长度选 RSA 2048,摘要算法选 SHA256withRSA,这是目前兼容性与安全性平衡最好的组合。再低一档 1024 位在验签工具里会被标记为弱算法,别省。
第二层是印章图片绘制。有两条路:先用 PS 做好透明底 PNG 再入库,或者 Java 里用 Graphics2D 现画。前者省事,但印章直径、文字、颜色只能写死,换一家公司就要重新走设计流程;后者虽然代码多几十行,但所有参数都能配置化,五角星大小、弧线文字、边框粗细都可以动态调整。源码包采用 Graphics2D 绘制方案,画圆形公章、椭圆合同章、带编号的方形章都是同一套逻辑改参数。现画的另一个好处是能直接在内存里拿到 BufferedImage,不需要额外管理图片文件的生命周期。
第三层是 PDF 签名库。这是最容易踩坑的选型点,给个对比表:
| 方案 | 许可协议 | 数字签名支持 | 适合场景 |
|---|---|---|---|
| iText 5.x | AGPL(商业需购买) | 完整,支持 PKCS#7 外部签名 | 商用合规签章 |
| iText 7.x | AGPL | 完整,API 更现代 | 新项目,有预算 |
| PDFBox 2.x | Apache 2.0 | 基础支持,验签优于签名 | 开源免费项目 |
| OpenPDF | LGPL | 支持基础签名 | 预算敏感的项目 |
我的建议是:能做商业授权就选 iText 5.x,资料最多,网上踩坑案例最全;纯内部工具、不涉及对外分发,用 PDFBox 也能把章盖上去,但签名域的封装不如 iText 完善。源码包以 iText 5 为主实现,验签部分同时给了一段 PDFBox 的读取方式,方便你对比。
2.3 签章流程的整体时序
整套流程在 Spring Boot 里串起来是这样:合同文件上传 → 按业务规则定位签名坐标 → 从 KeyStore 加载当前操作人的私钥与证书 → 调用签名服务生成印章图片并嵌入 PDF 指定位置 → 返回带签章的 PDF 到前端预览。签完之后,业务系统不能再去改动这个 PDF 文件,否则摘要校验会失败。这里有个常见的误解——有人以为签名是“把章盖在文档上面保存一下”,其实签名动作发生时,PDF 的字节流已经被写死,后续任何追加、压缩、改元数据都会导致验签失败。后面避坑章节我会专门展开。
3. 把电子签章跑起来:密钥生成、印章绘制与 PDF 签名落地
3.1 Spring Boot 工程结构与配置准备
源码包是一个标准的 Spring Boot 多模块结构,核心代码集中在sign-service模块下。建议你按下面这样组织包结构,职责清晰,后续加功能也好找:
sign-service ├── controller/ // 签章接口入口 ├── service/ // 签名流程编排 ├── util/ │ ├── SealGenerator.java // 印章图片绘制 │ ├── KeyStoreHelper.java // 密钥对加载与证书生成 │ └── PdfSignerHelper.java // PDF 签名核心 └── config/ └── SignProperties.java // 签章参数配置application.yml里需要关心这几个参数:
sign: keystore: path: classpath:keystore/contract-seal.p12 # 密钥库文件路径 password: change-me # 密钥库口令 alias: contract-seal # 密钥别名 seal: type: circle # circle / ellipse / square size: 160 # 印章直径(像素) color: "#D9001B" # 印章红色 org-name: 某某科技有限公司 sub-text: 合同专用章 # 印章下方文字 pdf: page: 1 # 默认签章页码 x: 420 # 签章坐标 X(PDF 坐标系,左下角原点) y: 320 # 签章坐标 Y参数说明:size建议控制在 120~200 像素之间,太大盖住正文,太小章内文字看不清。color用十六进制字符串,注意印章红的标准值各家略有差异,内部系统无所谓,对接公证机构时要用指定色值。坐标x、y是 PDF 坐标系中的位置,单位是 point,A4 页面尺寸是 595 x 842 point,左下角为原点,这个坐标习惯和前端 Canvas 完全不同,新手最容易在这翻车,第 4 章单独讲。
3.2 生成密钥对与自签名证书
如果你不想用 keytool 预先生成密钥库,也可以在系统启动时自动生成。下面这段用 BouncyCastle 生成 RSA 密钥对和 X.509 自签名证书,然后写入 PKCS12 密钥库:
// 生成 RSA 2048 密钥对 KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); kpg.initialize(2048); KeyPair keyPair = kpg.generateKeyPair(); // 构建证书有效期为 365 天 long now = System.currentTimeMillis(); Date notBefore = new Date(now); Date notAfter = new Date(now + 365L * 24 * 3600 * 1000); // 证书主体信息 X500Name subject = new X500Name("CN=某某科技有限公司, O=某某科技, C=CN"); JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( subject, new BigInteger(64, new SecureRandom()), notBefore, notAfter, subject, keyPair.getPublic()); X509CertificateHolder certHolder = certBuilder.build( new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate())); X509Certificate cert = new JcaX509CertificateConverter().getCertificate(certHolder); // 写入 PKCS12 KeyStore KeyStore ks = KeyStore.getInstance("PKCS12"); ks.load(null, null); ks.setKeyEntry("contract-seal", keyPair.getPrivate(), password.toCharArray(), new java.security.cert.Certificate[]{cert}); try (FileOutputStream fos = new FileOutputStream("/path/contract-seal.p12")) { ks.store(fos, password.toCharArray()); }逻辑说明:JcaX509v3CertificateBuilder是 BouncyCastle 提供的证书构建器,第一个参数是证书主体也是签发者(自签名场景两者相同),subject里的 CN 字段建议用企业全称,验签时阅读器会展示这个名称。BigInteger(64, new SecureRandom())生成证书序列号,序列号不能重复,用随机数是最稳的。JcaContentSignerBuilder指定摘要与签名算法为 SHA256withRSA,与密钥长度 2048 匹配。写入 KeyStore 时用setKeyEntry,最后一个参数是证书链,自签名传单元素数组即可。
参数说明:证书有效期我习惯设一年,生产环境最好接时间戳服务,否则证书到期后存量合同验签会告警,这个问题第 4 章细说。密码强度至少 12 位,密钥库文件不要提交进 Git 仓库。
3.3 用 Graphics2D 绘制一枚带五角星的圆形公章
生成印章图片这块,核心逻辑是 Graphics2D 的矢量绘图。圆形公章由四部分组成:外圆边框、上部单位名弧线文字、中间五角星、下部“合同专用章”横排文字。下面是一段可运行的核心绘制代码:
public BufferedImage generateCircleSeal(SealParam param) { int size = param.getSize(); BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g2d = image.createGraphics(); // 抗锯齿必须开,否则弧线文字边缘全是锯齿 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setColor(Color.decode(param.getColor())); g2d.setStroke(new BasicStroke(4f)); // 外框线宽 // 1. 画外圆,留出边距 int margin = 10; g2d.drawOval(margin, margin, size - 2 * margin, size - 2 * margin); // 2. 画五角星,中心在圆心位置,外接圆半径为 size/5 int centerX = size / 2, centerY = size / 2, starRadius = size / 5; int[] xPoints = new int[10], yPoints = new int[10]; for (int i = 0; i < 10; i++) { double angle = Math.PI / 5 * i - Math.PI / 2; int r = (i % 2 == 0) ? starRadius : starRadius / 2; xPoints[i] = (int) (centerX + r * Math.cos(angle)); yPoints[i] = (int) (centerY + r * Math.sin(angle)); } g2d.fillPolygon(xPoints, yPoints, 10); // 3. 沿圆弧绘制单位名称 g2d.setFont(new Font("宋体", Font.BOLD, 24)); g2d.setStroke(new BasicStroke(1f)); String orgName = param.getOrgName(); int arcRadius = size / 2 - 18; double angleStep = Math.toRadians(12); // 每个字符占据弧度 double startAngle = Math.PI + Math.toRadians(6) * (orgName.length() - 1) / 2; FontMetrics fm = g2d.getFontMetrics(); for (int i = 0; i < orgName.length(); i++) { double angle = startAngle - i * angleStep; int x = (int) (centerX + arcRadius * Math.cos(angle)); int y = (int) (centerY + arcRadius * Math.sin(angle)); g2d.translate(x, y); g2d.rotate(angle + Math.PI / 2); g2d.drawString(String.valueOf(orgName.charAt(i)), -fm.charWidth(orgName.charAt(i)) / 2, 0); g2d.rotate(-(angle + Math.PI / 2)); g2d.translate(-x, -y); } g2d.dispose(); return image; }逻辑说明:TYPE_INT_ARGB一定要用,保证背景是透明通道,否则导出的 PNG 在 PDF 里会显示白底,盖在合同上就像贴了块膏药。绘制弧线文字的原理是逐个字符设置位置和旋转角度——每次先translate到字符落点,再rotate让字符沿圆的切线方向排列,画完一个字符必须反向旋转和平移回来,否则坐标会累加漂移。单位名较长时,把angleStep改小到 8~10 度,或者加大印章尺寸,避免文字超出外圆。
参数说明:starRadius控制五角星大小,经验值是印章直径的五分之一,太大会挤压文字空间,太小则显得章面空旷。字体建议用“宋体”或“仿宋”,公章的视觉规范里这两个字体最常见。如果部署在精简版 Linux 系统上,没有宋体字库,文字会渲染成方块,这是经典坑,第 4 章会给解决方案。
3.4 把印章写进 PDF 并完成数字签名
这一步把前面生成的密钥、印章图片和 PDF 文件串起来。以 iText 5 为例,完整的外部签名流程分三步:加载 PDF → 设置签名外观 → 用私钥对摘要签名并写回。代码如下:
// 1. 加载待签名的 PDF PdfReader reader = new PdfReader(srcPath); FileOutputStream fos = new FileOutputStream(destPath); PdfStamper stamper = PdfStamper.createSignature(reader, fos, '\0'); // 2. 配置签名外观与位置 PdfSignatureAppearance appearance = stamper.getSignatureAppearance(); appearance.setVisibleSignature( new Rectangle(x, y, x + sealWidth, y + sealHeight), pageNum, "sign-field-1"); appearance.setImage(Image.getInstance(sealPngBytes)); appearance.setCertificationLevel(PdfSignatureAppearance.NOT_CERTIFIED); // 3. 计算摘要并签名 String providerName = "BC"; PdfSignature signature = new PdfSignature(PdfName.ADOBE_PPKLITE, PdfName.ADBE_PKCS7_SHA1); appearance.setCryptoDictionary(signature); // 计算文档摘要 HashMap<PdfName, Integer> exc = new HashMap<>(); exc.put(PdfName.CONTENTS, Integer.valueOf(-1)); PdfSignatureAppearance.PreClose preClose = appearance.getPreClose(exc); byte[] docDigest = DigestAlgorithms.digest(preClose.getUnderlyingContent(), "SHA-256", providerName); // 用私钥签名 CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); gen.addSigner(privateKey, cert, CMSSignedDataGenerator.DIGEST_SHA256); CMSSignedData signedData = gen.generate(new CMSProcessableByteArray(docDigest), false); // 写回签名值 byte[] signatureBytes = signedData.getEncoded(); preClose.setCryptoDictionary(signature); int contentSize = signatureBytes.length + 1024; preClose.setContents(new PdfDictionary() {{ put(PdfName.CONTENTS, new PdfString(signatureBytes)); }}); stamper.close(); reader.close();逻辑说明:setVisibleSignature的Rectangle参数决定章盖在页面的哪个位置,前两个参数是左下角坐标,后两个是宽高,单位是 point。签名域名称sign-field-1不能和已有的签名域重复,同一页盖多个章就依次命名sign-field-2、sign-field-3。setCertificationLevel设为NOT_CERTIFIED表示允许后续追加签名,设为CERTIFIED_NO_CHANGES_ALLOWED则禁止任何修改,一般合同场景用前者,因为一份合同可能有多方签署。
参数说明:PdfName.ADBE_PKCS7_SHA1是 PDF 签名机制的标识,虽然名字带 SHA1,但实际摘要我们用 SHA-256,这个标识是固定枚举值,不要改。contentSize预留 1024 字节是给未来签名扩展信息留的余量,留少了某些验签器会报“签名值截断”。如果嫌手动拼 PKCS#7 太繁琐,iText 也提供了PrivateKeySignature一次性封装,它内部帮你处理了 CMS 结构,代码量能少一半。源码包里两种写法都给了,建议先跑通第一种,理解原理后再换简化版。
补充一点关于 Word 合同的处理。很多业务系统的签章文件其实是 Word 而不是 PDF。常见做法是先用 Apache POI 生成或读取合同正文,再用 LibreOffice 或 Aspose.Words 转成 PDF,最后走上面的签名流程。POI 本身不支持数字签名,它只能往 Word 里塞图片,那种“章”验不了真伪,只能算可视化标记,合规性不达标。
4. 避坑排查:电子签章最容易翻车的五个坑
4.1 印章图片透明背景变成黑色或白色底块
现象:PNG 印章盖到 PDF 后,背景不是透明的,而是出现黑底或白底矩形,正文文字被遮挡。
原因:印章图片的BufferedImage类型创建成了TYPE_INT_RGB,这个类型没有透明通道;另一个可能是用 JPEG 格式保存了印章,JPEG 本身不支持透明。
解决:创建图片时强制使用TYPE_INT_ARGB;输出图片只允许 PNG 格式;如果是从外部图片读取,先做一次通道转换再传给 iText。检查方法很简单,用图片查看器打开生成的 PNG,把背景色调成黑白网格,网格纹路能透出来就说明透明通道正常。
4.2 签章坐标错位,章盖到了合同框外
现象:前端预览时章的位置是对的,后端生成的 PDF 里章跑到页面右下角甚至页面外。
原因:PDF 的坐标系原点在页面左下角,Y 轴向上;而前端组件(Canvas、浏览器截屏)坐标系原点一般在左上角,Y 轴向下。两边换算没做,直接把前端传的坐标塞进Rectangle,位置自然反了。
解决:统一以后端为准。前端传页面百分比或者 anchor 点名称(如“甲方盖章处”),后端根据页面尺寸换算。A4 页面 height 是 842 point,如果前端传的是左上角坐标(x1, y1),后端映射为(x1, 842 - y1 - sealHeight)。另一招是给每个合同模板预先定义盖章锚点坐标,存进数据库,前端只传模板编号和位置编号,彻底避免像素级换算问题。
4.3 签名后验签失败:文档被二次修改
现象:签章成功,本地看没问题,但用户用 Adobe Acrobat 打开后提示“文档自签名后已被修改”。
原因:最常见的场景是签完章后,业务系统又往 PDF 里追加了元数据、压缩流,或者用 PDFBox 重新打开保存了一次。PDF 签名是对原始字节流的摘要签名,任何字节变化都会导致校验失败。另一个隐蔽原因是签名流程里先调用了PdfReader读取,之后又用同一个PdfStamper重复写入,导致两次摘要不一致。
解决:把“签名”视为写操作终点。签名完成后,对这个文件只做只读操作,不再close()后重新打开写入。如果业务需要加水印、页眉,必须在签名之前完成,顺序是“排版 → 加固 → 签名”。我在代码里会强制把签名逻辑放在事务边界之外,并且签名后立即计算一次验签自检,发现失败直接记录日志并告警,防患于未然。
4.4 证书过期与系统时间导致的验签告警
现象:服务器上验签正常,运维把系统时间调慢了两小时,同一个 PDF 再验就报“证书不在有效期内”,业务因此卡住。
原因:自签名证书默认有效期设了一年,验签时校验的是签名证书的有效期和验签时刻的系统时间两件事。系统时间被改、证书到期、或者时间戳缺失,都会触发这个告警。
解决:生产环境务必接入 RFC 3161 时间戳服务器,签名时把时间戳一起打进 PKCS#7 数据里,这样即使证书过期,已签文档依然有效,因为签名的“时点”已经被时间戳钉死了。源码包里留了TimestampTokenGenerator的接入位置,没网环境可以先用本地时间兜底,但对外业务建议搭私有时间戳服务。另外在项目里加一个定时任务,提前 30 天扫描证书到期日期,发提醒给管理员,千万别等到合同系统集体报错才去查密钥库。
4.5 服务器缺中文字体,印章文字渲染成方块
现象:本地 Windows 上生成的印章一切正常,部署到 CentOS 服务器后,章上的公司名变成一排空心方块。
原因:印章绘制用的是new Font("宋体", Font.BOLD, 24),这个字体是逻辑字体,依赖操作系统实际安装的字库。Linux 服务器往往只装了 minimal 镜像,中文字体一个都没有,Graphics2D 找不到宋体就回退到默认字体,中文直接缺失。
解决:有两种思路。第一种,安装字库:yum install fontconfig然后把 Windows 的 simsun.ttc 传到/usr/share/fonts/chinese/,执行fc-cache -f刷新。第二种,代码里直接加载字体文件,不依赖系统环境。把 simsun.ttc 放进项目resources/fonts/目录,启动时用Font.createFont(Font.TRUETYPE_FONT, inputStream)注册,再deriveFont设置字号和粗体。我建议直接用第二种,Docker 化部署时不用额外做字体镜像,少一个环境依赖。
5. 验证与进阶:签名自检、批量盖章与把坑固化到流程里
签名做完先别急着交差,写一个自检工具,每次签名后自动验一遍。用 PDFBox 读取签名信息是成本最低的方式,不需要引入 iText 的额外许可负担。核心代码就这几行:
PDDocument doc = PDDocument.load(pdfBytes); PDSignatureTable sigTable = doc.getSignatureDictionaries(); List<PDSignature> sigs = sigTable.getSignatureDictionaries(); for (PDSignature sig : sigs) { byte[] content = sig.getContents(bytes); Certificate cert = sig.getCertificate(bytes); // 用证书公钥验签,并打印签名者 CN String signer = ((X509Certificate) cert).getSubjectX500Principal().getName(); log.info("签名域 {},签发者 {}", sig.getName(), signer); } doc.close();这段代码能验证三件事:签名域存在、证书能正确读取、签发者信息完整。如果getCertificate返回空,说明签名数据不完整,要回查签名流程,而不是把问题抛给验收方。
批量盖章的场景,建议把“单文件签名”封装成无状态服务。传入文件字节、签章参数、密钥别名,返回签名后的字节数组。Spring Boot 里用线程池处理,注意密钥库操作要加锁或使用线程安全的 KeyStore 实例,否则并发加载同一个 p12 文件会偶发KeyStoreException。实测 200 个文件并发,单节点吞吐量约每秒 15 份,瓶颈在 IO 和摘要计算,够一般公司用了。
最后说一个我真实栽过的跟头。之前做项目,合同签名后又被业务追加了一段“备注信息”水印,客户拿 Adobe 打开直接红字告警。从那以后我每条签名链路都强制走一遍“签名前完整性检查 → 签名 → 实时验签自检”,验签不过就立刻阻断出库。这套逻辑听起来繁琐,但能挡住九成以上的低级事故。希望帮到你。
本文还有配套的精品资源,点击获取