refactor(sdk)!: CMS→svc-hall 出站改走 YschOpenCall 命名目标 + 适配 SDK 领域分包
build / build-push (push) Successful in 1m36s

- 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 测试全绿。
This commit is contained in:
chenxing
2026-09-16 21:05:55 +08:00
parent 1cb95225a6
commit 649ad66264
8 changed files with 411 additions and 50 deletions
@@ -1,7 +1,7 @@
package com.yunhai.cms.service.impl; package com.yunhai.cms.service.impl;
import com.yunhai.platform.apistd.BizException; 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.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaModule;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -4,7 +4,7 @@ import com.yunhai.cms.model.vo.SchoolVo;
import com.yunhai.cms.model.vo.SiteVo; import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.repository.CmsArticleRepository; import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.platform.apistd.BizException; 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.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
@@ -1,76 +1,111 @@
package com.yunhai.cms.svcitem.service.impl; package com.yunhai.cms.svcitem.service.impl;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.http.IDHttp; import com.yunhai.platform.utils.http.IDHttp;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; 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 身份 * 办事大厅开放接口客户端(V2.0 Part C.3):经 SDK {@link YschOpenCall} 命名目标
* appKey=100005)调 svc-hall 的 /oapi/svc/**;办事大厅侧 RequireAppSignFilter 验签, * {@code svchall}(服务目录 {@code yh.open.services.svchall})调 svc-hall {@code /oapi/svc/**};
* 其 yh.app-auth.trusted-apps 须包含 CMS 的 appKey(运维项)。 * 出站签名/信封解包/错误映射全部由 SDK 承载——本应用零专属地址配置
* 信封 {code,msg,data} 由 IDHttp 解包(code!=0 抛业务异常,即办事大厅 code=2 语义在此转异常)。 * (《子应用配置优化方案》§4.2)。<br><br>
* <b>为何不能再用共享 {@code IDHttp}</b>:svc-hall 带 context-path {@code /svc-hall},而共享
* 拦截器只剥 {@code yh.open.base-path}(本应用 {@code /userbase})——原手工拼的 URL 会被签成
* {@code /svc-hall/oapi/...},服务端剥掉 ctx 后两侧 target 不一致,<b>必然 401</b>。
* {@link YschOpenCall} 的 per-target base-path 与提供方 context-path 对齐(同 svc-hall
* {@code CmsSiteDirectoryImpl} 的既有范式)。<br><br>
* <b>错误语义</b>:业务失败信封 {@code code!=0} → {@code DException}(携带 OpenApiErrorCode,
* 办事大厅"不存在/未发布"= code 2);传输/HTTP 失败 → {@code OpenApiException};401/403 →
* {@code DException("验签失败")}。降级判定见 {@code SvcItemRemoteQuery}。<br><br>
* 目录未显式配置时回落 legacy {@code yh.svchall.base-url}(迁移期兼容);两者皆无则构造期
* fail-fast——不给"沉默连不上 localhost"的假象。
*/ */
@Component @Component
public class SvcHallClient { public class SvcHallClient {
private static final Logger log = LoggerFactory.getLogger(SvcHallClient.class); private static final Logger log = LoggerFactory.getLogger(SvcHallClient.class);
private final IDHttp http; /** 服务目录命名目标(svc-hall 未晋升 PlatformService 枚举,按名直调) */
private final String baseUrl; static final String DIRECTORY_KEY = "svchall";
public SvcHallClient(IDHttp http, private static final String LEGACY_KEY = "yh.svchall.base-url";
@Value("${yh.svchall.base-url:http://localhost:8086}") String baseUrl) {
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.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(分页 + 三维筛选 + 关键词) */ /** GET /oapi/svc/items → data(分页 + 三维筛选 + 关键词) */
public JsonNode items(String siteCode, String objectKey, String categoryKey, Integer deptGroupId, public JsonNode items(String siteCode, String objectKey, String categoryKey, Integer deptGroupId,
String keyword, Integer page, Integer pageSize) { String keyword, Integer page, Integer pageSize) {
StringBuilder url = new StringBuilder(baseUrl + "/oapi/svc/items?siteCode=" + encode(siteCode)); Map<String, String> query = new LinkedHashMap<>();
append(url, "objectKey", objectKey); put(query, "siteCode", siteCode);
append(url, "categoryKey", categoryKey); put(query, "objectKey", objectKey);
append(url, "keyword", keyword); put(query, "categoryKey", categoryKey);
put(query, "keyword", keyword);
if (deptGroupId != null) { if (deptGroupId != null) {
append(url, "deptGroupId", String.valueOf(deptGroupId)); query.put("deptGroupId", String.valueOf(deptGroupId));
} }
if (page != null) { if (page != null) {
append(url, "page", String.valueOf(page)); query.put("page", String.valueOf(page));
} }
if (pageSize != null) { 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 */ /** GET /oapi/svc/filters → data */
public JsonNode filters(String siteCode) { public JsonNode filters(String siteCode) {
return http.get(baseUrl + "/oapi/svc/filters?siteCode=" + encode(siteCode), JsonNode.class); Map<String, String> query = new LinkedHashMap<>();
put(query, "siteCode", siteCode);
return get("/oapi/svc/filters", query);
} }
/** GET /oapi/svc/item/detail → data(itemId 或 itemCode 二选一) */ /** GET /oapi/svc/item/detail → data(itemId 或 itemCode 二选一) */
public JsonNode detail(String siteCode, Integer itemId, String itemCode) { public JsonNode detail(String siteCode, Integer itemId, String itemCode) {
StringBuilder url = new StringBuilder(baseUrl + "/oapi/svc/item/detail?siteCode=" + encode(siteCode)); Map<String, String> query = new LinkedHashMap<>();
put(query, "siteCode", siteCode);
if (itemId != null) { if (itemId != null) {
append(url, "itemId", String.valueOf(itemId)); query.put("itemId", String.valueOf(itemId));
} }
if (itemCode != null) { put(query, "itemCode", itemCode);
append(url, "itemCode", itemCode); return get("/oapi/svc/item/detail", query);
}
return http.get(url.toString(), JsonNode.class);
} }
/** POST /oapi/svc/item/view(siteCode+itemCode 口径;fire-and-forget,失败仅 warn) */ /** POST /oapi/svc/item/view(siteCode+itemCode 口径;fire-and-forget,失败仅 warn) */
public void view(String siteCode, String itemCode) { public void view(String siteCode, String itemCode) {
try { Map<String, String> query = new LinkedHashMap<>();
http.post(baseUrl + "/oapi/svc/item/view?siteCode=" + encode(siteCode) put(query, "siteCode", siteCode);
+ "&itemCode=" + encode(itemCode), null, JsonNode.class); put(query, "itemCode", itemCode);
} catch (Exception e) { safeView(query);
log.warn("[cms] 办事大厅浏览量累加失败 siteCode={} itemCode={}:{}", siteCode, itemCode, e.getMessage());
}
} }
/** POST /oapi/svc/item/view(itemId 直取口径;fire-and-forget,失败仅 warn) */ /** POST /oapi/svc/item/view(itemId 直取口径;fire-and-forget,失败仅 warn) */
@@ -78,33 +113,69 @@ public class SvcHallClient {
if (itemId == null) { if (itemId == null) {
return; return;
} }
Map<String, String> query = new LinkedHashMap<>();
query.put("itemId", String.valueOf(itemId));
safeView(query);
}
private void safeView(Map<String, String> query) {
try { 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) { } catch (Exception e) {
log.warn("[cms] 办事大厅浏览量累加失败 itemId={}:{}", itemId, e.getMessage()); log.warn("[cms] 办事大厅浏览量累加失败 {}:{}", query, e.getMessage());
} }
} }
/** GET /oapi/svc/item/attachments(itemId 直取;fileUrl 已绝对化) */ /** GET /oapi/svc/item/attachments(itemId 直取;fileUrl 已绝对化) */
public JsonNode attachments(Integer itemId) { public JsonNode attachments(Integer itemId) {
return http.get(baseUrl + "/oapi/svc/item/attachments?itemId=" + itemId, JsonNode.class); Map<String, String> query = new LinkedHashMap<>();
query.put("itemId", String.valueOf(itemId));
return get("/oapi/svc/item/attachments", query);
} }
/** GET /oapi/svc/item/related(itemId 直取:同类别已发布 Top N) */ /** GET /oapi/svc/item/related(itemId 直取:同类别已发布 Top N) */
public JsonNode related(Integer itemId, Integer limit) { public JsonNode related(Integer itemId, Integer limit) {
return http.get(baseUrl + "/oapi/svc/item/related?itemId=" + itemId Map<String, String> query = new LinkedHashMap<>();
+ (limit != null ? "&limit=" + limit : ""), JsonNode.class); 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<String, String> 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<String, String> query) {
StringBuilder url = new StringBuilder(legacyBaseUrl).append(path);
boolean first = true;
for (Map.Entry<String, String> e : query.entrySet()) {
url.append(first ? '?' : '&').append(e.getKey()).append('=').append(encode(e.getValue()));
first = false;
}
return url.toString();
}
private static void put(Map<String, String> query, String key, String value) {
if (value != null && !value.isEmpty()) { if (value != null && !value.isEmpty()) {
url.append("&").append(key).append("=").append(encode(value)); query.put(key, value);
} }
} }
private static String encode(String value) { private static String encode(String value) {
try { 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) { } catch (Exception e) {
return value; return value;
} }
@@ -11,6 +11,8 @@ import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
import com.yunhai.cms.svcitem.service.SvcItemQuery; import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.http.DHttpException; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -110,6 +112,15 @@ public class SvcItemRemoteQuery implements SvcItemQuery {
return null; return null;
} }
throw e; 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); return toPortalItem(row);
} }
@@ -131,6 +142,11 @@ public class SvcItemRemoteQuery implements SvcItemQuery {
return result; return result;
} }
throw e; throw e;
} catch (RuntimeException e) {
if (businessAbsent(e)) {
return result;
}
throw e;
} }
for (JsonNode rel : row.isArray() ? row : new ArrayList<JsonNode>()) { for (JsonNode rel : row.isArray() ? row : new ArrayList<JsonNode>()) {
PortalServiceItem vo = new PortalServiceItem(); PortalServiceItem vo = new PortalServiceItem();
@@ -159,6 +175,11 @@ public class SvcItemRemoteQuery implements SvcItemQuery {
return result; return result;
} }
throw e; throw e;
} catch (RuntimeException e) {
if (businessAbsent(e)) {
return result;
}
throw e;
} }
for (JsonNode att : row.isArray() ? row : new ArrayList<JsonNode>()) { for (JsonNode att : row.isArray() ? row : new ArrayList<JsonNode>()) {
SvcItemAttachmentVo vo = new SvcItemAttachmentVo(); 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) { private PortalServiceItem toPortalItem(JsonNode row) {
PortalServiceItem vo = new PortalServiceItem(); PortalServiceItem vo = new PortalServiceItem();
vo.setId(intVal(row, "id")); vo.setId(intVal(row, "id"));
+10 -7
View File
@@ -66,16 +66,19 @@ yh:
trusted-apps: trusted-apps:
"100018": "svc-hall-dev-secret-100018" "100018": "svc-hall-dev-secret-100018"
open: open:
enabled: true # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
base-path: /userbase base-path: /userbase
app-key: 100005 # 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 /
app-secret: 2c7885de594742cc840b8762910b2d2e # 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 数据源。 # 办事大厅(svc-hall)对接(V2.0 Part C.3):门户 service/guide 数据源。
# 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧 trusted-apps 须含 100005); # 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧须在 UserBase
# 入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。 # 注册表内可用);入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。
svchall: # 地址不再单独配置:见上方 yh.open.services.svchall(原 yh.svchall.base-url 已删)。
base-url: http://localhost:8086
# env-config 管理端运行时配置(免登录 GET /api/env-config?target=admin,数据源见 # env-config 管理端运行时配置(免登录 GET /api/env-config?target=admin,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_ADMIN # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_ADMIN
@@ -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 零专属地址配置)。<br><br>
* 迁移动机(此前是四个服务里唯一手搓 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} 的既有范式)。<br><br>
* 目录未显式配置时回落 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<Map<String, String>> 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<Map<String, String>> captor = queryCaptor();
verify(openCall).get(eq(TARGET), eq("/oapi/svc/items"), captor.capture(), eq(JsonNode.class));
Map<String, String> 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<Map<String, String>> captor = queryCaptor();
verify(openCall).get(eq(TARGET), eq("/oapi/svc/items"), captor.capture(), eq(JsonNode.class));
Map<String, String> 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<Map<String, String>> 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<String> 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<Map<String, String>> 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> T anyMap() {
return org.mockito.ArgumentMatchers.any();
}
}
@@ -6,6 +6,9 @@ import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository; import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem; import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.platform.http.DHttpException; 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.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -96,4 +99,45 @@ class SvcItemRemoteQueryTest {
assertEquals(Integer.valueOf(1), vo.getOnlineFlag()); assertEquals(Integer.valueOf(1), vo.getOnlineFlag());
assertEquals("https://x.example/apply", vo.getApplyUrl()); 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());
}
} }
+4 -3
View File
@@ -61,11 +61,12 @@ yh:
trusted-apps: trusted-apps:
"100018": "svc-hall-dev-secret-100018" "100018": "svc-hall-dev-secret-100018"
open: open:
enabled: true
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
base-path: /userbase base-path: /userbase
app-key: 100XXX # 服务目录:SvcHallClient 构造期要求「目录键 或 legacy yh.svchall.base-url」至少一个
app-secret: xxxxxxxx # (两者都缺即 fail-fast)——IT 起 Spring 上下文时必须有值。
services:
svchall: http://localhost:8086
cms: cms:
# 超管判定:BoaUser.type == 1(夹具 TestUsers.superAdmin 已对齐);校级角色经 BoaRoleInfo.schId 匹配 # 超管判定:BoaUser.type == 1(夹具 TestUsers.superAdmin 已对齐);校级角色经 BoaRoleInfo.schId 匹配