SpringBoot中实现生成文档-上传文件-调用外部平台创建合同的通用流程
一、这个流程在做什么
用一句话概括:将本地的业务数据,渲染成一份正式的PDF文档,上传到文件服务器获取公网链接,然后把这个链接提交给外部签约平台去创建一份电子合同。
类比现实世界:
你写好一份合同 → 打印出来 → 放到共享柜台上 → 把柜台号告诉对方说"去那里签字"
对应到技术流程:
组装数据 → 渲染PDF → 上传OSS获取URL → 调用签约平台API(传入URL) → 平台创建合同
二、为什么要这样设计(而不是直接传PDF内容)
问题:PDF文件可能有几百KB甚至几MB 方案A(不好):把PDF的Base64直接塞到HTTP请求体里发给签约平台 → 请求体太大,容易超时、网关限制 方案B(推荐):把PDF上传到OSS,只传一个URL给签约平台 → 签约平台自己去URL下载,解耦且高效注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
三、通用示例:生成发票PDF → 上传 → 提交到电子发票平台
场景假设
一个电商系统,用户点击"开具发票"后:
- 从数据库查出订单和商品数据
- 用模板渲染出一份发票PDF
- 上传PDF到文件服务器(OSS)
- 调用电子发票平台接口,传入PDF的URL,创建一张电子发票
- 发票平台返回发票编号,本地保存
步骤1:组装渲染数据
把数据库中分散的数据整合成模板需要的结构:
// 发票渲染所需的数据结构@DatapublicclassInvoiceRenderData{privateStringinvoiceNo;// 发票号privateStringbuyerName;// 购方名称privateStringsellerName;// 销方名称privateBigDecimaltotalAmount;// 总金额privateStringissueDate;// 开票日期privateList<InvoiceItem>items;// 商品明细行}@DatapublicclassInvoiceItem{privateStringproductName;// 商品名privateIntegerquantity;// 数量privateBigDecimalunitPrice;// 单价privateBigDecimalamount;// 金额}组装逻辑:
// 从数据库查出原始数据Orderorder=orderRepository.findById(orderId);List<OrderItem>orderItems=orderItemRepository.findByOrderId(orderId);Customercustomer=customerRepository.findById(order.getCustomerId());// 转换为渲染数据结构InvoiceRenderDatarenderData=newInvoiceRenderData();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->{InvoiceItemitem=newInvoiceItem();item.setProductName(oi.getProductName());item.setQuantity(oi.getQuantity());item.setUnitPrice(oi.getUnitPrice());item.setAmount(oi.getUnitPrice().multiply(BigDecimal.valueOf(oi.getQuantity())));returnitem;}).collect(Collectors.toList());renderData.setItems(items);步骤2:用模板引擎渲染PDF
模板引擎的作用:固定的排版格式 + 动态的业务数据 = 最终的PDF文件。
/** * 使用JasperReports将数据渲染为PDF. * * @param renderData 业务数据 * @return PDF的字节数组 */publicbyte[]renderPdf(InvoiceRenderDatarenderData){// 1. 加载预编译的模板文件(.jasper 是编译后的二进制模板)// 模板文件定义了页面布局、表格结构、字体、页眉页脚等ClassPathResourcetemplate=newClassPathResource("templates/invoice.jasper");// 2. 设置模板参数(全局变量,如公司Logo路径、子报表路径等)Map<String,Object>parameters=newHashMap<>();parameters.put("COMPANY_LOGO","templates/logo.png");// 3. 将Java对象集合转换为报表数据源// JasperReports 会遍历这个数据源,每条数据生成一行JRDataSourcedataSource=newJRBeanCollectionDataSource(Collections.singletonList(renderData));// 4. 填充模板:模板 + 参数 + 数据 → 内存中的报表对象JasperPrintjasperPrint=JasperFillManager.fillReport(template.getInputStream(),parameters,dataSource);// 5. 导出为PDF字节数组ByteArrayOutputStreamout=newByteArrayOutputStream();JRPdfExporterexporter=newJRPdfExporter();exporter.setExporterInput(newSimpleExporterInput(jasperPrint));exporter.setExporterOutput(newSimpleOutputStreamExporterOutput(out));exporter.exportReport();returnout.toByteArray();}类比理解:
.jasper模板文件 = Word模板(定义了样式和占位符)renderData= 要填入的数据JasperFillManager.fillReport()= 邮件合并exportReport()= 另存为PDF
步骤3:上传PDF到文件服务器
/** * 上传文件到OSS,返回公网可访问的URL. * * @param pdfBytes 文件内容 * @return 文件的公网URL */publicStringuploadToOss(byte[]pdfBytes){// 将字节数组包装为输入流ByteArrayInputStreaminputStream=newByteArrayInputStream(pdfBytes);// 调用OSS客户端上传// 内部会:生成唯一文件名 → 上传到OSS Bucket → 返回文件路径UploadResultresult=ossClient.uploadFile(inputStream,"pdf");if(result==null||result.getFilePath()==null){thrownewRuntimeException("文件上传失败");}// 拼接完整URL:域名 + 文件路径// 例如:https://cdn.example.com/files/2026/07/abc123.pdfStringfullUrl=ossBaseUrl+"/"+result.getFilePath();returnfullUrl;}上传后为什么要返回URL:签约平台不在我们的内网,它需要一个公网可访问的地址去下载这个PDF文件。
步骤4:组装签约平台参数
/** * 组装调用签约平台"创建合同"接口的参数. */publicContractCreateParambuildContractParam(StringpdfUrl,InvoiceRenderDatarenderData){ContractCreateParamparam=newContractCreateParam();// 合同基本信息param.setContractName("电子发票-"+renderData.getInvoiceNo());param.setFileUrl(pdfUrl);// PDF的公网链接(关键!)param.setFileName("发票.pdf");// 签署方A:卖方(我方,先盖章)ContractSignersellerSigner=newContractSigner();sellerSigner.setRole(1);// 甲方sellerSigner.setName(renderData.getSellerName());sellerSigner.setSignPosition("销方签章");// 在PDF中寻找这个关键字定位盖章位置sellerSigner.setSignOrder(1);// 第1个签// 签署方B:买方(客户,后签)ContractSignerbuyerSigner=newContractSigner();buyerSigner.setRole(2);// 乙方buyerSigner.setName(renderData.getBuyerName());buyerSigner.setSignPosition("购方签章");buyerSigner.setSignOrder(2);// 第2个签param.setSigners(Arrays.asList(sellerSigner,buyerSigner));param.setSignOrderly(true);// 顺序签署(A签完B才能签)returnparam;}签章位置定位原理:PDF中写了"销方签章"这几个字,签约平台解析PDF找到这几个字的坐标,在旁边放上电子印章图片。
步骤5:调用签约平台创建合同
/** * 调用签约平台HTTP接口创建合同. * * @param param 创建参数 * @return 合同ID(签约平台分配) */publicIntegercreateContractOnPlatform(ContractCreateParamparam){// HTTP POST 调用签约平台HttpResponseresponse=httpClient.post("https://sign-platform.example.com/api/contract/create",JsonUtil.toJson(param));PlatformResultresult=JsonUtil.fromJson(response.getBody(),PlatformResult.class);if(result.isSuccess()){returnresult.getContractId();// 签约平台返回的合同ID}else{thrownewRuntimeException("创建合同失败: "+result.getErrorMsg());}}步骤6:整合——完整流程串联
@ServicepublicclassInvoiceServiceImpl{/** * 完整流程:阶段1(事务内,同步). * 准备数据 + 生成PDF + 上传 + 组装参数 + 注册事务后回调. */@Transactional(rollbackFor=Exception.class)publicvoidissueInvoice(IntegerorderId){// ======== 1. 组装数据 ========InvoiceRenderDatarenderData=this.buildRenderData(orderId);// ======== 2. 渲染PDF ========byte[]pdfBytes=this.renderPdf(renderData);// ======== 3. 上传到OSS ========StringpdfUrl=this.uploadToOss(pdfBytes);// ======== 4. 组装签约平台参数 ========ContractCreateParamcontractParam=this.buildContractParam(pdfUrl,renderData);// ======== 5. 本地写库(状态改为"处理中") ========Invoiceinvoice=newInvoice();invoice.setOrderId(orderId);invoice.setInvoiceNo(renderData.getInvoiceNo());invoice.setStatus(InvoiceStatus.CREATING);// 创建中invoice.setPdfUrl(pdfUrl);invoiceRepository.save(invoice);// ======== 6. 注册事务后回调 → 发MQ ========AfterCommitActionCollectorcollector=newAfterCommitActionCollector();TransactionSynchronizationManager.registerSynchronization(collector);collector.addAction(()->invoiceCreateMqSender.send(contractParam,invoice.getId()));}/** * 完整流程:阶段2(MQ消费后,独立事务). * 调用签约平台 + 更新本地状态. */@Transactional(propagation=Propagation.REQUIRES_NEW)publicvoidcallSignPlatform(ContractCreateParamparam,IntegerinvoiceId){Invoiceinvoice=invoiceRepository.findById(invoiceId).orElse(null);if(invoice==null)return;try{// 调用签约平台IntegercontractId=this.createContractOnPlatform(param);// 成功:保存合同ID,状态改为"待签署"invoice.setContractId(contractId);invoice.setStatus(InvoiceStatus.WAITING_SIGN);}catch(Exceptione){// 失败:状态回退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 | 邮件发送服务 |
| 电子发票 | 发票版式文件生成 | 税务文件服务器 | 税务平台 |
核心步骤永远是:组装数据 → 生成文件 → 存到可访问的地方 → 告诉外部系统去哪里取。