diff --git a/src/main/java/com/yunhai/userhome/controller/api/AppearanceController.java b/src/main/java/com/yunhai/userhome/controller/api/AppearanceController.java new file mode 100644 index 0000000..78df6c1 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/controller/api/AppearanceController.java @@ -0,0 +1,55 @@ +package com.yunhai.userhome.controller.api; + +import com.yunhai.platform.utils.model.BoaUser; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 用户外观偏好接口(CAS 登录态,路径 {@code /{platform}/},外观偏好服务端化 2026-10-08)。 + *

服务端真源 = user_appearance(per-user 一行)+ sch_settings + logo 表; + * 消费方:UserHome/front 设置页双写(saveMyTheme/saveMyAvatarStyle)与各子应用 + * yh-theme-init.js 异步补齐段(GET appearance,挂载后 fetch、失败静默回落默认观感)。

+ *

三端点全部登录态、走本应用 /api/** 既有 CAS 链路,不进 oapi/enforce。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH) +public class AppearanceController { + + @Autowired + private IAppearanceService appearanceService; + + /** + * 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}。 + * myTheme/schTheme 为紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01}; + * avatarMy/avatarSch 为 {@code text|silhouette};logoUrl 为绝对 URL; + * 查不到/脏值/依赖故障的字段一律 null(init 侧按"null=清键回落默认"收敛)。 + */ + @GetMapping("appearance") + public Map appearance(BoaUser authUserWithDept) { + return appearanceService.getAppearance(authUserWithDept); + } + + /** 保存用户自选主题档:body {@code {"themeState":"ocean|1|0|1"}};null=跟随学校默认;非法值抛业务异常。 */ + @PostMapping("saveMyTheme") + public void saveMyTheme(BoaUser authUserWithDept, @RequestBody Map body) { + appearanceService.saveMyTheme(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("themeState"))); + } + + /** 保存用户自选头像样式:body {@code {"avatarStyle":"text"|"silhouette"}};null=跟随学校默认。 */ + @PostMapping("saveMyAvatarStyle") + public void saveMyAvatarStyle(BoaUser authUserWithDept, @RequestBody Map body) { + appearanceService.saveMyAvatarStyle(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("avatarStyle"))); + } + + private static String stringOrNull(Object v) { + return v == null ? null : String.valueOf(v); + } +} 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 02a4ee0..9c7f288 100644 --- a/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java +++ b/src/main/java/com/yunhai/userhome/controller/api/SchSettingsController.java @@ -8,8 +8,8 @@ import com.yunhai.userhome.entity.UserCurrentRole; import com.yunhai.userhome.service.ISchSettingsService; import com.yunhai.userhome.service.IUserCurrentRoleService; import com.yunhai.userhome.utils.OtherUtil; +import com.yunhai.userhome.utils.ThemeCodec; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -34,44 +34,19 @@ import java.util.Optional; *

默认头像轮(2026-09-30)同构新增学校默认头像样式三接口:schAvatarStyle 读(可空不兜底); * saveSchAvatarStyle 写(仅学校管理员/超管,值只接受 text|silhouette);clearSchAvatarStyle 清空(置空 * avatar_style 字段)。

+ *

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

*/ @RestController @RequestMapping(value = Config.API_PATH) public class SchSettingsController { - /** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */ - private static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"}; - - /** 合法头像样式(与前端 avatar.ts 的 AvatarStyle 一致;写入侧从严,对齐 encodeThemeState 做法) */ - private static final String[] AVATAR_STYLES = {"text", "silhouette"}; - @Autowired private ISchSettingsService schSettingsService; @Autowired private IUserCurrentRoleService userCurrentRoleService; - private Integer resolveSchId(BoaUser user) { - if (user.getSchool() != null) { - return user.getSchool().getId(); - } - if (user.getBureau() != null) { - return user.getBureau().getId(); - } - return 0; - } - - /** 主题档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属) */ - private Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) { - if (user.getType() != null && user.getType() == 1) { - return explicitSchId == null ? 0 : explicitSchId; - } - return userCurrentRoleService.findById(user.getId()) - .map(UserCurrentRole::getSchId) - .filter(schId -> schId != null && schId > 0) - .orElseGet(() -> resolveSchId(user)); - } - /** 主题档写权限:超管或学校管理员(先例:UserSettingsServiceImpl.findSchWallpaperManageList) */ private void requireThemeAdmin(BoaUser user) { boolean superAdmin = user.getType() != null && user.getType() == 1; @@ -83,64 +58,14 @@ public class SchSettingsController { } } - /** 紧凑编码 → ThemeState JSON;非本编码形态(旧档名/脏值/字段非法)一律 null(视同未配置) */ - private static String decodeThemeState(String encoded) { - if (!StringUtils.hasText(encoded)) return null; - String[] parts = encoded.trim().split("\\|"); - if (parts.length != 4) return null; - if (!isThemeKey(parts[0])) return null; - for (int i = 1; i < 4; i++) { - if (parts[i].length() != 1 || (parts[i].charAt(0) != '0' && parts[i].charAt(0) != '1')) return null; - } - return "{\"theme\":\"" + parts[0] + "\",\"sideDeep\":" + parts[1] - + ",\"gradOn\":" + parts[2] + ",\"sideFollow\":" + parts[3] + "}"; - } - - /** ThemeState JSON 校验(四字段齐全、档名合法)+ → 紧凑编码;非法抛业务异常(写入侧从严) */ - private static String encodeThemeState(String themeStateJson) { - if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空"); - com.fasterxml.jackson.databind.JsonNode n; - try { - n = new com.fasterxml.jackson.databind.ObjectMapper().readTree(themeStateJson); - } catch (Exception e) { - throw new DException("主题档格式不合法"); - } - if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep") || !n.has("gradOn") || !n.has("sideFollow")) { - throw new DException("主题档格式不合法"); - } - String theme = n.get("theme").asText(); - if (!isThemeKey(theme)) throw new DException("主题档名不合法: " + theme); - return theme + "|" + (n.get("sideDeep").asBoolean() ? 1 : 0) - + "|" + (n.get("gradOn").asBoolean() ? 1 : 0) - + "|" + (n.get("sideFollow").asBoolean() ? 1 : 0); - } - - private static boolean isThemeKey(String theme) { - if (theme == null) return false; - for (String k : THEME_KEYS) { - if (k.equals(theme)) return true; - } - return false; - } - - private static boolean isAvatarStyle(String style) { - if (style == null) return false; - for (String k : AVATAR_STYLES) { - if (k.equals(style)) return true; - } - return false; - } - - /** - * 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。 - * 未配置/脏值 → avatarStyle=null(读侧可空不兜底,对齐 schTheme 语义;前端"不写键、不报错")。 - */ + /** 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。 + * 未配置/脏值 → avatarStyle=null(读侧可空不兜底,对齐 schTheme 语义;前端"不写键、不报错")。 */ @GetMapping("schAvatarStyle") public Map schAvatarStyle(BoaUser authUserWithDept) { - Integer schId = resolveThemeSchId(authUserWithDept, null); + Integer schId = schSettingsService.resolveThemeSchId(authUserWithDept, null); String avatarStyle = schSettingsService.findBySchIdRaw(schId) .map(SchSettings::getAvatarStyle) - .filter(SchSettingsController::isAvatarStyle) + .filter(ThemeCodec::isAvatarStyle) .orElse(null); Map out = new HashMap<>(); out.put("avatarStyle", avatarStyle); @@ -154,10 +79,10 @@ public class SchSettingsController { @RequestParam("avatarStyle") String avatarStyle, @RequestParam(name = "schId", required = false) Integer schId) { requireThemeAdmin(authUserWithDept); - if (!isAvatarStyle(avatarStyle)) { + if (!ThemeCodec.isAvatarStyle(avatarStyle)) { throw new DException("头像样式不合法: " + avatarStyle); } - Integer targetSchId = resolveThemeSchId(authUserWithDept, schId); + Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId); SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new); schSettings.setCDate(new Date()); schSettings.setSchId(targetSchId); @@ -171,7 +96,7 @@ public class SchSettingsController { public void clearSchAvatarStyle(BoaUser authUserWithDept, @RequestParam(name = "schId", required = false) Integer schId) { requireThemeAdmin(authUserWithDept); - Integer targetSchId = resolveThemeSchId(authUserWithDept, schId); + Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId); Optional existing = schSettingsService.findBySchIdRaw(targetSchId); if (!existing.isPresent()) return; SchSettings schSettings = existing.get(); @@ -185,10 +110,10 @@ public class SchSettingsController { */ @GetMapping("schTheme") public Map schTheme(BoaUser authUserWithDept) { - Integer schId = resolveThemeSchId(authUserWithDept, null); + Integer schId = schSettingsService.resolveThemeSchId(authUserWithDept, null); String themeState = schSettingsService.findBySchIdRaw(schId) .map(SchSettings::getThemeName) - .map(SchSettingsController::decodeThemeState) + .map(ThemeCodec::decodeThemeState) .orElse(null); Map out = new HashMap<>(); out.put("themeState", themeState); @@ -202,8 +127,8 @@ public class SchSettingsController { @RequestParam("themeState") String themeState, @RequestParam(name = "schId", required = false) Integer schId) { requireThemeAdmin(authUserWithDept); - Integer targetSchId = resolveThemeSchId(authUserWithDept, schId); - String encoded = encodeThemeState(themeState); + Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId); + String encoded = ThemeCodec.encodeThemeState(themeState); SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new); schSettings.setCDate(new Date()); schSettings.setSchId(targetSchId); @@ -217,7 +142,7 @@ public class SchSettingsController { public void clearSchTheme(BoaUser authUserWithDept, @RequestParam(name = "schId", required = false) Integer schId) { requireThemeAdmin(authUserWithDept); - Integer targetSchId = resolveThemeSchId(authUserWithDept, schId); + Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId); Optional existing = schSettingsService.findBySchIdRaw(targetSchId); if (!existing.isPresent()) return; SchSettings schSettings = existing.get(); @@ -228,7 +153,7 @@ public class SchSettingsController { @PostMapping("saveSchTheme") public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) { if (schId == null || schId == 0) { - schId = resolveSchId(authUserWithDept); + schId = schSettingsService.resolveSchId(authUserWithDept); } SchSettings schSettings = new SchSettings(); Optional model = schSettingsService.findBySchId(schId); @@ -245,7 +170,7 @@ public class SchSettingsController { @PostMapping("saveSchLogo") public void saveSchLogo(BoaUser authUserWithDept, String logoUrl, @RequestParam(name = "schId", defaultValue = "0") Integer schId) { if (schId == null || schId == 0) { - schId = resolveSchId(authUserWithDept); + schId = schSettingsService.resolveSchId(authUserWithDept); } SchSettings schSettings = newSchSettingsOrLoad(schId, authUserWithDept); schSettings.setLogoUrl(logoUrl); @@ -257,7 +182,7 @@ public class SchSettingsController { public Optional getSchSettings(BoaUser authUserWithDept, @RequestParam(name = "schId", defaultValue = "0") Integer schId) { if (schId == null || schId == 0) { - schId = resolveSchId(authUserWithDept); + schId = schSettingsService.resolveSchId(authUserWithDept); } return schSettingsService.findBySchId(schId); } diff --git a/src/main/java/com/yunhai/userhome/entity/UserAppearance.java b/src/main/java/com/yunhai/userhome/entity/UserAppearance.java new file mode 100644 index 0000000..f75ce53 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/entity/UserAppearance.java @@ -0,0 +1,50 @@ +package com.yunhai.userhome.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.util.Date; + +/** + * 用户外观偏好(user_appearance 表,外观偏好服务端化 2026-10-08)。 + *

跨浏览器一致的真源,全局 per-user 一行(user_id 唯一)——与 localStorage 键同维度, + * 刻意不挂 user_settings 的 sch/role 维度。

+ *

theme_state 存紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code ocean|1|0|1}); + * avatar_style 取值 {@code text|silhouette};两列 NULL = 跟随学校默认。

+ */ +@AllArgsConstructor +@NoArgsConstructor +@Data +@Entity +@Table(name = "user_appearance", uniqueConstraints = {@UniqueConstraint(columnNames = "user_id", name = "uk_user")}) +public class UserAppearance { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** 用户自选主题档紧凑编码;NULL=跟随学校默认 */ + @Column(name = "theme_state") + private String themeState; + + /** 用户自选头像样式 text|silhouette;NULL=跟随学校默认 */ + @Column(name = "avatar_style") + private String avatarStyle; + + @Column(name = "c_date") + private Date cDate; + + @Column(name = "u_date") + private Date uDate; +} diff --git a/src/main/java/com/yunhai/userhome/repository/UserAppearanceRepository.java b/src/main/java/com/yunhai/userhome/repository/UserAppearanceRepository.java new file mode 100644 index 0000000..4051bea --- /dev/null +++ b/src/main/java/com/yunhai/userhome/repository/UserAppearanceRepository.java @@ -0,0 +1,13 @@ +package com.yunhai.userhome.repository; + +import com.yunhai.userhome.entity.UserAppearance; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserAppearanceRepository extends JpaRepository { + + Optional findByUserId(Integer userId); +} diff --git a/src/main/java/com/yunhai/userhome/service/IAppearanceService.java b/src/main/java/com/yunhai/userhome/service/IAppearanceService.java new file mode 100644 index 0000000..2531391 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/service/IAppearanceService.java @@ -0,0 +1,24 @@ +package com.yunhai.userhome.service; + +import com.yunhai.platform.utils.model.BoaUser; + +import java.util.Map; + +/** + * 用户外观偏好聚合服务(外观偏好服务端化 2026-10-08): + * 主题档/头像样式 per-user 真源(user_appearance)+ 学校默认(sch_settings)+ 学校 Logo(logo 表)。 + */ +public interface IAppearanceService { + + /** + * 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl},查不到/脏值/依赖故障的字段一律 null, + * 不抛异常(子应用异步补齐段按"缺字段=清键回落默认"消费)。 + */ + Map getAppearance(BoaUser user); + + /** 保存用户自选主题档(紧凑编码 theme|sideDeep|gradOn|sideFollow);themeState=null 表示跟随学校默认 */ + void saveMyTheme(Integer userId, String themeState); + + /** 保存用户自选头像样式(text|silhouette);avatarStyle=null 表示跟随学校默认 */ + void saveMyAvatarStyle(Integer userId, String avatarStyle); +} diff --git a/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java b/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java index ec0742d..8c08ccf 100644 --- a/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java +++ b/src/main/java/com/yunhai/userhome/service/ISchSettingsService.java @@ -1,5 +1,6 @@ package com.yunhai.userhome.service; +import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userhome.entity.SchSettings; import java.util.List; @@ -16,4 +17,11 @@ public interface ISchSettingsService { Optional findBySchIdRaw(Integer schId); List findBySchIds(List schIds); + + /** 主学校 → 主归属组 → 0(对齐旧 SchSettingsController.resolveSchId) */ + Integer resolveSchId(BoaUser user); + + /** 主题/头像默认档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属)。 + * 原为 SchSettingsController 私有方法,外观偏好轮(2026-10-08)下沉供 AppearanceController 共享。 */ + Integer resolveThemeSchId(BoaUser user, Integer explicitSchId); } diff --git a/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java new file mode 100644 index 0000000..06bb4a4 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/service/impl/AppearanceServiceImpl.java @@ -0,0 +1,159 @@ +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.repository.LogoRepository; +import com.yunhai.userhome.repository.UserAppearanceRepository; +import com.yunhai.userhome.service.IAppearanceService; +import com.yunhai.userhome.service.ISchSettingsService; +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 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", validCompactTheme(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", validCompactTheme(sch.getThemeName())); + out.put("avatarSch", validAvatarStyle(sch.getAvatarStyle())); + } + out.put("logoUrl", resolveLogoUrl(schId)); + } + } catch (Exception e) { + // 降级:学校段 null(含 logo),功能不受影响 + } + return out; + } + + @Override + public void saveMyTheme(Integer userId, String themeState) { + UserAppearance ua = loadOrCreate(userId); + if (themeState == null) { + // 跟随学校默认:置 NULL(列语义见实体注释) + ua.setThemeState(null); + } else { + String compact = themeState.trim(); + if (ThemeCodec.decodeThemeState(compact) == null) { + throw new DException("主题档不合法: " + themeState); + } + ua.setThemeState(compact); + } + 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; + }); + } + + /** 紧凑编码校验读(非法/脏值/旧格式 → null 视同未配置;合法返回原紧凑串) */ + private String validCompactTheme(String raw) { + if (!StringUtils.hasText(raw)) return null; + String compact = raw.trim(); + return ThemeCodec.decodeThemeState(compact) == null ? null : compact; + } + + 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; + } +} 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 cb73406..7574ad5 100644 --- a/src/main/java/com/yunhai/userhome/service/impl/SchSettingsServiceImpl.java +++ b/src/main/java/com/yunhai/userhome/service/impl/SchSettingsServiceImpl.java @@ -1,8 +1,11 @@ package com.yunhai.userhome.service.impl; +import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userhome.entity.SchSettings; +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 org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -18,6 +21,9 @@ public class SchSettingsServiceImpl implements ISchSettingsService { @Autowired private SchSettingsRepository schSettingsRepository; + @Autowired + private IUserCurrentRoleService userCurrentRoleService; + @Override public void save(SchSettings schSettings) { schSettingsRepository.save(schSettings); @@ -46,4 +52,26 @@ public class SchSettingsServiceImpl implements ISchSettingsService { public java.util.List findBySchIds(java.util.List schId) { return schSettingsRepository.getSchSettingList(schId); } + + @Override + public Integer resolveSchId(BoaUser user) { + if (user.getSchool() != null) { + return user.getSchool().getId(); + } + if (user.getBureau() != null) { + return user.getBureau().getId(); + } + return 0; + } + + @Override + public Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) { + if (user.getType() != null && user.getType() == 1) { + return explicitSchId == null ? 0 : explicitSchId; + } + return userCurrentRoleService.findById(user.getId()) + .map(UserCurrentRole::getSchId) + .filter(schId -> schId != null && schId > 0) + .orElseGet(() -> resolveSchId(user)); + } } diff --git a/src/main/java/com/yunhai/userhome/utils/ThemeCodec.java b/src/main/java/com/yunhai/userhome/utils/ThemeCodec.java new file mode 100644 index 0000000..ab14a94 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/utils/ThemeCodec.java @@ -0,0 +1,70 @@ +package com.yunhai.userhome.utils; + +import com.yunhai.platform.utils.exception.DException; +import org.springframework.util.StringUtils; + +/** + * 主题档/头像样式的编解码与校验(原 SchSettingsController 私有实现下沉,2026-10-08 外观偏好轮 + * 供 AppearanceController 共享;行为与原私有版本逐字节一致,勿改语义)。 + *

紧凑编码形态:{@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code teal|0|1|1}); + * 读侧宽松(脏值一律 null 视同未配置),写侧从严(非法抛 DException)。

+ */ +public final class ThemeCodec { + + /** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */ + public static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"}; + + /** 合法头像样式(与前端 avatar.ts 的 AvatarStyle 一致;写入侧从严,对齐 encodeThemeState 做法) */ + public static final String[] AVATAR_STYLES = {"text", "silhouette"}; + + private ThemeCodec() { + } + + /** 紧凑编码 → ThemeState JSON;非本编码形态(旧档名/脏值/字段非法)一律 null(视同未配置) */ + public static String decodeThemeState(String encoded) { + if (!StringUtils.hasText(encoded)) return null; + String[] parts = encoded.trim().split("\\|"); + if (parts.length != 4) return null; + if (!isThemeKey(parts[0])) return null; + for (int i = 1; i < 4; i++) { + if (parts[i].length() != 1 || (parts[i].charAt(0) != '0' && parts[i].charAt(0) != '1')) return null; + } + return "{\"theme\":\"" + parts[0] + "\",\"sideDeep\":" + parts[1] + + ",\"gradOn\":" + parts[2] + ",\"sideFollow\":" + parts[3] + "}"; + } + + /** ThemeState JSON 校验(四字段齐全、档名合法)+ → 紧凑编码;非法抛业务异常(写入侧从严) */ + public static String encodeThemeState(String themeStateJson) { + if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空"); + com.fasterxml.jackson.databind.JsonNode n; + try { + n = new com.fasterxml.jackson.databind.ObjectMapper().readTree(themeStateJson); + } catch (Exception e) { + throw new DException("主题档格式不合法"); + } + if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep") || !n.has("gradOn") || !n.has("sideFollow")) { + throw new DException("主题档格式不合法"); + } + String theme = n.get("theme").asText(); + if (!isThemeKey(theme)) throw new DException("主题档名不合法: " + theme); + return theme + "|" + (n.get("sideDeep").asBoolean() ? 1 : 0) + + "|" + (n.get("gradOn").asBoolean() ? 1 : 0) + + "|" + (n.get("sideFollow").asBoolean() ? 1 : 0); + } + + public static boolean isThemeKey(String theme) { + if (theme == null) return false; + for (String k : THEME_KEYS) { + if (k.equals(theme)) return true; + } + return false; + } + + public static boolean isAvatarStyle(String style) { + if (style == null) return false; + for (String k : AVATAR_STYLES) { + if (k.equals(style)) return true; + } + return false; + } +} diff --git a/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java b/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java new file mode 100644 index 0000000..a347cf8 --- /dev/null +++ b/src/test/java/com/yunhai/userhome/service/AppearanceServiceTest.java @@ -0,0 +1,220 @@ +package com.yunhai.userhome.service; + +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.repository.LogoRepository; +import com.yunhai.userhome.repository.UserAppearanceRepository; +import com.yunhai.userhome.service.impl.AppearanceServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * AppearanceService 单测(外观偏好服务端化 2026-10-08): + * 冻结 GET /api/appearance 聚合契约(myTheme/schTheme 紧凑编码、脏值与依赖故障降级 null、永不抛)、 + * upsert 置 NULL 语义(null=跟随学校默认)与 Logo 择优口径(本校 type=1 → 系统 type=0 → null)。 + */ +@DisplayName("AppearanceService - 聚合降级与 upsert 置 NULL 语义") +class AppearanceServiceTest { + + private UserAppearanceRepository userAppearanceRepository; + private ISchSettingsService schSettingsService; + private LogoRepository logoRepository; + private AppearanceServiceImpl service; + private BoaUser user; + + @BeforeEach + void setUp() { + userAppearanceRepository = mock(UserAppearanceRepository.class); + schSettingsService = mock(ISchSettingsService.class); + logoRepository = mock(LogoRepository.class); + service = new AppearanceServiceImpl(); + ReflectionTestUtils.setField(service, "userAppearanceRepository", userAppearanceRepository); + ReflectionTestUtils.setField(service, "schSettingsService", schSettingsService); + ReflectionTestUtils.setField(service, "logoRepository", logoRepository); + ReflectionTestUtils.setField(service, "fileWebPath", "http://base.yunhaixingtong.com/userhome/upload/"); + + user = new BoaUser(); + user.setId(10133); + user.setType(0); + when(schSettingsService.resolveThemeSchId(any(), any())).thenReturn(3); + } + + private static SchSettings schSettings(String themeName, String avatarStyle) { + SchSettings s = new SchSettings(); + s.setThemeName(themeName); + s.setAvatarStyle(avatarStyle); + return s; + } + + private static Logo logo(String url) { + Logo l = new Logo(); + l.setLogoUrl(url); + return l; + } + + @Test + @DisplayName("聚合:个人段+学校段+Logo 全存在 → 五字段齐全(紧凑编码原样、Logo 拼 FILE_WEB_PATH 绝对 URL)") + void aggregateFull() { + UserAppearance ua = new UserAppearance(); + ua.setThemeState("ocean|1|0|1"); + ua.setAvatarStyle("silhouette"); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua)); + when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.of(schSettings("ink|0|1|1", "text"))); + when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.singletonList(logo("/upload/logo/sch.png"))); + when(logoRepository.findBySchIdAndType(0, 0)).thenReturn(Collections.singletonList(logo("/upload/logo/sys.png"))); + + Map out = service.getAppearance(user); + assertEquals("ocean|1|0|1", out.get("myTheme")); + assertEquals("ink|0|1|1", out.get("schTheme")); + assertEquals("silhouette", out.get("avatarMy")); + assertEquals("text", out.get("avatarSch")); + assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", out.get("logoUrl")); + } + + @Test + @DisplayName("聚合:无任何数据 → 五字段全 null 且不抛(200 出参口径)") + void aggregateEmpty() { + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); + when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.empty()); + when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); + + Map out = service.getAppearance(user); + for (String k : new String[]{"myTheme", "schTheme", "avatarMy", "avatarSch", "logoUrl"}) { + assertTrue(out.containsKey(k)); + assertNull(out.get(k)); + } + } + + @Test + @DisplayName("聚合:脏值降级(theme_state 旧格式/头像样式非法 → 对应字段 null,其余字段不受影响)") + void aggregateDirtyValues() { + UserAppearance ua = new UserAppearance(); + ua.setThemeState("default-theme"); + ua.setAvatarStyle("circle"); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua)); + when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.of(schSettings("teal|0|1|1", "text"))); + when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); + + Map out = service.getAppearance(user); + assertNull(out.get("myTheme")); + assertNull(out.get("avatarMy")); + assertEquals("teal|0|1|1", out.get("schTheme")); + assertEquals("text", out.get("avatarSch")); + } + + @Test + @DisplayName("聚合:identity 跨仓故障 → 学校段(含 Logo)整体 null,个人段不受影响且不抛") + void aggregateIdentityFailure() { + UserAppearance ua = new UserAppearance(); + ua.setThemeState("ocean|1|0|1"); + ua.setAvatarStyle("text"); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua)); + when(schSettingsService.resolveThemeSchId(any(), any())).thenThrow(new RuntimeException("userbase down")); + + Map out = service.getAppearance(user); + assertEquals("ocean|1|0|1", out.get("myTheme")); + assertEquals("text", out.get("avatarMy")); + assertNull(out.get("schTheme")); + assertNull(out.get("avatarSch")); + assertNull(out.get("logoUrl")); + verify(logoRepository, never()).findBySchIdAndType(any(), any()); + } + + @Test + @DisplayName("聚合:本校 Logo 优先于系统 Logo;本校无 → 系统兜底;系统也无 → null") + void logoPreference() { + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); + when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.empty()); + + when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.emptyList()); + when(logoRepository.findBySchIdAndType(0, 0)).thenReturn(Collections.singletonList(logo("/upload/logo/sys.png"))); + assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sys.png", service.getAppearance(user).get("logoUrl")); + + when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.singletonList(logo("/upload/logo/sch.png"))); + assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", service.getAppearance(user).get("logoUrl")); + + when(logoRepository.findBySchIdAndType(eq(3), eq(1))).thenReturn(Arrays.asList(logo(""), logo("/upload/logo/sch.png"))); + assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", service.getAppearance(user).get("logoUrl")); + + when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList()); + assertNull(service.getAppearance(user).get("logoUrl")); + } + + @Test + @DisplayName("upsert:已有一行 → 更新原行;themeState=null 置 NULL(跟随学校默认)") + void upsertExistingSetNull() { + UserAppearance ua = new UserAppearance(); + ua.setId(7); + ua.setUserId(10133); + ua.setThemeState("ocean|1|0|1"); + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua)); + + service.saveMyTheme(10133, null); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserAppearance.class); + verify(userAppearanceRepository).save(captor.capture()); + assertEquals(7, captor.getValue().getId()); + assertNull(captor.getValue().getThemeState()); + assertNotNull(captor.getValue().getUDate()); + } + + @Test + @DisplayName("upsert:无行 → 插入新行(含 cDate)") + void upsertInsert() { + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); + service.saveMyAvatarStyle(10133, "silhouette"); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserAppearance.class); + verify(userAppearanceRepository).save(captor.capture()); + assertEquals(10133, captor.getValue().getUserId()); + assertEquals("silhouette", captor.getValue().getAvatarStyle()); + assertNotNull(captor.getValue().getCDate()); + } + + @Test + @DisplayName("saveMyTheme:非法紧凑编码抛 DException;合法值 trim 后存原样") + void saveMyThemeValidation() { + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); + assertThrows(DException.class, () -> service.saveMyTheme(10133, "red|0|1|1")); + assertThrows(DException.class, () -> service.saveMyTheme(10133, "{\"theme\":\"teal\"}")); + assertThrows(DException.class, () -> service.saveMyTheme(10133, "teal|2|1|1")); + + service.saveMyTheme(10133, " teal|0|1|1 "); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserAppearance.class); + verify(userAppearanceRepository).save(captor.capture()); + assertEquals("teal|0|1|1", captor.getValue().getThemeState()); + } + + @Test + @DisplayName("saveMyAvatarStyle:非法值抛 DException;null 置 NULL") + void saveMyAvatarStyleValidation() { + when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty()); + assertThrows(DException.class, () -> service.saveMyAvatarStyle(10133, "circle")); + + service.saveMyAvatarStyle(10133, null); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserAppearance.class); + verify(userAppearanceRepository).save(captor.capture()); + assertNull(captor.getValue().getAvatarStyle()); + } +} diff --git a/src/test/java/com/yunhai/userhome/utils/ThemeCodecTest.java b/src/test/java/com/yunhai/userhome/utils/ThemeCodecTest.java new file mode 100644 index 0000000..68638be --- /dev/null +++ b/src/test/java/com/yunhai/userhome/utils/ThemeCodecTest.java @@ -0,0 +1,85 @@ +package com.yunhai.userhome.utils; + +import com.yunhai.platform.utils.exception.DException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * ThemeCodec 单测(外观偏好轮 2026-10-08 下沉共享后的行为冻结): + * decode 宽松(脏值一律 null 视同未配置)、encode 从严(非法抛 DException)、往返一致。 + */ +@DisplayName("ThemeCodec - 紧凑编码编解码与校验") +class ThemeCodecTest { + + @Test + @DisplayName("decode:合法紧凑编码 → ThemeState JSON") + void decodeValid() { + assertEquals("{\"theme\":\"teal\",\"sideDeep\":0,\"gradOn\":1,\"sideFollow\":1}", + ThemeCodec.decodeThemeState("teal|0|1|1")); + assertEquals("{\"theme\":\"ocean\",\"sideDeep\":1,\"gradOn\":0,\"sideFollow\":0}", + ThemeCodec.decodeThemeState("ocean|1|0|0")); + } + + @Test + @DisplayName("decode:空白容忍、脏值一律 null(旧档名/段数不对/开关非法/非 0 1)") + void decodeDirty() { + assertEquals("{\"theme\":\"teal\",\"sideDeep\":0,\"gradOn\":1,\"sideFollow\":1}", + ThemeCodec.decodeThemeState(" teal|0|1|1 ")); + assertNull(ThemeCodec.decodeThemeState(null)); + assertNull(ThemeCodec.decodeThemeState("")); + assertNull(ThemeCodec.decodeThemeState("default-theme")); // findBySchId 兜底遗留值 + assertNull(ThemeCodec.decodeThemeState("red|0|1|1")); // 非法档名 + assertNull(ThemeCodec.decodeThemeState("teal|0|1")); // 段数不足 + assertNull(ThemeCodec.decodeThemeState("teal|0|1|1|1")); // 段数超出 + assertNull(ThemeCodec.decodeThemeState("teal|2|1|1")); // 开关非 0/1 + assertNull(ThemeCodec.decodeThemeState("teal|true|1|1")); // 开关非单字符 + } + + @Test + @DisplayName("encode:ThemeState JSON → 紧凑编码;非法从严抛 DException") + void encode() { + assertEquals("teal|0|1|1", ThemeCodec.encodeThemeState( + "{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}")); + assertEquals("ocean|1|0|0", ThemeCodec.encodeThemeState( + "{\"theme\":\"ocean\",\"sideDeep\":1,\"gradOn\":0,\"sideFollow\":0}")); + assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(null)); + assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("")); + assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("teal|0|1|1")); + assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("{\"theme\":\"red\"}")); + assertThrows(DException.class, () -> ThemeCodec.encodeThemeState( + "{\"theme\":\"teal\",\"sideDeep\":false}")); + } + + @Test + @DisplayName("encode→decode 往返一致(合法域内)") + void roundTrip() { + for (String theme : new String[]{"blue", "ocean", "ink", "violet", "teal"}) { + for (int d = 0; d <= 1; d++) for (int g = 0; g <= 1; g++) for (int f = 0; f <= 1; f++) { + String compact = theme + "|" + d + "|" + g + "|" + f; + String json = ThemeCodec.decodeThemeState(compact); + assertEquals(compact, ThemeCodec.encodeThemeState(json)); + } + } + } + + @Test + @DisplayName("isThemeKey / isAvatarStyle") + void validators() { + assertTrue(ThemeCodec.isThemeKey("blue")); + assertFalse(ThemeCodec.isThemeKey("red")); + assertFalse(ThemeCodec.isThemeKey(null)); + assertTrue(ThemeCodec.isAvatarStyle("text")); + assertTrue(ThemeCodec.isAvatarStyle("silhouette")); + assertFalse(ThemeCodec.isAvatarStyle("circle")); + assertFalse(ThemeCodec.isAvatarStyle(null)); + // 防御:encode 输出必须可被 isThemeKey 接受(档名域一致) + assertNotEquals(0, ThemeCodec.THEME_KEYS.length); + } +}