SpringBoot中实现文档生成与电子合同创建的完整指南
一、这个流程在做什么
用一句话概括:将本地的业务数据,渲染成一份正式的PDF文档,上传到文件服务器获取公网链接,然后把这个链接提交给外部签约平台去创建一份电子合同。
类比现实世界:你写好一份合同 → 打印出来 → 放到共享柜台上 → 把柜台号告诉对方说"去那里签字"
对应到技术流程:组装数据 → 渲染PDF → 上传OSS获取URL → 调用签约平台API(传入URL) → 平台创建合同
二、为什么要这样设计(而不是直接传PDF内容)
问题:PDF文件可能有几百KB甚至几MB
- 方案A(不好):把PDF的Base64直接塞到HTTP请求体里发给签约平台→ 请求体太大,容易超时、网关限制
- 方案B(推荐):把PDF上传到OSS,只传一个URL给签约平台→ 签约平台自己去URL下载,解耦且高效
三、通用示例:生成发票PDF → 上传 → 提交到电子发票平台
场景假设
一个电商系统,用户点击"开具发票"后:
- 从数据库查出订单和商品数据
- 用模板渲染出一份发票PDF
- 上传PDF到文件服务器(OSS)
- 调用电子发票平台接口,传入PDF的URL,创建一张电子发票
- 发票平台返回发票编号,本地保存
步骤1:组装渲染数据
把数据库中分散的数据整合成模板需要的结构:
// 发票渲染所需的数据结构
@Data
public class InvoiceRenderData {
private String invoiceNo; // 发票号
private String buyerName; // 购方名称
private String sellerName; // 销方名称
private BigDecimal totalAmount; // 总金额
private String issueDate; // 开票日期
private List<InvoiceItem> items; // 商品明细行
}
@Data
public class InvoiceItem {
private String productName; // 商品名
private Integer quantity; // 数量
private BigDecimal unitPrice; // 单价
private BigDecimal amount; // 金额
}
组装逻辑:
// 从数据库查出原始数据
Order order = orderRepository.findById(orderId);
List<OrderItem> orderItems = 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<InvoiceItem> 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);
步骤2:用模板引擎渲染PDF
模板引擎的作用:固定的排版格式 + 动态的业务数据 = 最终的PDF文件。
/**
* 使用JasperReports将数据渲染为PDF.
*
* @param renderData 业务数据
* @return PDF的字节数组
*/
public byte[] renderPdf(InvoiceRenderData renderData) {
// 1. 加载预编译的模板文件(.jasper 是编译后的二进制模板)
// 模板文件定义了页面布局、表格结构、字体、页眉页脚等
ClassPathResource template = new ClassPathResource("templates/invoice.jasper");
// 2. 设置模板参数(全局变量,如公司Logo路径、子报表路径等)
Map<String, Object> parameters = new HashMap<>();
parameters.put("COMPANY_LOGO", "templates/logo.png");
// 3. 将Java对象集合转换为报表数据源
// 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
步骤3:上传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文件。
步骤4:组装签约平台参数
/**
* 组装调用签约平台"创建合同"接口的参数.
*/
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找到这几个字的坐标,在旁边放上电子印章图片。
步骤5:调用签约平台创建合同
/**
* 调用签约平台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());
}
}
步骤6:整合——完整流程串联
@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.save(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.save(invoice);
}
}
四、各步骤之间的依赖关系
组装数据 ──→ 渲染PDF ──→ 上传OSS ──→ 组装平台参数 ──→ 发MQ ──→ 调用平台
│ │ │ │ │ │
│ │ │ │ │ │
需要数据库 需要步骤1 需要步骤2 需要步骤3的URL 需要步骤4 需要步骤5
查询的数据 的数据 的字节数组 作为参数字段 的参数 的消息
每一步的输出是下一步的输入,形成流水线:
| 步骤 | 输入 | 输出 | 可能失败的原因 |
|---|---|---|---|
| 组装数据 | 数据库记录 | 结构化DTO | 数据不存在、字段为空 |
| 渲染PDF | DTO + 模板 | byte[] | 模板文件损坏、数据格式不匹配 |
| 上传OSS | byte[] | URL字符串 | 网络超时、OSS服务不可用 |
| 组装平台参数 | URL + 业务数据 | 请求参数DTO | 纯内存操作,几乎不会失败 |
| 调用平台 | 请求参数 | 合同ID | 网络超时、平台校验不通过 |
五、为什么"渲染PDF + 上传OSS"放在事务内
你可能有疑问: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 | 邮件发送服务 |
核心步骤永远是:组装数据 → 生成文件 → 存到可访问的地方 → 告诉外部系统去哪里取。
到此这篇关于SpringBoot中实现文档生成与电子合同创建的完整指南的文章就介绍到这了,更多相关SpringBoot生成文档内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
关于Unsupported major.minor version 49.0的错误解决办法
这篇文章主要介绍了关于Unsupported major.minor version 49.0的错误解决办法的相关资料,需要的朋友可以参考下2015-11-11
Java实现茶叶售卖商城系统(java+SSM+JSP+EasyUi+mysql)
这篇文章主要介绍了基于SSM框架实现的一个茶叶售卖商城系统,应用到的技术有Jsp、SSM 、EasyUi,文中的示例代码具有一定的学习价值,需要的朋友可以参考一下2021-12-12


最新评论