裸 HttpClient 不走 YschOpenCall 自动拆信封:userbase 顶层取 id 恒失败=登录态 恒 503(160 实录 2026-10-12);userhome 信封原样透传会被 init 当异常形态跳过 收敛。unwrap 镜像 OpenApiEnvelope 语义(code≠0→503 / code==0 取 data / 无 code 视为已裸),data 裸 payload 透传。单测 stub 从裸 JSON 对齐真实信封形态并补 4 例信封语义用例(6→10,全量 225/225 绿)。
This commit is contained in:
@@ -44,8 +44,11 @@ import java.nio.charset.StandardCharsets;
|
|||||||
* (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后
|
* (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后
|
||||||
* <b>回环打网关真实 oapi 链</b>(同 ProxyTestController 形态:Sanitize→路由→验签→转发):
|
* <b>回环打网关真实 oapi 链</b>(同 ProxyTestController 形态:Sanitize→路由→验签→转发):
|
||||||
* ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance}
|
* ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance}
|
||||||
* 取生效外观;②的响应体【整体透传】(裸 payload {theme, avatar, logoUrl}),
|
* 取生效外观。两腿响应按平台统一信封 {@code {code,msg,data}} 解析(镜像
|
||||||
* 字段演进只在 UserHome+通道两端,本端零改动。
|
* {@code OpenApiEnvelope.unwrap} 语义:code≠0=上游业务失败→503;code==0 取 data;
|
||||||
|
* 无 code 视为已裸数据),②取出的 data【裸 payload 整体透传】,字段演进只在
|
||||||
|
* UserHome+通道两端,本端零改动。信封坑实录(2026-10-12):裸 HttpClient 不走
|
||||||
|
* YschOpenCall 的自动拆信封,顶层取 id 恒失败=登录态恒 503(单测裸 JSON stub 未对齐)。
|
||||||
* <p>
|
* <p>
|
||||||
* <b>三态失败语义</b>:无 CAS 会话/会话断言缺失 → 401;未配
|
* <b>三态失败语义</b>:无 CAS 会话/会话断言缺失 → 401;未配
|
||||||
* {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome
|
* {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome
|
||||||
@@ -119,20 +122,21 @@ public class AppearanceProxyController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String base = loopbackBase(req);
|
String base = loopbackBase(req);
|
||||||
JsonNode user = signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName",
|
JsonNode user = unwrapEnvelope(signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName",
|
||||||
"loginName=" + urlEncode(loginName), appKey.trim(), secret);
|
"loginName=" + urlEncode(loginName), appKey.trim(), secret));
|
||||||
if (user == null || user.isNull() || !user.path("id").canConvertToLong()) {
|
if (user == null || user.isNull() || !user.path("id").canConvertToLong()) {
|
||||||
LOG.warn("[appearance] userbase 未解析到登录人 {} 的 userId(503)", loginName);
|
LOG.warn("[appearance] userbase 未解析到登录人 {} 的 userId(503)", loginName);
|
||||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String payload = signedGetBody(base, TARGET_USERHOME, "/oapi/appearance",
|
JsonNode payload = unwrapEnvelope(signedGetJson(base, TARGET_USERHOME, "/oapi/appearance",
|
||||||
"userId=" + user.path("id").asLong(), appKey.trim(), secret);
|
"userId=" + user.path("id").asLong(), appKey.trim(), secret));
|
||||||
if (payload == null) {
|
if (payload == null || payload.isNull()) {
|
||||||
|
LOG.warn("[appearance] userhome 外观 data 为空/业务失败(503,init 侧静默不动本地键)");
|
||||||
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return payload;
|
return mapper.writeValueAsString(payload);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 降级铁律:sendError 直写 503 绕过全局异常处理器(否则被兜成 200 信封,
|
// 降级铁律:sendError 直写 503 绕过全局异常处理器(否则被兜成 200 信封,
|
||||||
// init 会把意外形态当脏值跳过——语义上浏览器必须拿到非 2xx)
|
// init 会把意外形态当脏值跳过——语义上浏览器必须拿到非 2xx)
|
||||||
@@ -211,6 +215,19 @@ public class AppearanceProxyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信封感知解析,语义镜像平台 SDK {@code OpenApiEnvelope.unwrap}(UserBase/UserHome oapi
|
||||||
|
* 统一信封:成功={code:0,msg,data};业务失败={code≠0,...} 仍 HTTP 200):code≠0 返 null
|
||||||
|
* (本端点无业务错误通道,按 503 语义);code==0 取 data(可为 null);无 code 视为
|
||||||
|
* 已裸数据原样返回。
|
||||||
|
*/
|
||||||
|
private static JsonNode unwrapEnvelope(JsonNode root) {
|
||||||
|
if (root != null && root.isObject() && root.has("code")) {
|
||||||
|
return root.path("code").asInt(-1) == 0 ? root.get("data") : null;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
/** 同 ProxyTestController:取本机直打地址(不经 Caddy),天然内部入口口径(一律验签) */
|
/** 同 ProxyTestController:取本机直打地址(不经 Caddy),天然内部入口口径(一律验签) */
|
||||||
private String loopbackBase(HttpServletRequest req) {
|
private String loopbackBase(HttpServletRequest req) {
|
||||||
if (loopbackBaseOverride != null) {
|
if (loopbackBaseOverride != null) {
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
* 外观偏好同源读端点契约(对接规范 §3,网关自建同构哑管道):
|
* 外观偏好同源读端点契约(对接规范 §3,网关自建同构哑管道):
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>无会话断言 → 401;未配 app-key / 密钥链解析不到 → 503(绝不 200+兜底值);</li>
|
* <li>无会话断言 → 401;未配 app-key / 密钥链解析不到 → 503(绝不 200+兜底值);</li>
|
||||||
* <li>happy path:byLoginName 换 userId → appearance 裸 payload 整体透传(无信封);</li>
|
* <li>两腿响应按平台统一信封 {code,msg,data} 解析(镜像 OpenApiEnvelope.unwrap:
|
||||||
|
* code==0 取 data,code≠0 或 data:null → 503;无 code 视为已裸);</li>
|
||||||
|
* <li>happy path:byLoginName 换 userId → appearance 的 data 裸 payload 整体透传;</li>
|
||||||
* <li>上游任一环节非 200 / 解析不到 userId → 503(init 侧静默保键语义的前提)。</li>
|
* <li>上游任一环节非 200 / 解析不到 userId → 503(init 侧静默保键语义的前提)。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* 回环用 JDK HttpServer 按路径分桩(standalone MockMvc 不装 advice,String 返回即裸体)。
|
* 回环用 JDK HttpServer 按路径分桩(standalone MockMvc 不装 advice,String 返回即裸体)。
|
||||||
|
* 信封坑(2026-10-12 线上实录):真实 UserBase/UserHome oapi 均包 {code,msg,data},
|
||||||
|
* 裸 JSON stub 曾造成顶层取 id 恒失败=登录态恒 503——stub 一律按真实信封形态。
|
||||||
*/
|
*/
|
||||||
class AppearanceProxyControllerTest {
|
class AppearanceProxyControllerTest {
|
||||||
|
|
||||||
@@ -118,7 +122,20 @@ class AppearanceProxyControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void happyPath_passesThroughBarePayload() throws Exception {
|
void happyPath_unwrapsEnvelopeAndPassesBarePayload() throws Exception {
|
||||||
|
controller.setAppKey("gw-appearance");
|
||||||
|
stubBodies.put("/userbase/oapi/user/byLoginName",
|
||||||
|
"{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353,\"loginName\":\"admin\"}}");
|
||||||
|
stubBodies.put("/userhome/oapi/appearance", "{\"code\":0,\"msg\":\"\",\"data\":" + PAYLOAD + "}");
|
||||||
|
|
||||||
|
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().json(PAYLOAD));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareFallback_stillResolvesAndPassesThrough() throws Exception {
|
||||||
|
// 防御兼容:上游若返回无 code 的裸数据形态,unwrap 原样放行(同 OpenApiEnvelope 语义)
|
||||||
controller.setAppKey("gw-appearance");
|
controller.setAppKey("gw-appearance");
|
||||||
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353,\"loginName\":\"admin\"}");
|
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353,\"loginName\":\"admin\"}");
|
||||||
stubBodies.put("/userhome/oapi/appearance", PAYLOAD);
|
stubBodies.put("/userhome/oapi/appearance", PAYLOAD);
|
||||||
@@ -128,10 +145,36 @@ class AppearanceProxyControllerTest {
|
|||||||
.andExpect(content().json(PAYLOAD));
|
.andExpect(content().json(PAYLOAD));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userbaseBusinessError_is503() throws Exception {
|
||||||
|
controller.setAppKey("gw-appearance");
|
||||||
|
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":4,\"msg\":\"无此用户\",\"data\":null}");
|
||||||
|
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost")))
|
||||||
|
.andExpect(status().isServiceUnavailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userhomeBusinessError_is503() throws Exception {
|
||||||
|
controller.setAppKey("gw-appearance");
|
||||||
|
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353}}");
|
||||||
|
stubBodies.put("/userhome/oapi/appearance", "{\"code\":5,\"msg\":\"外观服务异常\",\"data\":null}");
|
||||||
|
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||||
|
.andExpect(status().isServiceUnavailable());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void userhomeNullData_is503() throws Exception {
|
||||||
|
controller.setAppKey("gw-appearance");
|
||||||
|
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353}}");
|
||||||
|
stubBodies.put("/userhome/oapi/appearance", "{\"code\":0,\"msg\":\"\",\"data\":null}");
|
||||||
|
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||||
|
.andExpect(status().isServiceUnavailable());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void userNotFound_is503() throws Exception {
|
void userNotFound_is503() throws Exception {
|
||||||
controller.setAppKey("gw-appearance");
|
controller.setAppKey("gw-appearance");
|
||||||
stubBodies.put("/userbase/oapi/user/byLoginName", "null");
|
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":null}");
|
||||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost")))
|
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost")))
|
||||||
.andExpect(status().isServiceUnavailable());
|
.andExpect(status().isServiceUnavailable());
|
||||||
}
|
}
|
||||||
@@ -139,7 +182,7 @@ class AppearanceProxyControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void upstreamError_is503() throws Exception {
|
void upstreamError_is503() throws Exception {
|
||||||
controller.setAppKey("gw-appearance");
|
controller.setAppKey("gw-appearance");
|
||||||
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353}");
|
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353}}");
|
||||||
stubStatus.put("/userhome/oapi/appearance", 404);
|
stubStatus.put("/userhome/oapi/appearance", 404);
|
||||||
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
|
||||||
.andExpect(status().isServiceUnavailable());
|
.andExpect(status().isServiceUnavailable());
|
||||||
|
|||||||
Reference in New Issue
Block a user