外观偏好异步补齐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:
@@ -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)。
|
||||
* <p>服务端真源 = user_appearance(per-user 一行)+ sch_settings + logo 表;
|
||||
* 消费方:UserHome/front 设置页双写(saveMyTheme/saveMyAvatarStyle)与各子应用
|
||||
* yh-theme-init.js 异步补齐段(GET appearance,挂载后 fetch、失败静默回落默认观感)。</p>
|
||||
* <p>三端点全部登录态、走本应用 /api/** 既有 CAS 链路,不进 oapi/enforce。</p>
|
||||
*/
|
||||
@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<String, Object> appearance(BoaUser authUserWithDept) {
|
||||
return appearanceService.getAppearance(authUserWithDept);
|
||||
}
|
||||
|
||||
/** 保存用户自选主题档:body {@code {"themeState":"ocean|1|0|1"}};null=跟随学校默认;非法值抛业务异常。 */
|
||||
@PostMapping("saveMyTheme")
|
||||
public void saveMyTheme(BoaUser authUserWithDept, @RequestBody Map<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
* <p>跨浏览器一致的真源,全局 per-user 一行(user_id 唯一)——与 localStorage 键同维度,
|
||||
* 刻意不挂 user_settings 的 sch/role 维度。</p>
|
||||
* <p>theme_state 存紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code ocean|1|0|1});
|
||||
* avatar_style 取值 {@code text|silhouette};两列 NULL = 跟随学校默认。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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<UserAppearance, Integer> {
|
||||
|
||||
Optional<UserAppearance> findByUserId(Integer userId);
|
||||
}
|
||||
@@ -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<String, Object> 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);
|
||||
}
|
||||
@@ -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<SchSettings> findBySchIdRaw(Integer schId);
|
||||
|
||||
List<SchSettings> findBySchIds(List<Integer> schIds);
|
||||
|
||||
/** 主学校 → 主归属组 → 0(对齐旧 SchSettingsController.resolveSchId) */
|
||||
Integer resolveSchId(BoaUser user);
|
||||
|
||||
/** 主题/头像默认档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属)。
|
||||
* 原为 SchSettingsController 私有方法,外观偏好轮(2026-10-08)下沉供 AppearanceController 共享。 */
|
||||
Integer resolveThemeSchId(BoaUser user, Integer explicitSchId);
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
*
|
||||
* <p>消费方是各子应用 init 的异步补齐段(挂载后 fetch,失败静默回落默认观感),因此本服务
|
||||
* 对外承诺:聚合读永不抛异常——个人段/学校段各自独立降级(一段故障不影响另一段),
|
||||
* 查不到/脏值/依赖故障的字段一律 null。</p>
|
||||
*
|
||||
* <p>数据来源: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。</p>
|
||||
*/
|
||||
@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<String, Object> getAppearance(BoaUser user) {
|
||||
Map<String, Object> 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<UserAppearance> 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<Logo> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SchSettings> findBySchIds(java.util.List<Integer> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 共享;行为与原私有版本逐字节一致,勿改语义)。
|
||||
* <p>紧凑编码形态:{@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code teal|0|1|1});
|
||||
* 读侧宽松(脏值一律 null 视同未配置),写侧从严(非法抛 DException)。</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user