外观偏好异步补齐A:用户外观偏好服务端真源(user_appearance 实体+Appearance 三端点)

- 新实体 user_appearance(per-user 一行,theme_state/avatar_style 紧凑编码,NULL=跟随学校默认)
- 新增 GET appearance 聚合出参 + saveMyTheme/saveMyAvatarStyle 双写端点(登录态 /api/** 链路)
- 聚合读永不抛:个人段/学校段独立降级(identity 跨仓故障→学校段 null),脏值视同未配置
- Logo 择优复用 findLogoList 口径(本校 type=1 → 系统 type=0 → null),URL 拼法对齐 getLogoList
- ThemeCodec 编解码下沉 + resolveThemeSchId/resolveSchId 下沉 SchSettingsService(行为等价重构,供共享)
- 单测:ThemeCodecTest 5 例 + AppearanceServiceTest 9 例(聚合降级/upsert 置 NULL/择优/校验从严)
This commit is contained in:
chenxing
2026-10-08 18:20:29 +08:00
parent 0e57022555
commit c6acd5d71a
11 changed files with 730 additions and 93 deletions
@@ -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;
* <p>默认头像轮(2026-09-30)同构新增学校默认头像样式三接口:schAvatarStyle 读(可空不兜底);
* saveSchAvatarStyle 写(仅学校管理员/超管,值只接受 text|silhouette);clearSchAvatarStyle 清空(置空
* avatar_style 字段)。</p>
* <p>外观偏好轮(2026-10-08):编解码下沉 {@link ThemeCodec}、schId 解析下沉
* {@link ISchSettingsService#resolveThemeSchId}(行为等价重构,供 AppearanceController 共享)。</p>
*/
@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<String, Object> 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<String, Object> 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<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
if (!existing.isPresent()) return;
SchSettings schSettings = existing.get();
@@ -185,10 +110,10 @@ public class SchSettingsController {
*/
@GetMapping("schTheme")
public Map<String, Object> 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<String, Object> 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<SchSettings> 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<SchSettings> 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<SchSettings> 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);
}