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