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 绿
This commit is contained in:
chenxing
2026-10-08 23:28:40 +08:00
parent 54d1b848c3
commit 0ac521f286
8 changed files with 310 additions and 67 deletions
@@ -16,6 +16,13 @@ public interface IAppearanceService {
*/
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);
@@ -24,4 +24,38 @@ public interface ISchSettingsService {
/** 主题/头像默认档的目标学校:超管=显式 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;
}
}
}
@@ -5,10 +5,12 @@ 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;
@@ -42,6 +44,9 @@ public class AppearanceServiceImpl implements IAppearanceService {
@Autowired
private ISchSettingsService schSettingsService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
@Autowired
private LogoRepository logoRepository;
@@ -83,6 +88,57 @@ public class AppearanceServiceImpl implements IAppearanceService {
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);
@@ -6,6 +6,7 @@ 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;
@@ -74,4 +75,40 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
.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);
}
}