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