在接手这个Java电子合同与电子签名系统之前,我一度以为核心难点全在“电子签名”这四个字背后复杂的密码学原理上。可真把需求拆开来看才发现,整个项目真正的挑战其实在于三个层面的整合:后端Java服务如何把签署流程做得足够严谨、签章图片如何在前端还原出“纸上盖章”的观感、以及微信小程序这种受限环境里怎么把“用户体验”和“法律效力”平衡起来。这套源码我前后重构过三轮,踩过不少坑,也沉淀下一些通用性很强的代码片段,今天干脆把它完整拆开,从架构设计一直聊到具体的调优细节,希望能帮到正在做类似项目的朋友。
1. 项目整体设计与技术选型思路
先说结论:这套系统的核心价值在于把“签署”这件事变成了一条纯数字化的、可追溯、可校验的闭环链路。它不是一个简单的“在图片上加个签名”的绘图工具,而是一套包含合同上传、签署方认证、签章定位、签署动作执行、签名哈希固化、PDF文档防篡改校验、以及签署记录存证的法律级电子签章系统。
1.1 为什么后端体系选择Java
选择Java作为服务端主语言,核心原因是电子合同系统对稳定性、并发处理和生态成熟度要求极高。合同的签署往往伴随大量文件流传输、PDF解析、时间戳请求和证书链校验,这些依赖在Java生态里都非常成熟。
具体来说,这套项目里我用到的核心技术栈是这样的:
- Spring Boot 2.7.x:负责整体服务编排、REST接口暴露,以及拦截器链实现签署之前的身份校验
- MyBatis Plus:处理合同记录、签署记录、用户表等结构化数据的持久层
- Redis:存储用户登录态token、签署短时令牌、防重复提交的幂等键
- JDK原生Security包:生成RSA密钥对、计算文档哈希、实现数字签名/验签
- Apache PDFBox 2.x:用于解析、渲染和写入PDF签章层
- hutool工具库:处理二维码生成、随机数、Base64编解码等杂项逻辑
选这组方案而不是直接用Node.js或Python,最核心的考量有两个:一是PDFBox在Java下的文档处理能力明显优于其他语言的同类库,尤其在处理带表单域的PDF时,Java生态能精准定位坐标并插入内容;二是Spring Boot在这些常规业务场景里的事务回滚和并发控制写起来最顺手,对团队后续的代码维护最友好。
1.2 小程序端的技术定位
前端不是纯Web页面,而是选择微信小程序,这个决定是经过一番权衡的。微信小程序天然解决了两个问题:一是企业微信生态下的用户身份识别,在合同场景里,微信实名认证体系可以直接复用;二是移动端签署体验,手机端签名的操作路径比电脑端短得多,用户更愿意顺手完成。
但小程序的限制也必须提前认清:
- 小程序包里最大2MB(主包),所以PDF解析和渲染不能放在前端做
- Canvas绘制签名板是可行的,但要处理好触屏事件的坐标系偏移
- 文件下载只能走
wx.downloadFile,且需要License域名白名单配置
所以架构上小程序只做“展示和采集”,所有核心逻辑全部放到后端Java服务里完成,前端通过API与后端交互。
1.3 整体业务流程串联
为了方便理解,我把系统核心业务链路梳理成下面的步骤,这也是整套源码里最根本的执行顺序:
- 发起方(企业内部人员)通过小程序或管理后台创建合同,上传PDF文件
- 后端解析PDF,提取总页数和每一页的尺寸
- 发起方设置签署区位置(坐标、页码、签署人)
- 系统生成签署邀请链接或小程序消息通知,推送给接收方
- 接收方在微信小程序中完成实名认证
- 接收方进入待签署列表,查看文档内容
- 点击签署按钮,小程序Canvas签名板采集用户手写笔迹
- 签名图片上传至后端,后端将图片按坐标“盖”到PDF指定位置
- 后端计算签名哈希,加上时间戳和证书信息,生成数字签名数据
- 合同状态变更为“已签署”,同时记录完整的签署日志
不夸张地说,第8步和第9步是整个系统的灵魂,也是最容易出问题的地方。我把这三步的核心代码和调试经验完整拆出来,放在下面几个章节里逐个讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 签名图片处理的难点与实现方案
项目里有一个热搜词非常显眼:“电子签名怎么把背景变透明”。这个需求几乎每位做签章开发的同学都会遇到——我们通过小程序Canvas采集到的手写签名,背景正常情况下是纯白色的方块,但直接把这个白色方块贴到合同上,会跟原文背景产生肉眼可见的色块差异。尤其有些用户上传的合同是浅黄色底纹或带水印的,白底一盖上去,签章区域就是一个突兀的白框,观感非常糟糕。
2.1 签名背景变透明的核心原理
背景变透明的本质是把纯白色像素的Alpha通道改为0,同时保留所有书写笔迹的原始颜色和透明度。听起来很简单,但实际操作时要特别注意两个细节:
第一,不是所有接近白色的像素都应该透明化。如果用户签名用的笔是浅灰色的,或者书写力度较轻导致笔迹边缘有大量半透明像素,盲目的“白色全部置为透明”会把笔迹边缘削掉一圈,签出来的字变得又细又弱。
第二,签名图片需要保留一部分灰度渐变信息,以便在PDF合成时做合理混合。所以我的处理策略是:将RGB值同时满足“R>240且G>240且B>240”的像素视为背景像素,Alpha设为0;而其他像素按距离白色的远近做Alpha半透明过渡。
2.2 Java端使用ImageIO实现背景透明的完整代码
这里给出我在项目里实际使用的代码片段,直接将小程序上传的Base64格式签名图片转成透明背景PNG。
java复制import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.awt.Color;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
public class SignatureImageProcessor {
/**
* 将签名图片背景变透明
* @param base64Image 小程序上传的原始签名图片,Base64编码,格式为image/png
* @return 处理后的透明背景PNG图片,同样以Base64返回
*/
public static String makeBackgroundTransparent(String base64Image) throws Exception {
// 1. 解码Base64为字节数组
byte[] imageBytes = Base64.getDecoder().decode(base64Image);
ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes);
BufferedImage originalImage = ImageIO.read(bais);
// 2. 创建ARGB模式的空图片,确保有Alpha通道
int width = originalImage.getWidth();
int height = originalImage.getHeight();
BufferedImage transparentImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// 3. 遍历每个像素,判断是否接近白色
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int argb = originalImage.getRGB(x, y);
Color color = new Color(argb);
int alpha = 255;
int r = color.getRed();
int g = color.getGreen();
int b = color.getBlue();
// 判断是否为背景白色:RGB三个通道都大于阈值
if (r > 240 && g > 240 && b > 240) {
alpha = 0; // 完全透明
} else {
// 笔迹边缘做半透明过渡,避免边缘僵硬
int minComponent = Math.min(r, Math.min(g, b));
if (minComponent > 180) {
alpha = Math.max(0, 255 - (minComponent - 180) * 4);
}
}
// 4. 重新设置ARGB像素值
int newArgb = (alpha << 24) | (r << 16) | (g << 8) | b;
transparentImage.setRGB(x, y, newArgb);
}
}
// 5. 输出为PNG格式(PNG才支持透明通道)
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(transparentImage, "png", baos);
return Base64.getEncoder().encodeToString(baos.toByteArray());
}
}
代码里有两个容易被忽略的细节:
- 第一行输出格式必须是PNG。JPEG格式本身不支持透明通道,就算你处理完了保存为jpg,又会因为二次压缩多出灰色噪点,前功尽弃。
- 边缘过渡处理的算法我稍微做了个线性映射,
minComponent在180~240之间时,alpha从255平滑递减到0。这么做的好处是签名笔迹比较淡的笔画不会出现那种“被狗啃过”的锯齿感。
2.3 为什么不能直接前端canvas导出透明背景
有人可能会问,既然微信小程序Canvas本身支持导出带透明通道的PNG,为什么不直接在前端做完背景透明,非要绕一圈传回后端再处理?
原因是在小程序Canvas中,设置globalCompositeOperation = 'destination-out'确实可以实现擦除背景,但实际运行时会遇到兼容性问题。部分安卓机的Canvas 2D底层实现不支持这个混合模式,导出的图片依然是白底;还有一些机型则在擦除过程中把半透明的抗锯齿像素一并处理掉,导致笔迹边缘出现白边。
我曾经统计过,仅这一处问题就占据了签署类工单的三成左右。为了不跟微信各版本的基础库与厂商ROM斗智斗勇,最稳妥的方案就是在后端统一处理,前端只管采集原始笔迹就行了。
3. 签章定位与PDF合成实践
处理完透明背景,下一步就是把签名“盖”到PDF上正确的坐标位置。这一步很考验对PDF坐标体系的理解,因为整套系统里会涉及三套坐标的换算:小程序端点击位置的CSS坐标、PDF读取时的用户坐标空间、PDFBox写入时的PDF点坐标。任何一个环节不统一,印章位置就会出现肉眼可见的偏移。
3.1 PDF坐标系统与小程序坐标的换算逻辑
PDF文档概念上有两种坐标体系:
- 默认用户空间坐标:原点在页面左下角,x轴向右,y轴向上,单位为point(1/72英寸)
- 旋转后的用户空间坐标:取决于当前页面的
/Rotate属性值
而小程序端获取的是相对于屏幕左上角的CSS坐标(原点在左上,y轴向下),所以从屏幕坐标转换到PDF坐标,核心公式是:
text复制pdfX = (screenX / canvas实际宽度) * pdf页面宽度(点)
pdfY = pdf页面高度(点) - (screenY / canvas实际高度) * pdf页面高度(点)
这里的关键是,PDF框选区域通常不是全屏Canvas而是文档预览图上的一个区域,所以要用比例系数而不是像素绝对值。不然不同分辨率手机上看到的效果会完全错位。
3.2 使用PDFBox将透明PNG签名写入PDF
下面给出实际把签名图片写入PDF的具体代码,我自己封装了一个工具方法,生产环境直接使用的就是这套逻辑。
java复制import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.Base64;
public class PdfSigner {
/**
* 将签名图片添加到PDF指定位置
*
* @param pdfBytes 原始PDF字节数组
* @param signImageBase64 透明背景签名图片(Base64)
* @param pageIndex 签署页码(从1开始)
* @param x 目标坐标X(PDF用户坐标,单位pt)
* @param y 目标坐标Y(PDF用户坐标,单位pt)
* @param width 显示宽度(单位pt)
* @return 合成后的PDF字节数组
*/
public static byte[] addSignatureToPdf(byte[] pdfBytes,
String signImageBase64,
int pageIndex,
float x,
float y,
float width) throws Exception {
// 1. 加载PDF文档
try (PDDocument document = PDDocument.load(pdfBytes)) {
if (pageIndex < 1 || pageIndex > document.getNumberOfPages()) {
throw new IllegalArgumentException("页码越界: " + pageIndex);
}
PDPage page = document.getPage(pageIndex - 1);
// 2. 从Base64解码签名图片
byte[] imageBytes = Base64.getDecoder().decode(signImageBase64);
InputStream imageStream = new ByteArrayInputStream(imageBytes);
PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "signature");
// 3. 根据签名图片宽高比自动计算高度
float imageWidth = pdImage.getWidth();
float imageHeight = pdImage.getHeight();
float displayHeight = width * (imageHeight / imageWidth);
// 4. 创建内容流,覆盖在PDF页面内容之上
PDPageContentStream contentStream = new PDPageContentStream(
document, page,
PDPageContentStream.AppendMode.APPEND, true, true);
// 5. 在指定位置绘制图片
contentStream.drawImage(pdImage, x, y - displayHeight, width, displayHeight);
contentStream.close();
// 6. 返回生成后的字节数组
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
}
}
}
这里有一个必须强调的坑:drawImage的y坐标参数是图片左下角的位置,不是左上角。所以我们在向方法内传入y时,要先用“目标区域左上角y减去高度”,得到左下角坐标再传入。很多第一次写的同学,直接拿预览图上量好的y坐标传进来,结果图片整体往上窜出去一截,与预期位置完全对不上。
3.3 多签署区定位的约定
在真实合同里,一份文件上经常有多个签署位置:甲方盖章处、乙方盖章处、签署日期位置等。为了让发起方简单高效地录入这些签署位,系统里约定了一种签署区坐标协议,前端把每个签署区的信息打包成一个JSON数组传到后端:
json复制[
{
"signerId": "user_001",
"pageIndex": 1,
"xPer": 0.72,
"yPer": 0.85,
"width": 120,
"signType": "SIGNATURE"
},
{
"signerId": "user_002",
"pageIndex": 2,
"xPer": 0.26,
"yPer": 0.62,
"width": 100,
"signType": "COMPANY_SEAL"
}
]
这里的xPer和yPer是比例坐标,取值0到1之间,分别表示该签署区左上角相对于PDF页面宽高的百分比位置。这样传给后端时,只需根据页面实际尺寸乘回像素值即可,避免不同终端尺寸带来的误差。比例坐标的引入,同时解决了PC端和移动端预览显示位置不一致的问题。
4. 数字签名与防篡改机制解析
电子合同如果只有一张“盖章图片”,那它跟PS没有本质区别。真正让电子签名具备法律效力的,是背后的数字签名技术。数字签名的核心目标有三个:确认签署者身份、确认签署动作的意愿、确认签署后文档没有被篡改。
4.1 签名链路的密码学实现
这套系统的数字签名链路分五个阶段,我之前整理过一套流程图式的说明,这里用文字讲清楚:
- 计算文档摘要:后端将待签署PDF的字节流使用SHA-256算法计算出一个固定长度的哈希值。这个哈希值相当于PDF的“指纹”,任何字节的改动都会导致哈希完全不同。
- 生成签署材料:将“PDF哈希值 + 当前时间戳 + 签署者ID + 合同ID”组合成签名前的数据块。
- 签名者私钥签名:使用签署者的RSA私钥对这个数据块进行加密签名,得出signature值。
- 公钥校验:任何人都可以使用签署者的公钥对signature进行解密,比对解密后的哈希是否与文件当前哈希一致。
- 时间戳固化:将签名过程和签名时间发送到可信时间戳服务,获得一个权威时间戳,证明在某时刻该文档已经被签署。
这种技术的核心保护的是文档完整性:若签署完成后任何人篡改合同内容,哪怕只改一个字,重新计算的SHA-256都会与签名中固化下来的哈希不一致,验证方就能判定该文件已被修改。
4.2 数字签名核心代码片段
实际开发中我封装了下面这个签名与验签的工具类,这里只贴核心内容。
java复制import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
public class DigitalSignatureUtil {
private static final String SIGN_ALGORITHM = "SHA256withRSA";
/**
* 使用RSA私钥对数据进行签名
*
* @param data 待签名数据原文
* @param privateKeyStr Base64编码的私钥
* @return Base64编码的签名字符串
*/
public static String sign(byte[] data, String privateKeyStr) throws Exception {
PrivateKey privateKey = getPrivateKey(privateKeyStr);
Signature signature = Signature.getInstance(SIGN_ALGORITHM);
signature.initSign(privateKey);
signature.update(data);
return Base64.getEncoder().encodeToString(signature.sign());
}
/**
* 使用RSA公钥验证签名
*
* @param data 原始数据
* @param publicKeyStr Base64编码的公钥
* @param signStr Base64编码的签名
* @return 是否验证通过
*/
public static boolean verify(byte[] data, String publicKeyStr, String signStr) throws Exception {
PublicKey publicKey = getPublicKey(publicKeyStr);
Signature signature = Signature.getInstance(SIGN_ALGORITHM);
signature.initVerify(publicKey);
signature.update(data);
return signature.verify(Base64.getDecoder().decode(signStr));
}
private static PrivateKey getPrivateKey(String privateKeyStr) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(privateKeyStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
private static PublicKey getPublicKey(String publicKeyStr) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyStr);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePublic(keySpec);
}
}
4.3 实际业务中签名原数据包含什么
签名前的原始数据块,在这套系统的约定里通常是:
text复制contractId + "|" + pdfHash + "|" + signerId + "|" + signTime + "|" + deviceInfo
每家公司可以根据自己的业务场景增减字段,但是有几个原则不能丢:
- PDF哈希 + 合同ID必须绑定,防止签的是A合同、然后签名被挪到B合同上
- signTime必须使用服务器时间,不能使用客户端时间,防止用户修改本地时间造成签署时间纠纷
- deviceInfo是在小程序端收集的设备指纹信息,如机型、系统版本,通常不参与验签,仅用于事后风控审计
5. 小程序端的登录态与签署交互实现
小程序端最常遇到的问题,就是热搜词里的“小程序获取登录后的微信用户失败”。这个报错在微信官方文档里很多,项目里也经常遇到,我在这节把整个登录态和签署交互的全链路讲透,后面再结合问题排查章节再深入分析。
5.1 登录态设计与用户身份绑定
微信小程序登录的完整链路遵循官方推荐的intercode换openid模式,不能直接在前端拿微信的code去换用户信息,必须在后端通过HTTP请求微信的jscode2session接口换取。
核心代码逻辑在Java后端是这样的:
java复制@PostMapping("/api/auth/login")
public Result login(@RequestBody LoginRequest request) {
// 1. 使用wx.login返回的code换openid和session_key
String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + appId
+ "&secret=" + appSecret
+ "&js_code=" + request.getCode()
+ "&grant_type=authorization_code";
String response = restTemplate.getForObject(url, String.class);
JSONObject json = JSONObject.parseObject(response);
String openid = json.getString("openid");
String sessionKey = json.getString("session_key");
// 2. 根据openid查询系统用户,如果不存在则自动注册
SysUser user = userMapper.selectByOpenid(openid);
if (user == null) {
user = new SysUser();
user.setOpenid(openid);
user.setNickname("微信用户_" + openid.substring(openid.length() - 6));
user.setCreateTime(new Date());
userMapper.insert(user);
}
// 3. 生成自定义登录态token,存储Redis并设置过期时间
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set("login:token:" + token, user.getId().toString(), 7, TimeUnit.DAYS);
return Result.ok(ImmutableMap.of("token", token, "userId", user.getId()));
}
这里需要注意,小程序端wx.login()返回的code只能用一次,且有效期较短,一般5分钟左右,所以拿到code后要立即传给后端,不能先去做别的操作再传。
5.2 签名板组件开发
签名板是整个签署流程里最关键的交互组件。一个优秀的签名板,需要同时满足几个条件:书写顺滑、导出清晰、能准确采集笔迹轨迹的坐标变化。我用小程序原生Canvas实现了一个可用的版本。
签名板核心代码抽出来看:
javascript复制// pages/sign/signature.js
Page({
data: {
canvasWidth: 0,
canvasHeight: 240,
hasSigned: false
},
onReady() {
const query = wx.createSelectorQuery();
query.select('#signatureCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (res && res[0]) {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
this.canvas = canvas;
this.ctx = ctx;
// 设置Canvas实际尺寸,适配高分辨率屏幕
const dpr = wx.getWindowInfo().pixelRatio;
canvas.width = res[0].width * dpr;
canvas.height = res[0].height * dpr;
ctx.scale(dpr, dpr);
this.canvasWidth = res[0].width;
this.canvasHeight = res[0].height;
// 设置绘制样式
ctx.strokeStyle = '#000000';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// 绑定触摸事件
canvas.ontouchstart = this.handleTouchStart.bind(this);
canvas.ontouchmove = this.handleTouchMove.bind(this);
canvas.ontouchend = this.handleTouchEnd.bind(this);
}
});
},
handleTouchStart(e) {
const touch = e.touches[0];
const rect = this.canvas.getBoundingClientRect();
this.lastX = touch.clientX - rect.left;
this.lastY = touch.clientY - rect.top;
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
},
handleTouchMove(e) {
const touch = e.touches[0];
const rect = this.canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
this.ctx.lineTo(x, y);
this.ctx.stroke();
this.setData({ hasSigned: true });
},
handleTouchEnd() {
this.ctx.closePath();
},
// 导出签名图片
exportSignature() {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas: this.canvas,
success: (res) => {
// 读取临时文件为Base64
wx.getFileSystemManager().readFile({
filePath: res.tempFilePath,
encoding: 'base64',
success: (data) => {
resolve('data:image/png;base64,' + data.data);
},
fail: reject
});
},
fail: reject
});
});
}
});
写这个组件时有个重要细节:Canvas节点需要设置type="2d"属性,用旧版wx.createCanvasContext的话,在iOS和较新基础库上性能很差,写出来断断续续。改用Canvas 2D接口后,配合dpr缩放,笔画在真机上相当顺滑。
5.3 签署前在线预览的体验优化
签署前,用户必须能看到合同的原文内容。小程序端我不建议直接把PDF全量渲染,因为PDF解析在小程序侧需要引入三方库,体积大且兼容性一般。更稳妥的做法是后端把PDF的每一页转成高清PNG图片,然后小程序端用swiper加载所有图片,横滑翻页。
PDF转图片可以用PDFBox和PDFRenderer。但如果你只引入PDFBox,它本身不自带渲染器,需要额外依赖pdfbox-renderer或使用pdfbox-app。一个性能对比:纯图片预览比PDF实时渲染快大约3~4倍,而且后端渲染一次后可以缓存7天,签署过程中不必重复渲染。
后端渲染PDF为图片的核心代码:
java复制import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
public class PdfConverter {
public static List<String> pdfToBase64Images(byte[] pdfBytes, int dpi) throws Exception {
List<String> result = new ArrayList<>();
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
PDFRenderer renderer = new PDFRenderer(document);
int pageCount = document.getNumberOfPages();
for (int i = 0; i < pageCount; i++) {
BufferedImage image = renderer.renderImageWithDPI(i, dpi);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
result.add(Base64.getEncoder().encodeToString(baos.toByteArray()));
}
}
return result;
}
}
dpi参数直接决定图片清晰度和体积的平衡。预览场景用72dpi即可,签字场景建议至少144dpi,既保证清晰度又不会让图片大得让小程序的setData崩溃。实际项目里我取的是120dpi,综合平衡下来最稳定。
6. 常见问题与排查技巧实录
这部分完全是我自己维护这套系统过程中遇到过的真实问题,整理成速查手册的形式,遇到类似报错可以先往这几个方向查。
6.1 小程序端获取登录用户失败(报错信息里带appid的格式)
这个问题基本可以确定是后端调用jscode2session接口时参数有误,或接口返回错误码时没有正确处理。我在项目初期收到的报错长这样:
text复制获取登录后的微信用户失败: wx1cb4398e1413dce7
这个字符串本身不是错误信息,而是客户端的appid在日志打印时候输出出来的。关键是后端返回的errcode。常见的几个错误码:
| errcode | 含义 | 排查方向 |
|---|---|---|
| 40029 | code无效 | wx.login重新获取code;code有且只能使用一次,不能重复发送 |
| 40163 | code已使用 | 把登录请求防重了,Redis里做一次性校验 |
| 40226 | 高风险等级用户 | 微信侧的安全风控拒绝,通常需要微信申诉 |
| -1 | 系统繁忙 | 稍后重试 |
我遇到最多的场景是:前端把旧的code缓存了,切换页面后再用,导致二次提交。所以登录模块里,我会在每次执行wx.login()时强制将code置空,拿到新code后再发起请求。
6.2 签名透明后,导出PNG出现白边或黑底
这个问题的根源有两个:
- 如果出现白边,说明背景阈值太苛刻,某些浅灰背景被当成了正常像素保留下来。解决方法:把阈值从240提高到250,且同时判断R/G/B三通道差值不超过20。
- 如果出现黑底,说明ARGB字节序处理反了。
BufferedImage.getRGB()返回的是int,高8位是Alpha,但有些库读取时按RGBA顺序解析。处理方法是用Color包装一下,永远不要手写(argb >> 16) & 0xFF这种位运算拿颜色,容易搞混字节序。
6.3 小程序Canvas签名板写字断断续续
这个大多数情况下不是代码问题,而是Canvas的ontouchmove事件触发频率过高,导致绘图指令积压。解决方案有两个:
- 在
ontouchmove回调中,用requestAnimationFrame做节流,保证每秒最多重绘60次 - 每次move事件尽量合并多个坐标点,用
ctx.quadraticCurveTo做贝塞尔曲线拟合,而不是逐个lineTo
贝塞尔拟合的代码可以参考下面这段:
javascript复制handleTouchMove(e) {
const touch = e.touches[0];
const rect = this.canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
// 使用中点二次贝塞尔曲线,让笔画更平滑
const midX = (this.lastX + x) / 2;
const midY = (this.lastY + y) / 2;
this.ctx.quadraticCurveTo(this.lastX, this.lastY, midX, midY);
this.ctx.stroke();
this.lastX = x;
this.lastY = y;
}
用这个方案之后,笔画在主流机型上都非常流畅,几乎不会出现断线或卡顿。
6.4 签名位置偏移与缩放比例问题
如果签署出来的PDF在小程序预览时位置是对的,但在电脑端打开PDF又发现位置偏移,多数是PDFBox写入坐标与PDF启动时的视图缩放不一致。要统一,需严格确保三端都用“比例坐标”换算,不要用某一端的屏幕像素值。
常见的错误就是用前端拿到的一个默认宽高比如750px去计算坐标,实际上PDF当前页宽可能是595pt(A4宽度),对应关系根本对不上。我上一节给出的xPer和yPer方案就是为了彻底规避这个问题,建议所有签署区统一这种定位协议。
6.5 合同签名后无法校验通过
如果在签署完成后,用我们自己的验签接口校验失败,先按下面的顺序排查:
- 用PDF在线阅读器打开文件,确认文件本身没有损坏
- 校验时使用的PDF字节是否与签署时完全一致,注意不要经过“另存为”或服务器中间转码
- 检查签名时固化到signature里的
pdfHash是不是最终保存到存储里的那份PDF的哈希
我之前踩过一个很隐蔽的坑:PDFBox在保存文档时会自动压缩/重新组织内部对象,使得哈希值发生变化。所以正确做法是:先完整执行签名操作,生成最终PDF字节数组,再对这个字节数组计算哈希,最后把哈希写入签名数据。如果反了顺序——先算原PDF的哈希,再用PDFBox签名——最终验签永远对不上,因为落盘的文件已经被PDFBox重写了一次。
7. 代码托管与工程结构建议
很多初学者拿到这套源码以后,最头疼的是不知道如何组织代码结构。我这里给出一个经过生产验证的Maven工程目录建议,供参考。
text复制electronic-contract-system/
├── pom.xml
├── src/main/java/com/example/contract/
│ ├── ContractApplication.java
│ ├── config/
│ │ ├── RedisConfig.java
│ │ ├── WebMvcConfig.java
│ │ └── WxMaConfig.java
│ ├── controller/
│ │ ├── ContractController.java
│ │ ├── SignController.java
│ │ ├── UserController.java
│ │ └── AuthController.java
│ ├── service/
│ │ ├── ContractService.java
│ │ ├── SignService.java
│ │ ├── SignVerifyService.java
│ │ └── WxLoginService.java
│ ├── mapper/
│ │ ├── ContractMapper.java
│ │ ├── SignRecordMapper.java
│ │ └── UserMapper.java
│ ├── entity/
│ │ ├── Contract.java
│ │ ├── SignRecord.java
│ │ └── SysUser.java
│ ├── utils/
│ │ ├── PdfConverter.java
│ │ ├── PdfSigner.java
│ │ ├── DigitalSignatureUtil.java
│ │ └── SignatureImageProcessor.java
│ └── dto/
│ ├── LoginRequest.java
│ ├── CreateContractRequest.java
│ └── SignRequest.java
└── src/main/resources/
├── application.yml
├── mapper/
└── static/
如果你打算二次开发,我建议从SignService读起,因为它是整套业务的核心编排层。整个电子合同的“有效签署”动作都集中在里面,把它的输入输出吃透,再去看其他模块就会顺畅得多。
再补充一个项目思考:不同企业对接电子合同时,遇到最多的需求其实是“对接自己的CA证书体系”或“走第三方存证平台”。这套源码在数字签名模块上预留了良好扩展点,目前是自签RSA密钥对,如果要换成国密SM2或对接有资质的CA机构,只需在DigitalSignatureUtil中调整算法常量,同时补充证书链的生成逻辑即可,整体业务代码不需要大改。
要把这套系统真正落地到生产环境,还有两个工程化问题避不开:一是数据库的表设计要为签署记录建立严格的审计索引,二是部署需配置HTTPS并保证微信小程序合法域名白名单包含你的后端地址。这些都属于上线前的基础配置,比在简历里写“有Spring Boot经验”要实在得多,也正是这套源码能带给你的最大价值。
