From 256735fb43f4645460664906573a6b6132f04c1f Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 22 Oct 2026 17:14:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(openapi):=20=E5=AF=B9=E8=B4=A6=E6=B4=BE?= =?UTF-8?q?=E5=8F=91=E6=94=B9=20by-key=20=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E5=AF=BB=E5=9D=80=EF=BC=88appCode=20=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95+fail-closed=EF=BC=89=EF=BC=8C=E6=98=BE=E5=BC=8F=20tar?= =?UTF-8?q?gets=20=E4=BE=8B=E5=A4=96=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/AnswerReconcileConfiguration.java | 2 +- .../AnswerReconcileDispatchClient.java | 57 ++++++++---- .../service/AppCodeReconcileRoutingTest.java | 91 +++++++++++++++++++ 3 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 src/test/java/com/yunhai/formcenter/service/AppCodeReconcileRoutingTest.java diff --git a/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java b/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java index a0806aa..f5bd446 100644 --- a/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java +++ b/src/main/java/com/yunhai/formcenter/config/AnswerReconcileConfiguration.java @@ -79,7 +79,7 @@ public class AnswerReconcileConfiguration { private int maxDispatchAttempts = 4; private int backoffBaseSeconds = 60; private int backoffCapSeconds = 1800; - /** 消费方后台服务地址表:键=consumerAppKey(已验签身份),值=带 context-path 的完整基址 */ + /** 服务端显式覆盖/非标准消费者例外:appKey → http(s) 基址;标准消费者可留空,由 UserBase 注册 appCode 寻址。 */ private java.util.Map targets = new java.util.LinkedHashMap<>(); /** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */ private String dispatchPath = "/oapi/form-sync/reconcile"; diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java index 19c1d68..220c0fd 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReconcileDispatchClient.java @@ -17,8 +17,8 @@ import java.net.URI; * *

出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入, * 本应用身份取 {@code yh.app-auth},密钥不进代码也不进日志。 - * 目标地址只有一个来源——{@code fc.answer.reconcile.targets} 里按已验签 consumerAppKey - * 登记的后台服务基址;请求体里、浏览器里出现的任何 URL 都不参与寻址(任务书改动点 3 末句)。

+ * 寻址优先保留服务端显式 targets 覆盖;否则由 UserBase 注册记录把 consumerAppKey + * 映射为标准 appCode,再经 SDK 统一内部网关。消息体/浏览器自报 URL 不参与寻址。

* *

失败语义:本类不抛异常。派发失败对上层只是一个稳定错误码, * 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。

@@ -30,14 +30,18 @@ public class AnswerReconcileDispatchClient { private final YschOpenCall openCall; private final AnswerReconcileProperties properties; + @org.springframework.beans.factory.annotation.Autowired(required = false) + private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient; + public AnswerReconcileDispatchClient(YschOpenCall openCall, AnswerReconcileProperties properties) { this.openCall = openCall; this.properties = properties; } - /** 出站能力是否可用(未注入 SDK 或未登记任何后台地址=不可派发,任务留在 QUEUED 不丢) */ + /** 出站能力就绪不等于某消费者已登记;具体映射在派发时 fail closed。 */ public boolean enabled() { - return openCall != null && properties != null && !properties.getTargets().isEmpty(); + return openCall != null && properties != null + && (appRegistryClient != null || !properties.getTargets().isEmpty()); } /** 派发结果的本地结论:2xx 只说明对端收下了指令,绝不映射成任务终态 */ @@ -66,18 +70,25 @@ public class AnswerReconcileDispatchClient { } if (!enabled()) { return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, - "出站签名客户端不可用或 fc.answer.reconcile.targets 未登记任何后台地址", - instruction.getAttempt(), null, false); - } - TargetSpec spec = resolveTarget(instruction.getConsumerAppKey()); - if (spec == null) { - return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, - "消费方 " + instruction.getConsumerAppKey() + " 没有登记后台服务地址,不接浏览器 URL", + "出站签名客户端或平台注册查询不可用,且未配置服务端显式地址", instruction.getAttempt(), null, false); } try { - InstructionAck ack = openCall.post(spec, properties.getDispatchPath(), - java.util.Collections.emptyMap(), instruction, InstructionAck.class); + TargetSpec spec = resolveTarget(instruction.getConsumerAppKey()); + InstructionAck ack; + if (spec != null) { + ack = openCall.post(spec, properties.getDispatchPath(), + java.util.Collections.emptyMap(), instruction, InstructionAck.class); + } else { + String appCode = registeredAppCode(instruction.getConsumerAppKey()); + if (appCode == null) { + return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, + "消费方未登记标准 appCode 或缺少服务端例外地址", + instruction.getAttempt(), null, false); + } + ack = openCall.post(appCode, "/oapi/form-sync/reconcile", + java.util.Collections.emptyMap(), instruction, InstructionAck.class); + } if (ack == null || ack.getTaskId() == null || !ack.getTaskId().equals(instruction.getTaskId())) { // 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 2xx) return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, @@ -98,6 +109,19 @@ public class AnswerReconcileDispatchClient { } } + private String registeredAppCode(String consumerAppKey) { + String key = consumerAppKey.trim(); + if (appRegistryClient == null || !key.matches("[0-9]{1,10}")) { + return null; + } + com.fasterxml.jackson.databind.JsonNode registered = appRegistryClient.getAppByKey(key); + if (registered == null || !key.equals(registered.path("appKey").asText())) { + return null; + } + String code = registered.path("appCode").asText(""); + return code.matches("[a-z][a-z0-9-]{0,31}") ? code : null; + } + /** 登记的后台基址 → host + context-path(与 CMS/流程中心客户端同一拆解口径) */ private TargetSpec resolveTarget(String consumerAppKey) { String raw = properties.getTargets().get(consumerAppKey.trim()); @@ -105,9 +129,10 @@ public class AnswerReconcileDispatchClient { return null; } URI uri = URI.create(raw.trim()); - if (uri.getScheme() == null || uri.getRawAuthority() == null) { - log.warn("[对账] 登记的后台地址不是绝对 URL,忽略:appKey={}", consumerAppKey); - return null; + if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + || uri.getHost() == null || uri.getRawUserInfo() != null + || uri.getRawQuery() != null || uri.getRawFragment() != null) { + throw new IllegalArgumentException("消费方服务端例外地址必须为 http(s) 基址且不含凭据/query/fragment"); } String host = uri.getScheme() + "://" + uri.getRawAuthority(); String path = uri.getRawPath() == null ? "" : uri.getRawPath(); diff --git a/src/test/java/com/yunhai/formcenter/service/AppCodeReconcileRoutingTest.java b/src/test/java/com/yunhai/formcenter/service/AppCodeReconcileRoutingTest.java new file mode 100644 index 0000000..528841d --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/AppCodeReconcileRoutingTest.java @@ -0,0 +1,91 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.formcenter.config.AnswerReconcileConfiguration.AnswerReconcileProperties; +import com.yunhai.formcenter.model.FormSyncModels.InstructionAck; +import com.yunhai.formcenter.model.FormSyncModels.ReconcileInstruction; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import com.yunhai.platform.openapi.core.TargetSpec; +import com.yunhai.platform.openapi.core.YschOpenCall; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class AppCodeReconcileRoutingTest { + private YschOpenCall call; + private OpenApiAppClient registry; + private AnswerReconcileProperties props; + private AnswerReconcileDispatchClient client; + private ReconcileInstruction instruction; + + @BeforeEach + void setUp() { + call = mock(YschOpenCall.class); registry = mock(OpenApiAppClient.class); + props = new AnswerReconcileProperties(); + client = new AnswerReconcileDispatchClient(call, props); + ReflectionTestUtils.setField(client, "appRegistryClient", registry); + instruction = new ReconcileInstruction(); + instruction.setConsumerAppKey("100018"); instruction.setTaskId("test-task"); + instruction.setAttempt(2); + } + + private InstructionAck ack() { + InstructionAck ack = new InstructionAck(); + ack.setTaskId(instruction.getTaskId()); ack.setAccepted(true); ack.setAttempt(2); + return ack; + } + + @Test + void trustedRegistryCodeUsesNamedSdkWithoutConsumerUrl() throws Exception { + when(registry.getAppByKey("100018")).thenReturn(new ObjectMapper().readTree( + "{\"appKey\":100018,\"appCode\":\"svc-hall\"}")); + when(call.post(eq("svc-hall"), eq("/oapi/form-sync/reconcile"), anyMap(), same(instruction), + eq(InstructionAck.class))).thenReturn(ack()); + assertTrue(client.enabled()); + assertTrue(client.dispatch(instruction).isAccepted()); + verify(call).post("svc-hall", "/oapi/form-sync/reconcile", Collections.emptyMap(), instruction, + InstructionAck.class); + } + + @Test + void missingMismatchedOrUnsafeRegistryRecordCannotDispatch() throws Exception { + for (String json : new String[]{"null", "{}", "{\"appKey\":100019,\"appCode\":\"svc-hall\"}", + "{\"appKey\":100018,\"appCode\":\"../svc-hall\"}", + "{\"appKey\":100018,\"appCode\":\"100018\"}"}) { + when(registry.getAppByKey("100018")).thenReturn(new ObjectMapper().readTree(json)); + assertFalse(client.dispatch(instruction).isAccepted()); + } + verifyNoInteractions(call); + } + + @Test + void registryUnavailableDoesNotGuessOrChangeTask() { + when(registry.getAppByKey("100018")).thenThrow(new IllegalStateException("test registry down")); + assertFalse(client.dispatch(instruction).isAccepted()); + assertEquals(2, instruction.getAttempt()); + verifyNoInteractions(call); + } + + @Test + void explicitServerOverrideRetainsPriorityAndAckIdentityCheck() { + props.getTargets().put("100018", "https://external.example/ctx"); + InstructionAck wrong = ack(); wrong.setTaskId("other-task"); + when(call.post(any(TargetSpec.class), anyString(), anyMap(), same(instruction), eq(InstructionAck.class))) + .thenReturn(wrong); + assertFalse(client.dispatch(instruction).isAccepted()); + verifyNoInteractions(registry); + } + + @Test + void malformedExplicitExceptionFailsWithoutRegistryBypass() { + for (String url : new String[]{"file:///tmp/x", "https://u:p@external.example/ctx", "http://x/ctx?q=x"}) { + props.getTargets().put("100018", url); + assertFalse(client.dispatch(instruction).isAccepted()); + } + verifyNoInteractions(registry, call); + } +}