package com.yunhai.userhome.service.impl; import com.yunhai.platform.utils.exception.DException; 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; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; /** * 用户外观偏好聚合实现(外观偏好服务端化 2026-10-08)。 * *

消费方是各子应用 init 的异步补齐段(挂载后 fetch,失败静默回落默认观感),因此本服务 * 对外承诺:聚合读永不抛异常——个人段/学校段各自独立降级(一段故障不影响另一段), * 查不到/脏值/依赖故障的字段一律 null。

* *

数据来源:myTheme/avatarMy ← user_appearance(纯本地,per-user 一行); * schTheme/avatarSch/logoUrl ← sch_settings + logo 表(schId 复用 resolveThemeSchId—— * 超管=平台层 0,其余=当前角色学校;identity 跨仓查询失败时学校段整体降级 null)。 * Logo 择优复用 UserSettingsServiceImpl.findLogoList 口径:本校 type=1 优先 → 系统 type=0 → null。

*/ @Service public class AppearanceServiceImpl implements IAppearanceService { @Autowired private UserAppearanceRepository userAppearanceRepository; @Autowired private ISchSettingsService schSettingsService; @Autowired private IUserCurrentRoleService userCurrentRoleService; @Autowired private LogoRepository logoRepository; @Value("${FILE_WEB_PATH:}") private String fileWebPath; @Override public Map getAppearance(BoaUser user) { Map out = new HashMap<>(); out.put("myTheme", null); out.put("schTheme", null); out.put("avatarMy", null); out.put("avatarSch", null); out.put("logoUrl", null); // 个人段(纯本地查询) try { Optional ua = userAppearanceRepository.findByUserId(user.getId()); if (ua.isPresent()) { out.put("myTheme", validThemeState(ua.get().getThemeState())); out.put("avatarMy", validAvatarStyle(ua.get().getAvatarStyle())); } } catch (Exception e) { // 降级:个人段 null,不影响学校段 } // 学校段(schId 解析含 identity 跨仓调用;失败整体降级 null) try { Integer schId = schSettingsService.resolveThemeSchId(user, null); if (schId != null) { SchSettings sch = schSettingsService.findBySchIdRaw(schId).orElse(null); if (sch != null) { out.put("schTheme", validThemeState(sch.getThemeName())); out.put("avatarSch", validAvatarStyle(sch.getAvatarStyle())); } out.put("logoUrl", resolveLogoUrl(schId)); } } catch (Exception e) { // 降级:学校段 null(含 logo),功能不受影响 } return out; } /** * 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化+LOGO 文字): * {@code {theme, avatar, logoText, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0) * 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;logoText 本校→平台级联; * 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("logoText", 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 = validThemeState(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("logoText", schSettingsService.readLogoTextWithFallback(schId).getValue()); out.put("logoUrl", resolveLogoUrl(schId)); } catch (Exception e) { // 学校/平台段降级:theme/avatar/logoText/logoUrl 保持已得值或 null } // theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级 out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme)); out.put("avatar", avatar); return out; } @Override public void saveMyTheme(Integer userId, String themeState) { UserAppearance ua = loadOrCreate(userId); if (themeState == null) { // 跟随学校默认:置 NULL(列语义见实体注释) ua.setThemeState(null); } else { // 写侧从严:只收 JSON、产规范化 JSON 串(紧凑入参抛 DException,2026-10-09 JSON 化契约) ua.setThemeState(ThemeCodec.encodeThemeState(themeState)); } ua.setUDate(new Date()); userAppearanceRepository.save(ua); } @Override public void saveMyAvatarStyle(Integer userId, String avatarStyle) { UserAppearance ua = loadOrCreate(userId); if (avatarStyle == null) { ua.setAvatarStyle(null); } else { String style = avatarStyle.trim(); if (!ThemeCodec.isAvatarStyle(style)) { throw new DException("头像样式不合法: " + avatarStyle); } ua.setAvatarStyle(style); } ua.setUDate(new Date()); userAppearanceRepository.save(ua); } private UserAppearance loadOrCreate(Integer userId) { return userAppearanceRepository.findByUserId(userId).orElseGet(() -> { UserAppearance ua = new UserAppearance(); ua.setUserId(userId); ua.setCDate(new Date()); return ua; }); } /** 主题档校验读(JSON 校验→规范化;旧紧凑四段兼容转 JSON;脏值 → null 视同未配置) */ private String validThemeState(String raw) { return StringUtils.hasText(raw) ? ThemeCodec.decodeThemeState(raw) : null; } private String validAvatarStyle(String raw) { return ThemeCodec.isAvatarStyle(raw) ? raw : null; } /** Logo 择优:本校(type=1)优先 → 系统(type=0) → null;URL 拼法对齐 UserSettingsController.getLogoList */ private String resolveLogoUrl(Integer schId) { if (schId != null) { String url = firstLogoUrl(logoRepository.findBySchIdAndType(schId, 1)); if (url != null) return url; } return firstLogoUrl(logoRepository.findBySchIdAndType(0, 0)); } private String firstLogoUrl(List list) { if (list == null) return null; for (Logo logo : list) { String path = logo == null ? null : logo.getLogoUrl(); if (!StringUtils.hasText(path)) continue; // 本地上传路径拼 FILE_WEB_PATH 绝对 URL;已是绝对地址的(预留跨仓存储)原样透传 if (path.startsWith("http://") || path.startsWith("https://")) return path; return fileWebPath + path.replace("/upload", ""); } return null; } }