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);
+ }
+}