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
@@ -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 {
}
}
@@ -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();
@@ -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<String, Object> 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<String, Object> 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);
+16 -14
View File
@@ -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
@@ -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());
}
}