feat(openapi): 对账派发改 by-key 注册表寻址(appCode 白名单+fail-closed),显式 targets 例外保留
build / build-push (push) Successful in 1m27s

This commit is contained in:
chenxing
2026-10-22 17:14:22 +08:00
parent a00f97f0ec
commit 256735fb43
3 changed files with 133 additions and 17 deletions
@@ -79,7 +79,7 @@ public class AnswerReconcileConfiguration {
private int maxDispatchAttempts = 4; private int maxDispatchAttempts = 4;
private int backoffBaseSeconds = 60; private int backoffBaseSeconds = 60;
private int backoffCapSeconds = 1800; private int backoffCapSeconds = 1800;
/** 消费方后台服务地址表:键=consumerAppKey(已验签身份),值=带 context-path 的完整基址 */ /** 服务端显式覆盖/非标准消费者例外:appKey → http(s) 基址;标准消费者可留空,由 UserBase 注册 appCode 寻址。 */
private java.util.Map<String, String> targets = new java.util.LinkedHashMap<>(); private java.util.Map<String, String> targets = new java.util.LinkedHashMap<>();
/** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */ /** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */
private String dispatchPath = "/oapi/form-sync/reconcile"; private String dispatchPath = "/oapi/form-sync/reconcile";
@@ -17,8 +17,8 @@ import java.net.URI;
* *
* <p>出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入, * <p>出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入,
* 本应用身份取 {@code yh.app-auth},<b>密钥不进代码也不进日志</b>。 * 本应用身份取 {@code yh.app-auth},<b>密钥不进代码也不进日志</b>。
* 目标地址只有一个来源——{@code fc.answer.reconcile.targets} 里按<b>已验签 consumerAppKey</b> * 寻址优先保留服务端显式 targets 覆盖;否则由 UserBase 注册记录把 consumerAppKey
* 登记的后台服务基址;请求体里、浏览器里出现的任何 URL 都不参与寻址(任务书改动点 3 末句)。</p> * 映射为标准 appCode,再经 SDK 统一内部网关。消息体/浏览器自报 URL 不参与寻址。</p>
* *
* <p>失败语义:本类<b>不抛异常</b>。派发失败对上层只是一个稳定错误码, * <p>失败语义:本类<b>不抛异常</b>。派发失败对上层只是一个稳定错误码,
* 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。</p> * 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。</p>
@@ -30,14 +30,18 @@ public class AnswerReconcileDispatchClient {
private final YschOpenCall openCall; private final YschOpenCall openCall;
private final AnswerReconcileProperties properties; 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) { public AnswerReconcileDispatchClient(YschOpenCall openCall, AnswerReconcileProperties properties) {
this.openCall = openCall; this.openCall = openCall;
this.properties = properties; this.properties = properties;
} }
/** 出站能力是否可用(未注入 SDK 或未登记任何后台地址=不可派发,任务留在 QUEUED 不丢) */ /** 出站能力就绪不等于某消费者已登记;具体映射在派发时 fail closed。 */
public boolean enabled() { public boolean enabled() {
return openCall != null && properties != null && !properties.getTargets().isEmpty(); return openCall != null && properties != null
&& (appRegistryClient != null || !properties.getTargets().isEmpty());
} }
/** 派发结果的本地结论:2xx 只说明对端收下了指令,绝不映射成任务终态 */ /** 派发结果的本地结论:2xx 只说明对端收下了指令,绝不映射成任务终态 */
@@ -66,18 +70,25 @@ public class AnswerReconcileDispatchClient {
} }
if (!enabled()) { if (!enabled()) {
return DispatchOutcome.of(false, AnswerReconcileService.ERR_TARGET_NOT_REGISTERED, 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); instruction.getAttempt(), null, false);
} }
try { try {
InstructionAck ack = openCall.post(spec, properties.getDispatchPath(), TargetSpec spec = resolveTarget(instruction.getConsumerAppKey());
java.util.Collections.<String, String>emptyMap(), instruction, InstructionAck.class); InstructionAck ack;
if (spec != null) {
ack = openCall.post(spec, properties.getDispatchPath(),
java.util.Collections.<String, String>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.<String, String>emptyMap(), instruction, InstructionAck.class);
}
if (ack == null || ack.getTaskId() == null || !ack.getTaskId().equals(instruction.getTaskId())) { if (ack == null || ack.getTaskId() == null || !ack.getTaskId().equals(instruction.getTaskId())) {
// 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 2xx) // 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 2xx)
return DispatchOutcome.of(false, AnswerReconcileService.ERR_DISPATCH_FAILED, 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/流程中心客户端同一拆解口径) */ /** 登记的后台基址 → host + context-path(与 CMS/流程中心客户端同一拆解口径) */
private TargetSpec resolveTarget(String consumerAppKey) { private TargetSpec resolveTarget(String consumerAppKey) {
String raw = properties.getTargets().get(consumerAppKey.trim()); String raw = properties.getTargets().get(consumerAppKey.trim());
@@ -105,9 +129,10 @@ public class AnswerReconcileDispatchClient {
return null; return null;
} }
URI uri = URI.create(raw.trim()); URI uri = URI.create(raw.trim());
if (uri.getScheme() == null || uri.getRawAuthority() == null) { if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
log.warn("[对账] 登记的后台地址不是绝对 URL,忽略:appKey={}", consumerAppKey); || uri.getHost() == null || uri.getRawUserInfo() != null
return null; || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException("消费方服务端例外地址必须为 http(s) 基址且不含凭据/query/fragment");
} }
String host = uri.getScheme() + "://" + uri.getRawAuthority(); String host = uri.getScheme() + "://" + uri.getRawAuthority();
String path = uri.getRawPath() == null ? "" : uri.getRawPath(); String path = uri.getRawPath() == null ? "" : uri.getRawPath();
@@ -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);
}
}