From 649ad66264f3622aacb7bf3128255a94d57d8613 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 16 Sep 2026 21:05:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(sdk)!:=20CMS=E2=86=92svc-hall=20?= =?UTF-8?q?=E5=87=BA=E7=AB=99=E6=94=B9=E8=B5=B0=20YschOpenCall=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=9B=AE=E6=A0=87=20+=20=E9=80=82=E9=85=8D=20SDK=20?= =?UTF-8?q?=E9=A2=86=E5=9F=9F=E5=88=86=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SvcHallClient:由手搓 IDHttp + yh.svchall.base-url 拼 URL 改为 YschOpenCall 命名目标 svchall(服务目录 yh.open.services.svchall)。原形态对带 context-path 的 svc-hall 必 401(共享拦截器只剥 yh.open.base-path=/userbase);per-target base-path 与提供方 ctx 对齐后签名天然一致。目录未配时回落 legacy 键;两者皆缺则构造期 fail-fast。 - SvcItemRemoteQuery:新增 businessAbsent 判定,统一两条出站路径的「业务 code=2 → 404 哨兵/空集,其余上抛」语义(目录路径抛 DException,legacy 抛 DHttpException)。 - import 迁移:YschBaseOpenApi → openapi.base.*(SDK 领域分包)。 - application.yml:新增 yh.open.services.svchall,删 yh.svchall.base-url。 - 测试:新增 SvcHallClientTest(8 例:目录目标/参数映射/空参不下发/fire-and-forget/ 回落/fail-fast);SvcItemRemoteQueryTest 补目录路径错误语义 4 例。104 测试全绿。 --- .../service/impl/CmsUserCenterService.java | 2 +- .../cms/service/impl/SchoolService.java | 2 +- .../svcitem/service/impl/SvcHallClient.java | 147 +++++++++---- .../service/impl/SvcItemRemoteQuery.java | 37 ++++ src/main/resources/application.yml | 17 +- .../service/impl/SvcHallClientTest.java | 205 ++++++++++++++++++ .../service/impl/SvcItemRemoteQueryTest.java | 44 ++++ src/test/resources/application.yml | 7 +- 8 files changed, 411 insertions(+), 50 deletions(-) create mode 100644 src/test/java/com/yunhai/cms/svcitem/service/impl/SvcHallClientTest.java diff --git a/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java b/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java index 862ba08..cba1302 100644 --- a/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java +++ b/src/main/java/com/yunhai/cms/service/impl/CmsUserCenterService.java @@ -1,7 +1,7 @@ package com.yunhai.cms.service.impl; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaModule; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/yunhai/cms/service/impl/SchoolService.java b/src/main/java/com/yunhai/cms/service/impl/SchoolService.java index 5af5a39..6408815 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SchoolService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SchoolService.java @@ -4,7 +4,7 @@ import com.yunhai.cms.model.vo.SchoolVo; import com.yunhai.cms.model.vo.SiteVo; import com.yunhai.cms.repository.CmsArticleRepository; import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaUser; diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java index 4551431..a4cd7e6 100644 --- a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcHallClient.java @@ -1,76 +1,111 @@ package com.yunhai.cms.svcitem.service.impl; import com.fasterxml.jackson.databind.JsonNode; +import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.utils.http.IDHttp; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + /** - * 办事大厅开放接口客户端(V2.0 Part C.3):经共享 IDHttp(出站自动签名,yh.app-auth 身份 - * appKey=100005)调 svc-hall 的 /oapi/svc/**;办事大厅侧 RequireAppSignFilter 验签, - * 其 yh.app-auth.trusted-apps 须包含 CMS 的 appKey(运维项)。 - * 信封 {code,msg,data} 由 IDHttp 解包(code!=0 抛业务异常,即办事大厅 code=2 语义在此转异常)。 + * 办事大厅开放接口客户端(V2.0 Part C.3):经 SDK {@link YschOpenCall} 命名目标 + * {@code svchall}(服务目录 {@code yh.open.services.svchall})调 svc-hall {@code /oapi/svc/**}; + * 出站签名/信封解包/错误映射全部由 SDK 承载——本应用零专属地址配置 + * (《子应用配置优化方案》§4.2)。

+ * 为何不能再用共享 {@code IDHttp}:svc-hall 带 context-path {@code /svc-hall},而共享 + * 拦截器只剥 {@code yh.open.base-path}(本应用 {@code /userbase})——原手工拼的 URL 会被签成 + * {@code /svc-hall/oapi/...},服务端剥掉 ctx 后两侧 target 不一致,必然 401。 + * {@link YschOpenCall} 的 per-target base-path 与提供方 context-path 对齐(同 svc-hall + * {@code CmsSiteDirectoryImpl} 的既有范式)。

+ * 错误语义:业务失败信封 {@code code!=0} → {@code DException}(携带 OpenApiErrorCode, + * 办事大厅"不存在/未发布"= code 2);传输/HTTP 失败 → {@code OpenApiException};401/403 → + * {@code DException("验签失败")}。降级判定见 {@code SvcItemRemoteQuery}。

+ * 目录未显式配置时回落 legacy {@code yh.svchall.base-url}(迁移期兼容);两者皆无则构造期 + * fail-fast——不给"沉默连不上 localhost"的假象。 */ @Component public class SvcHallClient { private static final Logger log = LoggerFactory.getLogger(SvcHallClient.class); - private final IDHttp http; - private final String baseUrl; + /** 服务目录命名目标(svc-hall 未晋升 PlatformService 枚举,按名直调) */ + static final String DIRECTORY_KEY = "svchall"; - public SvcHallClient(IDHttp http, - @Value("${yh.svchall.base-url:http://localhost:8086}") String baseUrl) { + private static final String LEGACY_KEY = "yh.svchall.base-url"; + + private final YschOpenCall openCall; + private final IDHttp http; + private final String legacyBaseUrl; + private final boolean directoryResolved; + + public SvcHallClient(YschOpenCall openCall, + IDHttp http, + @Value("${yh.svchall.base-url:}") String baseUrl) { + this.openCall = openCall; this.http = http; - this.baseUrl = baseUrl == null ? "" : trimTrailing(baseUrl); + this.directoryResolved = openCall != null && openCall.hasTarget(DIRECTORY_KEY); + this.legacyBaseUrl = trimTrailing(baseUrl == null ? "" : baseUrl.trim()); + if (!directoryResolved && legacyBaseUrl.isEmpty()) { + throw new IllegalStateException("办事大厅调用地址未配置:请配置服务目录 yh.open.services." + + DIRECTORY_KEY + "(推荐,容器直连完整 URL)或回落键 " + LEGACY_KEY); + } + } + + /** 是否走服务目录命名目标(配置自检用) */ + public boolean directoryResolved() { + return directoryResolved; } /** GET /oapi/svc/items → data(分页 + 三维筛选 + 关键词) */ public JsonNode items(String siteCode, String objectKey, String categoryKey, Integer deptGroupId, String keyword, Integer page, Integer pageSize) { - StringBuilder url = new StringBuilder(baseUrl + "/oapi/svc/items?siteCode=" + encode(siteCode)); - append(url, "objectKey", objectKey); - append(url, "categoryKey", categoryKey); - append(url, "keyword", keyword); + Map query = new LinkedHashMap<>(); + put(query, "siteCode", siteCode); + put(query, "objectKey", objectKey); + put(query, "categoryKey", categoryKey); + put(query, "keyword", keyword); if (deptGroupId != null) { - append(url, "deptGroupId", String.valueOf(deptGroupId)); + query.put("deptGroupId", String.valueOf(deptGroupId)); } if (page != null) { - append(url, "page", String.valueOf(page)); + query.put("page", String.valueOf(page)); } if (pageSize != null) { - append(url, "pageSize", String.valueOf(pageSize)); + query.put("pageSize", String.valueOf(pageSize)); } - return http.get(url.toString(), JsonNode.class); + return get("/oapi/svc/items", query); } /** GET /oapi/svc/filters → data */ public JsonNode filters(String siteCode) { - return http.get(baseUrl + "/oapi/svc/filters?siteCode=" + encode(siteCode), JsonNode.class); + Map query = new LinkedHashMap<>(); + put(query, "siteCode", siteCode); + return get("/oapi/svc/filters", query); } /** GET /oapi/svc/item/detail → data(itemId 或 itemCode 二选一) */ public JsonNode detail(String siteCode, Integer itemId, String itemCode) { - StringBuilder url = new StringBuilder(baseUrl + "/oapi/svc/item/detail?siteCode=" + encode(siteCode)); + Map query = new LinkedHashMap<>(); + put(query, "siteCode", siteCode); if (itemId != null) { - append(url, "itemId", String.valueOf(itemId)); + query.put("itemId", String.valueOf(itemId)); } - if (itemCode != null) { - append(url, "itemCode", itemCode); - } - return http.get(url.toString(), JsonNode.class); + put(query, "itemCode", itemCode); + return get("/oapi/svc/item/detail", query); } /** POST /oapi/svc/item/view(siteCode+itemCode 口径;fire-and-forget,失败仅 warn) */ public void view(String siteCode, String itemCode) { - try { - http.post(baseUrl + "/oapi/svc/item/view?siteCode=" + encode(siteCode) - + "&itemCode=" + encode(itemCode), null, JsonNode.class); - } catch (Exception e) { - log.warn("[cms] 办事大厅浏览量累加失败 siteCode={} itemCode={}:{}", siteCode, itemCode, e.getMessage()); - } + Map query = new LinkedHashMap<>(); + put(query, "siteCode", siteCode); + put(query, "itemCode", itemCode); + safeView(query); } /** POST /oapi/svc/item/view(itemId 直取口径;fire-and-forget,失败仅 warn) */ @@ -78,33 +113,69 @@ public class SvcHallClient { if (itemId == null) { return; } + Map query = new LinkedHashMap<>(); + query.put("itemId", String.valueOf(itemId)); + safeView(query); + } + + private void safeView(Map query) { try { - http.post(baseUrl + "/oapi/svc/item/view?itemId=" + itemId, null, JsonNode.class); + if (directoryResolved) { + openCall.post(DIRECTORY_KEY, "/oapi/svc/item/view", query, null, JsonNode.class); + } else { + http.post(legacyUrl("/oapi/svc/item/view", query), null, JsonNode.class); + } } catch (Exception e) { - log.warn("[cms] 办事大厅浏览量累加失败 itemId={}:{}", itemId, e.getMessage()); + log.warn("[cms] 办事大厅浏览量累加失败 {}:{}", query, e.getMessage()); } } /** GET /oapi/svc/item/attachments(itemId 直取;fileUrl 已绝对化) */ public JsonNode attachments(Integer itemId) { - return http.get(baseUrl + "/oapi/svc/item/attachments?itemId=" + itemId, JsonNode.class); + Map query = new LinkedHashMap<>(); + query.put("itemId", String.valueOf(itemId)); + return get("/oapi/svc/item/attachments", query); } /** GET /oapi/svc/item/related(itemId 直取:同类别已发布 Top N) */ public JsonNode related(Integer itemId, Integer limit) { - return http.get(baseUrl + "/oapi/svc/item/related?itemId=" + itemId - + (limit != null ? "&limit=" + limit : ""), JsonNode.class); + Map query = new LinkedHashMap<>(); + query.put("itemId", String.valueOf(itemId)); + if (limit != null) { + query.put("limit", String.valueOf(limit)); + } + return get("/oapi/svc/item/related", query); } - private static void append(StringBuilder url, String key, String value) { + // ===== 出站(目录优先,legacy 回落)===== + + private JsonNode get(String path, Map query) { + if (directoryResolved) { + return openCall.get(DIRECTORY_KEY, path, query, JsonNode.class); + } + return http.get(legacyUrl(path, query), JsonNode.class); + } + + /** legacy 形态:base-url + path + query(与原手工拼接的语义一致,保住既有调用方预期) */ + private String legacyUrl(String path, Map query) { + StringBuilder url = new StringBuilder(legacyBaseUrl).append(path); + boolean first = true; + for (Map.Entry e : query.entrySet()) { + url.append(first ? '?' : '&').append(e.getKey()).append('=').append(encode(e.getValue())); + first = false; + } + return url.toString(); + } + + private static void put(Map query, String key, String value) { if (value != null && !value.isEmpty()) { - url.append("&").append(key).append("=").append(encode(value)); + query.put(key, value); } } private static String encode(String value) { try { - return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8.name()); + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); } catch (Exception e) { return value; } diff --git a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java index f71a8e1..aa61852 100644 --- a/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java +++ b/src/main/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQuery.java @@ -11,6 +11,8 @@ import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo; import com.yunhai.cms.svcitem.service.SvcItemQuery; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.http.DHttpException; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.IErrorCode; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -110,6 +112,15 @@ public class SvcItemRemoteQuery implements SvcItemQuery { return null; } throw e; + } catch (RuntimeException e) { + // 目录路径(YschOpenCall)下的同义语义:业务失败信封 code!=0 → DException(携带 + // OpenApiErrorCode,办事大厅"不存在/未发布"= 2);传输/HTTP 失败 → OpenApiException。 + // 降级口径与 legacy 分支逐字一致,见上方注释。 + if (businessAbsent(e)) { + log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage()); + return null; + } + throw e; } return toPortalItem(row); } @@ -131,6 +142,11 @@ public class SvcItemRemoteQuery implements SvcItemQuery { return result; } throw e; + } catch (RuntimeException e) { + if (businessAbsent(e)) { + return result; + } + throw e; } for (JsonNode rel : row.isArray() ? row : new ArrayList()) { PortalServiceItem vo = new PortalServiceItem(); @@ -159,6 +175,11 @@ public class SvcItemRemoteQuery implements SvcItemQuery { return result; } throw e; + } catch (RuntimeException e) { + if (businessAbsent(e)) { + return result; + } + throw e; } for (JsonNode att : row.isArray() ? row : new ArrayList()) { SvcItemAttachmentVo vo = new SvcItemAttachmentVo(); @@ -179,6 +200,22 @@ public class SvcItemRemoteQuery implements SvcItemQuery { // ===== 装配 ===== + /** + * "业务性缺失"判定(办事大厅 code=2 = 不存在/未发布)。两条出站路径的异常载体不同, + * 语义必须一致:legacy {@code IDHttp} → {@link DHttpException#getCode()} 为 int; + * 服务目录 {@code YschOpenCall} → {@link DException} 携带 {@code OpenApiErrorCode}。 + */ + private static boolean businessAbsent(RuntimeException e) { + if (e instanceof DHttpException) { + return ((DHttpException) e).getCode() == 2; + } + if (e instanceof DException) { + IErrorCode code = ((DException) e).getCode(); + return code != null && code.getCode() == 2; + } + return false; + } + private PortalServiceItem toPortalItem(JsonNode row) { PortalServiceItem vo = new PortalServiceItem(); vo.setId(intVal(row, "id")); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4e6a9f1..1290966 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,16 +66,19 @@ yh: trusted-apps: "100018": "svc-hall-dev-secret-100018" open: - enabled: true + # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删 base-host: http://base.yunhaixingtong.com base-path: /userbase - app-key: 100005 - app-secret: 2c7885de594742cc840b8762910b2d2e + # 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 / + # YschOpenCall。svc-hall 未晋升 PlatformService 枚举,按名直调(完整 URL 拆 host+ctx, + # per-target base-path 与提供方 context-path 对齐 → 签名天然一致)。 + # 容器直连形态(生产):http://svc-hall-service:10007/svc-hall(网关无 /oapi 路由)。 + services: + svchall: http://localhost:8086 # 办事大厅(svc-hall)对接(V2.0 Part C.3):门户 service/guide 数据源。 - # 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧 trusted-apps 须含 100005); - # 入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。 - svchall: - base-url: http://localhost:8086 + # 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧须在 UserBase + # 注册表内可用);入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。 + # 地址不再单独配置:见上方 yh.open.services.svchall(原 yh.svchall.base-url 已删)。 # env-config 管理端运行时配置(免登录 GET /api/env-config?target=admin,数据源见 # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_ADMIN diff --git a/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcHallClientTest.java b/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcHallClientTest.java new file mode 100644 index 0000000..a196b13 --- /dev/null +++ b/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcHallClientTest.java @@ -0,0 +1,205 @@ +package com.yunhai.cms.svcitem.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yunhai.platform.openapi.core.OpenApiException; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.http.IDHttp; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * CMS → 办事大厅(svc-hall)出站契约测试:跨服务调用统一走 SDK {@link YschOpenCall} 命名目标 + * {@code svchall}(服务目录 {@code yh.open.services.svchall}),应用内不再保留专属地址键 + * {@code yh.svchall.base-url}(《子应用配置优化方案》§4.2 零专属地址配置)。

+ * 迁移动机(此前是四个服务里唯一手搓 URL 的跨服务主链路):共享 {@code IDHttp} 的签名拦截器只剥 + * {@code yh.open.base-path}(本应用=/userbase),而 svc-hall 部署带 context-path + * {@code /svc-hall}——手工拼出的 URL 会被签成 {@code /svc-hall/oapi/...},服务端剥掉 ctx 后 + * 两侧 target 不一致,**必然 401**。{@link YschOpenCall} 的 per-target base-path 与提供方 + * context-path 对齐(同 svc-hall {@code CmsSiteDirectoryImpl} 的既有范式)。

+ * 目录未显式配置时回落 legacy {@code yh.svchall.base-url}(迁移期兼容);两者皆无则构造期 + * fail-fast,不给"沉默连不上 localhost"的假象。 + */ +@DisplayName("SvcHallClient - 走 SDK 服务目录(svchall)与 legacy 回落") +class SvcHallClientTest { + + private static final String TARGET = "svchall"; + private static final String LEGACY_BASE = "http://svc-hall-service:10007/svc-hall"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private YschOpenCall openCall; + private IDHttp http; + + @BeforeEach + void setUp() { + openCall = mock(YschOpenCall.class); + http = mock(IDHttp.class); + } + + private ObjectNode payload() { + ObjectNode node = objectMapper.createObjectNode(); + node.put("total", 3); + return node; + } + + @SuppressWarnings("unchecked") + private ArgumentCaptor> queryCaptor() { + return ArgumentCaptor.forClass(Map.class); + } + + @Test + @DisplayName("目录已配 svchall:items 走命名目标 + 查询参数齐全,IDHttp 零交互") + void items_usesDirectoryTarget() { + when(openCall.hasTarget(TARGET)).thenReturn(true); + when(openCall.get(eq(TARGET), eq("/oapi/svc/items"), anyMap(), eq(JsonNode.class))) + .thenReturn(payload()); + + SvcHallClient client = new SvcHallClient(openCall, http, ""); + JsonNode out = client.items("yhms", "obj1", "cat1", 8, "教师", 1, 10); + + assertEquals(3, out.get("total").asInt()); + verifyNoInteractions(http); + + ArgumentCaptor> captor = queryCaptor(); + verify(openCall).get(eq(TARGET), eq("/oapi/svc/items"), captor.capture(), eq(JsonNode.class)); + Map q = captor.getValue(); + assertEquals("yhms", q.get("siteCode")); + assertEquals("obj1", q.get("objectKey")); + assertEquals("cat1", q.get("categoryKey")); + assertEquals("8", q.get("deptGroupId")); + assertEquals("教师", q.get("keyword")); + assertEquals("1", q.get("page")); + assertEquals("10", q.get("pageSize")); + } + + @Test + @DisplayName("目录已配 svchall:空参数不下发(保持原 append 语义)") + void items_omitsBlankOptionalParams() { + when(openCall.hasTarget(TARGET)).thenReturn(true); + when(openCall.get(eq(TARGET), eq("/oapi/svc/items"), anyMap(), eq(JsonNode.class))) + .thenReturn(payload()); + + new SvcHallClient(openCall, http, "").items("yhms", null, "", null, " ", null, null); + + ArgumentCaptor> captor = queryCaptor(); + verify(openCall).get(eq(TARGET), eq("/oapi/svc/items"), captor.capture(), eq(JsonNode.class)); + Map q = captor.getValue(); + assertEquals("yhms", q.get("siteCode")); + assertTrue(!q.containsKey("objectKey")); + assertTrue(!q.containsKey("categoryKey")); + assertTrue(!q.containsKey("deptGroupId")); + assertTrue(!q.containsKey("page")); + assertTrue(!q.containsKey("pageSize")); + } + + @Test + @DisplayName("目录已配 svchall:detail 走命名目标(itemId 口径)") + void detail_usesDirectoryTarget() { + when(openCall.hasTarget(TARGET)).thenReturn(true); + when(openCall.get(eq(TARGET), eq("/oapi/svc/item/detail"), anyMap(), eq(JsonNode.class))) + .thenReturn(payload()); + + JsonNode out = new SvcHallClient(openCall, http, "").detail("yhms", 1, null); + + assertEquals(3, out.get("total").asInt()); + ArgumentCaptor> captor = queryCaptor(); + verify(openCall).get(eq(TARGET), eq("/oapi/svc/item/detail"), captor.capture(), eq(JsonNode.class)); + assertEquals("1", captor.getValue().get("itemId")); + assertTrue(!captor.getValue().containsKey("itemCode")); + } + + @Test + @DisplayName("目录已配 svchall:浏览量累加 fire-and-forget(出站失败不得冒泡)") + void view_isFireAndForget() { + when(openCall.hasTarget(TARGET)).thenReturn(true); + when(openCall.post(eq(TARGET), eq("/oapi/svc/item/view"), anyMap(), eq(null), eq(JsonNode.class))) + .thenThrow(new OpenApiException("OpenAPI request failed: ioError")); + + SvcHallClient client = new SvcHallClient(openCall, http, ""); + client.view("yhms", "teacher-onboard"); + client.viewByItemId(1); + + verify(openCall, org.mockito.Mockito.times(2)) + .post(eq(TARGET), eq("/oapi/svc/item/view"), anyMap(), eq(null), eq(JsonNode.class)); + } + + @Test + @DisplayName("viewByItemId(null):不出站") + void viewByItemId_nullIsNoop() { + when(openCall.hasTarget(TARGET)).thenReturn(true); + new SvcHallClient(openCall, http, "").viewByItemId(null); + verify(openCall, org.mockito.Mockito.never()) + .post(anyString(), anyString(), anyMap(), eq(null), eq(JsonNode.class)); + verifyNoInteractions(http); + } + + @Test + @DisplayName("目录未配:回落 legacy yh.svchall.base-url(迁移期兼容)") + void fallsBackToLegacyBaseUrl() { + when(openCall.hasTarget(TARGET)).thenReturn(false); + when(http.get(anyString(), eq(JsonNode.class))).thenReturn(payload()); + + JsonNode out = new SvcHallClient(openCall, http, LEGACY_BASE + "/").items( + "yhms", null, null, null, null, 1, 10); + + assertEquals(3, out.get("total").asInt()); + ArgumentCaptor url = ArgumentCaptor.forClass(String.class); + verify(http).get(url.capture(), eq(JsonNode.class)); + assertTrue(url.getValue().startsWith(LEGACY_BASE + "/oapi/svc/items?siteCode=yhms"), url.getValue()); + // 回落分支不得触达目录调用(构造期 hasTarget 探测除外) + verify(openCall, org.mockito.Mockito.never()) + .get(eq(TARGET), anyString(), anyMap(), eq(JsonNode.class)); + } + + @Test + @DisplayName("目录未配且 legacy 键为空:构造期 fail-fast,文案指向服务目录配置键") + void misconfiguredFailsFast() { + when(openCall.hasTarget(TARGET)).thenReturn(false); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> new SvcHallClient(openCall, http, "")); + assertTrue(e.getMessage().contains("yh.open.services." + TARGET), e.getMessage()); + assertTrue(e.getMessage().contains("yh.svchall.base-url"), e.getMessage()); + } + + @Test + @DisplayName("related/attachments:目录走命名目标且路径正确") + void relatedAndAttachments_useDirectoryTarget() { + when(openCall.hasTarget(TARGET)).thenReturn(true); + when(openCall.get(eq(TARGET), eq("/oapi/svc/item/related"), anyMap(), eq(JsonNode.class))) + .thenReturn(payload()); + when(openCall.get(eq(TARGET), eq("/oapi/svc/item/attachments"), anyMap(), eq(JsonNode.class))) + .thenReturn(payload()); + + SvcHallClient client = new SvcHallClient(openCall, http, ""); + assertEquals(3, client.related(1, 5).get("total").asInt()); + assertEquals(3, client.attachments(1).get("total").asInt()); + + ArgumentCaptor> captor = queryCaptor(); + verify(openCall).get(eq(TARGET), eq("/oapi/svc/item/related"), captor.capture(), eq(JsonNode.class)); + assertEquals("5", captor.getValue().get("limit")); + assertNull(captor.getValue().get("itemCode")); + verifyNoInteractions(http); + } + + private static T anyMap() { + return org.mockito.ArgumentMatchers.any(); + } +} diff --git a/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQueryTest.java b/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQueryTest.java index e13ba6c..97ce8e8 100644 --- a/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQueryTest.java +++ b/src/test/java/com/yunhai/cms/svcitem/service/impl/SvcItemRemoteQueryTest.java @@ -6,6 +6,9 @@ import com.yunhai.cms.entity.CmsSite; import com.yunhai.cms.repository.CmsSiteRepository; import com.yunhai.cms.svcitem.model.vo.PortalServiceItem; import com.yunhai.platform.http.DHttpException; +import com.yunhai.platform.openapi.core.OpenApiErrorCode; +import com.yunhai.platform.openapi.core.OpenApiException; +import com.yunhai.platform.utils.exception.DException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -96,4 +99,45 @@ class SvcItemRemoteQueryTest { assertEquals(Integer.valueOf(1), vo.getOnlineFlag()); assertEquals("https://x.example/apply", vo.getApplyUrl()); } + + // ===== 走 SDK 服务目录后的错误语义(YschOpenCall 承载,替代 IDHttp)===== + // 目录路径下出站异常不再是 DHttpException:业务失败信封 code!=0 → DException(code 载体 + // 为 OpenApiErrorCode,非本地枚举),传输/HTTP 失败 → OpenApiException。 + // 降级语义必须逐字保持:业务 code=2 → 404 哨兵/空集;其余一律上抛。 + + @Test + @DisplayName("portalItem:目录路径下业务 code=2(DException)→ null(门户 404 语义保持)") + void portalItem_directoryPathBusinessMissing_returnsNull() { + when(client.detail(anyString(), anyInt(), isNull())) + .thenThrow(new DException(new OpenApiErrorCode(2, "事项不存在或未发布"))); + assertNull(query.portalItem(1001, 1)); + } + + @Test + @DisplayName("portalItem:目录路径下传输失败(OpenApiException)→ 上抛(不混入 404)") + void portalItem_directoryPathTransportFailure_propagates() { + when(client.detail(anyString(), anyInt(), isNull())) + .thenThrow(new OpenApiException("OpenAPI request failed: ioError")); + assertThrows(OpenApiException.class, () -> query.portalItem(1001, 1)); + } + + @Test + @DisplayName("portalItem:目录路径下非 2 业务码(如 4)→ 上抛") + void portalItem_directoryPathOtherBusinessCode_propagates() { + when(client.detail(anyString(), anyInt(), isNull())) + .thenThrow(new DException(new OpenApiErrorCode(4, "参数错误"))); + assertThrows(DException.class, () -> query.portalItem(1001, 1)); + } + + @Test + @DisplayName("related/attachments:目录路径下业务 code=2 → 空集") + void relatedAndAttachments_directoryPathBusinessMissing_empty() { + when(client.related(anyInt(), anyInt())) + .thenThrow(new DException(new OpenApiErrorCode(2, "不存在"))); + assertEquals(0, query.relatedItems(1, 1001, "cat", 5).size()); + + when(client.attachments(anyInt())) + .thenThrow(new DException(new OpenApiErrorCode(2, "不存在"))); + assertEquals(0, query.attachments(1001).size()); + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index ef10ade..d485a35 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -61,11 +61,12 @@ yh: trusted-apps: "100018": "svc-hall-dev-secret-100018" open: - enabled: true base-host: http://base.yunhaixingtong.com base-path: /userbase - app-key: 100XXX - app-secret: xxxxxxxx + # 服务目录:SvcHallClient 构造期要求「目录键 或 legacy yh.svchall.base-url」至少一个 + # (两者都缺即 fail-fast)——IT 起 Spring 上下文时必须有值。 + services: + svchall: http://localhost:8086 cms: # 超管判定:BoaUser.type == 1(夹具 TestUsers.superAdmin 已对齐);校级角色经 BoaRoleInfo.schId 匹配