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;// 开票日期privateListInvoiceItemitems;// 商品明细行}DatapublicclassInvoiceItem{privateStringproductName;// 商品名privateIntegerquantity;// 数量privateBigDecimalunitPrice;// 单价privateBigDecimalamount;// 金额}组装逻辑// 从数据库查出原始数据OrderorderorderRepository.findById(orderId);ListOrderItemorderItemsorderItemRepository.findByOrderId(orderId);CustomercustomercustomerRepository.findById(order.getCustomerId());// 转换为渲染数据结构InvoiceRenderDatarenderDatanewInvoiceRenderData();renderData.setInvoiceNo(generateInvoiceNo());renderData.setBuyerName(customer.getName());renderData.setSellerName(XX科技有限公司);renderData.setTotalAmount(order.getTotalAmount());renderData.setIssueDate(LocalDate.now().toString());ListInvoiceItemitemsorderItems.stream().map(oi-{InvoiceItemitemnewInvoiceItem();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 是编译后的二进制模板// 模板文件定义了页面布局、表格结构、字体、页眉页脚等ClassPathResourcetemplatenewClassPathResource(templates/invoice.jasper);// 2. 设置模板参数全局变量如公司Logo路径、子报表路径等MapString,ObjectparametersnewHashMap();parameters.put(COMPANY_LOGO,templates/logo.png);// 3. 将Java对象集合转换为报表数据源// JasperReports 会遍历这个数据源每条数据生成一行JRDataSourcedataSourcenewJRBeanCollectionDataSource(Collections.singletonList(renderData));// 4. 填充模板模板 参数 数据 → 内存中的报表对象JasperPrintjasperPrintJasperFillManager.fillReport(template.getInputStream(),parameters,dataSource);// 5. 导出为PDF字节数组ByteArrayOutputStreamoutnewByteArrayOutputStream();JRPdfExporterexporternewJRPdfExporter();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){// 将字节数组包装为输入流ByteArrayInputStreaminputStreamnewByteArrayInputStream(pdfBytes);// 调用OSS客户端上传// 内部会生成唯一文件名 → 上传到OSS Bucket → 返回文件路径UploadResultresultossClient.uploadFile(inputStream,pdf);if(resultnull||result.getFilePath()null){thrownewRuntimeException(文件上传失败);}// 拼接完整URL域名 文件路径// 例如https://cdn.example.com/files/2026/07/abc123.pdfStringfullUrlossBaseUrl/result.getFilePath();returnfullUrl;}上传后为什么要返回URL签约平台不在我们的内网它需要一个公网可访问的地址去下载这个PDF文件。步骤4组装签约平台参数/** * 组装调用签约平台创建合同接口的参数. */publicContractCreateParambuildContractParam(StringpdfUrl,InvoiceRenderDatarenderData){ContractCreateParamparamnewContractCreateParam();// 合同基本信息param.setContractName(电子发票-renderData.getInvoiceNo());param.setFileUrl(pdfUrl);// PDF的公网链接关键param.setFileName(发票.pdf);// 签署方A卖方我方先盖章ContractSignersellerSignernewContractSigner();sellerSigner.setRole(1);// 甲方sellerSigner.setName(renderData.getSellerName());sellerSigner.setSignPosition(销方签章);// 在PDF中寻找这个关键字定位盖章位置sellerSigner.setSignOrder(1);// 第1个签// 签署方B买方客户后签ContractSignerbuyerSignernewContractSigner();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 调用签约平台HttpResponseresponsehttpClient.post(https://sign-platform.example.com/api/contract/create,JsonUtil.toJson(param));PlatformResultresultJsonUtil.fromJson(response.getBody(),PlatformResult.class);if(result.isSuccess()){returnresult.getContractId();// 签约平台返回的合同ID}else{thrownewRuntimeException(创建合同失败: result.getErrorMsg());}}步骤6整合——完整流程串联ServicepublicclassInvoiceServiceImpl{/** * 完整流程阶段1事务内同步. * 准备数据 生成PDF 上传 组装参数 注册事务后回调. */Transactional(rollbackForException.class)publicvoidissueInvoice(IntegerorderId){// 1. 组装数据 InvoiceRenderDatarenderDatathis.buildRenderData(orderId);// 2. 渲染PDF byte[]pdfBytesthis.renderPdf(renderData);// 3. 上传到OSS StringpdfUrlthis.uploadToOss(pdfBytes);// 4. 组装签约平台参数 ContractCreateParamcontractParamthis.buildContractParam(pdfUrl,renderData);// 5. 本地写库状态改为处理中 InvoiceinvoicenewInvoice();invoice.setOrderId(orderId);invoice.setInvoiceNo(renderData.getInvoiceNo());invoice.setStatus(InvoiceStatus.CREATING);// 创建中invoice.setPdfUrl(pdfUrl);invoiceRepository.save(invoice);// 6. 注册事务后回调 → 发MQ AfterCommitActionCollectorcollectornewAfterCommitActionCollector();TransactionSynchronizationManager.registerSynchronization(collector);collector.addAction(()-invoiceCreateMqSender.send(contractParam,invoice.getId()));}/** * 完整流程阶段2MQ消费后独立事务. * 调用签约平台 更新本地状态. */Transactional(propagationPropagation.REQUIRES_NEW)publicvoidcallSignPlatform(ContractCreateParamparam,IntegerinvoiceId){InvoiceinvoiceinvoiceRepository.findById(invoiceId).orElse(null);if(invoicenull)return;try{// 调用签约平台IntegercontractIdthis.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数据不存在、字段为空渲染PDFDTO 模板byte[]模板文件损坏、数据格式不匹配上传OSSbyte[]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/ExcelFTP/OSS海关系统营销邮件HTML模板渲染CDN邮件发送服务电子发票发票版式文件生成税务文件服务器税务平台核心步骤永远是组装数据 → 生成文件 → 存到可访问的地方 → 告诉外部系统去哪里取。