diff --git a/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java b/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java index b5519a9..cd9336f 100644 --- a/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java +++ b/src/main/java/com/yunhai/platform/gateway/sso/AppearanceProxyController.java @@ -44,8 +44,11 @@ import java.nio.charset.StandardCharsets; * (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后 * 回环打网关真实 oapi 链(同 ProxyTestController 形态:Sanitize→路由→验签→转发): * ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance} - * 取生效外观;②的响应体【整体透传】(裸 payload {theme, avatar, logoUrl}), - * 字段演进只在 UserHome+通道两端,本端零改动。 + * 取生效外观。两腿响应按平台统一信封 {@code {code,msg,data}} 解析(镜像 + * {@code OpenApiEnvelope.unwrap} 语义:code≠0=上游业务失败→503;code==0 取 data; + * 无 code 视为已裸数据),②取出的 data【裸 payload 整体透传】,字段演进只在 + * UserHome+通道两端,本端零改动。信封坑实录(2026-10-12):裸 HttpClient 不走 + * YschOpenCall 的自动拆信封,顶层取 id 恒失败=登录态恒 503(单测裸 JSON stub 未对齐)。 *

* 三态失败语义:无 CAS 会话/会话断言缺失 → 401;未配 * {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome @@ -119,20 +122,21 @@ public class AppearanceProxyController { try { String base = loopbackBase(req); - JsonNode user = signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName", - "loginName=" + urlEncode(loginName), appKey.trim(), secret); + JsonNode user = unwrapEnvelope(signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName", + "loginName=" + urlEncode(loginName), appKey.trim(), secret)); if (user == null || user.isNull() || !user.path("id").canConvertToLong()) { LOG.warn("[appearance] userbase 未解析到登录人 {} 的 userId(503)", loginName); res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable"); return null; } - String payload = signedGetBody(base, TARGET_USERHOME, "/oapi/appearance", - "userId=" + user.path("id").asLong(), appKey.trim(), secret); - if (payload == null) { + JsonNode payload = unwrapEnvelope(signedGetJson(base, TARGET_USERHOME, "/oapi/appearance", + "userId=" + user.path("id").asLong(), appKey.trim(), secret)); + if (payload == null || payload.isNull()) { + LOG.warn("[appearance] userhome 外观 data 为空/业务失败(503,init 侧静默不动本地键)"); res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable"); return null; } - return payload; + return mapper.writeValueAsString(payload); } catch (Exception e) { // 降级铁律:sendError 直写 503 绕过全局异常处理器(否则被兜成 200 信封, // 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),天然内部入口口径(一律验签) */ private String loopbackBase(HttpServletRequest req) { if (loopbackBaseOverride != null) { diff --git a/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java b/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java index 93cb404..7bbc8c5 100644 --- a/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java +++ b/src/test/java/com/yunhai/platform/gateway/sso/AppearanceProxyControllerTest.java @@ -30,10 +30,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * 外观偏好同源读端点契约(对接规范 §3,网关自建同构哑管道): *

* 回环用 JDK HttpServer 按路径分桩(standalone MockMvc 不装 advice,String 返回即裸体)。 + * 信封坑(2026-10-12 线上实录):真实 UserBase/UserHome oapi 均包 {code,msg,data}, + * 裸 JSON stub 曾造成顶层取 id 恒失败=登录态恒 503——stub 一律按真实信封形态。 */ class AppearanceProxyControllerTest { @@ -118,7 +122,20 @@ class AppearanceProxyControllerTest { } @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"); stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353,\"loginName\":\"admin\"}"); stubBodies.put("/userhome/oapi/appearance", PAYLOAD); @@ -128,10 +145,36 @@ class AppearanceProxyControllerTest { .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 void userNotFound_is503() throws Exception { 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"))) .andExpect(status().isServiceUnavailable()); } @@ -139,7 +182,7 @@ class AppearanceProxyControllerTest { @Test void upstreamError_is503() throws Exception { 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); mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin"))) .andExpect(status().isServiceUnavailable());