refactor(sdk): 出站全端点收敛到服务目录命名目标 + 零专属地址配置
build / build-push (push) Successful in 3m20s

- FlowCenterClient:apply/getInstanceByBiz/registerCallback 由 ad-hoc TargetSpec
  改走 YschFlowOpenApi 门面(ApplyBody/CallbackRegBody/ApplyResultVo/CallbackRegVo
  继承 SDK DTO);instance 走 raw() 预绑视图保留 Map 全字段透传。
- CmsSiteDirectoryImpl:DIRECTORY_KEY 包可见;目录未配且 svcchall.cms.base-url
  缺失时构造期 fail-fast(原 @Value 无默认值会直接炸启动,无明确提示)。
- FlowCenterClient:svchall.flow.base-url 默认值去掉 + 同款 fail-fast(enabled=false 不校验)。
- application.yml:新增 yh.open.services.{flowcenter,cms},删 svchall.cms.base-url /
  svchall.flow.base-url —— 地址唯一来源=服务目录。
- 测试:新增 FlowCenterClientFacadeTest(6 例,冻结门面/归一目标/回落/fail-fast);
  CmsSiteDirectoryImplTest 加目录命名目标 + fail-fast 用例。112 测试全绿。
This commit is contained in:
chenxing
2026-09-16 21:04:28 +08:00
parent 47beb4eaa4
commit e462b71bef
6 changed files with 336 additions and 46 deletions
@@ -19,8 +19,11 @@ 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.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;
@@ -157,4 +160,38 @@ class CmsSiteDirectoryImplTest {
// ctx 必须进 basePath,否则签名 target 与服务端剥掉 ctx 后不一致 → 401
assertEquals("/yschcms", spec.getBasePath());
}
// ===== 服务目录命名目标(零专属地址配置;CI 源仓迁移后由 projman 注入 yh.open.services.cms)=====
@Test
@DisplayName("目录已配 cms:走命名目标,且 legacy 键可缺省(不再要求 svchall.cms.base-url)")
void directoryConfigured_usesNamedTargetAndNeedsNoLegacyKey() {
YschOpenCall call = mock(YschOpenCall.class);
when(call.hasTarget(CmsSiteDirectoryImpl.DIRECTORY_KEY)).thenReturn(true);
when(call.getList(eq(CmsSiteDirectoryImpl.DIRECTORY_KEY), eq(PATH),
eq(Collections.emptyMap()), eq(VO_TYPE)))
.thenReturn(Collections.singletonList(vo("S1", "站点一", 1L)));
// legacy 键为空串:目录已配时不得因此失败("无需再配置地址")
CmsSiteDirectoryImpl d = new CmsSiteDirectoryImpl(call, "");
SiteDirectory.SiteInfo site = d.resolveSite("S1");
assertEquals("S1", site.getSiteCode());
assertEquals(1L, site.getSchId());
// 不得再触达 ad-hoc TargetSpec 分支(构造期 hasTarget 探测除外)
verify(call, never()).getList(any(TargetSpec.class), anyString(), anyMap(), eq(VO_TYPE));
}
@Test
@DisplayName("目录未配且 legacy 键为空:构造期 fail-fast,文案同时指向两个配置键")
void misconfiguredFailsFast() {
YschOpenCall call = mock(YschOpenCall.class);
when(call.hasTarget(CmsSiteDirectoryImpl.DIRECTORY_KEY)).thenReturn(false);
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> new CmsSiteDirectoryImpl(call, ""));
assertTrue(e.getMessage().contains("yh.open.services." + CmsSiteDirectoryImpl.DIRECTORY_KEY),
e.getMessage());
assertTrue(e.getMessage().contains("svchall.cms.base-url"), e.getMessage());
}
}
@@ -0,0 +1,195 @@
package com.yunhai.svchall.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.flow.YschFlowOpenApi;
import com.yunhai.platform.openapi.flow.impl.DefaultOpenApiFlowClient;
import com.yunhai.platform.openapi.flow.model.FlowApplyBody;
import com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody;
import com.yunhai.svchall.model.svc.SvcModels;
import com.yunhai.svchall.service.FlowClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* svc-hall → flow-center 出站契约测试:服务目录显式配置 {@code flowcenter} 时,
* <b>全部</b>出站(listFlows / getFlow / apply / instance / callback·reg)必须走 SDK 命名目标
* (门面 {@link YschFlowOpenApi} 或预绑 {@code BoundOpenCall}),不再回落应用专属
* {@code svchall.flow.base-url}(《子应用配置优化方案》§4.2 零专属地址配置)。<br><br>
* 此前只有 listFlows/getFlow 走门面,apply/instance/registerCallback 仍用 ad-hoc
* {@code TargetSpec.of(baseUrl,"")}——地址仍来自应用配置,且 {@code hasTarget} 探测与真实调用
* 分支不一致(探测说"目录已配",调用却打 legacy 地址)。
*/
@DisplayName("svc-hall FlowCenterClient - 服务目录命名目标(全端点)")
class FlowCenterClientFacadeTest {
private static final String TARGET = "flowcenter";
private static final String LEGACY_BASE = "http://flow-center-service:10008/flow-center";
private final ObjectMapper objectMapper = new ObjectMapper();
private YschOpenCall openCall;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
when(openCall.hasTarget(TARGET)).thenReturn(true);
// 门面静态入口:setCall 由被测类构造器完成,setClient 由 starter 完成——单测手工对齐装配
YschFlowOpenApi.setCall(openCall);
YschFlowOpenApi.setClient(new DefaultOpenApiFlowClient(openCall));
}
private FlowCenterClient client() {
return new FlowCenterClient(openCall, objectMapper, true, LEGACY_BASE);
}
@Test
@DisplayName("apply:走门面 → 命名目标 + SDK FlowApplyBody 子类 + 出参 instanceId/duplicated")
void apply_goesThroughFacade() {
SvcModels.ApplyResultVo stub = new SvcModels.ApplyResultVo();
stub.setInstanceId(5L);
stub.setDuplicated(true);
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply"), anyMap(),
any(FlowApplyBody.class), eq(SvcModels.ApplyResultVo.class))).thenReturn(stub);
FlowClient.ApplyResult result = client().apply(3L, "guid-1", "svc_item", "1", "刘文娟的教师入职",
"http://base.yunhai/svc-hall/front/#/apply-detail?id=1", "{\"a\":1}", 10133, "刘文娟", null);
assertEquals(Long.valueOf(5L), result.getInstanceId());
assertTrue(result.isDuplicated());
ArgumentCaptor<FlowApplyBody> body = ArgumentCaptor.forClass(FlowApplyBody.class);
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/apply"), query.capture(),
body.capture(), eq(SvcModels.ApplyResultVo.class));
assertEquals("3", query.getValue().get("schId"));
assertEquals("guid-1", body.getValue().getFlowGuid());
assertEquals("svc_item", body.getValue().getBizType());
assertEquals("1", body.getValue().getBizId());
assertEquals(Integer.valueOf(10133), body.getValue().getSubmitterId());
}
@Test
@DisplayName("apply:formDataJson 解析为 formData 对象;空串不传")
void apply_parsesFormData() {
SvcModels.ApplyResultVo stub = new SvcModels.ApplyResultVo();
stub.setInstanceId(6L);
when(openCall.post(eq(TARGET), eq("/oapi/flow/apply"), anyMap(),
any(FlowApplyBody.class), eq(SvcModels.ApplyResultVo.class))).thenReturn(stub);
client().apply(3L, "g", "svc_item", "2", "t", "u", null, 1, "n", "{\"x\":\"y\"}");
ArgumentCaptor<FlowApplyBody> body = ArgumentCaptor.forClass(FlowApplyBody.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/apply"), anyMap(),
body.capture(), eq(SvcModels.ApplyResultVo.class));
assertEquals("y", body.getValue().getFormData().get("x"));
}
@Test
@DisplayName("getInstanceByBiz:走预绑命名目标(Map 全字段透传,不用 ad-hoc spec)")
void instance_usesBoundTarget() {
when(openCall.get(eq(TARGET), eq("/oapi/flow/instance"), anyMap(), eq(Map.class)))
.thenReturn(java.util.Collections.singletonMap("status", 20));
Map<String, Object> out = client().getInstanceByBiz(3L, "svc_item", "2");
assertEquals(Integer.valueOf(20), out.get("status"));
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(eq(TARGET), eq("/oapi/flow/instance"), query.capture(), eq(Map.class));
assertEquals("svc_item", query.getValue().get("bizType"));
assertEquals("2", query.getValue().get("bizId"));
assertEquals("3", query.getValue().get("schId"));
}
@Test
@DisplayName("registerCallback:走门面 + basePath 从回调 URL 反推(/svc-hall 剥离口径)")
void registerCallback_goesThroughFacade() {
SvcModels.CallbackRegVo stub = new SvcModels.CallbackRegVo();
stub.setSecret("secret-1");
when(openCall.post(eq(TARGET), eq("/oapi/flow/callback/reg"), anyMap(),
any(FlowCallbackRegBody.class), eq(SvcModels.CallbackRegVo.class))).thenReturn(stub);
String secret = client().registerCallback(3L, "guid-1",
"http://svc-hall-service:10007/svc-hall/callback/flow", null);
assertEquals("secret-1", secret);
ArgumentCaptor<FlowCallbackRegBody> body = ArgumentCaptor.forClass(FlowCallbackRegBody.class);
verify(openCall).post(eq(TARGET), eq("/oapi/flow/callback/reg"), anyMap(),
body.capture(), eq(SvcModels.CallbackRegVo.class));
assertEquals("/svc-hall", body.getValue().getBasePath());
assertEquals("http://svc-hall-service:10007/svc-hall/callback/flow", body.getValue().getCallbackUrl());
assertNull(body.getValue().getEventTypes());
}
@Test
@DisplayName("listFlows/getFlow:门面命名目标(回归既有行为)")
void listAndDetail_useFacade() {
when(openCall.getList(eq(TARGET), eq("/oapi/flow/list"), anyMap(), eq(SvcModels.FlowSummaryVo.class)))
.thenReturn(java.util.Collections.singletonList(new SvcModels.FlowSummaryVo()));
when(openCall.get(eq(TARGET), eq("/oapi/flow/guid-1"), anyMap(), eq(SvcModels.FlowSummaryVo.class)))
.thenReturn(new SvcModels.FlowSummaryVo());
assertEquals(1, client().listFlows(3L).size());
org.junit.jupiter.api.Assertions.assertNotNull(client().getFlow(3L, "guid-1"));
}
@Test
@DisplayName("目录未配:回落 legacy svchall.flow.base-url(迁移期兼容,行为不变)")
void fallsBackToLegacyWhenDirectoryMissing() {
YschOpenCall legacyCall = mock(YschOpenCall.class);
when(legacyCall.hasTarget(TARGET)).thenReturn(false);
when(legacyCall.post(any(com.yunhai.platform.openapi.core.TargetSpec.class),
eq("/oapi/flow/apply"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class)))
.thenReturn(objectMapper.createObjectNode().put("instanceId", 9));
FlowClient.ApplyResult result = new FlowCenterClient(legacyCall, objectMapper, true, LEGACY_BASE)
.apply(3L, "g", "svc_item", "1", "t", "u", null, 1, "n", null);
assertEquals(Long.valueOf(9L), result.getInstanceId());
assertFalse(result.isDuplicated());
verify(legacyCall).post(any(com.yunhai.platform.openapi.core.TargetSpec.class),
eq("/oapi/flow/apply"), anyMap(), any(), eq(com.fasterxml.jackson.databind.JsonNode.class));
}
@Test
@DisplayName("目录与 legacy 键皆未配且 enabled=true:构造期 fail-fast(不静默打 localhost)")
void misconfiguredFailsFast() {
YschOpenCall noDirectory = mock(YschOpenCall.class);
when(noDirectory.hasTarget(TARGET)).thenReturn(false);
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> new FlowCenterClient(noDirectory, objectMapper, true, ""));
assertTrue(e.getMessage().contains("yh.open.services." + TARGET), e.getMessage());
assertTrue(e.getMessage().contains("svchall.flow.base-url"), e.getMessage());
}
@Test
@DisplayName("enabled=false 且地址全缺:不 fail-fast(显式未接入)")
void disabledWithoutAddressIsAllowed() {
YschOpenCall noDirectory = mock(YschOpenCall.class);
when(noDirectory.hasTarget(TARGET)).thenReturn(false);
FlowCenterClient c = new FlowCenterClient(noDirectory, objectMapper, false, "");
assertFalse(c.enabled());
com.yunhai.platform.apistd.BizException ex =
org.junit.jupiter.api.Assertions.assertThrows(com.yunhai.platform.apistd.BizException.class,
() -> c.listFlows(3L));
assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage());
}
}