您的位置:首页 >SpringBoot中实现文档生成与电子合同创建的完整指南
发布于2026-07-30 阅读(0)
扫一扫,手机访问
一句话总结整个流程的核心逻辑:把本地的业务数据,渲染成一份正式的PDF文档,然后上传到文件服务器获取公网链接,最后把这个链接提交给外部签约平台,用来创建一份电子合同。

用现实世界打个比方:你写好一份合同→打印出来→放到共享柜台上→把柜台号告诉对方,说“去那里签字”。
对应到技术流程就是:组装数据→渲染PDF→上传OSS获取URL→调用签约平台API(传入URL)→平台创建合同。
这里有个关键问题:PDF文件少说几百KB,多则几MB。
假设有一个电商系统,用户点击“开具发片”后,需要完成下面这些步骤:
这一步的核心,是把数据库中分散的数据整合成模板需要的结构:
// 发片渲染所需的数据结构
@Data
public class InvoiceRenderData {
private String invoiceNo; // 发片号
private String buyerName; // 购方名称
private String sellerName; // 销方名称
private BigDecimal totalAmount; // 总金额
private String issueDate; // 开票日期
private List items; // 商品明细行
}
@Data
public class InvoiceItem {
private String productName; // 商品名
private Integer quantity; // 数量
private BigDecimal unitPrice; // 单价
private BigDecimal amount; // 金额
}
组装逻辑:
// 从数据库查出原始数据 Order order = orderRepository.findById(orderId); ListorderItems = orderItemRepository.findByOrderId(orderId); Customer customer = customerRepository.findById(order.getCustomerId()); // 转换为渲染数据结构 InvoiceRenderData renderData = new InvoiceRenderData(); renderData.setInvoiceNo(generateInvoiceNo()); renderData.setBuyerName(customer.getName()); renderData.setSellerName("XX科技有限公司"); renderData.setTotalAmount(order.getTotalAmount()); renderData.setIssueDate(LocalDate.now().toString()); List items = orderItems.stream().map(oi -> { InvoiceItem item = new InvoiceItem(); item.setProductName(oi.getProductName()); item.setQuantity(oi.getQuantity()); item.setUnitPrice(oi.getUnitPrice()); item.setAmount(oi.getUnitPrice().multiply(BigDecimal.valueOf(oi.getQuantity()))); return item; }).collect(Collectors.toList()); renderData.setItems(items);
模板引擎的作用很直接:固定的排版格式 + 动态的业务数据 = 最终的PDF文件。
/**
* 使用JasperReports将数据渲染为PDF.
*
* @param renderData 业务数据
* @return PDF的字节数组
*/
public byte[] renderPdf(InvoiceRenderData renderData) {
// 1. 加载预编译的模板文件(.jasper 是编译后的二进制模板)
// 模板文件定义了页面布局、表格结构、字体、页眉页脚等
ClassPathResource template = new ClassPathResource("templates/invoice.jasper");
// 2. 设置模板参数(全局变量,如公司Logo路径、子报表路径等)
Map parameters = new HashMap<>();
parameters.put("COMPANY_LOGO", "templates/logo.png");
// 3. 将Ja va对象集合转换为报表数据源
// JasperReports 会遍历这个数据源,每条数据生成一行
JRDataSource dataSource = new JRBeanCollectionDataSource(
Collections.singletonList(renderData)
);
// 4. 填充模板:模板 + 参数 + 数据 → 内存中的报表对象
JasperPrint jasperPrint = JasperFillManager.fillReport(
template.getInputStream(), parameters, dataSource
);
// 5. 导出为PDF字节数组
ByteArrayOutputStream out = new ByteArrayOutputStream();
JRPdfExporter exporter = new JRPdfExporter();
exporter.setExporterInput(new SimpleExporterInput(jasperPrint));
exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out));
exporter.exportReport();
return out.toByteArray();
}
打个比方:
.jasper 模板文件 = Word模板(定义了样式和占位符)renderData = 要填入的数据JasperFillManager.fillReport() = 邮件合并exportReport() = 另存为PDF/**
* 上传文件到OSS,返回公网可访问的URL.
*
* @param pdfBytes 文件内容
* @return 文件的公网URL
*/
public String uploadToOss(byte[] pdfBytes) {
// 将字节数组包装为输入流
ByteArrayInputStream inputStream = new ByteArrayInputStream(pdfBytes);
// 调用OSS客户端上传
// 内部会:生成唯一文件名 → 上传到OSS Bucket → 返回文件路径
UploadResult result = ossClient.uploadFile(inputStream, "pdf");
if (result == null || result.getFilePath() == null) {
throw new RuntimeException("文件上传失败");
}
// 拼接完整URL:域名 + 文件路径
// 例如:https://cdn.example.com/files/2026/07/abc123.pdf
String fullUrl = ossBaseUrl + "/" + result.getFilePath();
return fullUrl;
}
上传后为什么要返回URL?签约平台不在我们的内网,它需要一个公网可访问的地址去下载这个PDF文件。
/**
* 组装调用签约平台"创建合同"接口的参数.
*/
public ContractCreateParam buildContractParam(String pdfUrl, InvoiceRenderData renderData) {
ContractCreateParam param = new ContractCreateParam();
// 合同基本信息
param.setContractName("电子发片-" + renderData.getInvoiceNo());
param.setFileUrl(pdfUrl); // PDF的公网链接(关键!)
param.setFileName("发片.pdf");
// 签署方A:卖方(我方,先盖章)
ContractSigner sellerSigner = new ContractSigner();
sellerSigner.setRole(1); // 甲方
sellerSigner.setName(renderData.getSellerName());
sellerSigner.setSignPosition("销方签章"); // 在PDF中寻找这个关键字定位盖章位置
sellerSigner.setSignOrder(1); // 第1个签
// 签署方B:买方(客户,后签)
ContractSigner buyerSigner = new ContractSigner();
buyerSigner.setRole(2); // 乙方
buyerSigner.setName(renderData.getBuyerName());
buyerSigner.setSignPosition("购方签章");
buyerSigner.setSignOrder(2); // 第2个签
param.setSigners(Arrays.asList(sellerSigner, buyerSigner));
param.setSignOrderly(true); // 顺序签署(A签完B才能签)
return param;
}
签章位置定位原理:PDF中写了“销方签章”这几个字,签约平台解析PDF找到这几个字的坐标,在旁边放上电子印章图片。
/**
* 调用签约平台HTTP接口创建合同.
*
* @param param 创建参数
* @return 合同ID(签约平台分配)
*/
public Integer createContractOnPlatform(ContractCreateParam param) {
// HTTP POST 调用签约平台
HttpResponse response = httpClient.post(
"https://sign-platform.example.com/api/contract/create",
JsonUtil.toJson(param)
);
PlatformResult result = JsonUtil.fromJson(response.getBody(), PlatformResult.class);
if (result.isSuccess()) {
return result.getContractId(); // 签约平台返回的合同ID
} else {
throw new RuntimeException("创建合同失败: " + result.getErrorMsg());
}
}
@Service
public class InvoiceServiceImpl {
/**
* 完整流程:阶段1(事务内,同步).
* 准备数据 + 生成PDF + 上传 + 组装参数 + 注册事务后回调.
*/
@Transactional(rollbackFor = Exception.class)
public void issueInvoice(Integer orderId) {
// ======== 1. 组装数据 ========
InvoiceRenderData renderData = this.buildRenderData(orderId);
// ======== 2. 渲染PDF ========
byte[] pdfBytes = this.renderPdf(renderData);
// ======== 3. 上传到OSS ========
String pdfUrl = this.uploadToOss(pdfBytes);
// ======== 4. 组装签约平台参数 ========
ContractCreateParam contractParam = this.buildContractParam(pdfUrl, renderData);
// ======== 5. 本地写库(状态改为"处理中") ========
Invoice invoice = new Invoice();
invoice.setOrderId(orderId);
invoice.setInvoiceNo(renderData.getInvoiceNo());
invoice.setStatus(InvoiceStatus.CREATING); // 创建中
invoice.setPdfUrl(pdfUrl);
invoiceRepository.sa ve(invoice);
// ======== 6. 注册事务后回调 → 发MQ ========
AfterCommitActionCollector collector = new AfterCommitActionCollector();
TransactionSynchronizationManager.registerSynchronization(collector);
collector.addAction(
() -> invoiceCreateMqSender.send(contractParam, invoice.getId())
);
}
/**
* 完整流程:阶段2(MQ消费后,独立事务).
* 调用签约平台 + 更新本地状态.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void callSignPlatform(ContractCreateParam param, Integer invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId).orElse(null);
if (invoice == null) return;
try {
// 调用签约平台
Integer contractId = this.createContractOnPlatform(param);
// 成功:保存合同ID,状态改为"待签署"
invoice.setContractId(contractId);
invoice.setStatus(InvoiceStatus.WAITING_SIGN);
} catch (Exception e) {
// 失败:状态回退
invoice.setStatus(InvoiceStatus.CREATE_FAILED);
log.warn("创建合同失败", e);
}
invoiceRepository.sa ve(invoice);
}
}
组装数据──→渲染PDF──→上传OSS──→组装平台参数──→发MQ──→调用平台
│ │ │ │ │ │
│ │ │ │ │ │
需要数据库 需要步骤1 需要步骤2 需要步骤3的URL 需要步骤4 需要步骤5
查询的数据 的数据 的字节数组 作为参数字段 的参数 的消息
每一步的输出,都是下一步的输入,整个流程就是一条流水线:
| 步骤 | 输入 | 输出 | 可能失败的原因 |
|---|---|---|---|
| 组装数据 | 数据库记录 | 结构化DTO | 数据不存在、字段为空 |
| 渲染PDF | DTO + 模板 | byte[] | 模板文件损坏、数据格式不匹配 |
| 上传OSS | byte[] | URL字符串 | 网络超时、OSS服务不可用 |
| 组装平台参数 | URL + 业务数据 | 请求参数DTO | 纯内存操作,几乎不会失败 |
| 调用平台 | 请求参数 | 合同ID | 网络超时、平台校验不通过 |
你可能会好奇:PDF生成和OSS上传明明不是数据库操作,为什么放在事务里?
原因很简单:它们是“组装MQ消息参数”的前置步骤。
如果放在事务外,会出现什么情况?
事务内写库(状态=处理中)→提交→生成PDF→上传失败!
→数据库已经是“处理中”了,但没有PDF,也没有发MQ。
→系统卡在中间状态,进退两难。
放在事务内的话:
事务内:写库 + 生成PDF + 上传OSS → 任何一步失败都回滚。
→要么全部准备就绪然后提交,要么全部回滚,当作什么都没发生。
代价是事务持有时间会稍长一些(多了PDF生成和OSS上传的时间),但收益是数据一致性有了保障。
但注意:调用签约平台这一步没有放在同一个事务内,因为它耗时更长,而且有重试需求,所以通过MQ异步处理。
┌─────────────────────────────────────────────────────────────┐
│ 事务内(同步) │
│ │
│ 初始状态 事务提交时状态 │
│ DRAFT ──[生成PDF+上传+写库]──→ CREATING │
│ │
└───────────────────────────────┬─────────────────────────────┘
│ (事务提交后发MQ)
▼
┌─────────────────────────────────────────────────────────────┐
│ MQ消费后(异步) │
│ │
│ CREATING ──[调用平台成功]──→ WAITING_SIGN (待签署) │
│ │ │
│ └──[调用平台失败]──→ CREATE_FAILED (创建失败,可重试) │
│ │
└─────────────────────────────────────────────────────────────┘
拆解到最后,这个流程本质上是一个文档生成 + 外部系统注册的通用模式:本地数据→渲染成文件→上传到公共存储→把文件地址告诉外部系统→外部系统据此创建任务。
现实中的其他场景,其实也是同样的套路:
| 场景 | 渲染 | 上传 | 外部系统 |
|---|---|---|---|
| 月对账单签章 | JasperReports生成对账PDF | 阿里云OSS | 签约中台 |
| 电子合同签署 | 模板填充生成合同PDF | 文件服务器 | 电子签章平台 |
| 报关单申报 | 生成报关单PDF/Excel | FTP/OSS | 海关系统 |
| 营销邮件 | HTML模板渲染 | CDN | 邮件发送服务 |
核心步骤永远不变:组装数据→生成文件→存到可访问的地方→告诉外部系统去哪里取。
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
售后无忧
立即购买>office旗舰店
正版软件
正版软件
正版软件
正版软件
正版软件
1
2
3
7
8