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);
+ }
+}