From 54d1b848c30f324a1416943ab32a0387bc8aba33 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 8 Oct 2026 23:08:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings+msgcenter):=20S7=20=E4=B8=BB?= =?UTF-8?q?=E9=A2=98/=E5=A4=B4=E5=83=8F=E8=AF=BB=E4=BE=A7=E7=BA=A7?= =?UTF-8?q?=E8=81=94=20+=20=E6=B6=88=E6=81=AF=E6=B5=8B=E8=AF=95=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=20appKey=20=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchSettingsController:schTheme/schAvatarStyle 读侧加学校→平台(schId=0)级联(readThemeWithFallback/readAvatarWithFallback), 回传 hitSchId 供设置中心区分「本校已配」与「回落平台」;写侧与接口签名不变 - MsgCenterProxyService:summary.todos 摘除 appKey 死字段(appKey 属后端签名标识不下发) - MsgCenterProxyServiceTest:两例更新为 appId 口径(字典翻译/字典固定一次出站)+ 补 countByApp 富化/降级、 appId 查无空页、todos 无 appKey 断言;34/34 绿 --- .../controller/api/SchSettingsController.java | 91 +++++++++++++++---- .../service/MsgCenterProxyService.java | 2 +- .../service/MsgCenterProxyServiceTest.java | 74 ++++++++++++++- 3 files changed, 141 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java b/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java index 9c7f288..1a8d1da 100644 --- a/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java +++ b/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java @@ -26,7 +26,7 @@ import java.util.Optional; *

getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig); * saveSchTheme/saveSchLogo 写本地 sch_settings 表。

*

主题设置轮(2026-09-30)新增学校默认主题档三接口(契约见 YschTheme《主题设置与通道改动-执行方案》§3.5): - * schTheme 读(已登录可读,可空不兜底——与 getSchSettings 的兜底语义刻意区分); + * schTheme 读(已登录可读,当时"可空不兜底"——S7 起改为学校→平台级联,见文末说明); * saveSchThemeState 写(仅学校管理员/超管);clearSchTheme 清空(置空 theme 字段——sch_settings 行还承载 * logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态:theme_name 列存紧凑四段编码 * {@code theme|sideDeep01|gradOn01|sideFollow01}(如 teal|0|1|1;不拆列、零 DDL,列宽未确认故不存整份 JSON; @@ -36,6 +36,10 @@ import java.util.Optional; * avatar_style 字段)。

*

外观偏好轮(2026-10-08):编解码下沉 {@link ThemeCodec}、schId 解析下沉 * {@link ISchSettingsService#resolveThemeSchId}(行为等价重构,供 AppearanceController 共享)。

+ *

三级重构 S7(2026-10-08):{@code schTheme}/{@code schAvatarStyle} 读侧加"学校 → 平台默认(schId=0)"级联 + * ({@link #readThemeWithFallback}/{@link #readAvatarWithFallback})——本校未配置或脏值时回落平台档, + * 平台也无配置才返回 null。写侧与接口签名、响应体结构均未变;{@code getSchSettings}/{@code findBySchId} + * 的"合成默认对象"兜底语义也未变。

*/ @RestController @RequestMapping(value = Config.API_PATH) @@ -58,18 +62,69 @@ public class SchSettingsController { } } - /** 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。 - * 未配置/脏值 → avatarStyle=null(读侧可空不兜底,对齐 schTheme 语义;前端"不写键、不报错")。 */ - @GetMapping("schAvatarStyle") - public Map schAvatarStyle(BoaUser authUserWithDept) { - Integer schId = schSettingsService.resolveThemeSchId(authUserWithDept, null); - String avatarStyle = schSettingsService.findBySchIdRaw(schId) + /** 平台默认层在 sch_settings 中的固定 schId(与 UsedAppController/壁纸/LOGO 的 0 层同口径) */ + private static final int PLATFORM_SCH_ID = 0; + + /** + * 主题档读侧级联(设置中心三级重构 S7,2026-10-08):本校行未配置/旧格式/脏值 → 再查平台默认层(schId=0), + * 命中即返回平台档;两级都无 → null。schId 自身即平台层时不二次回落(避免自查两次)。 + *

写侧不受影响:超管仍写 schId=0、校管写本校。

+ */ + private LayerValue readThemeWithFallback(Integer schId) { + String themeState = decodeTheme(schId); + if (themeState != null || schId == null || schId == PLATFORM_SCH_ID) return new LayerValue(themeState, schId); + return new LayerValue(decodeTheme(PLATFORM_SCH_ID), PLATFORM_SCH_ID); + } + + /** 头像样式读侧级联:语义与 {@link #readThemeWithFallback} 同构(本校空/脏 → 平台层 → null)。 */ + private LayerValue readAvatarWithFallback(Integer schId) { + String avatarStyle = readAvatar(schId); + if (avatarStyle != null || schId == null || schId == PLATFORM_SCH_ID) return new LayerValue(avatarStyle, schId); + return new LayerValue(readAvatar(PLATFORM_SCH_ID), PLATFORM_SCH_ID); + } + + /** + * 级联读命中结果:{@code value}=命中的档位值(两级都无则 null),{@code hitSchId}=**实际命中层**的 schId + * (回落发生时是 0,不是解析出的本机 schId)。 + *

响应体的 {@code schId} 字段因此采用"实际命中层"语义(方案 §6.S7 允许二选一)—— + * 设置中心的"本校默认"层要据此区分"本校已配置"与"本校未配置、正在回落平台默认"。 + * 现有前端只消费 themeState/avatarStyle,不依赖该字段。

+ */ + private static final class LayerValue { + final String value; + final Integer hitSchId; + + LayerValue(String value, Integer hitSchId) { + this.value = value; + this.hitSchId = hitSchId; + } + } + + /** 原样读某层主题档并解码(查无行/脏值 → null) */ + private String decodeTheme(Integer schId) { + return schSettingsService.findBySchIdRaw(schId) + .map(SchSettings::getThemeName) + .map(ThemeCodec::decodeThemeState) + .orElse(null); + } + + /** 原样读某层头像样式(查无行/脏值 → null) */ + private String readAvatar(Integer schId) { + return schSettingsService.findBySchIdRaw(schId) .map(SchSettings::getAvatarStyle) .filter(ThemeCodec::isAvatarStyle) .orElse(null); + } + + /** 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。 + * 未配置/脏值 → 回落平台默认层(见 {@link #readAvatarWithFallback});两级都无 → avatarStyle=null + * (前端"不写键、不报错")。 */ + @GetMapping("schAvatarStyle") + public Map schAvatarStyle(BoaUser authUserWithDept) { + LayerValue hit = readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null)); Map out = new HashMap<>(); - out.put("avatarStyle", avatarStyle); - out.put("schId", schId); + out.put("avatarStyle", hit.value); + out.put("schId", hit.hitSchId); return out; } @@ -91,7 +146,7 @@ public class SchSettingsController { schSettingsService.save(schSettings); } - /** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行,置空后读侧返回 null)。 */ + /** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行;置空后读侧回落平台层,平台也无则 null)。 */ @PostMapping("clearSchAvatarStyle") public void clearSchAvatarStyle(BoaUser authUserWithDept, @RequestParam(name = "schId", required = false) Integer schId) { @@ -104,20 +159,16 @@ public class SchSettingsController { schSettingsService.save(schSettings); } - /** - * 学校默认主题档(已登录可读):{themeState: "" | null, schId}。 - * 未配置/旧格式值/脏数据 → themeState=null(前端"不写键、不报错")。 + /** 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId};schId 为实际命中层(见 {@link LayerValue})。 + * 本校未配置/脏值 → 回落平台默认层(见 {@link #readThemeWithFallback});两级都无 → themeState=null + * (前端"不写键、不报错")。 */ @GetMapping("schTheme") public Map schTheme(BoaUser authUserWithDept) { - Integer schId = schSettingsService.resolveThemeSchId(authUserWithDept, null); - String themeState = schSettingsService.findBySchIdRaw(schId) - .map(SchSettings::getThemeName) - .map(ThemeCodec::decodeThemeState) - .orElse(null); + LayerValue hit = readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null)); Map out = new HashMap<>(); - out.put("themeState", themeState); - out.put("schId", schId); + out.put("themeState", hit.value); + out.put("schId", hit.hitSchId); return out; } diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java index bc35c8c..5aa57c7 100644 --- a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java +++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java @@ -272,7 +272,7 @@ public class MsgCenterProxyService { if (todos != null && todos.isArray()) { for (JsonNode t : todos) { Map row = new LinkedHashMap<>(); - row.put("appKey", text(t, "appKey")); + // appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp row.put("count", t.has("count") ? t.get("count").asInt() : 0); rows.add(row); } diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java index 98464c2..c191739 100644 --- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java +++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; @@ -81,6 +82,13 @@ class MsgCenterProxyServiceTest { .thenReturn(json(arr.toString())); } + /** 应用字典 stub:/oapi/app/list 返回单分组单应用(appId 翻译 / countByApp 富化数据源) */ + private void stubAppDict(int appId, String appKey, String name) throws Exception { + when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("[{\"apps\":[{\"id\":" + appId + ",\"appKey\":\"" + appKey + + "\",\"name\":\"" + name + "\"}]}]")); + } + @Test void list_targetSpecStripsUsermsgCtx_forPerTargetSign() { // 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg @@ -155,7 +163,8 @@ class MsgCenterProxyServiceTest { assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); } - /** jumpApp 与 appKey 均空 → 注册表无源可查 → null(前端不渲染跳转),且不产生判权/查址出站 */ + /** jumpApp 与 appKey 均空 → 注册表查址无源可查 → null(前端不渲染跳转),且无 by-code/by-key 出站。 + * 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */ @Test void list_noRegistrySource_jumpUrlNull() throws Exception { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) @@ -164,8 +173,10 @@ class MsgCenterProxyServiceTest { Map first = firstRow(); assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写"); assertEquals(Boolean.FALSE, first.get("showJump")); - verify(openCall, never()).get(eq("userbase"), anyString(), anyMap(), eq(JsonNode.class)); - verify(openCall, never()).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)); + verify(openCall, never()).get(eq("userbase"), contains("by-code"), isNull(), eq(JsonNode.class)); + verify(openCall, never()).get(eq("userbase"), contains("by-key"), isNull(), eq(JsonNode.class)); + verify(openCall, org.mockito.Mockito.times(1)) + .get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)); } // ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 ===== @@ -300,6 +311,11 @@ class MsgCenterProxyServiceTest { Map out = service.summary(201, 1); assertEquals(3L, out.get("unreadCount")); assertEquals(1, ((java.util.List) out.get("todos")).size()); + // appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count + @SuppressWarnings("unchecked") + Map todoRow = (Map) ((java.util.List) out.get("todos")).get(0); + assertFalse(todoRow.containsKey("appKey"), "待办角标不再下发 appKey"); + assertEquals(2, todoRow.get("count")); // 未读数不可达单独降级为 0,待办仍可用 when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) @@ -309,9 +325,11 @@ class MsgCenterProxyServiceTest { assertEquals(1, ((java.util.List) out2.get("todos")).size()); } - /** /msg 页服务端筛选(消息对接 2026-09-30):msgType/appKey/isRead/keyword 透传,空值不下发 */ + /** /msg 页服务端筛选(消息对接 2026-09-30;appId 口径 2026-10-08):msgType/appId/isRead/keyword 透传, + * 空值不下发。appId 不再由前端直传 appKey:先经应用字典翻译(去首尾空白),字典查无 → 空页且不出站 UserMsg。 */ @Test void list_filtersPassedThrough() throws Exception { + stubAppDict(100019, "100019", "流程中心"); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":0,\"content\":[]}")); @@ -321,7 +339,7 @@ class MsgCenterProxyServiceTest { ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); assertEquals("2", query.getValue().get("msgType")); - assertEquals("100019", query.getValue().get("appKey"), "appKey 去首尾空白后透传"); + assertEquals("100019", query.getValue().get("appKey"), "appId 去空白经字典翻译为 appKey 下行"); assertEquals("0", query.getValue().get("isRead")); assertEquals("请假", query.getValue().get("keyword")); @@ -337,6 +355,52 @@ class MsgCenterProxyServiceTest { assertFalse(query2.getAllValues().get(1).containsKey("keyword")); } + /** appId 字典查无(授权回收等)→ 视作该应用无消息:空页,且不出站 UserMsg 列表 */ + @Test + void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception { + // 字典未 stub → /oapi/app/list 返回 null → EMPTY + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("{\"total\":0,\"content\":[]}")); + Map out = service.list(201, 1, 10, null, null, "999999", null, null); + assertEquals(0, out.get("total")); + assertEquals(0, ((java.util.List) out.get("list")).size()); + verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)); + } + + /** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径; + * 字典查无 → appId/appName null(前端不展示);无 appKey 的行跳过。 */ + @Test + void countByApp_enrichesWithRegistryDict() throws Exception { + stubAppDict(100011, "100019", "流程中心"); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3}," + + "{\"appKey\":\"999999\",\"total\":5,\"unread\":1}," + + "{\"total\":2,\"unread\":0}]")); + java.util.List> rows = service.countByApp(201); + assertEquals(2, rows.size(), "无 appKey 行跳过"); + Map first = rows.get(0); + assertEquals(100011, first.get("appId")); + assertEquals("流程中心", first.get("appName")); + assertEquals(12L, first.get("total")); + assertEquals(3L, first.get("unread")); + assertNull(rows.get(1).get("appId"), "字典查无 appId=null,行保留由前端隐藏"); + assertNull(rows.get(1).get("appName")); + assertEquals(5L, rows.get(1).get("total")); + } + + /** countByApp 停机/未启用降级空列表(「按应用」清单空态,不 500) */ + @Test + void countByApp_disabledOrDown_degradesEmpty() { + properties.setUsermsgBaseUrl(""); + when(openCall.hasTarget("usermsg")).thenReturn(false); + assertTrue(service.countByApp(201).isEmpty()); + + properties.setUsermsgBaseUrl(PROD_BASE_URL); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class))) + .thenThrow(new OpenApiException("down")); + assertTrue(service.countByApp(201).isEmpty(), "UserMsg 不可达降级空,不抛出"); + } + /** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */ @Test void summary_prefersSummaryEndpoint() throws Exception {