Compare commits

...
3 Commits
Author SHA1 Message Date
chenxing 0ac521f286 feat(appearance): 开放接口 GET /oapi/appearance(生效值合成)+ 级联下沉 service(oapi 代理轮)
build / build-push (push) Successful in 3m18s
- 新增 controller/oapi/OapiAppearanceController:userId 入参,出参 {theme, avatar, logoUrl} 当前用户生效值
  (个人→学校→平台级联合成,层级知识不外露);出参裸 payload,Map 禁 String
- readThemeWithFallback/readAvatarWithFallback 从 SchSettingsController 下沉 ISchSettingsService(行为等价),
  供 schTheme 读端点与 oapi 合成共享——顺带修复「appearance 无学校→平台级联」审计缺陷 C
- OpenApiAuthEnforceConfiguration:/oapi/* 强制应用签名(无签名 401,对齐 UserMsg/UserBase 同名配置)
- oapi schId 解析走本地 user_current_role(不引 identity 跨仓);超管临时切校角色场景与 /api 版有差,登记偏差
- 单测:生效值合成 3 例(个人优先不触级联/级联命中/脏值+无角色落平台层),60/60 绿
2026-10-08 23:28:40 +08:00
chenxing 54d1b848c3 feat(settings+msgcenter): S7 主题/头像读侧级联 + 消息测试修复与 appKey 收尾
- SchSettingsController:schTheme/schAvatarStyle 读侧加学校→平台(schId=0)级联(readThemeWithFallback/readAvatarWithFallback),
  回传 hitSchId 供设置中心区分「本校已配」与「回落平台」;写侧与接口签名不变
- MsgCenterProxyService:summary.todos 摘除 appKey 死字段(appKey 属后端签名标识不下发)
- MsgCenterProxyServiceTest:两例更新为 appId 口径(字典翻译/字典固定一次出站)+ 补 countByApp 富化/降级、
  appId 查无空页、todos 无 appKey 断言;34/34 绿
2026-10-08 23:08:36 +08:00
chenxing c6acd5d71a 外观偏好异步补齐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/择优/校验从严)
2026-10-08 18:20:29 +08:00
15 changed files with 1109 additions and 114 deletions
@@ -0,0 +1,50 @@
package com.yunhai.userhome.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 强制开放接口必须带应用签名(对齐 UserMsg/UserBase 同名配置)。<br>
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
* 但 UserHome 的 /oapi/**(外观偏好开放接口)绝不允许匿名访问。本过滤器在 AppAuthFilter 之后运行,
* 校验其写入的请求属性:已验签(属性存在)则放行,否则 401。<br>
* 顺序须晚于 AppAuthFilter(HIGHEST_PRECEDENCE+10),故置为 +11。
*/
@Configuration
public class OpenApiAuthEnforceConfiguration {
@Bean
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new RequireAppSignFilter());
bean.addUrlPatterns("/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
return bean;
}
public static class RequireAppSignFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
return;
}
chain.doFilter(request, response);
}
}
}
@@ -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;
@@ -26,7 +26,7 @@ import java.util.Optional;
* <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig);
* saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p>
* <p>主题设置轮(2026-09-30)新增学校默认主题档三接口(契约见 YschTheme《主题设置与通道改动-执行方案》§3.5):
* schTheme 读(已登录可读,可空不兜底——与 getSchSettings 的兜底语义刻意区分);
* schTheme 读(已登录可读,当时"可空不兜底"——S7 起改为学校→平台级联,见文末说明);
* saveSchThemeState 写(仅学校管理员/超管);clearSchTheme 清空(置空 theme 字段——sch_settings 行还承载
* logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态:theme_name 列存紧凑四段编码
* {@code theme|sideDeep01|gradOn01|sideFollow01}(如 teal|0|1|1;不拆列、零 DDL,列宽未确认故不存整份 JSON;
@@ -34,44 +34,23 @@ 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>
* <p>三级重构 S7(2026-10-08):{@code schTheme}/{@code schAvatarStyle} 读侧加"学校 → 平台默认(schId=0)"级联
* (实现已随 oapi 代理轮下沉 {@link ISchSettingsService#readThemeWithFallback}/{@link ISchSettingsService#readAvatarWithFallback})
* ——本校未配置或脏值时回落平台档,平台也无配置才返回 null。写侧与接口签名、响应体结构均未变;
* {@code getSchSettings}/{@code findBySchId} 的"合成默认对象"兜底语义也未变。</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,68 +62,16 @@ 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}。
* 未配置/脏值 → 回落平台默认层(级联见 {@link ISchSettingsService#readAvatarWithFallback});两级都无 →
* avatarStyle=null(前端"不写键、不报错")。 */
@GetMapping("schAvatarStyle")
public Map<String, Object> schAvatarStyle(BoaUser authUserWithDept) {
Integer schId = resolveThemeSchId(authUserWithDept, null);
String avatarStyle = schSettingsService.findBySchIdRaw(schId)
.map(SchSettings::getAvatarStyle)
.filter(SchSettingsController::isAvatarStyle)
.orElse(null);
ISchSettingsService.LayerValue hit =
schSettingsService.readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
Map<String, Object> out = new HashMap<>();
out.put("avatarStyle", avatarStyle);
out.put("schId", schId);
out.put("avatarStyle", hit.getValue());
out.put("schId", hit.getHitSchId());
return out;
}
@@ -154,10 +81,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);
@@ -166,12 +93,12 @@ public class SchSettingsController {
schSettingsService.save(schSettings);
}
/** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行,置空后读侧返回 null)。 */
/** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行;置空后读侧回落平台层,平台也无则 null)。 */
@PostMapping("clearSchAvatarStyle")
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();
@@ -179,20 +106,18 @@ public class SchSettingsController {
schSettingsService.save(schSettings);
}
/**
* 学校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null, schId}。
* 未配置/旧格式值/脏数据 → themeState=null(前端"不写键、不报错")。
/** 学校默认主题档(已登录可读):{themeState: "&lt;ThemeState JSON&gt;" | null, schId};schId 为实际命中层
* (见 {@link ISchSettingsService.LayerValue})。本校未配置/脏值 → 回落平台默认层
* (级联见 {@link ISchSettingsService#readThemeWithFallback});两级都无 → themeState=null
* (前端"不写键、不报错")。
*/
@GetMapping("schTheme")
public Map<String, Object> schTheme(BoaUser authUserWithDept) {
Integer schId = resolveThemeSchId(authUserWithDept, null);
String themeState = schSettingsService.findBySchIdRaw(schId)
.map(SchSettings::getThemeName)
.map(SchSettingsController::decodeThemeState)
.orElse(null);
ISchSettingsService.LayerValue hit =
schSettingsService.readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
Map<String, Object> out = new HashMap<>();
out.put("themeState", themeState);
out.put("schId", schId);
out.put("themeState", hit.getValue());
out.put("schId", hit.getHitSchId());
return out;
}
@@ -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,34 @@
package com.yunhai.userhome.controller.oapi;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 外观偏好开放接口(oapi 代理轮 2026-10-08):子应用后端经 open-gateway 签名直调(target=userhome),
* 替代浏览器直连 /api/appearance + CAS iframe 引导。入站验签由 {@code OpenApiAuthEnforceConfiguration}
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
*
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感)。
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
*/
@RestController
@RequestMapping(value = Config.OPEN_API_PATH)
public class OapiAppearanceController {
@Autowired
private IAppearanceService appearanceService;
/** 当前用户生效外观。userId 由调用方(子应用后端)从其登录态取,服务间签名保证调用方可信。 */
@GetMapping("appearance")
public Map<String, Object> appearance(@RequestParam("userId") Integer userId) {
return appearanceService.getEffectiveAppearance(userId);
}
}
@@ -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,31 @@
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);
/**
* 当前用户生效外观(oapi 代理轮 2026-10-08):{theme, avatar, logoUrl}——主题/头像为合成后的
* 【生效值】(个人 → 学校 → 平台级联,层级知识不外露);logoUrl 本校→平台择优。
* 查不到/脏值/依赖故障的字段 null(init 侧清对应键回落默认观感)。
*/
Map<String, Object> getEffectiveAppearance(Integer userId);
/** 保存用户自选主题档(紧凑编码 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,45 @@ 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);
/** 平台默认层在 sch_settings 中的固定 schId(与 UsedAppController/壁纸/LOGO 的 0 层同口径) */
int PLATFORM_SCH_ID = 0;
/**
* 主题档读侧级联(设置中心三级重构 S7,2026-10-08;oapi 代理轮下沉 service 供 schTheme 读端点与
* /oapi/appearance 合成共享):本校行未配置/旧格式/脏值 → 再查平台默认层(schId=0),
* 命中即返回平台档;两级都无 → value=null。schId 自身即平台层时不二次回落(避免自查两次)。
*/
LayerValue readThemeWithFallback(Integer schId);
/** 头像样式读侧级联:语义与 {@link #readThemeWithFallback} 同构(本校空/脏 → 平台层 → null)。 */
LayerValue readAvatarWithFallback(Integer schId);
/** 级联读命中结果:{@code value}=命中的档位值(两级都无则 null),{@code hitSchId}=实际命中层的 schId
* (回落发生时是 0,不是解析出的本校 schId)。设置中心读端点的响应体 schId 即此语义——
* 前端据此区分「本校已配置」与「本校未配置、正在回落平台默认」。 */
final class LayerValue {
private final String value;
private final Integer hitSchId;
public LayerValue(String value, Integer hitSchId) {
this.value = value;
this.hitSchId = hitSchId;
}
public String getValue() {
return value;
}
public Integer getHitSchId() {
return hitSchId;
}
}
}
@@ -272,7 +272,7 @@ public class MsgCenterProxyService {
if (todos != null && todos.isArray()) {
for (JsonNode t : todos) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("appKey", text(t, "appKey"));
// appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp
row.put("count", t.has("count") ? t.get("count").asInt() : 0);
rows.add(row);
}
@@ -0,0 +1,215 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.Logo;
import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserAppearance;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.repository.LogoRepository;
import com.yunhai.userhome.repository.UserAppearanceRepository;
import com.yunhai.userhome.service.IAppearanceService;
import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.ThemeCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 用户外观偏好聚合实现(外观偏好服务端化 2026-10-08)。
*
* <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 IUserCurrentRoleService userCurrentRoleService;
@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;
}
/**
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源):
* {@code {theme, avatar, logoUrl}}——主题/头像为【合成后的生效值】(个人自选 → 学校默认 → 平台默认(schId=0)
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);logoUrl 为本校 type=1 → 平台 type=0 择优。
*
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
* 无角色/跨校态回落 0(平台层)。与 /api/appearance 的 resolveThemeSchId(超管强制 0)仅在「超管临时切入
* 某校角色」场景不同——该场景按当前角色学校取默认反而更贴近其当下观感,属登记偏差而非回归。</p>
*/
@Override
public Map<String, Object> getEffectiveAppearance(Integer userId) {
Map<String, Object> out = new HashMap<>();
out.put("theme", null);
out.put("avatar", null);
out.put("logoUrl", null);
if (userId == null) {
return out;
}
Integer schId = userCurrentRoleService.findById(userId)
.map(UserCurrentRole::getSchId)
.filter(s -> s != null && s > 0)
.orElse(ISchSettingsService.PLATFORM_SCH_ID);
String myTheme = null;
String myAvatar = null;
try {
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(userId);
if (ua.isPresent()) {
myTheme = validCompactTheme(ua.get().getThemeState());
myAvatar = validAvatarStyle(ua.get().getAvatarStyle());
}
} catch (Exception e) {
// 个人段降级:myTheme/myAvatar 保持 null,不影响学校/平台段
}
String theme = myTheme;
String avatar = myAvatar;
try {
if (theme == null) {
theme = schSettingsService.readThemeWithFallback(schId).getValue();
}
if (avatar == null) {
avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
}
out.put("logoUrl", resolveLogoUrl(schId));
} catch (Exception e) {
// 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null
}
out.put("theme", theme);
out.put("avatar", avatar);
return out;
}
@Override
public void saveMyTheme(Integer userId, String themeState) {
UserAppearance ua = loadOrCreate(userId);
if (themeState == null) {
// 跟随学校默认:置 NULL(列语义见实体注释)
ua.setThemeState(null);
} else {
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,12 @@
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 com.yunhai.userhome.utils.ThemeCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -18,6 +22,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 +53,62 @@ 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));
}
@Override
public ISchSettingsService.LayerValue readThemeWithFallback(Integer schId) {
String themeState = decodeTheme(schId);
if (themeState != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
return new ISchSettingsService.LayerValue(themeState, schId);
}
return new ISchSettingsService.LayerValue(decodeTheme(ISchSettingsService.PLATFORM_SCH_ID),
ISchSettingsService.PLATFORM_SCH_ID);
}
@Override
public ISchSettingsService.LayerValue readAvatarWithFallback(Integer schId) {
String avatarStyle = readAvatarAt(schId);
if (avatarStyle != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
return new ISchSettingsService.LayerValue(avatarStyle, schId);
}
return new ISchSettingsService.LayerValue(readAvatarAt(ISchSettingsService.PLATFORM_SCH_ID),
ISchSettingsService.PLATFORM_SCH_ID);
}
/** 原样读某层主题档并解码(查无行/旧格式/脏值 → null) */
private String decodeTheme(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId)
.map(SchSettings::getThemeName)
.map(ThemeCodec::decodeThemeState)
.orElse(null);
}
/** 原样读某层头像样式(查无行/非法值 → null) */
private String readAvatarAt(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId)
.map(SchSettings::getAvatarStyle)
.filter(ThemeCodec::isAvatarStyle)
.orElse(null);
}
}
@@ -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;
}
}
@@ -0,0 +1,296 @@
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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<UserAppearance> 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<UserAppearance> 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<UserAppearance> 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<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
verify(userAppearanceRepository).save(captor.capture());
assertNull(captor.getValue().getAvatarStyle());
}
// ===== 生效值合成(oapi 代理轮 2026-10-08,/oapi/appearance 出参真源)=====
private IUserCurrentRoleService userCurrentRoleService;
private void stubCurrentRole(Integer schId) {
userCurrentRoleService = mock(IUserCurrentRoleService.class);
ReflectionTestUtils.setField(service, "userCurrentRoleService", userCurrentRoleService);
if (schId == null) {
when(userCurrentRoleService.findById(10133)).thenReturn(Optional.empty());
} else {
com.yunhai.userhome.entity.UserCurrentRole role = new com.yunhai.userhome.entity.UserCurrentRole();
role.setUserId(10133);
role.setSchId(schId);
when(userCurrentRoleService.findById(10133)).thenReturn(Optional.of(role));
}
}
@Test
@DisplayName("生效值:个人自选优先——theme/avatar 取 user_appearance,级联读不出站")
void effective_personalOverridesCascade() {
stubCurrentRole(3);
UserAppearance ua = new UserAppearance();
ua.setThemeState("ocean|1|0|1");
ua.setAvatarStyle("silhouette");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133);
assertEquals("ocean|1|0|1", out.get("theme"));
assertEquals("silhouette", out.get("avatar"));
assertNull(out.get("logoUrl"));
verify(schSettingsService, never()).readThemeWithFallback(any());
verify(schSettingsService, never()).readAvatarWithFallback(any());
}
@Test
@DisplayName("生效值:个人未设 → 学校/平台级联(缺陷 C 修复验证:合成链走 readThemeWithFallback)")
void effective_fallsToCascade() {
stubCurrentRole(3);
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
when(schSettingsService.readThemeWithFallback(3))
.thenReturn(new ISchSettingsService.LayerValue("ink|0|1|1", 0));
when(schSettingsService.readAvatarWithFallback(3))
.thenReturn(new ISchSettingsService.LayerValue("text", 3));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133);
assertEquals("ink|0|1|1", out.get("theme"), "本校空回落平台档(hitSchId=0)");
assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)");
// 出参只三字段,不外露 my/sch 拆分
assertTrue(out.containsKey("theme") && out.containsKey("avatar") && out.containsKey("logoUrl"));
assertEquals(3, out.size());
}
@Test
@DisplayName("生效值:个人脏值视同未配置 → 落级联;两级皆无 → null;无当前角色 → 平台层(0)")
void effective_dirtyAndNoRole_fallsToPlatformLayer() {
stubCurrentRole(null);
UserAppearance ua = new UserAppearance();
ua.setThemeState("default-theme");
ua.setAvatarStyle("circle");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(schSettingsService.readThemeWithFallback(0))
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(schSettingsService.readAvatarWithFallback(0))
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133);
assertNull(out.get("theme"), "脏值落级联且两级皆无 → null");
assertNull(out.get("avatar"));
assertNull(out.get("logoUrl"));
verify(schSettingsService).readThemeWithFallback(0);
verify(schSettingsService).readAvatarWithFallback(0);
}
}
@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
@@ -81,6 +82,13 @@ class MsgCenterProxyServiceTest {
.thenReturn(json(arr.toString()));
}
/** 应用字典 stub:/oapi/app/list 返回单分组单应用(appId 翻译 / countByApp 富化数据源) */
private void stubAppDict(int appId, String appKey, String name) throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"apps\":[{\"id\":" + appId + ",\"appKey\":\"" + appKey
+ "\",\"name\":\"" + name + "\"}]}]"));
}
@Test
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
@@ -155,7 +163,8 @@ class MsgCenterProxyServiceTest {
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
}
/** jumpApp 与 appKey 均空 → 注册表无源可查 → null(前端不渲染跳转),且不产生判权/查址出站 */
/** jumpApp 与 appKey 均空 → 注册表查址无源可查 → null(前端不渲染跳转),且无 by-code/by-key 出站。
* 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */
@Test
void list_noRegistrySource_jumpUrlNull() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
@@ -164,8 +173,10 @@ class MsgCenterProxyServiceTest {
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
assertEquals(Boolean.FALSE, first.get("showJump"));
verify(openCall, never()).get(eq("userbase"), anyString(), anyMap(), eq(JsonNode.class));
verify(openCall, never()).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
verify(openCall, never()).get(eq("userbase"), contains("by-code"), isNull(), eq(JsonNode.class));
verify(openCall, never()).get(eq("userbase"), contains("by-key"), isNull(), eq(JsonNode.class));
verify(openCall, org.mockito.Mockito.times(1))
.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class));
}
// ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 =====
@@ -300,6 +311,11 @@ class MsgCenterProxyServiceTest {
Map<String, Object> out = service.summary(201, 1);
assertEquals(3L, out.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out.get("todos")).size());
// appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count
@SuppressWarnings("unchecked")
Map<String, Object> todoRow = (Map<String, Object>) ((java.util.List<?>) out.get("todos")).get(0);
assertFalse(todoRow.containsKey("appKey"), "待办角标不再下发 appKey");
assertEquals(2, todoRow.get("count"));
// 未读数不可达单独降级为 0,待办仍可用
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
@@ -309,9 +325,11 @@ class MsgCenterProxyServiceTest {
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
}
/** /msg 页服务端筛选(消息对接 2026-09-30):msgType/appKey/isRead/keyword 透传,空值不下发 */
/** /msg 页服务端筛选(消息对接 2026-09-30;appId 口径 2026-10-08):msgType/appId/isRead/keyword 透传,
* 空值不下发。appId 不再由前端直传 appKey:先经应用字典翻译(去首尾空白),字典查无 → 空页且不出站 UserMsg。 */
@Test
void list_filtersPassedThrough() throws Exception {
stubAppDict(100019, "100019", "流程中心");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}"));
@@ -321,7 +339,7 @@ class MsgCenterProxyServiceTest {
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class));
assertEquals("2", query.getValue().get("msgType"));
assertEquals("100019", query.getValue().get("appKey"), "appKey 去首尾空白后透传");
assertEquals("100019", query.getValue().get("appKey"), "appId 去空白经字典翻译为 appKey 下行");
assertEquals("0", query.getValue().get("isRead"));
assertEquals("请假", query.getValue().get("keyword"));
@@ -337,6 +355,52 @@ class MsgCenterProxyServiceTest {
assertFalse(query2.getAllValues().get(1).containsKey("keyword"));
}
/** appId 字典查无(授权回收等)→ 视作该应用无消息:空页,且不出站 UserMsg 列表 */
@Test
void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception {
// 字典未 stub → /oapi/app/list 返回 null → EMPTY
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}"));
Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", null, null);
assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size());
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class));
}
/** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径;
* 字典查无 → appId/appName null(前端不展示);无 appKey 的行跳过。 */
@Test
void countByApp_enrichesWithRegistryDict() throws Exception {
stubAppDict(100011, "100019", "流程中心");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3},"
+ "{\"appKey\":\"999999\",\"total\":5,\"unread\":1},"
+ "{\"total\":2,\"unread\":0}]"));
java.util.List<Map<String, Object>> rows = service.countByApp(201);
assertEquals(2, rows.size(), "无 appKey 行跳过");
Map<String, Object> first = rows.get(0);
assertEquals(100011, first.get("appId"));
assertEquals("流程中心", first.get("appName"));
assertEquals(12L, first.get("total"));
assertEquals(3L, first.get("unread"));
assertNull(rows.get(1).get("appId"), "字典查无 appId=null,行保留由前端隐藏");
assertNull(rows.get(1).get("appName"));
assertEquals(5L, rows.get(1).get("total"));
}
/** countByApp 停机/未启用降级空列表(「按应用」清单空态,不 500) */
@Test
void countByApp_disabledOrDown_degradesEmpty() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
assertTrue(service.countByApp(201).isEmpty());
properties.setUsermsgBaseUrl(PROD_BASE_URL);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class)))
.thenThrow(new OpenApiException("down"));
assertTrue(service.countByApp(201).isEmpty(), "UserMsg 不可达降级空,不抛出");
}
/** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */
@Test
void summary_prefersSummaryEndpoint() throws Exception {
@@ -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);
}
}