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;
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;
@@ -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;
@@ -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)。<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
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<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> query = new LinkedHashMap<>();
query.put("itemId", String.valueOf(itemId));
safeView(query);
}
private void safeView(Map<String, String> 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<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) */
public JsonNode related(Integer itemId, Integer limit) {
return http.get(baseUrl + "/oapi/svc/item/related?itemId=" + itemId
+ (limit != null ? "&limit=" + limit : ""), JsonNode.class);
Map<String, String> 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<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()) {
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;
}
@@ -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<JsonNode>()) {
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<JsonNode>()) {
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"));