Spring Boot +微信小程序实现电子合同功能(电子签、手签)
一、需求介绍
项目采用电子合同对客户签约入驻管理流程进行标准化操作,并基于Spring Boot框架开发了完整的系统,主要包含两大核心模块:电子签章功能和合同管理模块。
功能项:电子签章功能 - 合同管理
功能1:合同模板管理
功能描述
- 模板创建与编辑 * 功能概述 :支持让用户创建新的合同模板或修改现有模板,并管理签章位置信息字段。
- 实现方式 :通过
contract_templates表存储模板数据,包含字段如模板名称、类型以及内容等详细信息。 - 用户界面 :在模板管理界面提供一个表单页面供用户输入必要的信息,并支持上传或编辑模板内容。
- 【
功能2:合同管理
功能描述
- 合同期望查看 * 功能概述 :本模块旨在支持显示所有合同的状态信息。
- 获取方式 :通过访问数据库中的
contract_contracts表,并根据其中存储的status字段获取当前状态数据。 - 展示界面 :在管理界面中展示所有合同的状态信息,并采用颜色或图标区分不同状态类别(包括草稿中、待签署中、部分完成后及已完成四种状态)。
- 操作处理 :本模块允许用户对处于待完成阶段的事务进行处理。
- 实现机制 :当事务被标记为完成时,在关联的事务记录上更新相应的字段。
- 同步更新 :系统会同步更新关联的事务记录信息,并将完成时间记录至事务跟踪表中。
- 历史追踪 :本模块能够完整记录每个事务的所有操作历史。
- 详细日志 :提供详细的历史操作日志供管理层查阅。
- 筛选功能 :该模块集成了高效的筛选和搜索功能组件。
- 条件支持 :支持基于名称字段或其他关键属性进行多维度条件筛选和排序组合查询需求
功能3:发起签署
功能描述
预览与提交
签署提醒与通知
功能4:合同任务
功能描述
- 系统定时任务的功能概述表明该系统会持续监控新注册的客户以及尚未完成协议的新客户,并自动生成相应的签订计划。
- 具体实现方法包括开发定时任务脚本以及利用调度工具在后台周期性调用
customers表的数据来识别并生成相应的签订计划。 - 操作界面设计方面显示该功能无需通过操作界面直接进行交互设置即可完成工作流程管理。
二、合同管理功能实现逻辑
1. 合同创建与模板选择
用户创建模板 :
- 当用户在创建新的合同模板时, 系统将在该
contract_templates表中新增一条记录, 并存储详细信息. - 实现代码 :
1. @PostMapping("/templates")
2. public ResponseEntity<String> createTemplate(@RequestBody ContractTemplate template) {
3. contractTemplateRepository.save(template);
4. return ResponseEntity.ok("Template created successfully");
5. }
用户选择模板 :
- 该系统允许用户在创建合同时选择已有的模板。
- 实现代码如下:
1. @PostMapping("/contracts")
2. public ResponseEntity<String> createContract(@RequestBody Contract contract) {
3. // 根据模板ID加载模板信息
4. ContractTemplate template = contractTemplateRepository.findById(contract.getTemplateId()).orElseThrow(() -> new ResourceNotFoundException("Template not found"));
5.
6. // 填写合同详细内容
7. contract.setTemplateName(template.getName());
8. contract.setStatus(0); // 初始状态为草稿
9.
10. if (template.requiresSignature()) {
11. // 如果模板要求签订,则生成客户签署任务
12. for (String clientId : contract.getClientIds()) {
13. Signature signature = new Signature();
14. signature.setContractId(contract.getId());
15. signature.setClientId(clientId);
16. signature.setStatus(0); // 初始状态为待签署
17. signatureRepository.save(signature);
18. }
19. }
20.
21. contractRepository.save(contract);
22. return ResponseEntity.ok("Contract created successfully");
23. }
2. 合同签署
- 客户签订合同:
- 用户在签订合同时, 系统会执行如下操作: 更新
contract_signatures表中对应记录的签署状态字段, 并基于该字段的状态信息动态修改contract_contracts表中相关记录的状态字段. - 具体实现细节请参见附录A。
1. @PostMapping("/signatures/{signatureId}")
2. public ResponseEntity<String> signContract(@PathVariable Long signatureId) {
3. Signature signature = signatureRepository.findById(signatureId).orElseThrow(() -> new ResourceNotFoundException("Signature not found"));
4.
5. // 更新签署状态
6. signature.setStatus(1); // 已签署
7. signature.setSignedAt(new Date());
8. signatureRepository.save(signature);
9. // 更新合同状态
10. updateContractStatus(signature.getContractId());
11. return ResponseEntity.ok("Contract signed successfully");
12. }
13.
14. private void updateContractStatus(Long contractId) {
15. List<Signature> signatures = signatureRepository.findByContractId(contractId);
16. int totalSignatures = signatures.size();
17. int signedCount = (int) signatures.stream().filter(s -> s.getStatus() == 1).count();
18. Contract contract = contractRepository.findById(contractId).orElseThrow(() -> new ResourceNotFoundException("Contract not found"));
19.
20. if (signedCount == totalSignatures) {
21. contract.setStatus(3); // 已签署
22. } else if (signedCount > 0) {
23. contract.setStatus(2); // 部分签署
24. } else {
25. contract.setStatus(1); // 待签署
26. }
27.
28. contractRepository.save(contract);
29. }
3. 合同状态更新
- 系统定时操作:
- 系统通过持续监控
contract_signatures表中的签署状态,并动态调整contract_contracts表中的相关记录来反映所有签署方的状态信息。 - 代码模块设计:
- 首先初始化必要的数据结构以支持后续的操作流程。
- 接着进入循环过程:遍历每个合同签名记录并执行相应的逻辑判断。
- 根据评估结果动态更新关键字段值以保持数据的一致性和完整性。
- 以上步骤确保了系统的高效运行和数据的安全性。
1. @Scheduled(fixedRate = 60000) // 每分钟执行一次
2. public void updateContractStatusTask() {
3. List<Contract> contracts = contractRepository.findAllByStatusNot(3); // 只处理未完成的合同
4. for (Contract contract : contracts) {
5. updateContractStatus(contract.getId());
6. }
7. }
4. 合同查询与管理
用户访问管理界面 :
- 当用户进入管理界面时, 系统会检索对应的所有合同信息.
- 具体实现代码如下:
1. @GetMapping("/contracts")
2. public ResponseEntity<List<Contract>> getAllContracts() {
3. List<Contract> contracts = contractRepository.findAll();
4. return ResponseEntity.ok(contracts);
5. }
用户查询合同详情 :
- 当用户试图获取特定合同的详细信息时, 系统通过索引化搜索机制快速定位到
contract_contracts表和contract_signatures表中的相关记录. - // 代码实现注释部分:
1. @GetMapping("/contracts/{contractId}")
2. public ResponseEntity<ContractDetail> getContractDetails(@PathVariable Long contractId) {
3. Contract contract = contractRepository.findById(contractId).orElseThrow(() -> new ResourceNotFoundException("Contract not found"));
4. List<Signature> signatures = signatureRepository.findByContractId(contractId);
5. ContractDetail detail = new ContractDetail();
6. detail.setContract(contract);
7. detail.setSignatures(signatures);
8.
9. return ResponseEntity.ok(detail);
10. }
管理员编辑或删除合同 :
管理员可对合同记录进行修改或清除contract_contracts表中的相关记录。
1. @PutMapping("/contracts/{contractId}")
2. public ResponseEntity<String> updateContract(@PathVariable Long contractId, @RequestBody Contract updatedContract) {
3. Contract contract = contractRepository.findById(contractId).orElseThrow(() -> new ResourceNotFoundException("Contract not found"));
4. contract.setName(updatedContract.getName());
5. contract.setDescription(updatedContract.getDescription());
6. contract.setEffectiveDate(updatedContract.getEffectiveDate());
7. contract.setExpirationDate(updatedContract.getExpirationDate());
8. contractRepository.save(contract);
9. return ResponseEntity.ok("Contract updated successfully");
10. }
11.
12. @DeleteMapping("/contracts/{contractId}")
13. public ResponseEntity<String> deleteContract(@PathVariable Long contractId) {
14. Contract contract = contractRepository.findById(contractId).orElseThrow(() -> new ResourceNotFoundException("Contract not found"));
15. contractRepository.delete(contract);
16. return ResponseEntity.ok("Contract deleted successfully");
17. }
三、合同电子文件签章处理
前端应用基于微信小程序平台,在PDF预览功能上相对不足。为提升整体处理能力,在后端系统中引入了专门的文件处理模块。
在pom.xml文件中配置处理PDF的相关依赖项。建议采用iText库作为解决方案;该方案因其强大的功能和简便的易用性而备受推崇。
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itext7-core</artifactId>
<version>7.1.15</version>
</dependency>
前端发送签名和公章
该系统需确保前端能够正确传输电子签名(一般为文件类型)和印章至 backend。这些电子签名或印章可能以Base64编码字符串或文件流的形式存在。如果采用Base64编码,则可以直接放置于 HTTP 请求体中;若采用 multipart/form-data 格式,则需将其作为附件上传至 backend。
后端接收并处理
基于Spring Boot框架构建一个Controller用于捕获来自前端的数据显示,并对PDF格式的文件进行解析和管理。
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Image;
import com.itextpdf.io.image.ImageDataFactory;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
@RestController
public class PdfSignatureController {
@PostMapping("/add-signature")
public String addSignature(@RequestParam("file") MultipartFile file,
@RequestParam("signature") MultipartFile signatureFile,
@RequestParam("stamp") MultipartFile stampFile,
@RequestParam("x") int x,
@RequestParam("y") int y) throws IOException {
// 将MultipartFile转换为File
File pdfFile = convertMultiPartToFile(file);
File signature = convertMultiPartToFile(signatureFile);
File stamp = convertMultiPartToFile(stampFile);
// 使用iText读取PDF文档
PdfDocument pdfDoc = new PdfDocument(new PdfReader(pdfFile), new PdfWriter("output.pdf"));
Document doc = new Document(pdfDoc);
// 添加签名
Image imgSignature = new Image(ImageDataFactory.create(signature));
imgSignature.setFixedPosition(x, y);
// 添加公章
Image imgStamp = new Image(ImageDataFactory.create(stamp));
imgStamp.setFixedPosition(x + 100, y - 50); // 假设公章位于签名右侧下方
// 将图像添加到PDF中
doc.add(imgSignature);
doc.add(imgStamp);
// 关闭文档
doc.close();
return "签名和公章已成功添加到PDF文件";
}
private File convertMultiPartToFile(MultipartFile multiPart) throws IOException {
File convFile = new File(multiPart.getOriginalFilename());
FileOutputStream fos = new FileOutputStream(convFile);
fos.write(multiPart.getBytes());
fos.close();
return convFile;
}
}
必须首先确定好放置签章的位置。 在文档中找到要放置的相应区域。 另外,在文档中可以插入空白区域。 后续内容将详细讲解。
1. 获取PDF页面尺寸
首先必须明确PDF页面的实际尺寸参数以便精确计算坐标范围iText能够提供获取PDF页面尺寸的具体方法
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.geom.PageSize;
// 打开PDF文档
PdfDocument pdfDoc = new PdfDocument(new PdfReader("input.pdf"), new PdfWriter("output.pdf"));
// 获取第一页
PdfPage page = pdfDoc.getFirstPage();
// 获取页面尺寸
PageSize pageSize = page.getPageSize();
float width = pageSize.getWidth();
float height = pageSize.getHeight();
System.out.println("Page Width: " + width);
System.out.println("Page Height: " + height);
pdfDoc.close();
2. 计算坐标
基于页面尺寸的大小参数值...即可计算出应有签名或公章的坐标位置;例如,在想将签名放置于页面底部中央的情况下...具体步骤如下:
float x = (width - signatureWidth) / 2; // 中心对齐
float y = 50; // 距离底部50个单位
3. 使用固定坐标
如果已经明确了具体的坐标信息,则可以直接采用这些数据。为了实现项目展示的效果目标,在指定区域放置标志物。将主要标志物放置于区域中心位置(155, 155),并附加辅助标记以突出关键点。
Image imgSignature = new Image(ImageDataFactory.create(signaturePath));
imgSignature.setFixedPosition(100, 100);
Image imgStamp = new Image(ImageDataFactory.create(stampPath));
imgStamp.setFixedPosition(200, 100);
4. 动态计算坐标
若需动态计算出坐标值,则可以根据页面内容或其他元素的位置来调整。以一个标题栏为例,在其高度设定为100个单位的情况下,则可将签名放置于标题栏下方居中位置以实现目标定位。
float titleBarHeight = 100;
float x = (width - signatureWidth) / 2;
float y = height - titleBarHeight - 50; // 标题栏下方50个单位
Image imgSignature = new Image(ImageDataFactory.create(signaturePath));
imgSignature.setFixedPosition(x, y);
5. 测试和调整
在实际应用过程中,可能涉及反复调试坐标值以确保签名及公章的位置达到预期要求。可借助相应的调试工具输出中间状态信息辅助排查问题。
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Image;
import com.itextpdf.io.image.ImageDataFactory;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
public class PdfSignatureUtil {
public static void main(String[] args) throws IOException {
// 输入和输出文件路径
String inputPdfPath = "input.pdf";
String outputPdfPath = "output.pdf";
String signaturePath = "signature.png";
String stampPath = "stamp.png";
// 打开PDF文档
PdfDocument pdfDoc = new PdfDocument(new PdfReader(inputPdfPath), new PdfWriter(outputPdfPath));
// 获取第一页
PdfPage page = pdfDoc.getFirstPage();
// 获取页面尺寸
Rectangle pageSize = page.getPageSize();
float width = pageSize.getWidth();
float height = pageSize.getHeight();
// 计算签名和公章的坐标
float signatureWidth = 100; // 假设签名宽度为100
float signatureHeight = 50; // 假设签名为50
float stampWidth = 100; // 假设公章宽度为100
float stampHeight = 100; // 假设公章高度为100
float xSignature = (width - signatureWidth) / 2; // 签名居中
float ySignature = 50; // 距离底部50个单位
float xStamp = (width - stampWidth) / 2; // 公章居中
float yStamp = ySignature - stampHeight - 10; // 公章在签名下方10个单位
// 创建Document对象
Document doc = new Document(pdfDoc);
// 添加签名
Image imgSignature = new Image(ImageDataFactory.create(signaturePath));
imgSignature.setFixedPosition(xSignature, ySignature);
// 添加公章
Image imgStamp = new Image(ImageDataFactory.create(stampPath));
imgStamp.setFixedPosition(xStamp, yStamp);
// 将图像添加到PDF中
doc.add(imgSignature);
doc.add(imgStamp);
// 关闭文档
doc.close();
pdfDoc.close();
System.out.println("签名和公章已成功添加到PDF文件");
}
}
6.坐标解释
在PDF文档中,默认情况下使用的是'用户单位'(User Units)。按照PDF标准规定,在这种情况下,默认情况下每个用户的单元长度为1/72英寸左右。换言之,在这种情况下,默认情况下每个用户的单元长度约为一个点(point)的大小。因此,在这种情况下,默认情况下使用的坐标系统通常基于点进行表示
详细解释
用户单位(User Units) :
* PDF中的默认用户单位是1/72英寸,即1点(point)。
* 1英寸 = 72点。
* 1厘米 ≈ 28.346点。
坐标系 :
* PDF页面的坐标系原点(0, 0)位于页面的左下角。
* x轴向右增加,y轴向上增加。
1、示例
假设有一个A4纸大小的PDF页面,A4纸的尺寸是210mm × 297mm。
- 将毫米转换为点:
- 宽度:210mm * 28.346 ≈ 595.28点
- 高度:297mm * 28.346 ≈ 841.89点
因此,A4纸的PDF页面尺寸大约是595.28点 × 841.89点。
2、坐标示例
假设希望将公章放在页面的中心位置:
- 页面宽度:595.28点
- 页面高度:841.89点
- 公章宽度:100点
- 公章高度:100点
计算公章的中心坐标:
- x坐标:(595.28 - 100) / 2 = 247.64点
- y坐标:(841.89 - 100) / 2 = 370.945点
7.多页pdf处理
处理多页PDF文件时,在某些情况下需要在某些页面上添加签名或公章。iText库提供了多种手段来访问和操作PDF文件中的不同页面。
import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfReader;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.kernel.pdf.PdfPage;
import com.itextpdf.kernel.geom.Rectangle;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Image;
import com.itextpdf.io.image.ImageDataFactory;
import java.io.IOException;
public class PdfSignatureUtil {
public static void main(String[] args) throws IOException {
// 输入和输出文件路径
String inputPdfPath = "input.pdf";
String outputPdfPath = "output.pdf";
String signaturePath = "signature.png";
String stampPath = "stamp.png";
// 打开PDF文档
PdfDocument pdfDoc = new PdfDocument(new PdfReader(inputPdfPath), new PdfWriter(outputPdfPath));
// 获取第2页
int pageNumber = 2;
PdfPage page = pdfDoc.getPage(pageNumber);
// 获取页面尺寸
Rectangle pageSize = page.getPageSize();
float width = pageSize.getWidth();
float height = pageSize.getHeight();
// 假设公章的位置在 (100, 100),宽度为 100,高度为 100
float xSignature = 100;
float ySignature = 100;
float signatureWidth = 100;
float signatureHeight = 50;
float xStamp = 200;
float yStamp = 100;
float stampWidth = 100;
float stampHeight = 100;
// 创建Document对象
Document doc = new Document(pdfDoc);
// 添加签名
Image imgSignature = new Image(ImageDataFactory.create(signaturePath));
imgSignature.setFixedPosition(pageNumber, xSignature, ySignature);
// 添加公章
Image imgStamp = new Image(ImageDataFactory.create(stampPath));
imgStamp.setFixedPosition(pageNumber, xStamp, yStamp);
// 将图像添加到PDF中
doc.add(imgSignature);
doc.add(imgStamp);
// 关闭文档
doc.close();
pdfDoc.close();
System.out.println("签名和公章已成功添加到PDF文件的第 " + pageNumber + " 页");
}
}
8.pdf转图片处理
在小程序内预览PDF文件时,默认采用Web-View方式;但开通业务域名后会受限于域名端口限制,在线访问时无法直接查看PDF文件;此时可将PDF转换为图像并进行展示
在 pom.xml 中添加以下依赖
<dependencies>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.27</version>
</dependency>
</dependencies>
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
public class PdfToSingleImageConverter {
public static void main(String[] args) {
String inputPdfPath = "input.pdf";
String outputImagePath = "output.png"; // 确保文件名包含扩展名
int dpi = 300; // DPI (dots per inch)
int pageSpacing = 20; // 每页之间的间距
try (PDDocument document = PDDocument.load(new File(inputPdfPath))) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
int numberOfPages = document.getNumberOfPages();
int totalHeight = 0;
int maxWidth = 0;
// 计算总高度和最大宽度
for (int page = 0; page < numberOfPages; ++page) {
BufferedImage bim = pdfRenderer.renderImageWithDPI(page, dpi, ImageType.RGB);
totalHeight += bim.getHeight();
maxWidth = Math.max(maxWidth, bim.getWidth());
}
// 加上每页之间的间距
totalHeight += (numberOfPages - 1) * pageSpacing;
// 创建最终的合并图像
BufferedImage combinedImage = new BufferedImage(maxWidth, totalHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = combinedImage.createGraphics();
// 将每一页的图像绘制到合并图像中
int yPosition = 0;
for (int page = 0; page < numberOfPages; ++page) {
BufferedImage bim = pdfRenderer.renderImageWithDPI(page, dpi, ImageType.RGB);
g2d.drawImage(bim, 0, yPosition, null);
yPosition += bim.getHeight();
if (page < numberOfPages - 1) {
yPosition += pageSpacing; // 添加每页之间的间距
}
}
g2d.dispose();
// 保存合并后的图像
File outputFile = new File(outputImagePath);
ImageIO.write(combinedImage, "PNG", outputFile);
System.out.println("PDF pages combined into a single image and saved as: " + outputFile.getAbsolutePath());
} catch (IOException e) {
e.printStackTrace();
}
}
}
至此为止,则已完成对文件的处理工作。随后,请将处理完成的文件上传至服务器系统中。最后返回前端请求地址即可完成整个流程。
至此为止,则已完成对文件的处理工作。随后,请将处理完成的文件上传至服务器系统中。最后返回前端请求地址即可完成整个流程。
