diff --git a/src/main/java/com/yunhai/userhome/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userhome/config/OpenApiAuthEnforceConfiguration.java new file mode 100644 index 0000000..bf5845e --- /dev/null +++ b/src/main/java/com/yunhai/userhome/config/OpenApiAuthEnforceConfiguration.java @@ -0,0 +1,50 @@ +package com.yunhai.userhome.config; + +import com.yunhai.platform.appauth.AppAuthFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 强制开放接口必须带应用签名(对齐 UserMsg/UserBase 同名配置)。
+ * yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流), + * 但 UserHome 的 /oapi/**(外观偏好开放接口)绝不允许匿名访问。本过滤器在 AppAuthFilter 之后运行, + * 校验其写入的请求属性:已验签(属性存在)则放行,否则 401。
+ * 顺序须晚于 AppAuthFilter(HIGHEST_PRECEDENCE+10),故置为 +11。 + */ +@Configuration +public class OpenApiAuthEnforceConfiguration { + + @Bean + public FilterRegistrationBean requireAppSignFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new RequireAppSignFilter()); + bean.addUrlPatterns("/oapi/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11); + return bean; + } + + public static class RequireAppSignFilter implements Filter { + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature"); + return; + } + chain.doFilter(request, response); + } + } +} 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 1a8d1da..09223ed 100644 --- a/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java +++ b/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java @@ -37,9 +37,9 @@ import java.util.Optional; *

外观偏好轮(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} - * 的"合成默认对象"兜底语义也未变。

+ * (实现已随 oapi 代理轮下沉 {@link ISchSettingsService#readThemeWithFallback}/{@link ISchSettingsService#readAvatarWithFallback}) + * ——本校未配置或脏值时回落平台档,平台也无配置才返回 null。写侧与接口签名、响应体结构均未变; + * {@code getSchSettings}/{@code findBySchId} 的"合成默认对象"兜底语义也未变。

*/ @RestController @RequestMapping(value = Config.API_PATH) @@ -62,69 +62,16 @@ public class SchSettingsController { } } - /** 平台默认层在 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 - * (前端"不写键、不报错")。 */ + * 未配置/脏值 → 回落平台默认层(级联见 {@link ISchSettingsService#readAvatarWithFallback});两级都无 → + * avatarStyle=null(前端"不写键、不报错")。 */ @GetMapping("schAvatarStyle") public Map schAvatarStyle(BoaUser authUserWithDept) { - LayerValue hit = readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null)); + ISchSettingsService.LayerValue hit = + schSettingsService.readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null)); Map out = new HashMap<>(); - out.put("avatarStyle", hit.value); - out.put("schId", hit.hitSchId); + out.put("avatarStyle", hit.getValue()); + out.put("schId", hit.getHitSchId()); return out; } @@ -159,16 +106,18 @@ public class SchSettingsController { schSettingsService.save(schSettings); } - /** 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId};schId 为实际命中层(见 {@link LayerValue})。 - * 本校未配置/脏值 → 回落平台默认层(见 {@link #readThemeWithFallback});两级都无 → themeState=null + /** 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId};schId 为实际命中层 + * (见 {@link ISchSettingsService.LayerValue})。本校未配置/脏值 → 回落平台默认层 + * (级联见 {@link ISchSettingsService#readThemeWithFallback});两级都无 → themeState=null * (前端"不写键、不报错")。 */ @GetMapping("schTheme") public Map schTheme(BoaUser authUserWithDept) { - LayerValue hit = readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null)); + ISchSettingsService.LayerValue hit = + schSettingsService.readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null)); Map out = new HashMap<>(); - out.put("themeState", hit.value); - out.put("schId", hit.hitSchId); + out.put("themeState", hit.getValue()); + out.put("schId", hit.getHitSchId()); return out; } diff --git a/src/main/java/com/yunhai/userhome/controller/oapi/OapiAppearanceController.java b/src/main/java/com/yunhai/userhome/controller/oapi/OapiAppearanceController.java new file mode 100644 index 0000000..82e9ea1 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/controller/oapi/OapiAppearanceController.java @@ -0,0 +1,34 @@ +package com.yunhai.userhome.controller.oapi; + +import com.yunhai.userhome.config.Config; +import com.yunhai.userhome.service.IAppearanceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 外观偏好开放接口(oapi 代理轮 2026-10-08):子应用后端经 open-gateway 签名直调(target=userhome), + * 替代浏览器直连 /api/appearance + CAS iframe 引导。入站验签由 {@code OpenApiAuthEnforceConfiguration} + * 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。 + * + *

出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成, + * 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感)。 + * 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。

+ */ +@RestController +@RequestMapping(value = Config.OPEN_API_PATH) +public class OapiAppearanceController { + + @Autowired + private IAppearanceService appearanceService; + + /** 当前用户生效外观。userId 由调用方(子应用后端)从其登录态取,服务间签名保证调用方可信。 */ + @GetMapping("appearance") + public Map appearance(@RequestParam("userId") Integer userId) { + return appearanceService.getEffectiveAppearance(userId); + } +} diff --git a/src/main/java/com/yunhai/userhome/service/IAppearanceService.java b/src/main/java/com/yunhai/userhome/service/IAppearanceService.java index 2531391..489b45a 100644 --- a/src/main/java/com/yunhai/userhome/service/IAppearanceService.java +++ b/src/main/java/com/yunhai/userhome/service/IAppearanceService.java @@ -16,6 +16,13 @@ public interface IAppearanceService { */ Map getAppearance(BoaUser user); + /** + * 当前用户生效外观(oapi 代理轮 2026-10-08):{theme, avatar, logoUrl}——主题/头像为合成后的 + * 【生效值】(个人 → 学校 → 平台级联,层级知识不外露);logoUrl 本校→平台择优。 + * 查不到/脏值/依赖故障的字段 null(init 侧清对应键回落默认观感)。 + */ + Map getEffectiveAppearance(Integer userId); + /** 保存用户自选主题档(紧凑编码 theme|sideDeep|gradOn|sideFollow);themeState=null 表示跟随学校默认 */ void saveMyTheme(Integer userId, String themeState); diff --git a/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java b/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java index 8c08ccf..31991da 100644 --- a/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java +++ b/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java @@ -24,4 +24,38 @@ public interface ISchSettingsService { /** 主题/头像默认档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属)。 * 原为 SchSettingsController 私有方法,外观偏好轮(2026-10-08)下沉供 AppearanceController 共享。 */ Integer resolveThemeSchId(BoaUser user, Integer explicitSchId); + + /** 平台默认层在 sch_settings 中的固定 schId(与 UsedAppController/壁纸/LOGO 的 0 层同口径) */ + int PLATFORM_SCH_ID = 0; + + /** + * 主题档读侧级联(设置中心三级重构 S7,2026-10-08;oapi 代理轮下沉 service 供 schTheme 读端点与 + * /oapi/appearance 合成共享):本校行未配置/旧格式/脏值 → 再查平台默认层(schId=0), + * 命中即返回平台档;两级都无 → value=null。schId 自身即平台层时不二次回落(避免自查两次)。 + */ + LayerValue readThemeWithFallback(Integer schId); + + /** 头像样式读侧级联:语义与 {@link #readThemeWithFallback} 同构(本校空/脏 → 平台层 → null)。 */ + LayerValue readAvatarWithFallback(Integer schId); + + /** 级联读命中结果:{@code value}=命中的档位值(两级都无则 null),{@code hitSchId}=实际命中层的 schId + * (回落发生时是 0,不是解析出的本校 schId)。设置中心读端点的响应体 schId 即此语义—— + * 前端据此区分「本校已配置」与「本校未配置、正在回落平台默认」。 */ + final class LayerValue { + private final String value; + private final Integer hitSchId; + + public LayerValue(String value, Integer hitSchId) { + this.value = value; + this.hitSchId = hitSchId; + } + + public String getValue() { + return value; + } + + public Integer getHitSchId() { + return hitSchId; + } + } } diff --git a/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java index 06bb4a4..b29b632 100644 --- a/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java +++ b/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java @@ -5,10 +5,12 @@ import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userhome.entity.Logo; import com.yunhai.userhome.entity.SchSettings; import com.yunhai.userhome.entity.UserAppearance; +import com.yunhai.userhome.entity.UserCurrentRole; import com.yunhai.userhome.repository.LogoRepository; import com.yunhai.userhome.repository.UserAppearanceRepository; import com.yunhai.userhome.service.IAppearanceService; import com.yunhai.userhome.service.ISchSettingsService; +import com.yunhai.userhome.service.IUserCurrentRoleService; import com.yunhai.userhome.utils.ThemeCodec; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -42,6 +44,9 @@ public class AppearanceServiceImpl implements IAppearanceService { @Autowired private ISchSettingsService schSettingsService; + @Autowired + private IUserCurrentRoleService userCurrentRoleService; + @Autowired private LogoRepository logoRepository; @@ -83,6 +88,57 @@ public class AppearanceServiceImpl implements IAppearanceService { return out; } + /** + * 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源): + * {@code {theme, avatar, logoUrl}}——主题/头像为【合成后的生效值】(个人自选 → 学校默认 → 平台默认(schId=0) + * 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);logoUrl 为本校 type=1 → 平台 type=0 择优。 + * + *

schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校, + * 无角色/跨校态回落 0(平台层)。与 /api/appearance 的 resolveThemeSchId(超管强制 0)仅在「超管临时切入 + * 某校角色」场景不同——该场景按当前角色学校取默认反而更贴近其当下观感,属登记偏差而非回归。

+ */ + @Override + public Map getEffectiveAppearance(Integer userId) { + Map out = new HashMap<>(); + out.put("theme", null); + out.put("avatar", null); + out.put("logoUrl", null); + if (userId == null) { + return out; + } + Integer schId = userCurrentRoleService.findById(userId) + .map(UserCurrentRole::getSchId) + .filter(s -> s != null && s > 0) + .orElse(ISchSettingsService.PLATFORM_SCH_ID); + String myTheme = null; + String myAvatar = null; + try { + Optional ua = userAppearanceRepository.findByUserId(userId); + if (ua.isPresent()) { + myTheme = validCompactTheme(ua.get().getThemeState()); + myAvatar = validAvatarStyle(ua.get().getAvatarStyle()); + } + } catch (Exception e) { + // 个人段降级:myTheme/myAvatar 保持 null,不影响学校/平台段 + } + String theme = myTheme; + String avatar = myAvatar; + try { + if (theme == null) { + theme = schSettingsService.readThemeWithFallback(schId).getValue(); + } + if (avatar == null) { + avatar = schSettingsService.readAvatarWithFallback(schId).getValue(); + } + out.put("logoUrl", resolveLogoUrl(schId)); + } catch (Exception e) { + // 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null + } + out.put("theme", theme); + out.put("avatar", avatar); + return out; + } + @Override public void saveMyTheme(Integer userId, String themeState) { UserAppearance ua = loadOrCreate(userId); diff --git a/src/main/java/com/yunhai/userhome/service/impl/SchSettingsServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/SchSettingsServiceImpl.java index 7574ad5..3df6cd2 100644 --- a/src/main/java/com/yunhai/userhome/service/impl/SchSettingsServiceImpl.java +++ b/src/main/java/com/yunhai/userhome/service/impl/SchSettingsServiceImpl.java @@ -6,6 +6,7 @@ import com.yunhai.userhome.entity.UserCurrentRole; import com.yunhai.userhome.repository.SchSettingsRepository; import com.yunhai.userhome.service.ISchSettingsService; import com.yunhai.userhome.service.IUserCurrentRoleService; +import com.yunhai.userhome.utils.ThemeCodec; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -74,4 +75,40 @@ public class SchSettingsServiceImpl implements ISchSettingsService { .filter(schId -> schId != null && schId > 0) .orElseGet(() -> resolveSchId(user)); } + + @Override + public ISchSettingsService.LayerValue readThemeWithFallback(Integer schId) { + String themeState = decodeTheme(schId); + if (themeState != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) { + return new ISchSettingsService.LayerValue(themeState, schId); + } + return new ISchSettingsService.LayerValue(decodeTheme(ISchSettingsService.PLATFORM_SCH_ID), + ISchSettingsService.PLATFORM_SCH_ID); + } + + @Override + public ISchSettingsService.LayerValue readAvatarWithFallback(Integer schId) { + String avatarStyle = readAvatarAt(schId); + if (avatarStyle != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) { + return new ISchSettingsService.LayerValue(avatarStyle, schId); + } + return new ISchSettingsService.LayerValue(readAvatarAt(ISchSettingsService.PLATFORM_SCH_ID), + ISchSettingsService.PLATFORM_SCH_ID); + } + + /** 原样读某层主题档并解码(查无行/旧格式/脏值 → null) */ + private String decodeTheme(Integer schId) { + return schSettingsRepository.findFirstBySchId(schId) + .map(SchSettings::getThemeName) + .map(ThemeCodec::decodeThemeState) + .orElse(null); + } + + /** 原样读某层头像样式(查无行/非法值 → null) */ + private String readAvatarAt(Integer schId) { + return schSettingsRepository.findFirstBySchId(schId) + .map(SchSettings::getAvatarStyle) + .filter(ThemeCodec::isAvatarStyle) + .orElse(null); + } } diff --git a/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java b/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java index a347cf8..f1f597d 100644 --- a/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java +++ b/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java @@ -217,4 +217,80 @@ class AppearanceServiceTest { verify(userAppearanceRepository).save(captor.capture()); assertNull(captor.getValue().getAvatarStyle()); } + + // ===== 生效值合成(oapi 代理轮 2026-10-08,/oapi/appearance 出参真源)===== + + private IUserCurrentRoleService userCurrentRoleService; + + private void stubCurrentRole(Integer schId) { + userCurrentRoleService = mock(IUserCurrentRoleService.class); + ReflectionTestUtils.setField(service, "userCurrentRoleService", userCurrentRoleService); + if (schId == null) { + when(userCurrentRoleService.findById(10133)).thenReturn(Optional.empty()); + } else { + com.yunhai.userhome.entity.UserCurrentRole role = new com.yunhai.userhome.entity.UserCurrentRole(); + role.setUserId(10133); + role.setSchId(schId); + when(userCurrentRoleService.findById(10133)).thenReturn(Optional.of(role)); + } + } + + @Test + @DisplayName("生效值:个人自选优先——theme/avatar 取 user_appearance,级联读不出站") + void effective_personalOverridesCascade() { + stubCurrentRole(3); + UserAppearance ua = new UserAppearance(); + ua.setThemeState("ocean|1|0|1"); + ua.setAvatarStyle("silhouette"); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua)); + when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); + + Map out = service.getEffectiveAppearance(10133); + assertEquals("ocean|1|0|1", out.get("theme")); + assertEquals("silhouette", out.get("avatar")); + assertNull(out.get("logoUrl")); + verify(schSettingsService, never()).readThemeWithFallback(any()); + verify(schSettingsService, never()).readAvatarWithFallback(any()); + } + + @Test + @DisplayName("生效值:个人未设 → 学校/平台级联(缺陷 C 修复验证:合成链走 readThemeWithFallback)") + void effective_fallsToCascade() { + stubCurrentRole(3); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); + when(schSettingsService.readThemeWithFallback(3)) + .thenReturn(new ISchSettingsService.LayerValue("ink|0|1|1", 0)); + when(schSettingsService.readAvatarWithFallback(3)) + .thenReturn(new ISchSettingsService.LayerValue("text", 3)); + when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); + + Map out = service.getEffectiveAppearance(10133); + assertEquals("ink|0|1|1", out.get("theme"), "本校空回落平台档(hitSchId=0)"); + assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)"); + // 出参只三字段,不外露 my/sch 拆分 + assertTrue(out.containsKey("theme") && out.containsKey("avatar") && out.containsKey("logoUrl")); + assertEquals(3, out.size()); + } + + @Test + @DisplayName("生效值:个人脏值视同未配置 → 落级联;两级皆无 → null;无当前角色 → 平台层(0)") + void effective_dirtyAndNoRole_fallsToPlatformLayer() { + stubCurrentRole(null); + UserAppearance ua = new UserAppearance(); + ua.setThemeState("default-theme"); + ua.setAvatarStyle("circle"); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua)); + when(schSettingsService.readThemeWithFallback(0)) + .thenReturn(new ISchSettingsService.LayerValue(null, 0)); + when(schSettingsService.readAvatarWithFallback(0)) + .thenReturn(new ISchSettingsService.LayerValue(null, 0)); + when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); + + Map out = service.getEffectiveAppearance(10133); + assertNull(out.get("theme"), "脏值落级联且两级皆无 → null"); + assertNull(out.get("avatar")); + assertNull(out.get("logoUrl")); + verify(schSettingsService).readThemeWithFallback(0); + verify(schSettingsService).readAvatarWithFallback(0); + } }