diff --git a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java index 0d3c6b9..19331d4 100644 --- a/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java +++ b/src/main/java/com/yunhai/svchall/model/svc/SvcModels.java @@ -471,11 +471,35 @@ public final class SvcModels { private Integer pageSize = 10; } - // ===== 流程(管理端回显) ===== + // ===== 流程(管理端回显 + flow-center 门面出入参) ===== /** 字段单一事实源=SDK 门面 DTO(可直接作 YschFlowOpenApi 泛型出入参) */ @Data @lombok.EqualsAndHashCode(callSuper = true) public static class FlowSummaryVo extends com.yunhai.platform.openapi.flow.model.FlowSummary { } + + /** 代发起请求体(门面 {@code YschFlowOpenApi.apply} 的 B 绑定;字段继承 SDK FlowApplyBody) */ + @Data + @lombok.EqualsAndHashCode(callSuper = true) + public static class ApplyBody extends com.yunhai.platform.openapi.flow.model.FlowApplyBody { + } + + /** 代发起响应(门面 R 绑定;{@code duplicated=true} 表示命中 biz uk 幂等返回既有办件) */ + @Data + @lombok.EqualsAndHashCode(callSuper = true) + public static class ApplyResultVo extends com.yunhai.platform.openapi.flow.model.FlowApplyResult { + } + + /** 回调注册请求体(门面 {@code YschFlowOpenApi.registerCallback} 的 B 绑定) */ + @Data + @lombok.EqualsAndHashCode(callSuper = true) + public static class CallbackRegBody extends com.yunhai.platform.openapi.flow.model.FlowCallbackRegBody { + } + + /** 回调注册响应(下发的 secret 用于验 X-Flw-Signature) */ + @Data + @lombok.EqualsAndHashCode(callSuper = true) + public static class CallbackRegVo extends com.yunhai.platform.openapi.flow.model.FlowCallbackRegResult { + } } diff --git a/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java b/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java index 6b84e14..64976a7 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java +++ b/src/main/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImpl.java @@ -43,21 +43,28 @@ public class CmsSiteDirectoryImpl implements SiteDirectory { .build(); /** 服务目录命名目标(cms 未晋升枚举,按名直调);目录未配置时为 null=走 legacy ad-hoc spec */ - private static final String DIRECTORY_KEY = "cms"; + static final String DIRECTORY_KEY = "cms"; + + private static final String LEGACY_KEY = "svchall.cms.base-url"; private final YschOpenCall openCall; private final TargetSpec cmsSpec; private final boolean directoryResolved; public CmsSiteDirectoryImpl(YschOpenCall openCall, - @Value("${svchall.cms.base-url}") String cmsBaseUrl) { + @Value("${svchall.cms.base-url:}") String cmsBaseUrl) { this.openCall = openCall; - this.directoryResolved = openCall.hasTarget(DIRECTORY_KEY); + this.directoryResolved = openCall != null && openCall.hasTarget(DIRECTORY_KEY); if (directoryResolved) { this.cmsSpec = null; return; } String base = trimTrailing(cmsBaseUrl == null ? "" : cmsBaseUrl.trim()); + if (base.isEmpty()) { + // 目录未配且 legacy 键也空:构造期 fail-fast——别把"地址没配"伪装成"站点服务不可达" + throw new IllegalStateException("CMS 站点目录调用地址未配置:请配置服务目录 yh.open.services." + + DIRECTORY_KEY + "(推荐,容器直连完整 URL)或回落键 " + LEGACY_KEY); + } URI uri = URI.create(base); String host = uri.getScheme() + "://" + uri.getRawAuthority(); String path = uri.getRawPath() == null ? "" : uri.getRawPath(); diff --git a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java index 5f34498..301a093 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java +++ b/src/main/java/com/yunhai/svchall/service/impl/FlowCenterClient.java @@ -42,20 +42,27 @@ public class FlowCenterClient implements FlowClient { public FlowCenterClient(YschOpenCall openCall, ObjectMapper objectMapper, @Value("${svchall.flow.enabled:false}") boolean enabled, - @Value("${svchall.flow.base-url:http://localhost:8085}") String baseUrl) { + @Value("${svchall.flow.base-url:}") String baseUrl) { this.openCall = openCall; this.objectMapper = objectMapper; this.enabled = enabled; String base = trimTrailing(baseUrl == null ? "" : baseUrl.trim()); - URI uri = URI.create(base); - String host = uri.getScheme() + "://" + uri.getRawAuthority(); - String path = uri.getRawPath() == null ? "" : uri.getRawPath(); - this.flowSpec = TargetSpec.of(host, path); - this.directoryResolved = openCall.hasTarget( + this.directoryResolved = openCall != null && openCall.hasTarget( com.yunhai.platform.openapi.core.PlatformService.FLOWCENTER.key()); if (directoryResolved) { com.yunhai.platform.openapi.flow.YschFlowOpenApi.setCall(openCall); } + if (enabled && base.isEmpty() && !directoryResolved) { + // 目录未配且 legacy 键也空:构造期 fail-fast——别把"地址没配"伪装成"流程中心不可达"。 + // enabled=false(显式未接入)时不校验——地址本就不需要。 + throw new IllegalStateException("流程中心调用地址未配置:请配置服务目录 yh.open.services." + + com.yunhai.platform.openapi.core.PlatformService.FLOWCENTER.key() + + "(推荐,容器直连完整 URL)或回落键 svchall.flow.base-url"); + } + URI uri = URI.create(base); + String host = uri.getScheme() + "://" + uri.getRawAuthority(); + String path = uri.getRawPath() == null ? "" : uri.getRawPath(); + this.flowSpec = TargetSpec.of(host, path); } @Override @@ -97,26 +104,30 @@ public class FlowCenterClient implements FlowClient { String bizUrl, String bizSnapshot, Integer submitterId, String submitterName, String formDataJson) { requireEnabled(); - Map body = new HashMap<>(); - body.put("flowGuid", flowGuid); - body.put("bizType", bizType); - body.put("bizId", bizId); - body.put("bizTitle", bizTitle); - if (bizUrl != null) { - body.put("bizUrl", bizUrl); - } - if (bizSnapshot != null) { - body.put("bizSnapshot", bizSnapshot); - } - body.put("submitterId", submitterId); - body.put("submitterName", submitterName); + SvcModels.ApplyBody body = new SvcModels.ApplyBody(); + body.setFlowGuid(flowGuid); + body.setBizType(bizType); + body.setBizId(bizId); + body.setBizTitle(bizTitle); + body.setBizUrl(bizUrl); + body.setBizSnapshot(bizSnapshot); + body.setSubmitterId(submitterId); + body.setSubmitterName(submitterName); if (formDataJson != null && !formDataJson.isEmpty()) { try { - body.put("formData", objectMapper.readValue(formDataJson, Map.class)); + body.setFormData(objectMapper.readValue(formDataJson, Map.class)); } catch (Exception e) { throw new BizException(ErrorCode.PARAM_ERROR, "表单数据不是合法 JSON 对象"); } } + if (directoryResolved) { + SvcModels.ApplyResultVo result = com.yunhai.platform.openapi.flow.YschFlowOpenApi.apply( + schId, body, SvcModels.ApplyResultVo.class); + if (result == null || result.getInstanceId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心代发起返回异常(缺少 instanceId)"); + } + return new ApplyResult(result.getInstanceId(), result.isDuplicated()); + } JsonNode data = openCall.post(flowSpec, "/oapi/flow/apply", Collections.singletonMap("schId", String.valueOf(schId)), body, JsonNode.class); if (data == null || data.get("instanceId") == null) { @@ -133,24 +144,38 @@ public class FlowCenterClient implements FlowClient { query.put("schId", String.valueOf(schId)); query.put("bizType", bizType); query.put("bizId", bizId); + if (directoryResolved) { + // 门面 instance(...) 只反序列化 FlowInstance 的字段子集;本方法契约是 Map 全字段透传 + // (对账/查看守卫要 status/version/assigneeIds/nodeSummary…),故走预绑 flowcenter 的 + // 泛化视图 raw()——目标名由门面承载,不再回落应用专属地址。 + return com.yunhai.platform.openapi.flow.YschFlowOpenApi.raw() + .get("/oapi/flow/instance", query, Map.class); + } return openCall.get(flowSpec, "/oapi/flow/instance", query, Map.class); } @Override public String registerCallback(Long schId, String flowGuid, String callbackUrl, String eventTypes) { requireEnabled(); - Map body = new HashMap<>(); - body.put("flowGuid", flowGuid); - body.put("callbackUrl", callbackUrl); - if (eventTypes != null && !eventTypes.isEmpty()) { - body.put("eventTypes", eventTypes); - } + SvcModels.CallbackRegBody body = new SvcModels.CallbackRegBody(); + body.setFlowGuid(flowGuid); + body.setCallbackUrl(callbackUrl); + body.setEventTypes(eventTypes); // 申报本服务 context-path(从回调 URL path 段推导,去掉尾部 /callback/flow): // 推送方按它剥离后再签应用签名,与本服务 AppAuthFilter/FlowCallbackVerifyFilter 剥 - // contextPath 后的 target 对齐——本地裸地址(无 ctx)推导为空串,行为不变。 + // contextPath 后的 target 对齐——本地裸地址(无 ctx)推导为空串,行为不变(不下发空串)。 String basePath = deriveCallbackBasePath(callbackUrl); if (basePath != null && !basePath.isEmpty()) { - body.put("basePath", basePath); + body.setBasePath(basePath); + } + if (directoryResolved) { + SvcModels.CallbackRegVo result = com.yunhai.platform.openapi.flow.YschFlowOpenApi + .registerCallback(schId, body, SvcModels.CallbackRegVo.class); + Object secret = result == null ? null : result.getSecret(); + if (secret == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "流程中心回调注册返回异常(缺少 secret)"); + } + return String.valueOf(secret); } Map data = openCall.post(flowSpec, "/oapi/flow/callback/reg", Collections.singletonMap("schId", String.valueOf(schId)), body, Map.class); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 489b8d1..4aa8c59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,13 +59,17 @@ yh: trusted-apps: 100005: 2c7885de594742cc840b8762910b2d2e open: - enabled: true + # 身份密钥唯一来源 = yh.app-auth.app-key/app-secret(本文件 app-auth 段),open.* 副本已删 base-host: http://base.yunhaixingtong.com base-path: /userbase - app-key: 100018 - # 联调态密钥(与 flow-center trusted-apps["100018"] 逐字一致); - # 注意旧占位"裸 32 个 0"会被 YAML 按八进制整数解析成 "0",签名时 SignUtil 校验直接失败,必须带字母+引号 - app-secret: "svc-hall-dev-secret-100018" + # 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 / + # YschOpenCall。cms 未晋升 PlatformService 枚举(按名直调);flowcenter 已入枚举,此处 + # 显式覆盖为本地端口。完整 URL 拆 host+ctx,per-target base-path 与提供方 context-path + # 对齐 → 签名天然一致。生产改容器直连:flowcenter=http://flow-center-service:10008/flow-center、 + # cms=http://yschcms-service:10005/yschcms。 + services: + flowcenter: http://localhost:8085 + cms: http://base.yunhaixingtong.com:10005/yschcms # ===== 办事大厅专属配置 ===== svchall: @@ -78,17 +82,15 @@ svchall: # 服务部门"容器组"类型(UserBase GroupTypeEnum,默认 7=教师组;口径承袭 CMS cms.svc.item.dept-group-type) dept-group-type: 7 cms: - # CMS 基址(站点目录 /oapi/site/list 数据源)。必须指向**后端**且带 context-path: - # 内网 CMS 挂在 base 域下 /yschcms,但 Caddy 只反代 /yschcms/{front,api,portal}, - # 没有 /oapi 路由——用 http://base.yunhaixingtong.com/yschcms 打 /oapi/* 会拿到 Caddy 404, - # 因此走容器直连端口 10005(实测 /yschcms/oapi/site/list 能到应用)。 - # 本机起 CMS(Cms/local-debug/start-cms.bat)时改用 http://localhost:8072/cms; - # 生产由 projman 注入覆盖。 - base-url: http://base.yunhaixingtong.com:10005/yschcms + # CMS 基址已迁至服务目录 yh.open.services.cms(上方 open 段)。 + # 务必指向**后端**且带 context-path:内网 CMS 挂在 base 域下 /yschcms,但 Caddy 只反代 + # /yschcms/{front,api,portal},没有 /oapi 路由——用 http://base.yunhaixingtong.com/yschcms + # 打 /oapi/* 会拿到 Caddy 404,因此必须是容器直连端口 10005 + # (实测 /yschcms/oapi/site/list 能到应用)。 flow: - # 流程中心 flow-center 基址;2026-09-12 联调置 true(本地 8085 已部署 flow-center M1) + # 流程中心 flow-center 基址已迁至服务目录 yh.open.services.flowcenter(上方 open 段)。 + # enabled=false(本地未部署 flow-center)时全部方法抛业务异常,消费方按需降级。 enabled: true - base-url: http://localhost:8085 # 对账 Job 应急闸(契约冻结"消费方免每日对账":flow-center 持续重试+死信+挂起告警为主链路)。 # 默认 false 停用;仅大面积漂移事故应急时置 true 开闸(每日 04:00 跑) reconcile-enabled: false diff --git a/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java b/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java index 4e581d7..7a3c2e8 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/CmsSiteDirectoryImplTest.java @@ -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()); + } } diff --git a/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java b/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java new file mode 100644 index 0000000..8a5c715 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/FlowCenterClientFacadeTest.java @@ -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} 时, + * 全部出站(listFlows / getFlow / apply / instance / callback·reg)必须走 SDK 命名目标 + * (门面 {@link YschFlowOpenApi} 或预绑 {@code BoundOpenCall}),不再回落应用专属 + * {@code svchall.flow.base-url}(《子应用配置优化方案》§4.2 零专属地址配置)。

+ * 此前只有 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 body = ArgumentCaptor.forClass(FlowApplyBody.class); + ArgumentCaptor> 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 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 out = client().getInstanceByBiz(3L, "svc_item", "2"); + + assertEquals(Integer.valueOf(20), out.get("status")); + ArgumentCaptor> 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 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()); + } +}