refactor(sdk)!: CMS→svc-hall 出站改走 YschOpenCall 命名目标 + 适配 SDK 领域分包
build / build-push (push) Successful in 1m36s
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:
@@ -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"));
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 匹配
|
||||||
|
|||||||
Reference in New Issue
Block a user