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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user