feat(condition): SERVICE 判断收口——flw_router 增 direct_host/reg_app_key 两列(登记即持有,跨应用覆盖拒绝);新端点 POST /oapi/flow/router/reg(应用签名即归属);RouteJudgeClient 对 direct_host 行跳过服务目录解析直连(TargetSpec.fromUrl),存量手工行走目录解析零变化;契约测试与 SQL 留档

This commit is contained in:
chenxing
2026-10-12 17:28:13 +08:00
parent 4eefe078e3
commit 62aff51fed
9 changed files with 440 additions and 5 deletions
@@ -19,14 +19,15 @@ import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 路由判断客户端单测(2026-09-24 §4.3):重试纪律(3s×3 退避重试,Mock 出网)、
* flw_router.base_path 对目录前缀的覆盖、信封 data.outlet 解析。
* 引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。
* flw_router.base_path 对目录前缀的覆盖、direct_host 自注册行直连(跳过目录解析,2026-10-12 收口)、
* 信封 data.outlet 解析。引擎行为(选路/挂起/重跑)由 FlowEngineIT 覆盖。
*/
class RouteJudgeClientTest {
@@ -88,6 +89,55 @@ class RouteJudgeClientTest {
assertEquals("/dirctx", spec.getValue().getBasePath(), "base_path 空=沿用目录条目 context-path");
}
@Test
void directHost_非空时拆基址直连_不查服务目录() {
FlwRouter r = router("");
r.setDirectHost("http://open-gateway-service:10013/work-leave");
Map<String, Object> data = new HashMap<>();
data.put("outlet", "甲线");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenReturn(data);
RouteJudgeClient.JudgeResult result = client.call(r, body());
assertEquals("甲线", result.outlet);
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
assertEquals("http://open-gateway-service:10013", spec.getValue().getBaseHost(), "direct_host 拆 authority");
assertEquals("/work-leave", spec.getValue().getBasePath(), "direct_host 的 path 段即 context-path(签名剥离面)");
assertEquals((Long) 3000L, spec.getValue().getReadTimeoutMs(), "直连仍用判断专用 3s 超时");
verify(openCall, never()).resolveTarget(anyString());
}
@Test
void directHost_无path段_basePath为空() {
FlwRouter r = router("");
r.setDirectHost("http://work-leave:8080");
Map<String, Object> data = new HashMap<>();
data.put("outlet", "甲线");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenReturn(data);
client.call(r, body());
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).post(spec.capture(), anyString(), anyMap(), any(), eq(Map.class));
assertEquals("http://work-leave:8080", spec.getValue().getBaseHost());
assertEquals("", spec.getValue().getBasePath());
}
@Test
void directHost为空_存量行仍走目录解析() {
Map<String, Object> data = new HashMap<>();
data.put("outlet", "甲线");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
.thenReturn(data);
client.call(router(""), body());
verify(openCall).resolveTarget("notice");
}
@Test
void transportFailure_retriesThreeTimes_thenThrows() {
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(Map.class)))
@@ -3,7 +3,9 @@ package com.yunhai.flowcenter.oapi;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.service.DiagramTokenService;
import com.yunhai.flowcenter.service.FlowRouterAdminService;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.flowcenter.support.FlowTestSupport;
import com.yunhai.flowcenter.support.OapiSignedClient;
@@ -50,6 +52,9 @@ class OapiFlowContractIT extends FlowTestSupport {
@Autowired
private OapiFlowService flowService;
@Autowired
private FlowRouterAdminService routerAdminService;
@Autowired
private DiagramTokenService tokenService;
@@ -507,6 +512,78 @@ class OapiFlowContractIT extends FlowTestSupport {
}
}
/**
* 判断服务自注册契约(2026-10-12 SERVICE 收口,yh-flow-callback starter 的落点):未验签 401;
* 登记即持有(新增行带 directHost、regAppKey/serviceKey=签名 appKey);同键重复登记幂等更新
* (status 不被翻正);校验项业务码拒绝。跨应用覆盖拒绝见 FlowRouterAdminServiceRegisterTest。
*/
@Test
void routerReg_selfRegistration_contract() {
String reg = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断\","
+ "\"judgePath\":\"/oapi/flow/route/judge\","
+ "\"directHost\":\"http://open-gateway-service:10013/svc-hall\"}";
// 未验签(不带签名头)→ 401,不落行(本路径同受 /oapi/* 强制验签保护)
assertEquals(401, client.getRaw("/oapi/flow/router/reg").getStatusCodeValue());
assertFalse(routerRepository.findByRouterKey("svc-hall.demo-judge").isPresent());
// 登记 → 行落库,归属=签名 appKey
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class)
.getBody().get("code").asInt(), "自注册应成功");
FlwRouter row = routerRepository.findByRouterKey("svc-hall.demo-judge").get();
assertEquals(APP_KEY, row.getRegAppKey());
assertEquals(APP_KEY, row.getServiceKey(), "serviceKey 填归属 appKey(管理端『来源』展示用)");
assertEquals("http://open-gateway-service:10013/svc-hall", row.getDirectHost());
assertEquals("/oapi/flow/route/judge", row.getPath());
assertEquals(1, row.getStatus().intValue());
Long id = row.getId();
// 管理员停用后自注册 → 仍成功但不翻正 status(控制权在管理员)
routerAdminService.setStatus(id, false);
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class).getBody().get("code").asInt());
assertEquals(0, routerRepository.findByRouterKey("svc-hall.demo-judge").get().getStatus().intValue(),
"停用状态不被自注册翻正");
// 重复登记(改名+换址)→ 同一行更新,不新增
String again = "{\"routerKey\":\"svc-hall.demo-judge\",\"name\":\"演示判断2\","
+ "\"judgePath\":\"/oapi/flow/route/judge2\",\"directHost\":\"http://new-host:9000/svc-hall\"}";
assertEquals(0, client.post("/oapi/flow/router/reg", again, JsonNode.class)
.getBody().get("code").asInt());
FlwRouter updated = routerRepository.findByRouterKey("svc-hall.demo-judge").get();
assertEquals(id, updated.getId(), "幂等 upsert,不应产生第二行");
assertEquals("演示判断2", updated.getName());
assertEquals("http://new-host:9000/svc-hall", updated.getDirectHost());
assertEquals("/oapi/flow/route/judge2", updated.getPath());
// 校验项拒绝
assertNotEquals(0, client.post("/oapi/flow/router/reg",
"{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"oapi/x\",\"directHost\":\"http://h:1\"}",
JsonNode.class).getBody().get("code").asInt(), "judgePath 缺前导 / 应拒绝");
assertNotEquals(0, client.post("/oapi/flow/router/reg",
"{\"routerKey\":\"a.b\",\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"h:1\"}",
JsonNode.class).getBody().get("code").asInt(), "directHost 非 http(s) 基址应拒绝");
assertNotEquals(0, client.post("/oapi/flow/router/reg",
"{\"name\":\"n\",\"judgePath\":\"/oapi/x\",\"directHost\":\"http://h:1\"}",
JsonNode.class).getBody().get("code").asInt(), "routerKey 缺失应拒绝");
}
/**
* 自注册行天然满足 SERVICE 发布校验(2026-10-12 验收项):DefValidator 只认 routerKey
* 已注册且启用,与 serviceKey 形态无关(自注册行 serviceKey 填的是数值 appKey)。
*/
@Test
void routerReg_selfRegisteredRow_passesServicePublishValidation() {
mapDept("km", 101, "甲");
mapDept("kb", 102, "乙");
String reg = "{\"routerKey\":\"svc-hall.cond-judge\",\"name\":\"条件判断\","
+ "\"judgePath\":\"/oapi/flow/route/judge\",\"directHost\":\"http://h:1/svc-hall\"}";
assertEquals(0, client.post("/oapi/flow/router/reg", reg, JsonNode.class)
.getBody().get("code").asInt());
FlwDef def = saveAndPublishDef("t17", "自注册引用", conditionDef("svc-hall.cond-judge", "km", "kb"));
assertEquals(1, def.getVersion().intValue(), "引用自注册行的 SERVICE 定义应发布成功");
}
/** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */
private boolean awaitPayloadContains(List<String> payloads, String needle) throws InterruptedException {
for (int i = 0; i < 25; i++) {
@@ -0,0 +1,171 @@
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwRouter;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwRouterRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 判断服务自注册登记(2026-10-12 SERVICE 收口,/oapi/flow/router/reg 的服务层):
* 登记即持有(新增行 serviceKey=归属 appKey、directHost 非空)、同 appKey 幂等更新且 status 不动、
* 手工行首次自注册认领、跨应用覆盖拒绝、校验项与未验签(appKey 空)拒绝。
*/
class FlowRouterAdminServiceRegisterTest {
private static final String APP = "100021";
private FlwRouterRepository routerRepository;
private FlowRouterAdminService service;
@BeforeEach
void setUp() {
routerRepository = mock(FlwRouterRepository.class);
service = new FlowRouterAdminService(routerRepository,
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
mock(com.yunhai.flowcenter.repository.FlwDefRepository.class),
mock(com.yunhai.flowcenter.engine.FlowEngine.class),
new com.fasterxml.jackson.databind.ObjectMapper());
when(routerRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
}
private OapiModels.RouterRegBody body() {
OapiModels.RouterRegBody b = new OapiModels.RouterRegBody();
b.setRouterKey("work-leave.days-judge");
b.setName("请假天数分级");
b.setJudgePath("/oapi/flow/route/judge");
b.setDirectHost("http://open-gateway-service:10013/work-leave");
return b;
}
private FlwRouter existing(String regAppKey, Integer status) {
FlwRouter r = new FlwRouter();
r.setId(7L);
r.setRouterKey("work-leave.days-judge");
r.setName("旧名");
r.setServiceKey("work-leave");
r.setPath("/oapi/legacy/judge");
r.setBasePath("/work-leave");
r.setRegAppKey(regAppKey);
r.setStatus(status);
return r;
}
@Test
void 新键_建行填归属并带直连基址() {
when(routerRepository.findByRouterKey(anyString())).thenReturn(Optional.empty());
FlwRouter saved = service.registerByApp(APP, body());
ArgumentCaptor<FlwRouter> captor = ArgumentCaptor.forClass(FlwRouter.class);
verify(routerRepository).save(captor.capture());
FlwRouter row = captor.getValue();
assertEquals("work-leave.days-judge", row.getRouterKey());
assertEquals("请假天数分级", row.getName());
assertEquals("/oapi/flow/route/judge", row.getPath(), "path=judgePath");
assertEquals("http://open-gateway-service:10013/work-leave", row.getDirectHost());
assertEquals(APP, row.getServiceKey(), "NOT NULL 列填归属 appKey(展示来源用)");
assertEquals(APP, row.getRegAppKey());
assertEquals(1, row.getStatus().intValue());
assertNull(row.getBasePath(), "自注册行不填 base_path(直连不再需要目录前缀)");
}
@Test
void 同appKey重复登记_幂等更新地址_status不动() {
when(routerRepository.findByRouterKey("work-leave.days-judge"))
.thenReturn(Optional.of(existing(APP, 0)));
OapiModels.RouterRegBody b = body();
b.setName("改名后的判断");
b.setDirectHost("http://new-host:9000/work-leave");
service.registerByApp(APP, b);
ArgumentCaptor<FlwRouter> captor = ArgumentCaptor.forClass(FlwRouter.class);
verify(routerRepository).save(captor.capture());
FlwRouter row = captor.getValue();
assertEquals("改名后的判断", row.getName());
assertEquals("http://new-host:9000/work-leave", row.getDirectHost());
assertEquals("/oapi/flow/route/judge", row.getPath());
assertEquals(0, row.getStatus().intValue(), "停用状态不被自注册翻正");
}
@Test
void 管理端手工行_首次自注册即认领() {
when(routerRepository.findByRouterKey("work-leave.days-judge"))
.thenReturn(Optional.of(existing(null, 1)));
service.registerByApp(APP, body());
ArgumentCaptor<FlwRouter> captor = ArgumentCaptor.forClass(FlwRouter.class);
verify(routerRepository).save(captor.capture());
assertEquals(APP, captor.getValue().getRegAppKey());
assertEquals("http://open-gateway-service:10013/work-leave", captor.getValue().getDirectHost());
}
@Test
void 跨应用覆盖_拒绝并保留原行() {
when(routerRepository.findByRouterKey("work-leave.days-judge"))
.thenReturn(Optional.of(existing("100099", 1)));
BizException e = assertThrows(BizException.class, () -> service.registerByApp(APP, body()));
assertEquals(ErrorCode.FORBIDDEN, e.getCode());
assertTrue(e.getMessage().contains("100099"), "错误信息要能定位占用方:" + e.getMessage());
verify(routerRepository, never()).save(any());
}
@Test
void 未验签调用_appKey空_拒绝() {
assertEquals(ErrorCode.UNAUTHORIZED,
assertThrows(BizException.class, () -> service.registerByApp(null, body())).getCode());
assertEquals(ErrorCode.UNAUTHORIZED,
assertThrows(BizException.class, () -> service.registerByApp(" ", body())).getCode());
verify(routerRepository, never()).save(any());
}
@Test
void 校验项逐个拒绝() {
assertParam(reg -> reg.setRouterKey(" "));
assertParam(reg -> reg.setRouterKey(repeat("k", 65)));
assertParam(reg -> reg.setName(" "));
assertParam(reg -> reg.setName(repeat("名", 129)));
assertParam(reg -> reg.setJudgePath("oapi/flow/route/judge"));
assertParam(reg -> reg.setDirectHost("open-gateway-service:10013/work-leave"));
assertParam(reg -> reg.setDirectHost("ftp://host/work-leave"));
// 校验全过:一条正向兜底,防"全部断言都靠拒绝"漏掉正常路径
when(routerRepository.findByRouterKey(anyString())).thenReturn(Optional.empty());
service.registerByApp(APP, body());
verify(routerRepository).save(any());
}
private void assertParam(java.util.function.Consumer<OapiModels.RouterRegBody> mutate) {
OapiModels.RouterRegBody b = body();
mutate.accept(b);
BizException e = assertThrows(BizException.class, () -> service.registerByApp(APP, b));
assertEquals(ErrorCode.PARAM_ERROR, e.getCode());
}
private static String repeat(String s, int times) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < times; i++) {
sb.append(s);
}
return sb.toString();
}
}