采购订单提交后,遗留供应商系统没有响应

订单 726 已在本地 PostgreSQL 提交,适配器把确认请求发到遗留供应商 SOAP 服务。读响应时超时,供应商可能已经接收并创建单据,也可能没有收到任何字节。将这个异常直接解释为“外部操作失败”并立即用新请求号重试,供应商端可能创建第二份确认;把它当作成功,又可能永远缺少确认。本篇只选一个外部系统:受控本地 SOAP 1.2 供应商 stub,提供 RegisterOrder 操作、供应商幂等键及查询操作的合同。Connectors(Jakarta Connectors,JCA)资源适配器是另一种集成路径,不与本实验叠加,也不声称本地 SOAP stub 验证过资源适配器部署。

目前累计工程的 ProcurementUseCases.order 在本地建订单;并无 SOAP stub、WSDL、外部映射表或 HTTP 出站适配器。deploy/server.xml 的受管 jdbc/Procurement DataSource 只连接本地库,不能把它当成供应商连接器。先修第 03 篇解释 WAR 的类路径与依赖装配,第 18 篇说明受管事务边界,第 32 篇覆盖 HTTP 超时与未知结果;在这三处缺证据时不得把本章写成完成的跨系统提交。

先冻结远端操作的语义,再讨论 SOAP 序列化

合同还应约定“同键不同内容”如何判定:比较服务器核价后的金额、币种、订单 ID 和供应商 ID 的规范化摘要,而不是比较整个 XML 字节串;XML 前缀不同或属性顺序不同可以描述同一业务请求。幂等结果要在供应商 stub 的持久状态里保留,重启后同键仍返回原外部 ID,才能支撑本地任务恢复;若仅把已处理键存在一个 Map 中,stub 重启后会忘记此前接收。若系统没有能力立即查询供应商结果,UNKNOWN 任务不应卡住全局队列,应该隔离到对账状态并按有限周期重查。一个订单的 UNKNOWN 不应阻塞其他租户订单。

WSDL 作为合同应记录服务地址、操作 QName、消息结构、Fault 类型与字段约束,但可以为隔离实验把网络地址由部署配置提供,而不是把真实内网 URL 硬编码进 WAR。只有固定合同里的 RegisterOrder 能作为写操作,另设 FindOrderByBusinessKey 用于未知结果对账;读操作也要限流,避免所有超时任务重启时向同一供应商打满并发。若外部接口不提供查询,同键重试仅在供应商承诺幂等时安全;否则人工对账和暂缓重发是无法避免的业务边界。审批权限由本地受认证主体决定,供应商回执不能把一张本地未批准申请“倒推”成批准状态。

在本地 stub 的合同中,RegisterOrder(tenantCode, businessKey, requestId, amount) 接收订单标识和服务端计算金额。businessKey 由本地租户及订单 ID 稳定生成,不在每次网络重试时重新随机生成;供应商应对同键同内容返回同一个外部 ID、对同键不同内容明确冲突。如果供应商不提供幂等或按键查询,超时后系统无法自动判定已创建还是未创建,只能将任务保持 UNKNOWN 并人工对账,不能靠换 SOAP 库消除未知窗口。tenantCode 由可信组织映射产生,不从 X-Lab-Tenant 直接复制。错误与响应应含外部关联 ID,日志仅存受限业务标识,不写入供应商口令或完整采购清单。

SOAP 1.2 的 Envelope 使用 http://www.w3.org/2003/05/soap-envelope 命名空间,HTTP 媒体类型常为 application/soap+xml。Envelope 的 Body 包含业务元素;SOAP Fault 表示远端可表达的协议/业务错误,它不同于网络超时、HTTP 502 网关失败或本地 XML 解析失败。WSDL/Schema 须固定版本,尤其是金额小数精度、货币、时区、空值、附加字段与错误码;读到 HTTP 200 不等于成功解析到匹配 businessKey 的回执。出站 XML 按固定协议和安全 XML 库生成,解析端关闭外部实体及 DTD,限制文档和响应体尺寸,避免读取本地文件或被实体扩张拖死;如果签名、WS-Security 或双向 TLS 是遗留系统合同的一部分,还须冻结独立实现与密钥管理,不能由 SOAP 1.2 核心规范推导“自动有身份安全”。

1
2
3
4
5
6
本地订单事务提交 -> outbox 外联任务可见
-> 读取受信 tenant + 业务键 + 固定 SOAP 合同
-> 连接、写请求、等待响应
-> 成功:验证外部 ID、业务键,记录关联
-> 明确拒绝:记录失败码,等待修正或人工
-> 响应未知:按业务键查远端或保留 UNKNOWN

边界最容易被混淆的地方是本地事务:外部 HTTP 请求并不会因为 @Transactional 注解加入 JDBC 数据库事务。先在本地事务内写订单与 outbox,提交后由受管任务发送;供应商已经创建后本地记录外部 ID 失败时,恢复靠稳定业务键和远端查询,不是用 rollback() 撤销外部系统。若在数据库事务未提交时发远端请求,随后本地回滚,供应商就拥有孤儿记录,故顺序不能颠倒。即使完成 outbox,重复投递仍可能发生,必须由供应商幂等或对账处理;不要宣称“两端恰好一次”。

固定请求字节的入口

请求失败需要分清“连接未建立”、“发送途中中断”、“全部发出但等不到响应”与“收到 SOAP Fault”。客户端设置连接超时和整个请求超时只是防止资源被永久占用,不能凭超时点准确证明对端没有开始处理。重试上限和熔断器只能限制本地资源消耗,不能赋予远端撤销能力;如果远端成功但本地回执没保存,恢复阶段要从供应商的持久映射补写本地外部 ID。外联接口若未支持相同键查询,显示给采购员的状态应该是“待核对”而不是“失败,请再点一次下单”。本地订单已经提交,与外部确认 pending 并不矛盾。

这里选择 Java SE 21 HttpClient 作为单一 SOAP 1.2 传输入口;不在 Jakarta EE 11 运行时假设自动带有 JAX-WS 实现。以下代码只发送一份固定样本 Envelope,便于核对 HTTP 方法、媒体类型、超时与状态;它不是完整的生成器,也没有安全解析、身份与业务账本,所以不能拿它直接处理生产字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package blog.javaee.optional;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class SoapProbe {
public static HttpResponse<String> register(URI localStub) throws Exception {
String envelope = """
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body><RegisterOrder xmlns="urn:procurement:supplier:v1">
<businessKey>tenant-one:order-726</businessKey>
</RegisterOrder></env:Body>
</env:Envelope>""";
HttpRequest request = HttpRequest.newBuilder(localStub)
.timeout(Duration.ofSeconds(3))
.header("Content-Type", "application/soap+xml; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(envelope))
.build();
return HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2))
.build().send(request, HttpResponse.BodyHandlers.ofString());
}
}

本片段在 JDK 21 语法上可编译,但没有 main,不会自行联系不存在的本地 stub;示例不向公网投递请求。落地时应固定本地地址(避免用户提交目标 URL 导致 SSRF),限制重定向、出站连接、响应大小和调用并发。BodyHandlers.ofString() 会把响应整体读入内存,只适合有长度上限的实验响应,生产适配器应有限流的解析路径。真实供应商要求 TLS、认证及签名时独立配置;不能假定 127.0.0.1 的明文实验具备真实部署的保密性。

正常、故障与恢复的可核对结果

SOAP 响应解析必须拒绝超出上限的嵌套或超长 Body,校验期望的 Envelope 命名空间与业务响应 QName,不能因为 XML 中出现一个 externalId 字段就认定是 RegisterOrderResponse。对 Fault 要解析规范的 Code/Reason 与可选 Detail,按明确可重试/不可重试分类;含采购明细的原始 XML 不宜直接写入异常日志。没有数字签名的本地 stub 测试不能验证生产环境的消息级完整性;需要 WS-Security 时必须单独选择实现、冻结算法和密钥及校验入口。应用读出站事务任务时同样要检查租户边界,不能让攻击者把另一个租户的订单 ID 作为 businessKey 带给供应商。

最终账本也须保留撤销和补偿空间。供应商已经生成确认而采购员后来撤销订单,若外部接口没有取消操作,不能对本地事务执行回滚来“撤销”之前的 SOAP 副作用;要定义单独的取消合同、对账和人工处理。请求超时之后即使再次调用得到相同外部 ID,也要核对金额、币种及供应商身份是否与第一次一致,避免远端错误地把同键不同请求合并。对外部回执中的新字段采取显式兼容策略:可忽略的可选字段与合同变更、额外的强制字段应区别处理,不允许悄悄丢掉决定财务金额的字段。

正常路径使用隔离 PostgreSQL 与本地 SOAP stub:向本地创建一张申请并下单,检查唯一订单和 outbox;消费者以固定键发出 SOAP,请求 body 满足冻结的 XSD,stub 只保留一次外部关联,响应业务键匹配;本地关系表保存这一外部 ID。明确失败路径让 stub 返回 SOAP Fault(例如缺失供应商编码),请求不应变成“已确认”,账本记录原因码、订单仍留在已提交状态。未知结果路径让 stub 在存入远端映射后断开连接,消费者可重试相同业务键并得到相同外部 ID,或按键查询后补记,而不是重新创建;再构造远端未收到请求的超时,对比两种时间线。证据至少包括脱敏 SOAP 请求/响应、stub 的按键行、outbox 状态、本地订单和外部关联表,以及断线注入位置。恢复只在本地隔离 stub 中进行,不对真实供应商做故障注入。

目前没有本地 stub、WSDL/XSD 或外联业务表,且 E04 不实现 JCA 资源适配器。**正常、SOAP Fault、请求未到达、对端已处理但回包丢失及重试恢复全部 NOT_RUN。**上述时间线是实验断言而非观测结果;即使第 32 篇有 HTTP 说明,也不能替 E04 的 SOAP 解析和远端账本验收。

本地故障注入可以精确控制三个关键时点:在 stub 接收第一个字节之前断开、存下业务键以后丢弃响应、返回 Fault 但不存业务键。对每个时点保存单独的请求 ID、stub 的映射表快照和本地任务状态;不能在同一轮测试里既改端口又删除映射表,然后仅凭最终 500 判因。恢复时按同一键重查,观察是否有一条已存外部映射,以及本地关联何时补齐。如果条件不具备,比如没有可重复注入的本地 stub,就将实验标 NOT_RUN 而非拿远端模拟响应正文假装测试通过。

正常回包的匹配也有先后:先核对传输层接收完成且消息未超出上限,再按安全配置解析 XML,然后核对 Envelope/Body 类型、业务键、订单金额和外部 ID,最后才在本地事务更新关联行。若同一业务键已有一个不同的外部 ID,本地唯一约束应拒绝静默覆盖并触发对账;若响应正文完整却没有外部 ID,不能因 HTTP 200 就把任务标记为已确认。这里的“安全解析”不仅禁止外部实体,还应限制实体大小与深度,固定命名空间,不把远端 Fault 的任意文本作为可执行 HTML 直接显示给采购员。

另一条失败链是外部系统整体变慢。每个消费工作者借用一个连接、等待几秒再占用重试额度;没有并发限制时,成批订单会压满受管执行器,连本地 outbox 的正常消费也停滞。将外部连接/读取超时、单租户并发上限、任务租约长度和重试间隔一起设置并监控,租约必须大于合理传输时间且允许进程异常后恢复。不能因为降低 HTTP 超时就一定“修复”供应商慢响应:超时缩短会增加结果未知的比例。应从 stub 的请求接收时间、处理完成时间与本地最终账本共同判断调整策略。

重放判据还需要同时检查负路径的最终业务状态。Fault 表示供应商明确拒绝,申请与订单不会凭此重新变成草稿;本地任务应留可诊断的失败类别。连接未建立时可以在有界退避后用同一业务键重试,发送后未知时优先查询供应商映射。若两次查询都因远端故障超时,不应给客户端显示“外部已确认”或“必定失败”,而应继续维持 UNKNOWN,告知运营有待对账任务。数据层至少核对订单只有一行、外部关联至多一个供应商 ID、未知任务仍可恢复;HTTP 状态不是唯一判据。

若改动 stub 合同的金额精度或业务键格式,原有 UNKNOWN 任务不能自动按新格式重放;任务应记录合同版本,并用其原始格式重试或走显式迁移。否则“相同业务键”的字符串在升级后可能变成另一笔远端请求。测试应在旧格式任务尚未确认时部署新版适配器,对照两次请求的业务键和远端映射,核对是否仍只有一个外部 ID。此版本交错场景当前没有 stub 与迁移代码,保持 NOT_RUN。

两道带答案的练习

练习一: 发送后超时,工程师调用本地事务回滚,再用新 businessKey 重试。远端已创建时会有什么结果?

解: 本地事务可能早已提交,新的回滚不能撤销远端 SOAP 调用;新键让远端把请求当另一笔单据。应以同一键查询/重试,记录 UNKNOWN 到关联恢复完成,独立核对本地订单只有一条、远端映射只有一条。没有远端幂等/查询能力则暂停自动重试并对账,不编造确定性结果。

练习二: 本地服务收到 HTTP 500 和合法 SOAP Fault,另一次收到 HTTP 200 却解析到其他订单的外部 ID。哪次可记为成功?

解: 两次都不能。Fault 是明确的协议/业务拒绝,需按 Fault 码分类;HTTP 200 也要验证业务键、租户和外部 ID 的对应关系,不匹配应报警并保持未知/拒绝关联,不能污染本地账本。验收读取 stub 和本地两本账,本章未做请求。

版本资料与选择范围

此选修冻结的是 W3C SOAP 1.2 Part 1 与 SOAP 1.2 Part 2 的线协议、JDK 21 的 HttpClient API;运行背景为 Jakarta EE 11 Platform。若选择生成式 SOAP 客户端,应另行冻结 Jakarta XML Web Services 4.0 及实际独立实现,不能假设主线 WAR 已有该库。另一条未选择的集成方法是 Jakarta Connectors 2.1,它的连接和资源适配器契约与此 SOAP 传输不同;本文不据 SOAP 测试推断 JCA 结果。