diff --git a/src/main/java/com/yunhai/userbase/config/CacheNames.java b/src/main/java/com/yunhai/userbase/config/CacheNames.java
index f7a3d37..702be50 100644
--- a/src/main/java/com/yunhai/userbase/config/CacheNames.java
+++ b/src/main/java/com/yunhai/userbase/config/CacheNames.java
@@ -87,6 +87,50 @@ public final class CacheNames {
*/
public static final String APP_KEY_TO_ID = "appKeyToId";
+ /**
+ * 用户当前激活身份(GET /oapi/identity/current 读路径,压测占比 40%)。
+ * key 规则:{@code 'id:' + userId};TTL 5min(用户身份型:脏 currentRole = 权限事故,与 boaUser 同档同事件);
+ * 存放:Redis;依赖表:sys_user_current_role、sys_user_role_rel、sys_user_group_rel、sys_group;
+ * 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
+ * (身份切换/换组/回填等全部用户域写口 AFTER_COMMIT 精确驱逐)+
+ * {@link com.yunhai.userbase.event.GroupChangedEvent}(结果内嵌校名,组改名后不能吃 5min 旧值)。
+ * 设计说明:不用 sync=true——方法对「连主身份都没有」的用户合法返回 null(unless 拦截不入缓存),
+ * SD Redis 2.7 的 sync 路径对 null 会无条件 put 并在 {@code disableCachingNullValues} 下抛 IAE
+ * (同 {@link com.yunhai.userbase.service.impl.BoaUserLoader} 既有纪律)。
+ */
+ public static final String IDENTITY_CURRENT = "identityCurrent";
+
+ /**
+ * 用户可见应用列表(GET /oapi/app/list 读路径,压测占比 20%,每请求 ~8 条 SQL)。
+ * key 规则:{@code 'u:' + userId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())
+ * + ':s:' + (schoolId 为 null ? 'auto' : schoolId)}——client 维度共 1+7=8 个;
+ * 学校维度:未显式传校归一为字面量 'auto'(生效学校随 userId 由身份/主归属推导,可精确枚举驱逐),
+ * 显式传校后缀为学校 id(不可枚举,用户自身变更场景靠 TTL 自愈,别名/授权/组/应用变更均整清);
+ * TTL 60s(结果敏感型短档:脏了=用户看到不该看的菜单;叠加全哨口事件后 TTL 仅兜直改 DB);
+ * 存放:Redis;依赖表:sys_group_app_rel、sys_group_module_perm、sys_user_app_role_rel、sys_app_role、
+ * sys_app_role_module_rel、app_module、app_category、application_info、sys_group、
+ * sys_school_app_override、sys_school_category_override(2026-09-10 起后两者经
+ * SchoolAppOverrideService.applyAlias 正式在读链上:返回前合并学校私有别名/图标/排序);
+ * 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(应用授权域 24 写口整清,
+ * 含学校别名覆盖全部写口)+
+ * {@link com.yunhai.userbase.event.UserChangedEvent}(按用户 16 条可枚举 key 精确驱逐)+
+ * {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
+ * (组移动改变继承开通面 / 应用启停排序删除整清)。
+ */
+ public static final String USER_APP_LIST = "userAppList";
+
+ /**
+ * 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。
+ * key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
+ * 整批重写组关系无法从事件反推旧 key,多维只能整清,写流量下会打穿缓存);
+ * TTL 5min(用户身份型;全部写口有 UserChangedEvent,TTL 仅兜直改 DB);
+ * 存放:Redis;依赖表:sys_user_group_rel;
+ * 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}(含 groupState 写口本次补发事件)+
+ * {@link com.yunhai.userbase.event.GroupChangedEvent}(组删除级联的卫生性整清)。
+ * 无组关系用户为空 Map 恒非 null,sync=true 安全(穿透防护天然成立)。
+ */
+ public static final String USER_GROUP_REL = "userGroupRel";
+
// ===================== 注册表元数据(驱动 CacheConfig 与对账接口) =====================
/** 存放策略:Redis(跨重启、单条小总量大)或 LOCAL(Caffeine 仅本地,单条大/读频极高/配置型小数据)。 */
@@ -174,6 +218,22 @@ public final class CacheNames {
"#appKey",
"ApplicationInfoCacheChangedEvent → CacheInvalidationListener(搭车 clear)",
"application_info"));
+ m.put(IDENTITY_CURRENT, new Meta(IDENTITY_CURRENT, Store.REDIS, Duration.ofMinutes(5), 0.1,
+ "'id:'+userId",
+ "UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
+ "sys_user_current_role, sys_user_role_rel, sys_user_group_rel, sys_group"));
+ m.put(USER_APP_LIST, new Meta(USER_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
+ "'u:'+userId+':c:'+(clientType?.name()?:'all')+':s:'+(schoolId?:'auto')(8 client × auto/显式校)",
+ "AppAuthzChangedEvent(整清,含学校别名写口)+ UserChangedEvent(按用户 16 条可枚举 key 驱逐,"
+ + "显式学校后缀 TTL 自愈)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent(整清)"
+ + " → CacheInvalidationListener",
+ "sys_group_app_rel, sys_group_module_perm, sys_user_app_role_rel, sys_app_role,"
+ + " sys_app_role_module_rel, app_module, app_category, application_info, sys_group,"
+ + " sys_school_app_override, sys_school_category_override(2026-09-10 起在读链:applyAlias 合并别名)"));
+ m.put(USER_GROUP_REL, new Meta(USER_GROUP_REL, Store.REDIS, Duration.ofMinutes(5), 0.1,
+ "'u:'+userId(整张 groupId→status Map)",
+ "UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
+ "sys_user_group_rel"));
REGISTRY = Collections.unmodifiableMap(m);
}
diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
index efc3ebb..4971bc0 100644
--- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
+++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java
@@ -55,20 +55,24 @@ public class AppController {
}
/**
- * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。
+ * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
* 超级管理员返回全部;不透出 appSecret。
- * clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
+ * clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
+ * schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
+ * 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
*
* @param userId 用户ID(必填)
* @param clientType 客户端类型 key(可选)
+ * @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
*/
@GetMapping("list")
public List list(@RequestParam("userId") Integer userId,
- @RequestParam(value = "clientType", required = false) Integer clientType) {
+ @RequestParam(value = "clientType", required = false) Integer clientType,
+ @RequestParam(value = "schoolId", required = false) Integer schoolId) {
ClientEnum clientEnum = (clientType == null) ? null
: IEnum.getByKey(ClientEnum.class, clientType);
- return appCategoryService.listBoaAppType(userId, clientEnum);
+ return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
}
}
diff --git a/src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java b/src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java
new file mode 100644
index 0000000..07a14f1
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java
@@ -0,0 +1,29 @@
+package com.yunhai.userbase.event;
+
+import org.springframework.context.ApplicationEvent;
+
+/**
+ * 应用授权域数据变更事件:组应用开通(sys_group_app_rel)、组模块授权(sys_group_module_perm)、
+ * 业务角色及其模块/用户绑定(sys_app_role / sys_app_role_module_rel / sys_user_app_role_rel)、
+ * 应用分类(app_category)、模块(app_module)、学校别名覆盖(sys_school_app_override /
+ * sys_school_category_override)任意写操作事务提交后发布。
+ * 消费方:{@code CacheInvalidationListener} 整清 {@code userAppList}
+ * (/oapi/app/list 的组开通/角色授权输入面广,无法从事件反推受影响用户,按「宁可失效宽泛」原则整缓存 clear)。
+ * 宽泛口径说明:部分发布哨口(组模块授权、学校别名覆盖)经查不影响 /oapi/app/list 当前返回,
+ * 按失效总原则仍纳入——多清无害,防未来读链演化漏驱。
+ * 粒度:单一事件类型——所有发布方语义均为「应用授权面变了,重建列表」,不做精确到用户/组的最小化裁剪。
+ */
+public class AppAuthzChangedEvent extends ApplicationEvent {
+
+ /** 触发本次变更的写口(assignAppsToGroup/saveRoleModules/editCategory 等),仅用于日志定位。 */
+ private final String sourceOp;
+
+ public AppAuthzChangedEvent(Object source, String sourceOp) {
+ super(source);
+ this.sourceOp = sourceOp;
+ }
+
+ public String getSourceOp() {
+ return sourceOp;
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java
index 3364831..5fcb9de 100644
--- a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java
+++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java
@@ -50,7 +50,7 @@ public class OapiMetricsController {
vo.setDays(d);
vo.setTop(n);
- Object[] total = repository.aggregateTotal(since, 0);
+ Object[] total = firstRow(repository.aggregateTotal(since, 0));
vo.setTotalCnt(asLong(total, 0));
vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1)));
vo.setTotalErrCnt(asLong(total, 2));
@@ -82,7 +82,7 @@ public class OapiMetricsController {
}
// 压测流量合计(单列口径,不与线上热度混算)
- Object[] lt = repository.aggregateTotal(since, 1);
+ Object[] lt = firstRow(repository.aggregateTotal(since, 1));
vo.setLoadTestCnt(asLong(lt, 0));
return vo;
}
@@ -97,6 +97,17 @@ public class OapiMetricsController {
return row;
}
+ /**
+ * Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构
+ * (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。
+ */
+ private Object[] firstRow(Object[] arr) {
+ if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) {
+ return (Object[]) arr[0];
+ }
+ return arr;
+ }
+
private long asLong(Object[] arr, int i) {
if (arr == null || i >= arr.length || arr[i] == null) {
return 0L;
diff --git a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java
index 13772b4..d36a284 100644
--- a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java
+++ b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java
@@ -36,17 +36,23 @@ public interface IAppCategoryService {
List listWithApps();
/**
- * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组)。
+ * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组),
+ * 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。
* 规则:
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);
- * ③ 不透出 appSecret。
- * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无有权应用的类型 apps 为空数组。
+ * ③ 不透出 appSecret;
+ * ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖,
+ * 覆盖字段为空回落主表真名(无别名=真实名);生效学校三级兜底:
+ * 显式 schoolId(须合法学校锚点且用户属该校,违规抛 DException)→ 当前激活身份学校
+ * (sys_user_current_role)→ 主归属学校;均解析不到则不合并。
+ * 分类间/分类内均按生效排序升序(空视 999、并列取 id 小);无有权应用的类型 apps 为空数组。
*
* @param userId 用户ID
* @param clientType 客户端类型(null=不按端过滤)
- * @return 应用类型 + 有权应用列表
+ * @param schoolId 学校上下文(可选;null 走身份/主归属学校兜底)
+ * @return 应用类型 + 有权应用列表(已合并学校别名)
*/
- List listBoaAppType(Integer userId, ClientEnum clientType);
+ List listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId);
}
diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java
index 0fbbb21..c678d92 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java
@@ -5,8 +5,12 @@ import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
+import com.yunhai.platform.utils.model.BoaRoleInfo;
+import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.ApplicationInfo;
+import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.appauthz.AppAuthzAppRow;
import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo;
import com.yunhai.userbase.model.appcategory.AppCategoryQuery;
@@ -14,10 +18,15 @@ import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
import com.yunhai.userbase.repository.AppCategoryRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IGroupAuthzService;
+import com.yunhai.userbase.service.ISysGroupService;
+import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -49,6 +58,24 @@ public class AppCategoryService implements IAppCategoryService {
@Autowired
private IGroupAuthzService groupAuthzService;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+
+ @Autowired
+ private SchoolAppOverrideService schoolAppOverrideService;
+
+ @Autowired
+ private IdentityService identityService;
+
+ @Autowired
+ private MainGroupResolver mainGroupResolver;
+
+ @Autowired
+ private ISysGroupService sysGroupService;
+
+ @Autowired
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+
@Override
@Transactional
@@ -68,6 +95,7 @@ public class AppCategoryService implements IAppCategoryService {
}
appCategoryRepository.save(entity);
+ publishAuthzEvent("add");
}
@Override
@@ -84,6 +112,7 @@ public class AppCategoryService implements IAppCategoryService {
entity.setSortNo(request.getSortNo());
}
appCategoryRepository.save(entity);
+ publishAuthzEvent("edit");
}
@Override
@@ -93,6 +122,7 @@ public class AppCategoryService implements IAppCategoryService {
throw new RuntimeException("该类别下存在应用,无法删除");
}
appCategoryRepository.deleteById(id);
+ publishAuthzEvent("delete");
}
@Override
@@ -115,6 +145,7 @@ public class AppCategoryService implements IAppCategoryService {
}
appCategoryRepository.updateSortNoById(ids.get(i), i + 1);
}
+ publishAuthzEvent("sort");
}
@Override
@@ -146,8 +177,26 @@ public class AppCategoryService implements IAppCategoryService {
return vo;
}
+ /**
+ * /oapi/app/list 读路径缓存({@link CacheNames#USER_APP_LIST},
+ * key='u:'+userId+':c:'+client+':s:'+school 维度,TTL 60s)。恒返回非 null 列表
+ * (无分类/无权限时为空列表),sync=true 白得同 key 排队回源;
+ * clientType 入参已由 Controller 层 IEnum.getByKey 归一(非法/缺失 → null → key 维度 'all'),
+ * 用枚举 name() 而非原始参数做 key 天然防垃圾维度。
+ * 学校维度 key 规则:未显式传 schoolId 时归一为字面量 'auto'(结果按用户当前身份/主归属学校
+ * 推导,随 userId 确定),可随用户精确枚举驱逐;显式传入则后缀学校 id——该维度不可枚举,
+ * 用户自身变更场景靠 60s TTL 自愈(别名/授权/组/应用变更均整清,见失效责任注册表)。
+ * 返回前经 {@link SchoolAppOverrideService#applyAlias} 合并学校私有别名/图标/排序
+ * (无别名回落主表真名),合并结果入缓存。失效责任见 CacheNames 注册表(全哨口事件)。
+ */
@Override
- public List listBoaAppType(Integer userId, ClientEnum clientType) {
+ @Cacheable(value = CacheNames.USER_APP_LIST,
+ key = "'u:' + #userId + ':c:' + (#clientType == null ? 'all' : #clientType.name())"
+ + " + ':s:' + (#schoolId == null ? 'auto' : #schoolId)",
+ sync = true)
+ public List listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) {
+ // 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并
+ Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId);
Set authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId));
List categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List apps = applicationInfoRepository.findAll();
@@ -161,8 +210,11 @@ public class AppCategoryService implements IAppCategoryService {
}
appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app);
}
+ // categoryId -> 主表分类 sortNo(BoaAppType 契约无排序字段,供别名合并后分类重排兜底)
+ Map categoryBaseSortNo = new HashMap<>();
List result = new ArrayList<>();
for (AppCategory category : categories) {
+ categoryBaseSortNo.put(category.getId(), category.getSortNo());
BoaAppType type = new BoaAppType();
type.setId(category.getId());
type.setName(category.getName());
@@ -181,7 +233,48 @@ public class AppCategoryService implements IAppCategoryService {
type.setApps(boaApps);
result.add(type);
}
- return result;
+ return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool);
+ }
+
+ /**
+ * oapi 生效学校三级兜底:
+ * ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组),
+ * 并校验用户至少一条组关系解析到该校(与 move/groupState 的 schoolId 子树纪律一致),违规抛
+ * DException(→code=5);
+ * ② 当前激活身份学校({@link IdentityService#getCurrent},即 sys_user_current_role,
+ * UserHome 选校/切换身份已写该表,故调用方不传参也能跟随所选学校);
+ * ③ 主归属学校({@link MainGroupResolver} 主组 → resolveSchoolId 向上最近学校组)。
+ * 均解析不到(平台账号/区域链用户)返回 null → 不合并别名;③ 兜底解析到区域/根 id 时
+ * 天然无覆盖行命中,等价不合并,无需二次判型。
+ */
+ private Integer resolveEffectiveSchool(Integer userId, Integer schoolId) {
+ if (schoolId != null) {
+ sysGroupService.assertSchoolId(schoolId);
+ assertUserInSchool(userId, schoolId);
+ return schoolId;
+ }
+ BoaRoleInfo current = identityService.getCurrent(userId);
+ if (current != null && current.getSchId() != null) {
+ return current.getSchId();
+ }
+ SysUserGroupRel main = mainGroupResolver.resolve(userId).orElse(null);
+ if (main == null || main.getGroupId() == null) {
+ return null;
+ }
+ return sysGroupService.resolveSchoolId(main.getGroupId());
+ }
+
+ /** 显式学校上下文边界:用户至少一条组关系沿祖先链解析到该学校,否则拒绝(防任意传校窥探他校别名) */
+ private void assertUserInSchool(Integer userId, Integer schoolId) {
+ List groupIds = new ArrayList<>();
+ for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) {
+ if (rel.getGroupId() != null) {
+ groupIds.add(rel.getGroupId());
+ }
+ }
+ if (groupIds.isEmpty() || !sysGroupService.resolveSchoolIds(groupIds).containsValue(schoolId)) {
+ throw new DException("用户不属于该学校");
+ }
}
/**
@@ -237,6 +330,14 @@ public class AppCategoryService implements IAppCategoryService {
return false;
}
+ /**
+ * 分类写口统一发 {@link AppAuthzChangedEvent}:app_category 不在 app/list 的用户过滤面内,
+ * 但分类名/排序直接出现在 app/list 返回结构中,必须即时失效(AFTER_COMMIT + fallback 兜底)。
+ */
+ private void publishAuthzEvent(String sourceOp) {
+ eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppCategoryService." + sourceOp));
+ }
+
}
diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java
index d3ebd9e..8809fb3 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java
@@ -2,6 +2,7 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.AppModule;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.appmodule.AppModuleQuery;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.model.appmodule.AppModuleVo;
@@ -12,6 +13,7 @@ import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import com.yunhai.userbase.service.IAppModuleService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@@ -46,6 +48,9 @@ public class AppModuleService implements IAppModuleService {
@Autowired
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+
@Override
@Transactional
public void add(AppModuleRequest request) {
@@ -62,6 +67,7 @@ public class AppModuleService implements IAppModuleService {
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
// sortNo 默认为空(由前端编辑时带入),不自动递增
appModuleRepository.save(entity);
+ publishAuthzEvent("add");
}
@Override
@@ -85,6 +91,7 @@ public class AppModuleService implements IAppModuleService {
} else {
appModuleRepository.save(entity);
}
+ publishAuthzEvent("edit");
}
@Override
@@ -102,6 +109,7 @@ public class AppModuleService implements IAppModuleService {
// AI-FIX(角色授权): 删除模块时级联清理各业务角色对该模块的授权(逻辑外键无物理 FK)
sysAppRoleModuleRelRepository.deleteByModuleIdInBulk(Collections.singletonList(id));
appModuleRepository.deleteById(id);
+ publishAuthzEvent("delete");
}
@Override
@@ -274,4 +282,13 @@ public class AppModuleService implements IAppModuleService {
BeanUtils.copyProperties(entity, vo);
return vo;
}
+
+ /**
+ * 模块域写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList
+ * (模块增改删改变模块授权面与 cascade 清理记录,宽泛纳入;moveSubtree 为 edit 的私有内部路径,
+ * 由 edit 统一发布,不重复发)。
+ */
+ private void publishAuthzEvent(String sourceOp) {
+ eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppModuleService." + sourceOp));
+ }
}
diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
index f1023d9..a95fa00 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java
@@ -7,6 +7,7 @@ import com.yunhai.userbase.entity.SysAppRole;
import com.yunhai.userbase.entity.SysAppRoleModuleRel;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserAppRoleRel;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo;
import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
@@ -26,6 +27,7 @@ import com.yunhai.userbase.service.IAppRoleService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@@ -77,6 +79,9 @@ public class AppRoleService implements IAppRoleService {
@Autowired
private SysUserRepository sysUserRepository;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+
// ===================== 角色 CRUD =====================
@Override
@@ -123,6 +128,7 @@ public class AppRoleService implements IAppRoleService {
entity.setRemark(request.getRemark());
entity.setDisabled(Boolean.TRUE.equals(request.getDisabled()));
sysAppRoleRepository.save(entity);
+ publishAuthzEvent("add");
}
@Override
@@ -153,6 +159,7 @@ public class AppRoleService implements IAppRoleService {
entity.setDisabled(request.getDisabled());
}
sysAppRoleRepository.save(entity);
+ publishAuthzEvent("edit");
}
@Override
@@ -166,6 +173,7 @@ public class AppRoleService implements IAppRoleService {
sysAppRoleModuleRelRepository.deleteByRoleIdBulk(id);
sysUserAppRoleRelRepository.deleteByRoleIdBulk(id);
sysAppRoleRepository.deleteById(id);
+ publishAuthzEvent("delete");
}
// ===================== 角色模块授权 =====================
@@ -253,6 +261,7 @@ public class AppRoleService implements IAppRoleService {
if (!batch.isEmpty()) {
sysAppRoleModuleRelRepository.saveAll(batch);
}
+ publishAuthzEvent("saveRoleModules");
}
// ===================== 用户角色绑定 =====================
@@ -318,6 +327,7 @@ public class AppRoleService implements IAppRoleService {
if (!batch.isEmpty()) {
sysUserAppRoleRelRepository.saveAll(batch);
}
+ publishAuthzEvent("saveRoleUsers");
}
@Override
@@ -348,6 +358,7 @@ public class AppRoleService implements IAppRoleService {
if (!batch.isEmpty()) {
sysUserAppRoleRelRepository.saveAll(batch);
}
+ publishAuthzEvent("saveUserRoles");
}
@Override
@@ -362,6 +373,15 @@ public class AppRoleService implements IAppRoleService {
throw new DException("用户不存在: " + userId);
}
sysUserAppRoleRelRepository.deleteByRoleIdAndUserId(roleId, userId);
+ publishAuthzEvent("removeRoleUser");
+ }
+
+ /**
+ * 业务角色域写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList
+ * (角色模块授权是 app/list 授权并集的一侧输入,无法精确反推受影响用户,宽泛整清)。
+ */
+ private void publishAuthzEvent(String sourceOp) {
+ eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppRoleService." + sourceOp));
}
// ===================== 私有工具 =====================
diff --git a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java
index bcfc9e2..8e04417 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java
@@ -1,7 +1,9 @@
package com.yunhai.userbase.service.impl;
+import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.entity.SysUser;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent;
import com.yunhai.userbase.event.GroupChangedEvent;
import com.yunhai.userbase.event.UserChangedEvent;
@@ -20,14 +22,20 @@ import java.util.List;
/**
* 缓存失效统一监听器(写失效唯一通道:AFTER_COMMIT 事件驱动)。
*
- * 订阅三个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:
+ * 订阅四个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:
*
- * - {@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId;
- * - {@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照(下次读取排队重建)+
- * 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名,组改名后不能吃 5min 旧值);
- * - {@link UserChangedEvent} → 按 id:{userId} 精确驱逐,并回查当前 account/phone 驱逐
- * login:/phone: 维度;改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时
- * 整清兜底(无法反查 account/phone,删除是罕见操作)。
+ * - {@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId +
+ * 整清 userAppList(应用启停/排序/删除改变用户可见应用面);
+ * - {@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照 +
+ * 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名)+ 整清 identityCurrent(内嵌校名)+
+ * userAppList(组移动/删除改变继承开通面)+ userGroupRel(组删除级联的卫生性整清);
+ * - {@link UserChangedEvent} → 按 id:{userId} 精确驱逐(boaUser/identityCurrent/userGroupRel),
+ * 并回查当前 account/phone 驱逐 login:/phone: 维度 + userAppList 的 16 条可枚举 key
+ * (8 client 维度 × {旧格式, :s:auto};显式学校后缀维度靠 60s TTL 自愈);
+ * 改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时整清兜底
+ * (无法反查 account/phone,删除是罕见操作);
+ * - {@link AppAuthzChangedEvent} → 整清 userAppList(组开通/角色授权/分类/模块/学校别名等
+ * 应用授权域写口,输入面广无法精确反推用户,按「宁可失效宽泛」原则整清)。
*
*
* 护栏(方案 5.2):所有监听器 {@code fallbackExecution=true}——发布方不在事务中时事件
@@ -55,7 +63,8 @@ public class CacheInvalidationListener {
public void onApplicationInfoChanged(ApplicationInfoCacheChangedEvent event) {
clearQuietly(CacheNames.APP_INFO);
clearQuietly(CacheNames.APP_KEY_TO_ID);
- log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId",
+ clearQuietly(CacheNames.USER_APP_LIST);
+ log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList",
event.getAppKey(), event.getType());
}
@@ -66,7 +75,11 @@ public class CacheInvalidationListener {
evictQuietly(CacheNames.GROUP_SNAPSHOT, SNAPSHOT_KEY);
clearQuietly(CacheNames.BOA_USER);
clearQuietly(CacheNames.BOA_USER_NULL);
- log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser", event.getSourceOp());
+ clearQuietly(CacheNames.IDENTITY_CURRENT);
+ clearQuietly(CacheNames.USER_APP_LIST);
+ clearQuietly(CacheNames.USER_GROUP_REL);
+ log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/userGroupRel",
+ event.getSourceOp());
}
// ===================== 用户域 =====================
@@ -81,6 +94,10 @@ public class CacheInvalidationListener {
}
evictQuietly(CacheNames.BOA_USER, "id:" + userId);
evictQuietly(CacheNames.BOA_USER_NULL, "id:" + userId);
+ // 身份/组关系缓存与 boaUser 同源同事件:切换身份/换组/转出后即时失效
+ evictQuietly(CacheNames.IDENTITY_CURRENT, "id:" + userId);
+ evictQuietly(CacheNames.USER_GROUP_REL, "u:" + userId);
+ evictUserAppListByUser(userId);
// 回查当前 account/phone:绝大多数写口(换组/禁用/身份切换等)不改变账号手机号,
// 按当前值驱逐即可精确覆盖 login:/phone: 维度
SysUser user = sysUserRepository.findById(userId).orElse(null);
@@ -106,12 +123,39 @@ public class CacheInvalidationListener {
evictQuietly(CacheNames.BOA_USER, hint);
evictQuietly(CacheNames.BOA_USER_NULL, hint);
}
- log.info("[Cache失效] 用户变更 userIds={} hints={} → boaUser 精确驱逐完成",
+ log.info("[Cache失效] 用户变更 userIds={} hints={} → boaUser/identityCurrent/userGroupRel/userAppList 精确驱逐完成",
userIds, event.getStaleKeyHints());
}
+ // ===================== 应用授权域 =====================
+
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
+ public void onAppAuthzChanged(AppAuthzChangedEvent event) {
+ clearQuietly(CacheNames.USER_APP_LIST);
+ log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList", event.getSourceOp());
+ }
+
// ===================== 驱逐工具(error 可观测,TTL 自愈兜底) =====================
+ /**
+ * 按用户驱逐 userAppList 的全部可枚举 key(2026-09-10 起 key 含 ':s:' 学校维度):
+ * 每个 client 维度({@code all} + 7 个 {@link ClientEnum#name()})驱逐两条——
+ * 无后缀旧格式(发布过渡期存量,TTL 内自然清零后可移除)与 {@code :s:auto}
+ * (schoolId 未显式传入时归一为字面量 auto,结果随 userId 确定,可精确枚举)。
+ * 显式学校后缀维度({@code :s:{schoolId}})不可枚举:schoolId 由调用方任意传入,
+ * 用户自身变更(换组/身份切换)时无法反推全部后缀,该维度靠 60s TTL 自愈——
+ * 别名/授权/组/应用变更均走整清已全覆盖,仅 UserChangedEvent 一路存在 ≤TTL 的窗口,
+ * 符合本类「精确驱逐尽力、整清兜底、TTL 自愈」纪律。
+ */
+ private void evictUserAppListByUser(Integer userId) {
+ evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:all");
+ evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:all:s:auto");
+ for (ClientEnum client : ClientEnum.values()) {
+ evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:" + client.name());
+ evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:" + client.name() + ":s:auto");
+ }
+ }
+
private void evictQuietly(String cacheName, Object key) {
try {
Cache cache = cacheManager.getCache(cacheName);
diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
index 4981543..713420f 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
@@ -7,6 +7,7 @@ import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.userbase.enums.SysModeEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest;
import com.yunhai.userbase.model.groupauthz.GroupAppVo;
import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo;
@@ -38,6 +39,7 @@ import com.yunhai.userbase.service.impl.UserCenterService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
@@ -115,6 +117,9 @@ public class GroupAuthzService implements IGroupAuthzService {
@Autowired
private GroupSnapshotService groupSnapshotService;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+
// ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId)
// =====================
@@ -209,6 +214,7 @@ public class GroupAuthzService implements IGroupAuthzService {
if (!batch.isEmpty()) {
sysGroupAppRelRepository.saveAll(batch);
}
+ publishAuthzEvent("assignAppsToGroup");
}
// ===================== 功能2:组授权 =====================
@@ -377,6 +383,7 @@ public class GroupAuthzService implements IGroupAuthzService {
if (!toDelete.isEmpty()) {
sysGroupModulePermRepository.deleteAll(toDelete);
}
+ publishAuthzEvent("saveModulePermissions");
}
// ===================== 新增:树节点式增量授权 =====================
@@ -436,6 +443,7 @@ public class GroupAuthzService implements IGroupAuthzService {
sysGroupModulePermRepository.deleteByGroupIdAndModuleIdInBulk(schId, moduleIds);
}
}
+ publishAuthzEvent("setSchoolApps");
}
@Override
@@ -483,6 +491,15 @@ public class GroupAuthzService implements IGroupAuthzService {
}
}
}
+ publishAuthzEvent("setGroupModulePerms");
+ }
+
+ /**
+ * 组应用开通/组模块授权写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList
+ * (组开通面与模块授权直接决定用户可见应用,无法精确反推受影响用户,宽泛整清)。
+ */
+ private void publishAuthzEvent(String sourceOp) {
+ eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "GroupAuthzService." + sourceOp));
}
/**
diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java
index 5d16ad7..342b6a2 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java
@@ -3,6 +3,7 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaRoleInfo;
+import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
@@ -17,6 +18,7 @@ import com.yunhai.userbase.event.UserChangedEvent;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@@ -80,8 +82,14 @@ public class IdentityService {
/**
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。
- * 连主身份都没有时返回 null(前端引导创建身份)。
+ * 连主身份都没有时返回 null(前端引导创建身份)。
+ *
+ * 缓存({@link CacheNames#IDENTITY_CURRENT},key='id:'+userId):仅经 oapi 代理调用(IdentityController),
+ * 结果只由 userId 决定。不使用 sync=true——对无身份用户合法返回 null(unless 拦截不入缓存),
+ * SD Redis 2.7 的 sync 路径对 null 会无条件 put 并抛 IAE(BoaUserLoader 既有纪律);
+ * 失效责任:UserChangedEvent 精确驱逐 + GroupChangedEvent 整清(结果内嵌校名)。
*/
+ @Cacheable(value = CacheNames.IDENTITY_CURRENT, key = "'id:' + #userId", unless = "#result == null")
public BoaRoleInfo getCurrent(Integer userId) {
Optional currentOpt = sysUserCurrentRoleRepository.findByUserId(userId);
if (currentOpt.isPresent()) {
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java
index 8d157f7..e0df1d9 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java
@@ -1,12 +1,15 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.platform.utils.model.BoaApp;
+import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysGroupAppRel;
import com.yunhai.userbase.entity.SysSchoolAppOverride;
import com.yunhai.userbase.entity.SysSchoolCategoryOverride;
import com.yunhai.userbase.enums.GroupTypeEnum;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.appcategory.AppCategoryVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
@@ -22,6 +25,7 @@ import com.yunhai.userbase.service.resolve.AccessScope;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
@@ -45,7 +49,9 @@ import java.util.stream.Collectors;
* 名称/描述/图标/排序 + 应用类型改名/排序做该校私有覆盖,主数据零侵入。
* 读写口径:应用范围恒为「该(些)学校在 sys_group_app_rel 已开通的应用并集」;
* override 行字段为 null 表示沿用主表值(语义即「别名」)。
- * 仅管理端调用(AppSettingsController),oapi / 认证链路不感知本服务。
+ * 调用方:管理端(AppSettingsController 读写)+ oapi 读链({@code AppCategoryService.listBoaAppType}
+ * → {@link #applyAlias},2026-09-10 接入,兑现写口事件「防未来读链合并别名时漏驱」预留钩子);
+ * 认证链路不感知本服务。
*
* @author chenx
* @date 2026/9/4
@@ -74,6 +80,9 @@ public class SchoolAppOverrideService {
@Autowired
private SysGroupRepository sysGroupRepository;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
+
// ===================== 学校解析 =====================
/** 目标学校集合:schoolId 指定则单校(必须 ∈ 可管理学校),否则全部可管理学校(并集模式) */
@@ -316,6 +325,79 @@ public class SchoolAppOverrideService {
return result;
}
+ // ===================== oapi 读链合并(/oapi/app/list) =====================
+
+ /**
+ * oapi 读链别名合并(2026-09-10 接入):按单一学校覆盖 {@link BoaAppType}/{@link BoaApp}
+ * 的名称/图标/排序,override 字段非空才覆盖(无别名回落主表真名),语义与管理端
+ * {@link #mergeAppVo}/{@link #mergeCategoryList} 完全一致。
+ * 排序:分类内应用按生效 orderNumber(override sortNo 覆盖后)升序、并列取 id 小;
+ * 分类间按生效 sortNo(override 优先,空回落 {@code categoryBaseSortNo} 主表值,再空视 999)、
+ * 并列取 id 小重排——BoaAppType 契约无排序字段,主表分类序由调用方以映射传入。
+ * schoolId 为 null(用户无学校语境:平台/区域链账号)或该校两类覆盖行均不存在时原样返回;
+ * 非学校组 id 天然无覆盖行命中,等价原样返回。原地修改并返回同一列表(调用方为
+ * {@code listBoaAppType} 缓存方法内新构建的临时结构,无共享风险)。
+ *
+ * @param types 已组装的应用类型树
+ * @param categoryBaseSortNo categoryId → 主表分类 sortNo(分类重排兜底用;可为 null 视为空映射)
+ * @param schoolId 生效学校组 id(null=不合并)
+ */
+ public List applyAlias(List types, Map categoryBaseSortNo,
+ Integer schoolId) {
+ if (schoolId == null || types == null || types.isEmpty()) {
+ return types;
+ }
+ List schools = Collections.singletonList(schoolId);
+ Map appOverrides = appOverridesFor(schools);
+ Map categoryOverrides = categoryOverridesFor(schools);
+ if (appOverrides.isEmpty() && categoryOverrides.isEmpty()) {
+ return types;
+ }
+ Map baseSort = categoryBaseSortNo == null ? Collections.emptyMap() : categoryBaseSortNo;
+ for (BoaAppType type : types) {
+ SysSchoolCategoryOverride co = type.getId() == null ? null : categoryOverrides.get(type.getId());
+ if (co != null && StringUtils.isNotBlank(co.getName())) {
+ type.setName(co.getName());
+ }
+ if (type.getApps() == null || type.getApps().isEmpty()) {
+ continue;
+ }
+ for (BoaApp app : type.getApps()) {
+ SysSchoolAppOverride ao = app.getId() == null ? null : appOverrides.get(app.getId());
+ if (ao == null) {
+ continue;
+ }
+ if (StringUtils.isNotBlank(ao.getName())) {
+ app.setName(ao.getName());
+ }
+ if (StringUtils.isNotBlank(ao.getIcon())) {
+ app.setIcon(ao.getIcon());
+ }
+ if (ao.getSortNo() != null) {
+ app.setOrderNumber(ao.getSortNo());
+ }
+ }
+ type.getApps().sort(Comparator
+ .comparingInt((BoaApp a) -> a.getOrderNumber() == null ? SORT_NULL_FALLBACK : a.getOrderNumber())
+ .thenComparing(a -> a.getId() == null ? 0 : a.getId()));
+ }
+ types.sort(Comparator
+ .comparingInt((BoaAppType t) -> effectiveCategorySort(t.getId(), baseSort, categoryOverrides))
+ .thenComparing(t -> t.getId() == null ? 0 : t.getId()));
+ return types;
+ }
+
+ /** 分类生效排序:override sortNo 优先,空回落主表 sortNo(categoryBaseSortNo),再空视 999 */
+ private int effectiveCategorySort(Integer categoryId, Map categoryBaseSortNo,
+ Map overrides) {
+ SysSchoolCategoryOverride o = categoryId == null ? null : overrides.get(categoryId);
+ if (o != null && o.getSortNo() != null) {
+ return o.getSortNo();
+ }
+ Integer base = categoryId == null ? null : categoryBaseSortNo.get(categoryId);
+ return base == null ? SORT_NULL_FALLBACK : base;
+ }
+
// ===================== 写操作(SCHOOL) =====================
/**
@@ -342,6 +424,7 @@ public class SchoolAppOverrideService {
override.setDescription(StringUtils.trimToNull(request.getDesc()));
override.setIcon(StringUtils.trimToNull(request.getPcIcon()));
appOverrideRepository.save(override);
+ publishAuthzEvent("editAppForSchool");
}
/** 学校应用排序:写覆盖表 sort_no(覆盖保存整份顺序快照;纯 upsert,不动名称等其它覆盖字段) */
@@ -366,6 +449,7 @@ public class SchoolAppOverrideService {
override.setSortNo(i + 1);
appOverrideRepository.save(override);
}
+ publishAuthzEvent("saveAppSortForSchool");
}
/** 学校编辑应用类型:仅改名,写分类覆盖表 */
@@ -383,6 +467,7 @@ public class SchoolAppOverrideService {
}
override.setName(StringUtils.trimToNull(name));
categoryOverrideRepository.save(override);
+ publishAuthzEvent("editCategoryForSchool");
}
/** 学校类型排序:写分类覆盖表 sort_no(覆盖保存整份顺序快照) */
@@ -407,6 +492,7 @@ public class SchoolAppOverrideService {
override.setSortNo(i + 1);
categoryOverrideRepository.save(override);
}
+ publishAuthzEvent("saveCategorySortForSchool");
}
// ===================== 级联清理(删除应用/类型时) =====================
@@ -415,12 +501,24 @@ public class SchoolAppOverrideService {
@Transactional
public void deleteAppOverrides(Integer appId) {
appOverrideRepository.deleteByAppIdBulk(appId);
+ publishAuthzEvent("deleteAppOverrides");
}
/** 删除应用类型时级联清理该类型在所有学校的覆盖行 */
@Transactional
public void deleteCategoryOverrides(Integer categoryId) {
categoryOverrideRepository.deleteByCategoryIdBulk(categoryId);
+ publishAuthzEvent("deleteCategoryOverrides");
+ }
+
+ /**
+ * 学校别名覆盖写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList。
+ * 2026-09-10 起两张覆盖表已经 {@link #applyAlias} 接入 /oapi/app/list 读链,
+ * 本事件即读链别名即时性的保障(管理端改别名 → 门户最迟下次请求即见);
+ * 覆盖行与具体用户无法精确反推,维持「宁可失效宽泛」整清口径。
+ */
+ private void publishAuthzEvent(String sourceOp) {
+ eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "SchoolAppOverrideService." + sourceOp));
}
// ===================== 辅助:学校下拉数据(多校管理员切换) =====================
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
index 3f052e3..2f469d7 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
@@ -742,6 +742,13 @@ public class SysGroupService implements ISysGroupService {
// ===================== 子树聚合 =====================
+ /**
+ * 自身 + 全部子孙 ID(BFS)。数据源为 {@link GroupSnapshotService} 本地快照(Caffeine 30min,
+ * GroupChangedEvent 驱逐,数据同源 sys_group 全表),消除此前每请求一次 {@code findAll()} 全表扫。
+ * 调用方均为「事务写操作之前的边界校验」(assertWithinSchool/assertMoveWithinSchool/
+ * assertGroupWithinSchool/transferToSchool 前置校验/列表组范围解析),不存「同事务先写组后校验」路径,
+ * 吃快照安全;组域写事务内的环检测/边界校验仍直查库(见 GroupSnapshotService 只读约定)。
+ */
@Override
public List listSelfAndDescendantIds(Integer groupId) {
List result = new ArrayList<>();
@@ -749,22 +756,20 @@ public class SysGroupService implements ISysGroupService {
return result;
}
result.add(groupId);
- // parentId -> 直接子节点ID列表
- Map> childrenMap = new HashMap<>();
- for (SysGroup g : sysGroupRepository.findAll()) {
- Integer parent = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
- childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(g.getId());
- }
+ // parentId(null 归一 0)→ 直接子节点列表,取自本地快照
+ Map> childrenMap = groupSnapshotService.snapshot().getChildrenMap();
// 广度优先向下收集所有子孙
List frontier = new ArrayList<>();
frontier.add(groupId);
while (!frontier.isEmpty()) {
List next = new ArrayList<>();
for (Integer id : frontier) {
- List children = childrenMap.get(id);
+ List children = childrenMap.get(id);
if (children != null) {
- result.addAll(children);
- next.addAll(children);
+ for (SysGroup child : children) {
+ result.add(child.getId());
+ next.add(child.getId());
+ }
}
}
frontier = next;
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
index 4d959fb..a423bc2 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
@@ -130,6 +130,9 @@ public class SysUserService implements ISysUserService {
@Autowired
private AccessScopeService accessScopeService;
+ @Autowired
+ private UserGroupRelLoader userGroupRelLoader;
+
@Autowired
private PasswordEncoder passwordEncoder;
@@ -404,6 +407,9 @@ public class SysUserService implements ISysUserService {
.orElseThrow(() -> new DException("用户不在该组,无法设置排序号"));
rel.setSortNo(sortNo);
sysUserGroupRelRepository.save(rel);
+ // 宽泛失效:sort_no 不影响缓存值(userGroupRel 只存 status),按「凡写口必发事件」纪律补发;
+ // 本方法此前是 sys_user_group_rel 唯二无事件写口之一(另一为 changeUserGroupState),本行顺带使其补齐
+ eventPublisher.publishEvent(new UserChangedEvent(this, Collections.singletonList(userId), null));
}
@Override
@@ -422,15 +428,23 @@ public class SysUserService implements ISysUserService {
.orElseThrow(() -> new DException("该用户不在此分组中"));
rel.setStatus(state == 1 ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL);
sysUserGroupRelRepository.save(rel);
+ // 转出状态是 userGroupRel 缓存的直接数据源(oapi groupState 写口),必须发 UserChangedEvent
+ // 即时驱逐;只发缓存事件,不动 DataSync 下游推送行为(推送语义见移动组方案:状态非成员语义)
+ eventPublisher.publishEvent(new UserChangedEvent(this, Collections.singletonList(userId), null));
}
@Override
public Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId) {
assertGroupWithinSchool(groupId, schoolId);
- SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId)
- .orElseThrow(() -> new DException("该用户不在此分组中"));
- // 存量数据 status 为 NULL,按正常返回
- return rel.getStatus() == null ? UserGroupRelStatusEnum.NORMAL.getKey() : rel.getStatus().getKey();
+ // 数据源走 userGroupRel 缓存(按 userId 整张 Map,TTL 5min,UserChangedEvent 即时驱逐):
+ // 校验链顺序与异常语义原样保留——组不在学校范围抛 DException;Map 无该 groupId 抛同款「不在此分组中」;
+ // 异常路径不入缓存(loader 的 @Cacheable 仅对正常返回生效)。status 已由 loader 归一(null→0=正常)
+ Map stateMap = userGroupRelLoader.loadByUserId(userId);
+ Integer status = stateMap.get(groupId);
+ if (status == null) {
+ throw new DException("该用户不在此分组中");
+ }
+ return status;
}
@Override
diff --git a/src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java b/src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java
new file mode 100644
index 0000000..837d972
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java
@@ -0,0 +1,50 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.userbase.config.CacheNames;
+import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 用户组关系状态缓存加载器({@code userGroupRel} 缓存的唯一回源入口,
+ * 注册表见 {@link CacheNames#USER_GROUP_REL})。
+ *
+ * 按 userId 缓存整张 groupId→status Map(不做 (userId,groupId) 多维 key——changeGroups
+ * 整批重写组关系无法从事件反推旧 key 做精确驱逐,多维只能整清,写流量下会打穿缓存;
+ * 按用户整 Map 缓存则 UserChangedEvent 逐 key 精确驱逐,与 boaUser 的 {@code id:} 维度同一循环)。
+ * status 为 NULL 的存量行归一为 0=正常;无组关系用户返回空 Map 恒非 null,sync=true 安全
+ * (穿透防护天然成立,无需空值哨兵)。
+ * 注解要求经代理调用(调用方 SysUserService.getUserGroupState 注入本类),
+ * 禁止同类内部自调用。
+ * 失效责任:UserChangedEvent 按 {@code u:{userId}} 精确驱逐(含 groupState 写口)+
+ * GroupChangedEvent 卫生性整清。
+ */
+@Service
+public class UserGroupRelLoader {
+
+ @Autowired
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+
+ /**
+ * 加载用户全部组关系状态(缓存 key = u:{userId}):groupId → status(null 归一 0=正常)。
+ */
+ @Cacheable(value = CacheNames.USER_GROUP_REL, key = "'u:' + #userId", sync = true)
+ public Map loadByUserId(Integer userId) {
+ Map result = new HashMap<>();
+ for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) {
+ if (rel.getGroupId() != null) {
+ int status = rel.getStatus() == null
+ ? UserGroupRelStatusEnum.NORMAL.getKey()
+ : rel.getStatus().getKey();
+ result.put(rel.getGroupId(), status);
+ }
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java b/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java
index ab6c313..ad613a8 100644
--- a/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java
+++ b/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java
@@ -4,6 +4,7 @@ import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.StatusEnum;
+import com.yunhai.userbase.event.UserChangedEvent;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
@@ -14,6 +15,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -53,6 +55,8 @@ public class IdentityBackfillTask implements ApplicationRunner {
private ISysGroupService sysGroupService;
@Autowired
private MainGroupResolver mainGroupResolver;
+ @Autowired
+ private ApplicationEventPublisher eventPublisher;
@org.springframework.beans.factory.annotation.Value("${userbase.identity-backfill.enabled:false}")
private boolean enabled;
@@ -69,6 +73,8 @@ public class IdentityBackfillTask implements ApplicationRunner {
int skippedNoRoles = 0;
int skippedNoGroup = 0;
int skippedExisting = 0;
+ // 本次实际写入身份行的用户:回填完成后统一发 UserChangedEvent 驱逐其缓存(boaUser/identityCurrent 等)
+ List backfilledUserIds = new ArrayList<>();
// 已有身份行的用户集合(幂等:重复执行不覆盖)
List existingUserIds = sysUserRoleRelRepository.findAll().stream()
@@ -133,10 +139,16 @@ public class IdentityBackfillTask implements ApplicationRunner {
}
sysUserRoleRelRepository.saveAll(rows);
inserted++;
+ backfilledUserIds.add(user.getId());
log.info("[IdentityBackfill] 用户 {}({})→ schoolId={}(主组 {}「{}」)roles={}",
user.getAccount(), user.getId(), schoolId, mainRel.getGroupId(),
groupNameMap.getOrDefault(mainRel.getGroupId(), "?"), roles);
}
+ // 回填写 sys_user_role_rel 后按写口纪律发缓存事件:boaUser(角色列表/身份推导)、identityCurrent
+ // (主身份兜底)等随 UserChangedEvent 精确驱逐。纯保险——默认关的一次性幂等迁移。
+ if (!backfilledUserIds.isEmpty()) {
+ eventPublisher.publishEvent(new UserChangedEvent(this, backfilledUserIds, null));
+ }
log.info("[IdentityBackfill] 回填完成:写入 {} 人,跳过(roles 为空){} 人,跳过(无组关系){} 人,"
+ "跳过(已有身份行){} 人。请核对上方日志主组与身份归属一致。",
inserted, skippedNoRoles, skippedNoGroup, skippedExisting);
diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java
new file mode 100644
index 0000000..4ac40cd
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java
@@ -0,0 +1,312 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.userbase.entity.AppCategory;
+import com.yunhai.userbase.entity.AppModule;
+import com.yunhai.userbase.entity.SysAppRole;
+import com.yunhai.userbase.entity.SysGroupAppRel;
+import com.yunhai.userbase.event.AppAuthzChangedEvent;
+import com.yunhai.userbase.model.appcategory.AppCategoryRequest;
+import com.yunhai.userbase.model.appmodule.AppModuleRequest;
+import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest;
+import com.yunhai.userbase.model.approle.AppRoleUserBindRequest;
+import com.yunhai.userbase.model.approle.SysAppRoleRequest;
+import com.yunhai.userbase.model.approle.UserAppRoleBindRequest;
+import com.yunhai.userbase.model.application.ApplicationInfoRequest;
+import com.yunhai.userbase.repository.AppCategoryRepository;
+import com.yunhai.userbase.repository.AppModuleRepository;
+import com.yunhai.userbase.repository.ApplicationInfoRepository;
+import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
+import com.yunhai.userbase.repository.SysAppRoleRepository;
+import com.yunhai.userbase.repository.SysGroupAppRelRepository;
+import com.yunhai.userbase.repository.SysGroupModulePermRepository;
+import com.yunhai.userbase.repository.SysGroupRepository;
+import com.yunhai.userbase.repository.SysSchoolAppOverrideRepository;
+import com.yunhai.userbase.repository.SysSchoolCategoryOverrideRepository;
+import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
+import com.yunhai.userbase.repository.SysUserRepository;
+import com.yunhai.userbase.enums.AccessLevelEnum;
+import com.yunhai.userbase.service.resolve.AccessScope;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.util.Collections;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 应用授权域写口 → {@link AppAuthzChangedEvent} 发布断言(oapi 三接口缓存方案 5.2 哨口完备性)。
+ * 覆盖 AppRoleService(7 口)/ AppCategoryService(4 口)/ AppModuleService(3 口)/
+ * SchoolAppOverrideService(6 口):每个写口执行后必须恰好发布一次事件,sourceOp 可定位写口。
+ * GroupAuthzService 的 4 口断言在其自身测试类中。
+ */
+class AppAuthzEventPublishTest {
+
+ // ===================== AppRoleService(7 口) =====================
+
+ @InjectMocks
+ private AppRoleService appRoleService;
+
+ @Mock
+ private SysAppRoleRepository sysAppRoleRepository;
+ @Mock
+ private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
+ @Mock
+ private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
+ @Mock
+ private ApplicationInfoRepository applicationInfoRepository;
+ @Mock
+ private AppModuleRepository appModuleRepository;
+ @Mock
+ private SysUserRepository sysUserRepository;
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ // AppCategoryService
+ @InjectMocks
+ private AppCategoryService appCategoryService;
+ @Mock
+ private AppCategoryRepository appCategoryRepository;
+
+ // AppModuleService
+ @InjectMocks
+ private AppModuleService appModuleService;
+ @Mock
+ private SysGroupModulePermRepository sysGroupModulePermRepository;
+
+ // SchoolAppOverrideService
+ @InjectMocks
+ private SchoolAppOverrideService schoolAppOverrideService;
+ @Mock
+ private SysSchoolAppOverrideRepository appOverrideRepository;
+ @Mock
+ private SysSchoolCategoryOverrideRepository categoryOverrideRepository;
+ @Mock
+ private SysGroupAppRelRepository sysGroupAppRelRepository;
+ @Mock
+ private SysGroupRepository sysGroupRepository;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ }
+
+ private AppAuthzChangedEvent capturedOp() {
+ ArgumentCaptor captor = ArgumentCaptor.forClass(AppAuthzChangedEvent.class);
+ verify(eventPublisher).publishEvent(captor.capture());
+ return captor.getValue();
+ }
+
+ // ===================== AppRoleService =====================
+
+ @Test
+ void appRole_add_publishes() {
+ when(sysAppRoleRepository.existsByNameAndAllAppFlagTrue("r")).thenReturn(false);
+ SysAppRoleRequest request = new SysAppRoleRequest();
+ request.setName("r");
+ request.setAllAppFlag(true);
+ appRoleService.add(request);
+ assertEquals("AppRoleService.add", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appRole_edit_publishes() {
+ SysAppRole role = new SysAppRole();
+ role.setId(1);
+ role.setAllAppFlag(true);
+ when(sysAppRoleRepository.findById(1)).thenReturn(Optional.of(role));
+ when(sysAppRoleRepository.existsByNameAndAllAppFlagTrueAndIdNot("r2", 1)).thenReturn(false);
+ SysAppRoleRequest request = new SysAppRoleRequest();
+ request.setName("r2");
+ request.setAllAppFlag(true);
+ appRoleService.edit(1, request);
+ assertEquals("AppRoleService.edit", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appRole_delete_publishes() {
+ when(sysAppRoleRepository.existsById(1)).thenReturn(true);
+ appRoleService.delete(1);
+ assertEquals("AppRoleService.delete", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appRole_saveRoleModules_publishes() {
+ SysAppRole role = new SysAppRole();
+ role.setId(1);
+ role.setAllAppFlag(true);
+ when(sysAppRoleRepository.findById(1)).thenReturn(Optional.of(role));
+ AppRoleModuleAssignRequest request = new AppRoleModuleAssignRequest();
+ request.setRoleId(1);
+ request.setModuleIds(Collections.emptyList());
+ appRoleService.saveRoleModules(request);
+ assertEquals("AppRoleService.saveRoleModules", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appRole_saveRoleUsers_publishes() {
+ when(sysAppRoleRepository.existsById(1)).thenReturn(true);
+ when(sysUserRepository.existsById(2)).thenReturn(true);
+ AppRoleUserBindRequest request = new AppRoleUserBindRequest();
+ request.setRoleId(1);
+ request.setUserIds(Collections.singletonList(2));
+ appRoleService.saveRoleUsers(request);
+ assertEquals("AppRoleService.saveRoleUsers", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appRole_saveUserRoles_publishes() {
+ when(sysUserRepository.existsById(2)).thenReturn(true);
+ when(sysAppRoleRepository.existsById(1)).thenReturn(true);
+ UserAppRoleBindRequest request = new UserAppRoleBindRequest();
+ request.setUserId(2);
+ request.setRoleIds(Collections.singletonList(1));
+ appRoleService.saveUserRoles(request);
+ assertEquals("AppRoleService.saveUserRoles", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appRole_removeRoleUser_publishes() {
+ when(sysAppRoleRepository.existsById(1)).thenReturn(true);
+ when(sysUserRepository.existsById(2)).thenReturn(true);
+ appRoleService.removeRoleUser(1, 2);
+ assertEquals("AppRoleService.removeRoleUser", capturedOp().getSourceOp());
+ }
+
+ // ===================== AppCategoryService =====================
+
+ @Test
+ void appCategory_add_publishes() {
+ when(appCategoryRepository.existsByName("分类")).thenReturn(false);
+ when(appCategoryRepository.findMaxSortNo()).thenReturn(null);
+ AppCategoryRequest request = new AppCategoryRequest();
+ request.setName("分类");
+ appCategoryService.add(request);
+ assertEquals("AppCategoryService.add", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appCategory_edit_publishes() {
+ AppCategory entity = new AppCategory();
+ entity.setId(1);
+ when(appCategoryRepository.findById(1)).thenReturn(Optional.of(entity));
+ when(appCategoryRepository.existsByNameAndIdNot("分类2", 1)).thenReturn(false);
+ AppCategoryRequest request = new AppCategoryRequest();
+ request.setName("分类2");
+ appCategoryService.edit(1, request);
+ assertEquals("AppCategoryService.edit", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appCategory_delete_publishes() {
+ when(applicationInfoRepository.countByCategoryId(5)).thenReturn(0);
+ appCategoryService.delete(5);
+ assertEquals("AppCategoryService.delete", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appCategory_sort_publishes() {
+ when(appCategoryRepository.existsById(5)).thenReturn(true);
+ appCategoryService.sort(Collections.singletonList(5));
+ assertEquals("AppCategoryService.sort", capturedOp().getSourceOp());
+ }
+
+ // ===================== AppModuleService =====================
+
+ @Test
+ void appModule_add_publishes() {
+ when(applicationInfoRepository.existsById(10)).thenReturn(true);
+ when(appModuleRepository.existsByAppIdAndCustomId(10, "m1")).thenReturn(false);
+ AppModuleRequest request = new AppModuleRequest();
+ request.setAppId(10);
+ request.setName("模块1");
+ request.setCustomId("m1");
+ appModuleService.add(request);
+ assertEquals("AppModuleService.add", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appModule_edit_publishes() {
+ AppModule entity = new AppModule();
+ entity.setId(1);
+ entity.setAppId(10);
+ entity.setParentId(null);
+ when(appModuleRepository.findById(1)).thenReturn(Optional.of(entity));
+ when(appModuleRepository.existsByAppIdAndCustomIdAndIdNot(10, "m1x", 1)).thenReturn(false);
+ AppModuleRequest request = new AppModuleRequest();
+ request.setParentId(null);
+ request.setCustomId("m1x");
+ appModuleService.edit(1, request);
+ assertEquals("AppModuleService.edit", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void appModule_delete_publishes() {
+ when(appModuleRepository.existsById(1)).thenReturn(true);
+ when(appModuleRepository.countByParentId(1)).thenReturn(0L);
+ appModuleService.delete(1);
+ assertEquals("AppModuleService.delete", capturedOp().getSourceOp());
+ }
+
+ // ===================== SchoolAppOverrideService(宽泛口径哨口) =====================
+
+ private AccessScope schoolScope() {
+ return AccessScope.school(Collections.singletonList(16));
+ }
+
+ @Test
+ void schoolOverride_editAppForSchool_publishes() {
+ when(applicationInfoRepository.existsById(100)).thenReturn(true);
+ when(sysGroupAppRelRepository.existsByGroupIdAndAppId(16, 100)).thenReturn(true);
+ when(appOverrideRepository.findBySchoolIdAndAppId(16, 100)).thenReturn(null);
+ schoolAppOverrideService.editAppForSchool(100, new ApplicationInfoRequest(), schoolScope(), 16);
+ assertEquals("SchoolAppOverrideService.editAppForSchool", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void schoolOverride_saveAppSortForSchool_publishes() {
+ SysGroupAppRel opened = new SysGroupAppRel();
+ opened.setGroupId(16);
+ opened.setAppId(100);
+ when(sysGroupAppRelRepository.findByGroupIdIn(Collections.singletonList(16)))
+ .thenReturn(Collections.singletonList(opened));
+ when(appOverrideRepository.findBySchoolIdAndAppId(16, 100)).thenReturn(null);
+ schoolAppOverrideService.saveAppSortForSchool(Collections.singletonList(100), schoolScope(), 16);
+ assertEquals("SchoolAppOverrideService.saveAppSortForSchool", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void schoolOverride_editCategoryForSchool_publishes() {
+ when(appCategoryRepository.existsById(5)).thenReturn(true);
+ when(categoryOverrideRepository.findBySchoolIdAndCategoryId(16, 5)).thenReturn(null);
+ schoolAppOverrideService.editCategoryForSchool(5, "别名", schoolScope(), 16);
+ assertEquals("SchoolAppOverrideService.editCategoryForSchool", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void schoolOverride_saveCategorySortForSchool_publishes() {
+ when(appCategoryRepository.existsById(5)).thenReturn(true);
+ when(categoryOverrideRepository.findBySchoolIdAndCategoryId(16, 5)).thenReturn(null);
+ schoolAppOverrideService.saveCategorySortForSchool(Collections.singletonList(5), schoolScope(), 16);
+ assertEquals("SchoolAppOverrideService.saveCategorySortForSchool", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void schoolOverride_deleteAppOverrides_publishes() {
+ schoolAppOverrideService.deleteAppOverrides(100);
+ assertEquals("SchoolAppOverrideService.deleteAppOverrides", capturedOp().getSourceOp());
+ }
+
+ @Test
+ void schoolOverride_deleteCategoryOverrides_publishes() {
+ schoolAppOverrideService.deleteCategoryOverrides(5);
+ assertEquals("SchoolAppOverrideService.deleteCategoryOverrides", capturedOp().getSourceOp());
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java
new file mode 100644
index 0000000..3bab450
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java
@@ -0,0 +1,226 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.platform.utils.model.BoaAppType;
+import com.yunhai.platform.utils.model.BoaRoleInfo;
+import com.yunhai.userbase.entity.AppCategory;
+import com.yunhai.userbase.entity.ApplicationInfo;
+import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.repository.AppCategoryRepository;
+import com.yunhai.userbase.repository.ApplicationInfoRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
+import com.yunhai.userbase.service.IGroupAuthzService;
+import com.yunhai.userbase.service.ISysGroupService;
+import com.yunhai.userbase.service.resolve.MainGroupResolver;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * AppCategoryService.listBoaAppType 生效学校解析单元测试(oapi 别名接入,2026-09-10)。
+ * 契约(三级兜底):
+ *
+ * - 显式 schoolId → assertSchoolId 锚点校验 + 用户归属校验后直接生效;锚点非法/非所属学校
+ * 抛 DException 且不进入构建与合并;
+ * - 未传 → 当前激活身份学校(IdentityService.getCurrent.schId);schId 为空继续降级;
+ * - 再兜底 → 主归属组解析学校(MainGroupResolver + resolveSchoolId);
+ * - 均无 → null 透传 applyAlias(不合并);
+ * - categoryBaseSortNo 映射按 categoryId→主表 sortNo 传给合并口。
+ *
+ */
+class AppCategoryServiceSchoolAliasTest {
+
+ @InjectMocks
+ private AppCategoryService appCategoryService;
+
+ @Mock
+ private AppCategoryRepository appCategoryRepository;
+
+ @Mock
+ private ApplicationInfoRepository applicationInfoRepository;
+
+ @Mock
+ private IGroupAuthzService groupAuthzService;
+
+ @Mock
+ private SchoolAppOverrideService schoolAppOverrideService;
+
+ @Mock
+ private IdentityService identityService;
+
+ @Mock
+ private MainGroupResolver mainGroupResolver;
+
+ @Mock
+ private ISysGroupService sysGroupService;
+
+ @Mock
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ // 最小数据集:1 分类(10,"办公管理",sort 1) + 1 应用(100,"通知公告",sort 1),用户 1 有权
+ AppCategory category = new AppCategory();
+ category.setId(10);
+ category.setName("办公管理");
+ category.setSortNo(1);
+ when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category));
+ ApplicationInfo app = new ApplicationInfo();
+ app.setId(100);
+ app.setCategoryId(10);
+ app.setName("通知公告");
+ app.setSortNo(1);
+ when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app));
+ when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(100));
+ // 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest)
+ when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
+ .thenAnswer(inv -> inv.getArgument(0));
+ }
+
+ // ===================== ① 显式 schoolId =====================
+
+ /** 显式且合法(锚点通过 + 用户属该校)→ 原样生效 */
+ @Test
+ void explicitSchool_valid_passesThrough() {
+ when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(groupRel(5)));
+ when(sysGroupService.resolveSchoolIds(anyList())).thenReturn(schoolMap(5, 77));
+
+ List result = appCategoryService.listBoaAppType(1, null, 77);
+
+ verify(sysGroupService).assertSchoolId(77);
+ assertEquals(77, capturedSchool());
+ assertEquals("办公管理", result.get(0).getName());
+ verifyNoInteractions(identityService, mainGroupResolver);
+ }
+
+ /** 锚点非法(非学校组/不存在)→ assertSchoolId 抛 DException,不构建不合并 */
+ @Test
+ void explicitSchool_invalidAnchor_throws() {
+ doThrow(new DException("学校不存在")).when(sysGroupService).assertSchoolId(999);
+
+ assertThrows(DException.class, () -> appCategoryService.listBoaAppType(1, null, 999));
+ verify(schoolAppOverrideService, never()).applyAlias(anyList(), anyMap(), any());
+ verifyNoInteractions(groupAuthzService);
+ }
+
+ /** 用户不属于该校(组关系均解析到他校)→ 拒绝,防任意传校窥探别名 */
+ @Test
+ void explicitSchool_userNotInSchool_throws() {
+ when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(groupRel(5)));
+ when(sysGroupService.resolveSchoolIds(anyList())).thenReturn(schoolMap(5, 88));
+
+ assertThrows(DException.class, () -> appCategoryService.listBoaAppType(1, null, 77));
+ verify(schoolAppOverrideService, never()).applyAlias(anyList(), anyMap(), any());
+ }
+
+ // ===================== ② 当前身份档 =====================
+
+ /** 未传校 + 有当前激活身份 → 身份学校生效,不再走主归属兜底 */
+ @Test
+ void noParam_usesCurrentIdentitySchool() {
+ BoaRoleInfo current = new BoaRoleInfo();
+ current.setSchId(66);
+ when(identityService.getCurrent(1)).thenReturn(current);
+
+ appCategoryService.listBoaAppType(1, null, null);
+
+ assertEquals(66, capturedSchool());
+ verifyNoInteractions(mainGroupResolver, sysUserGroupRelRepository);
+ }
+
+ /** 身份存在但 schId 为空(脏数据/未落校)→ 继续降级主归属 */
+ @Test
+ void identityWithoutSchool_fallsBackToMainGroup() {
+ when(identityService.getCurrent(1)).thenReturn(new BoaRoleInfo());
+ when(mainGroupResolver.resolve(1)).thenReturn(Optional.of(groupRel(5)));
+ when(sysGroupService.resolveSchoolId(5)).thenReturn(55);
+
+ appCategoryService.listBoaAppType(1, null, null);
+
+ assertEquals(55, capturedSchool());
+ }
+
+ // ===================== ③ 主归属档 / 无学校语境 =====================
+
+ /** 无身份记录 → 主归属组向上解析学校 */
+ @Test
+ void noIdentity_fallsBackToMainGroupSchool() {
+ when(identityService.getCurrent(1)).thenReturn(null);
+ when(mainGroupResolver.resolve(1)).thenReturn(Optional.of(groupRel(5)));
+ when(sysGroupService.resolveSchoolId(5)).thenReturn(55);
+
+ appCategoryService.listBoaAppType(1, null, null);
+
+ assertEquals(55, capturedSchool());
+ }
+
+ /** 平台账号(无身份、无组关系)→ null 透传,不合并 */
+ @Test
+ void noSchoolContext_passesNull() {
+ when(identityService.getCurrent(1)).thenReturn(null);
+ when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
+
+ appCategoryService.listBoaAppType(1, null, null);
+
+ assertNull(capturedSchool());
+ }
+
+ // ===================== 合并入参 =====================
+
+ /** categoryBaseSortNo 按 categoryId→主表 sortNo 传给合并口 */
+ @Test
+ void passesCategoryBaseSortMap() {
+ when(identityService.getCurrent(1)).thenReturn(null);
+ when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
+
+ appCategoryService.listBoaAppType(1, null, null);
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor