feat(openapi): 对账派发改 by-key 注册表寻址(appCode 白名单+fail-closed),显式 targets 例外保留
build / build-push (push) Successful in 1m27s
build / build-push (push) Successful in 1m27s
This commit is contained in:
@@ -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<String, String> targets = new java.util.LinkedHashMap<>();
|
||||
/** 派发路径(默认对账指令接收端点;不开放成可配任意外部 URL 的形态) */
|
||||
private String dispatchPath = "/oapi/form-sync/reconcile";
|
||||
|
||||
@@ -17,8 +17,8 @@ import java.net.URI;
|
||||
*
|
||||
* <p>出站走平台 Tier-0 {@link YschOpenCall}:签名三件套由 starter 拦截器注入,
|
||||
* 本应用身份取 {@code yh.app-auth},<b>密钥不进代码也不进日志</b>。
|
||||
* 目标地址只有一个来源——{@code fc.answer.reconcile.targets} 里按<b>已验签 consumerAppKey</b>
|
||||
* 登记的后台服务基址;请求体里、浏览器里出现的任何 URL 都不参与寻址(任务书改动点 3 末句)。</p>
|
||||
* 寻址优先保留服务端显式 targets 覆盖;否则由 UserBase 注册记录把 consumerAppKey
|
||||
* 映射为标准 appCode,再经 SDK 统一内部网关。消息体/浏览器自报 URL 不参与寻址。</p>
|
||||
*
|
||||
* <p>失败语义:本类<b>不抛异常</b>。派发失败对上层只是一个稳定错误码,
|
||||
* 由任务状态机决定退避重派还是 FAILED——把网络异常抛穿到扫描线程只会让闹钟自己死掉。</p>
|
||||
@@ -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.<String, String>emptyMap(), instruction, InstructionAck.class);
|
||||
TargetSpec spec = resolveTarget(instruction.getConsumerAppKey());
|
||||
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())) {
|
||||
// 应答里的任务身份对不上=不能算这条指令被收下(宁可重派,也不要一个来路不明的 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user