java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java电子签名系统

基于Java编写一个电子签名系统(附示例代码)

作者:菩提风

电子签名作为身份认证与数据防篡改的关键技术,在电子合同、金融单据等场景中已成为必备基础,本文会基于Java实现一个简单的电子签名系统,感兴趣的小伙伴可以了解下

去年我帮一家企业落地了一套基于Java的电子合同电子签名系统,签署端直接做成了微信小程序。业务方当时的诉求很清晰:销售合同、补充协议、采购确认单,不能总靠邮寄和线下盖章,一群人出差在外,合同要签就得等回公司,效率低得让人头疼。系统上线之后,从发起合同、实名认证、在线签署,到最终归档存证,整个流程全部在线上完成。

这篇文章我不讲概念,直接把我源码里的核心模块拆开聊,包括透明印章怎么生成、签名文件如何防篡改、小程序端登录和签署链路怎么打通,以及我在实际开发里踩过的那些坑。代码片段都是可以落地的,拿过去改改就能用。适合正在做电子合同、电子签章、小程序签署类项目的人参考,也适合想搞懂“电子签名到底怎么实现”的后端开发者。

这里先提醒一句:电子签名是强合规场景,技术上的“能用”只是第一步,业务上能不能作为有效凭证,关键要看流程设计,这一点后面会专门展开。

1. 项目整体架构与设计思路

1.1 核心模块拆分:后端、管理端、小程序端各自干什么

这套系统的模块划分,我按“谁在用、用来干什么”来拆,而不是按技术层硬分。整体上分成三块:管理端、服务端、小程序签署端。

管理端是给企业内部用的,做合同模板管理、印章管理、发起合同、查看签署进度、下载归档文件。操作对象是合同和印章,权限控制比较重,公司里面不是所有人都能发起合同,也不是所有人都有权使用公章。

服务端承担所有核心逻辑,包括合同文件解析、印章图片处理、签名摘要生成、签署记录落库、存证上报。小程序端只做一件事:让签署人完成实名认证、查看合同内容、确认签署位置并提交签署。小程序端越轻越好,复杂的文件处理和签名计算尽量放在后端,否则小程序包体积和渲染性能都会出问题。

1.2 技术栈选型:为什么是Spring Boot + MySQL + Redis + MinIO

很多人一听说“电子合同系统”,第一反应就是上微服务、上消息队列、上一堆中间件。我这边实际落地用的是单体应用,Spring Boot 2.7 + MySQL 8.0 + Redis 7.0 + MinIO,部署也简单,一台4核8G的云服务器加一个对象存储桶就够了。

选单体不是偷懒,而是基于业务规模做的判断。电子合同系统的核心瓶颈不在并发,而在文件处理和签署流程的状态管理。以实际场景为例,一个几十人规模的销售团队,一天最多发起几百份合同,这个量级用单体完全撑得住。如果一开始就拆微服务,服务间调用的网络开销、数据一致性问题、部署复杂度会直接拖垮开发进度,尤其是合同签署这种对数据一致性要求极高的业务,分布式事务的成本远大于收益。

MySQL负责合同表、签署记录表、用户表等结构化数据,存的是业务事实。Redis负责会话缓存和短信验证码,比如用户登录后的小程序token、实名认证时下发的验证码,过期时间设置得很短。MinIO负责存PDF合同原件、印章图片、签署后生成的合同快照,对象存储天然适合存这种“只读多、写入少、偶尔需要覆盖更新”的文件。

1.3 电子合同签署流程的业务闭环:从发起、签署到存证

整个签署流程我画成一条状态链,核心是五个状态:草稿、待签署、签署中、已完成、已归档。

发起人先在管理端上传一份PDF合同,或者从模板库生成合同,然后选择需要签署的对方(个人或企业),系统生成签署任务,此时合同状态是“待签署”。对方点开小程序里的签署链接,先完成实名认证,然后看到合同预览,确认内容无误后,在合同指定位置点“签署”。后端收到签署请求后会做一系列校验——签名是否有效、合同是否在有效期内、用户有没有重复签署——全部通过后生成签署快照,合同状态变成“已完成”,最后异步归档到对象存储并记录存证信息。

这套闭环里最容易忽略的是“存证”这一步。很多开发者在做电子合同时,觉得签完字、生成图片就结束了,但实际业务中如果发生纠纷,法院要求你提供合同未被篡改的证明,拿不出来就没有说理的地方。所以我在系统里对每一份签署完成的合同,都会把PDF的哈希值、签署时间、签署人身份标识、印章图片哈希打包成一个存证记录,输出到独立的存证表里,保证后续有据可查。

2. 电子签名后端实现原理与关键代码

2.1 一张“透明印章”是怎么做出来的:背景变透明的完整思路

“电子签名怎么把背景变透明”是很多人搜这个问题时卡住的地方,我一开始也在这个细节上折腾了很久。直接说结论:印章源文件必须用PNG格式并且带透明通道,不能用JPG。JPG天生不支持透明,哪怕你把白色背景抠掉了,保存成JPG又会重新糊上一层白底。

实操中有两种情况。第一种是设计人员能直接提供透明PNG,这是最理想的,代码里直接使用就行。第二种是业务方只给了白底红色印章的JPG扫描件,这时候需要代码处理。处理思路很简单:像素遍历,判断每个像素的背景色,如果接近白色就置为透明,否则保留原有颜色。

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;

public class SealImageUtils {

    /**
     * 去除印章图片的白色背景,返回透明PNG
     * @param srcPath  原图路径,通常是白底JPG
     * @param destPath 输出路径,建议PNG
     */
    public static void removeWhiteBackground(String srcPath, String destPath) throws Exception {
        BufferedImage src = ImageIO.read(new File(srcPath));
        int w = src.getWidth();
        int h = src.getHeight();
        // 创建ARGB格式图片,支持透明通道
        BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);

        for (int x = 0; x < w; x++) {
            for (int y = 0; y < h; y++) {
                int rgb = src.getRGB(x, y);
                int a = (rgb >> 24) & 0xff;
                int r = (rgb >> 16) & 0xff;
                int g = (rgb >> 8) & 0xff;
                int b = rgb & 0xff;
                // 常见的白色背景是RGB(255,255,255),考虑到扫描件可能有噪点,阈值放宽到240
                // 阈值调得太大,印章边缘的浅红色区域也会被误删,导致印章颜色发白
                if (r > 240 && g > 240 && b > 240) {
                    out.setRGB(x, y, 0x00ffffff); // 完全透明
                } else {
                    out.setRGB(x, y, rgb);
                }
            }
        }
        ImageIO.write(out, "png", new File(destPath));
    }
}

这段代码本身不复杂,真正麻烦的是边缘处理。市面上的扫描件像素质量参差不齐,印章边缘容易出现半透明的浅红色,直接保留会让最终合成效果有“白边”。我的处理方案是加一个二次处理逻辑:如果像素点不是白色,但红色通道低于180,就按比例降低透明度。这个“按比例”的阈值没有标准答案,需要你看实际图调,我在项目里换过三轮图才调出最舒服的效果。

注意:印章图片分辨率必须高于合同展示分辨率。如果合同页面显示宽度是800像素,印章源图至少要有1600像素宽,这样合成时即使缩放也不会发虚。别为了省体积用200像素的小图,最后合成的印章在屏幕上会糊成一团。

2.2 合同文件签名与防篡改:哈希摘要、可信时间戳与数字证书链路

电子签名不是“把印章图片贴上PDF”那么简单,真正有法律效力的电子签名,核心是“内容防篡改”和“签署人身份认证”。

内容防篡改的底层逻辑是哈希摘要。PDF文件本质上是一串字节,任何微小的改动——哪怕只改了合同编号里的一个数字——都会导致整份文件的SHA-256哈希值翻天覆地地变化。所以在用户确认签署之前,后端会把原始PDF的哈希值算出来存库,用户点“签署”之后,系统再次计算哈希值,两者对上才允许签名,对不上就直接终止流程,提示“合同内容已被篡改”。

import java.security.MessageDigest;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HexFormat;

public class HashUtils {

    /**
     * 计算文件的SHA-256摘要,用于合同防篡改校验和存证
     */
    public static String sha256(Path filePath) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] fileBytes = Files.readAllBytes(filePath);
        byte[] hashBytes = digest.digest(fileBytes);
        return HexFormat.of().formatHex(hashBytes);
    }
}

哈希只能保证“文件没变”,不能保证“这是某某人签的”,所以还需要数字证书。电子签名系统在实际部署时会上CA证书体系:企业用户拿到一张代表企业身份的数字证书,签署时用证书对应的私钥对合同哈希值做签名,验签时用公钥验证。这就像你写保证书,签字代表你本人意愿,私钥比你的手写签名更难伪造。

我在项目中实际使用了两个层面的实现:第一层是“业务签名”,用企业自己的RSA密钥对合同哈希签名,保证内部链路可追溯;第二层是“第三方可信时间戳”,签署完成时把合同哈希发给第三方时间戳服务,由对方返回带时间戳的签名结果,证明合同在某个时间点之前已经存在且未被改动。时间戳服务这层一般由正规CA机构或存证平台提供,生产环境建议走这个链路,司法举证时会省很多事。

2.3 关键Java代码片段:PDF合成签名、生成存证摘要

合同文件我处理过两种主流方案,一是直接操作PDF底层元数据,二是把PDF渲染成图片再合成印章。实际项目里选的是后者:把PDF每一页转成高清图片,在指定坐标贴上透明印章,再拼回PDF。这种方式对用户来说最直观——你在手机端滑动看到的就是真实合同内容,所见即所得。

贴印章的坐标换算有一个坑:PDF的坐标系原点在左下角,图片坐标系原点在左上角,y轴方向完全相反。下单印章位置时,小程序端上传的是“从上往下数第几行、从左往右第几列”,但后端渲染PDF时必须先做一次坐标翻转,否则印章位置会垂直镜像,原本盖在页面下部的印章会跑到页面上部。

合成代码核心片段如下:

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;

public class ContractSignService {

    /**
     * 将透明印章合成到PDF指定位置
     * @param pdfPath    合同原件路径
     * @param sealPath   透明印章PNG路径
     * @param targetPath 输出合同路径
     * @param pageIndex  要盖章的页码,从0开始
     * @param x          印章左上角X坐标(PDF坐标系)
     * @param y          印章左上角Y坐标(PDF坐标系)
     */
    public static void addSealToPdf(String pdfPath, String sealPath,
                                    String targetPath, int pageIndex,
                                    float x, float y) throws Exception {
        try (PDDocument doc = PDDocument.load(new File(pdfPath))) {
            PDImageXObject sealImage = PDImageXObject.createFromFile(sealPath, doc);
            PDPageContentStream contentStream = new PDPageContentStream(
                    doc, doc.getPage(pageIndex),
                    PDPageContentStream.AppendMode.APPEND, true, true);
            // 这里x, y需要由业务层做坐标翻转计算
            contentStream.drawImage(sealImage, x, y, 160, 160);
            contentStream.close();
            doc.save(targetPath);
        }
    }
}

坐标翻转逻辑单独抽一个工具方法:

public static float flipY(float uiY, float pageHeight, float sealHeight) {
    // uiY是左上角坐标体系的Y值,pageHeight是PDF页面的总高度
    // 返回的是PDF坐标系中的Y值
    return pageHeight - uiY - sealHeight;
}

2.4 权限与合规要点:实名认证、意愿认证、印控权限

代码写得再漂亮,合规这块没做好,系统就是个玩具。我在项目中把合规要求做成硬性流程,不是可选项。

实名认证这一层,个人用户要走身份证OCR识别加人脸比对,企业用户要走营业执照识别加法人/经办人身份验证。在实际项目中我不建议自己做这套,接一个合规的实名认证服务商,省时省力还降低风险。实名信息拿到后,后续每次签署都绑定到这份实名信息上。

意愿认证这一层,签署人在点“签署”按钮之前,必须完成短信验证码确认。我的做法是:签署请求发出后,后端下发一条短信验证码,用户输入验证码后才真正执行签名逻辑。这样能证明“是本人操作”,而不是被旁边人代点的。

印控权限是企业管理侧的刚需。公章不是谁都能用的,系统里给印章设置使用权限,只有被授权的用户才能调用指定印章发起签署。每次印章使用都会记录操作人、时间、所签合同编号,形成完整的审计日志。这个审计日志同样要存证,万一以后出现“这章是不是被公司内部人盗用”的争议,能拿出数据说话。

3. 小程序端签署流程的实现

3.1 微信登录态获取与常见失败原因

小程序端登录,我用的标准流程是wx.login -> 获取code -> 后端拿着code去微信接口换openid和session_key,然后生成自有token返回前端。这个链路本身不复杂,但我见过太多人在这一步翻车,包括我自己。

最常见的失败场景是:小程序开发者工具里能登录,真机上登录不了。原因是开发者工具有“不校验合法域名”的开关,默认开着,模拟器里所有请求都能发出去,真机上一旦域名没配到微信公众平台的后台白名单里,请求直接失败。而且request接口的域名在微信后台设置后需要一两分钟才能生效,改完别急着测,先等一等。

另一个高频问题是code重复使用。微信的code是短时凭证,有效期只有五分钟,而且只能用一次。有一次我的代码里登录成功后没有把code及时置空,用户在网络波动的场景下连续点了两次登录,第二次请求携带的是同一个code,微信接口返回错误码40029。解决办法很简单:每次登录都重新调wx.login,后端拿到code后记得在逻辑上做一次性校验。

下面是前端登录的标准代码:

// 小程序端登录
wx.login({
  success: async (res) => {
    if (!res.code) {
      wx.showToast({ title: '登录失败', icon: 'none' });
      return;
    }
    // 把code传给后端,后端换取session信息
    const loginResult = await new Promise((resolve, reject) => {
      wx.request({
        url: 'https://api.yourdomain.com/auth/login',
        method: 'POST',
        data: { code: res.code },
        success: resolve,
        fail: reject
      });
    });
    const { token, userInfo } = loginResult.data.data;
    wx.setStorageSync('token', token);
    wx.setStorageSync('userInfo', userInfo);
  }
});

后端换session的Java代码我就不贴完整了,核心是请求微信的jscode2session接口,把返回的openid和unionid存到用户表,同时生成你自己的token返回给前端。注意appsecret只能放在后端,绝对不能打进小程序包里。

3.2 签署页交互与合同展示实现

合同展示我用的是后端把PDF渲染成高清图片,前端用页面轮播的方式看图。这种方案简单可靠,不需要在小程序里集成PDF解析SDK,也不存在不同手机系统上渲染效果不一致的问题。每页合同图片控制单张不超过800KB,超过就压缩,否则在小程序里滑动会明显卡顿。

签署位置的选择有两种交互方式。第一种是用户手动拖拽印章到指定位置,体验灵活但容易误操作;第二种是发起方在发起合同时预先用矩形坐标标注好“此处需要签署”,签署人只需要点击“确认签署”按钮,系统自动在预定位置盖章。我项目里用的是第二种,更符合企业合同签署的习惯——毕竟合同的排版不是签收人随便定的。

签署按钮的点击流程要加上防重复提交标志。用户双击“确认签署”按钮,如果前端不做拦截,后端同一份合同会收到两次签署请求,直接造成重复签署记录。我在前端用一个isSubmitting变量控制,首次点击之后立即置为true,请求结束后再恢复;后端同样做了幂等校验,同一个签署任务在已签署状态下再收到请求,直接返回“已签署”而不是重复执行。

3.3 调用后端签名接口、回显签署结果的实现

签署接口的调用分两步走。第一步:前端把当前签署人信息、合同编号、签署位置一起发给后端,后端校验通过后下发短信验证码。第二步:用户输入验证码,前端携带验证码再次请求“确认签署”接口,后端完成PDF合成、哈希存证、签署记录落库,然后向前端返回签署后的合同文件地址。前端拿到地址后展示“签署成功”页面,并提供预览和下载入口。

前端签署提交的核心代码:

submitSign() {
  if (this.isSubmitting) return;
  this.isSubmitting = true;
  wx.showLoading({ title: '签署中...' });
  
  const contractNo = this.contractInfo.contractNo;
  const token = wx.getStorageSync('token');
  
  wx.request({
    url: 'https://api.yourdomain.com/sign/confirm',
    method: 'POST',
    header: { 'Authorization': `Bearer ${token}` },
    data: {
      contractNo: contractNo,
      verifyCode: this.verifyCode
    },
    success: (res) => {
      if (res.data.code === 0) {
        this.signedFileUrl = res.data.data.fileUrl;
        wx.showToast({ title: '签署成功', icon: 'success' });
        // 跳转签署成功页
        wx.navigateTo({
          url: `/pages/sign-success/index?fileUrl=${this.signedFileUrl}`
        });
      } else {
        wx.showToast({ title: res.data.msg, icon: 'none' });
      }
    },
    complete: () => {
      this.isSubmitting = false;
      wx.hideLoading();
    }
  });
}

这里有一个非常值得注意的细节:签署完成后的fileUrl必须是可以被小程序访问的合法域名地址,而且这个地址不能带随机内网端口。我们曾经用过MinIO默认的9000端口,但小程序后台配置的合法域名不支持自定义端口,必须通过Nginx反向代理把9000端口映射到80或443,否则预览会直接打不开。

3.4 小程序项目里几个容易忽略的配置坑

小程序端配置上我踩过几个坑,统一列在这里。第一个是页面路径问题:签署页面必须在小程序后台配置为“页面路径”,否则分享出去的签署链接打不开。第二个是分包问题:小程序主包体积限制2MB,合同图片和字体资源最好放到分包或远程服务器,否则提审会被打回。第三个是隐私协议问题:涉及身份证识别、人脸比对的功能,都需要在微信公众平台填写用户隐私保护指引,否则真机上摄像头能力会被限制。

举个例子,我们的签署页面需要调用用户摄像头做人脸识别,在开发工具里一切正常,真机测试时直接提示“无法使用摄像头”。排查了好久才发现,是忘了在微信公众平台提交“隐私协议”并勾选“摄像头”权限。这类问题不报错、不弹窗,唯一的排查手段就是对比开发者工具和真机的权限配置差异。

4. 源码结构与数据库设计

4.1 源码目录结构:一个可直接落地的Spring Boot工程

项目源码的目录结构我保持简洁,没有用过度设计的包名分层,按业务模块划分。核心结构如下:

src/main/java/com/example/contract/
├── controller/          // 接口层
│   ├── AuthController.java       // 登录、实名认证
│   ├── ContractController.java   // 合同发起、签署任务
│   └── SignController.java       // 确认签署、验证码
├── service/              // 业务层
│   ├── ContractService.java      // 合同状态流转
│   ├── SealService.java          // 印章处理与合成
│   ├── SignService.java          // 签署核心逻辑
│   └── CertifyService.java       // 实名认证调用
├── repository/          // 数据访问层
│   ├── ContractRepository.java
│   ├── SignRecordRepository.java
│   └── UserRepository.java
├── entity/              // 数据库实体
│   ├── Contract.java
│   ├── SignRecord.java
│   ├── SealInfo.java
│   └── UserInfo.java
├── config/              // 配置类
│   ├── RedisConfig.java
│   ├── MinioConfig.java
│   └── WebMvcConfig.java
└── utils/               // 工具类
    ├── HashUtils.java
    ├── SealImageUtils.java
    └── PdfUtils.java

这个结构没有把controller、service、repository强行按模块拆包,原因很简单:项目规模不大,拆太细反而增加查找成本。如果后续计划扩展成多产品线,再考虑按业务域拆分,比如contract-domain、user-domain、certify-domain。

4.2 核心数据表与合同状态机设计

数据库我设计了六张核心表:用户表、企业表、印章表、合同表、签署记录表、存证表。这里列出其中最关键的三张表。

合同表字段设计如下:

CREATE TABLE contract (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  contract_no VARCHAR(32) NOT NULL COMMENT '合同编号',
  title VARCHAR(128) NOT NULL COMMENT '合同标题',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0草稿,1待签署,2签署中,3已完成,4已撤销,5已过期',
  file_url VARCHAR(512) COMMENT '合同原件地址',
  pdf_hash VARCHAR(64) COMMENT 'PDF SHA-256摘要',
  created_by BIGINT COMMENT '创建人ID',
  created_at DATETIME COMMENT '创建时间',
  expired_at DATETIME COMMENT '签署截止时间',
  UNIQUE KEY uk_contract_no (contract_no),
  KEY idx_status (status),
  KEY idx_created_by (created_by)
) COMMENT '合同信息表';

签署记录表:

CREATE TABLE sign_record (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  contract_id BIGINT NOT NULL COMMENT '合同ID',
  user_id BIGINT NOT NULL COMMENT '签署人ID',
  seal_id BIGINT COMMENT '使用的印章ID',
  sign_status TINYINT NOT NULL DEFAULT 0 COMMENT '签署状态: 0待签,1已签,2拒绝',
  sign_x DECIMAL(8,2) COMMENT '签署位置X',
  sign_y DECIMAL(8,2) COMMENT '签署位置Y',
  page_index INT COMMENT '签署页码',
  hash_value VARCHAR(64) COMMENT '签署后合同哈希',
  timestamp_token VARCHAR(256) COMMENT '可信时间戳凭证',
  sign_time DATETIME COMMENT '签署时间',
  UNIQUE KEY uk_contract_user (contract_id, user_id),
  KEY idx_user_id (user_id)
) COMMENT '签署记录表';

合同的状态机我建议用显式状态流转,不要搞隐式的“根据时间推断状态”。比如用户发起一份七天有效期的合同,到期后状态应该是“已过期”,但这个状态不能靠查询时动态判断,而应该由一个定时任务在每天凌晨把过期扫描出来的合同状态改成“已过期”并落库。这样做的好处是,后续报表统计、列表筛选、提醒推送都可以直接查状态字段,不用每次都跑时间判断逻辑。

4.3 定时任务与过期合同的中台处理

电子合同系统里每天都有大量待签合同过期,没人处理就会堆积成垃圾数据。我在项目里用Spring的@Scheduled注解写了一个定时任务,每三十分钟扫描一次合同表,把所有expired_at小于当前时间且状态为“待签署”的合同批量改成“已过期”,同时给发起人推送一条提醒。

@Component
public class ContractExpireTask {

    private static final Logger log = LoggerFactory.getLogger(ContractExpireTask.class);

    @Scheduled(cron = "0 */30 * * * ?")
    public void markExpiredContracts() {
        int updated = contractRepository.markExpired(new Date());
        if (updated > 0) {
            log.info("本次扫描共标记 {} 份合同为已过期", updated);
        }
    }
}

这里的“批量修改”我用的是SQL层面的UPDATE,而不是先把数据捞回内存再逐条update。道理很简单:上万条待签合同,逐条更新会产生大量数据库连接开销,一条UPDATE语句把状态一次改到位,性能高好几十倍。注意这里不能把所有过期合同一并修改,应该带上“状态必须是待签署”的条件,否则已签署或已撤销的合同也会被覆盖。

5. 常见问题与排查技巧实录

5.1 高频率问题速查表

把这几个月外部反馈和内部测试遇到的问题做成了一张速查表,方便你出问题时对照排查:

问题现象可能原因排查方向
小程序登录报“获取登录后的微信用户失败”code重复使用、域名白名单未生效、appid与secret不匹配检查请求是否携带了新code,后台域名是否配置且已生效
印章合成后背景发黑贴图时用了JPEG格式或未保留Alpha通道确认印章是PNG且带透明,合成用AlphaComposite
合同预览图片加载慢单张图片过大后端压缩图片到单张800KB以内
中文合同内容乱码PDF字体缺失渲染时指定中文字体,如Noto Sans CJK
签署提交后页面一直转圈后端幂等锁未释放检查签署接口的分布式锁或数据库唯一索引
小程序真机预览打不开合同对象存储地址端口号非443Nginx代理到443并配置合法域名
短信验证码收不到签名模板审核未通过检查短信服务商审核状态和模板内容

5.2 排查案例一:小程序登录后获取不到微信用户信息

这个问题的表现很典型:小程序开发工具里一切正常,真机上一登录就提示“获取登录后的微信用户失败”。我排查了三个层面。

先是查看后端日志,发现openid字段返回为空,说明后端用code换session时就没拿到用户身份。接着我单独用curl请求微信接口,把code替换成真实值,发现同样的code在第二次请求时返回了40029错误码。这就说明问题不在后端,而是前端往请求体里塞了同一个code。再看前端代码,果然在页面onLoad里执行了一次wx.login,用户点击登录按钮时又执行了一次,两次共用了一个code变量。处理方案:wx.login只在用户主动登录时调用一次,不放进onLoad生命周期里。

第二层面是域名白名单问题。开发环境WiFi下请求正常,切到4G网就失败,基本可以断定是域名配置问题。这里有个细节:微信小程序request接口的合法域名一个都不能少,如果后端接口和静态资源域名不同,两边都要配置。配置完还不行,就检查是不是没有发布线上版本,本地开发版不会真正走域名校验逻辑。

5.3 排查案例二:印章合成后背景发黑或透明度丢失

这个问题我们大概烧了两天时间才彻底解决。

最开始的表现是:印章贴到PDF上,整个矩形区域都是纯黑色,印章内容完全看不到。我首先怀疑是PDImageXObject加载透明的ARGB图片出了兼容性问题。看了几遍PDFBox文档,换成JPEG色空间处理,结果PNG透明区域全变成了白色,还不如黑色。

后来我去扒了PDFBox的API,发现PDImageXObject.createFromFile默认把图片作为JPX(JPEG 2000)色空间处理,而透明通道在这种模式下会被丢弃。解决方法是先让Java的ImageIO读入PNG,再转换成PDImageXObject,同时指定ARGB格式:

BufferedImage bufferedImage = ImageIO.read(new File(sealPath));
PDImageXObject sealImage = LosslessFactory.createFromImage(doc, bufferedImage);

把createFromFile换成LosslessFactory之后,黑色和白色背景全部消失,透明通道正确保留。强烈建议用PDFBox做大位图合成时不要走createFromFile的老路,遇到透明图就主动换成LosslessFactory。

5.4 排查案例三:合同大文件上传超时或内存溢出

合同PDF一般不会太大,但有时候用户会上传带扫描附件的合同,一个文件能到20MB以上。开始我在后端直接读byte[]再处理,跑了几次出现了OutOfMemoryError: insufficient memory的报错。

处理方案分三层,第一层是Spring Boot的配置文件里把上传大小限制调大,同时配合Nginx的client_max_body_size,否则Nginx层就会先拦截掉大文件:

spring:
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 50MB

第二层是Java内存参数调整,开发环境默认的堆内存太小,JVM参数调整为:

java -Xms512m -Xmx2048m -jar contract-system.jar

第三层是从代码层面优化:不要一次性把整份文件读入内存,而是用InputStream流转发到MinIO,或者优先用分片上传的方式。我在项目里对大文件单独走了一个上传接口,前端先拿到MinIO的预签名URL,再把文件直接传到对象存储,后端只保存文件地址,完全避开文件字节流在应用内存里过一遍的问题。这个方案实测下来20MB的文件上传稳定在3秒以内,JVM内存也没有明显波动。

最后分享一点个人体会。做电子合同系统,技术难点不在“签名”这个动作本身,而是在这之前和之后的链路设计:签名之前要保证身份真实,签名之中要保证意愿真实,签名之后要保证内容不被篡改。把这三条链路打通了,代码哪怕写得朴素一点,系统在业务上也是可靠的。后面如果你要做多端合同签署、合同模板可视化编辑、用印审批流这类功能,这套架构的扩展空间也足够,不会让你推倒重来。

以上就是基于Java编写一个电子签名系统(附示例代码)的详细内容,更多关于Java电子签名系统的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文