feat: oapi /oapi/app/list 接入学校别名 + userbase 缓存事件失效基建
build / build-push (push) Failing after 20s

别名接入(本次):
- SchoolAppOverrideService 新增上下文无关合并口 applyAlias:按单一学校覆盖
  BoaAppType/BoaApp 名称/图标/排序,覆盖字段为空回落主表真名(无别名=真实名),
  分类间/分类内按生效排序重排,口径与管理端 mergeAppVo/mergeCategoryList 一致
- listBoaAppType 新增可选 schoolId:生效学校三级兜底=显式传校(assertSchoolId
  锚点+用户归属校验,违规 code=5)→ 当前激活身份学校 → 主归属学校,均无则不合并
- userAppList 缓存 key 扩 :s: 维度:auto 字面量随用户精确枚举驱逐(8→16 条),
  显式学校后缀不可枚举靠 60s TTL 自愈;别名写口 AppAuthzChangedEvent 整清链路复用
- 新增 SchoolAppOverrideServiceApplyAliasTest(10)/AppCategoryServiceSchoolAliasTest(8),
  更新 CacheInvalidationListenerTest 16-key 断言;全量 102 用例全绿

缓存事件失效基建(前次会话实现,随本 commit 入库):
- AppAuthzChangedEvent/UserGroupRelLoader/CacheInvalidationListener 四事件
  AFTER_COMMIT 失效链路 + CacheNames 注册表(TTL/抖动/存放路由/依赖表/失效责任)
- 应用授权域/组域/用户域各写口发布失效事件(AppModule/AppRole/GroupAuthz/
  SysGroup/SysUser/Identity 等服务),配套 AppAuthzEventPublishTest 等用例
This commit is contained in:
chenxing
2026-09-10 11:13:30 +08:00
parent 46a9587160
commit 3421851b6f
24 changed files with 1819 additions and 38 deletions
@@ -87,6 +87,50 @@ public final class CacheNames {
*/
public static final String APP_KEY_TO_ID = "appKeyToId";
/**
* 用户当前激活身份(GET /oapi/identity/current 读路径,压测占比 40%)。<br>
* key 规则:{@code 'id:' + userId};TTL 5min(用户身份型:脏 currentRole = 权限事故,与 boaUser 同档同事件);<br>
* 存放:Redis;依赖表:sys_user_current_role、sys_user_role_rel、sys_user_group_rel、sys_group;<br>
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
* (身份切换/换组/回填等全部用户域写口 AFTER_COMMIT 精确驱逐)+
* {@link com.yunhai.userbase.event.GroupChangedEvent}(结果内嵌校名,组改名后不能吃 5min 旧值)。<br>
* 设计说明:<b>不用 sync=true</b>——方法对「连主身份都没有」的用户合法返回 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)。<br>
* key 规则:{@code 'u:' + userId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())
* + ':s:' + (schoolId 为 null ? 'auto' : schoolId)}——client 维度共 1+7=8 个;
* 学校维度:未显式传校归一为字面量 'auto'(生效学校随 userId 由身份/主归属推导,可精确枚举驱逐),
* 显式传校后缀为学校 id(不可枚举,用户自身变更场景靠 TTL 自愈,别名/授权/组/应用变更均整清);<br>
* TTL 60s(结果敏感型短档:脏了=用户看到不该看的菜单;叠加全哨口事件后 TTL 仅兜直改 DB);<br>
* 存放: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 正式在读链上:返回前合并学校私有别名/图标/排序);<br>
* 失效责任:{@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=正常)。<br>
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
* 整批重写组关系无法从事件反推旧 key,多维只能整清,写流量下会打穿缓存);<br>
* TTL 5min(用户身份型;全部写口有 UserChangedEvent,TTL 仅兜直改 DB);<br>
* 存放:Redis;依赖表:sys_user_group_rel;<br>
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}(含 groupState 写口本次补发事件)+
* {@link com.yunhai.userbase.event.GroupChangedEvent}(组删除级联的卫生性整清)。<br>
* 无组关系用户为空 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);
}
@@ -55,20 +55,24 @@ public class AppController {
}
/**
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。<br>
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
* 超级管理员返回全部;不透出 appSecret。<br>
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
*
* @param userId 用户ID(必填)
* @param clientType 客户端类型 key(可选)
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
*/
@GetMapping("list")
public List<BoaAppType> 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);
}
}
@@ -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)任意写操作事务提交后发布。<br>
* <b>消费方</b>:{@code CacheInvalidationListener} 整清 {@code userAppList}
* (/oapi/app/list 的组开通/角色授权输入面广,无法从事件反推受影响用户,按「宁可失效宽泛」原则整缓存 clear)。<br>
* <b>宽泛口径说明</b>:部分发布哨口(组模块授权、学校别名覆盖)经查不影响 /oapi/app/list 当前返回,
* 按失效总原则仍纳入——多清无害,防未来读链演化漏驱。<br>
* <b>粒度</b>:单一事件类型——所有发布方语义均为「应用授权面变了,重建列表」,不做精确到用户/组的最小化裁剪。
*/
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;
}
}
@@ -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;
@@ -36,17 +36,23 @@ public interface IAppCategoryService {
List<AppAuthzCategoryVo> listWithApps();
/**
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组)。<br>
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组),
* 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。<br>
* 规则:<br>
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});<br>
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
* ③ 不透出 appSecret。<br>
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无有权应用的类型 apps 为空数组。
* ③ 不透出 appSecret;<br>
* ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖,
* 覆盖字段为空回落主表真名(无别名=真实名);生效学校三级兜底:
* 显式 schoolId(须合法学校锚点且用户属该校,违规抛 DException)→ 当前激活身份学校
* (sys_user_current_role)→ 主归属学校;均解析不到则不合并。<br>
* 分类间/分类内均按生效排序升序(空视 999、并列取 id 小);无有权应用的类型 apps 为空数组。
*
* @param userId 用户ID
* @param clientType 客户端类型(null=不按端过滤)
* @return 应用类型 + 有权应用列表
* @param schoolId 学校上下文(可选;null 走身份/主归属学校兜底)
* @return 应用类型 + 有权应用列表(已合并学校别名)
*/
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType);
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId);
}
@@ -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 天然防垃圾维度。<br>
* 学校维度 key 规则:未显式传 schoolId 时归一为字面量 'auto'(结果按用户当前身份/主归属学校
* 推导,随 userId 确定),可随用户精确枚举驱逐;显式传入则后缀学校 id——该维度不可枚举,
* 用户自身变更场景靠 60s TTL 自愈(别名/授权/组/应用变更均整清,见失效责任注册表)。<br>
* 返回前经 {@link SchoolAppOverrideService#applyAlias} 合并学校私有别名/图标/排序
* (无别名回落主表真名),合并结果入缓存。失效责任见 CacheNames 注册表(全哨口事件)。
*/
@Override
public List<BoaAppType> 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<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) {
// 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并
Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId);
Set<Integer> authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId));
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List<ApplicationInfo> 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<Integer, Integer> categoryBaseSortNo = new HashMap<>();
List<BoaAppType> 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 生效学校三级兜底:<br>
* ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组),
* 并校验用户至少一条组关系解析到该校(与 move/groupState 的 schoolId 子树纪律一致),违规抛
* DException(→code=5);<br>
* ② 当前激活身份学校({@link IdentityService#getCurrent},即 sys_user_current_role,
* UserHome 选校/切换身份已写该表,故调用方不传参也能跟随所选学校);<br>
* ③ 主归属学校({@link MainGroupResolver} 主组 → resolveSchoolId 向上最近学校组)。<br>
* 均解析不到(平台账号/区域链用户)返回 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<Integer> 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));
}
}
@@ -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));
}
}
@@ -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));
}
// ===================== 私有工具 =====================
@@ -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 事件驱动)。<br><br>
*
* 订阅三个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:<br>
* 订阅四个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:<br>
* <ul>
* <li>{@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId;</li>
* <li>{@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照(下次读取排队重建)+
* 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名,组改名后不能吃 5min 旧值);</li>
* <li>{@link UserChangedEvent} → 按 id:{userId} 精确驱逐,并回查当前 account/phone 驱逐
* login:/phone: 维度;改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时
* 整清兜底(无法反查 account/phone,删除是罕见操作)。</li>
* <li>{@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId +
* 整清 userAppList(应用启停/排序/删除改变用户可见应用面);</li>
* <li>{@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照 +
* 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名)+ 整清 identityCurrent(内嵌校名)+
* userAppList(组移动/删除改变继承开通面)+ userGroupRel(组删除级联的卫生性整清);</li>
* <li>{@link UserChangedEvent} → 按 id:{userId} 精确驱逐(boaUser/identityCurrent/userGroupRel),
* 并回查当前 account/phone 驱逐 login:/phone: 维度 + userAppList 的 16 条可枚举 key
* (8 client 维度 × {旧格式, :s:auto};显式学校后缀维度靠 60s TTL 自愈);
* 改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时整清兜底
* (无法反查 account/phone,删除是罕见操作);</li>
* <li>{@link AppAuthzChangedEvent} → 整清 userAppList(组开通/角色授权/分类/模块/学校别名等
* 应用授权域写口,输入面广无法精确反推用户,按「宁可失效宽泛」原则整清)。</li>
* </ul>
*
* <b>护栏</b>(方案 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 确定,可精确枚举)。<br>
* <b>显式学校后缀维度({@code :s:{schoolId}})不可枚举</b>: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);
@@ -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));
}
/**
@@ -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(前端引导创建身份)。<br><br>
*
* 缓存({@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<SysUserCurrentRole> currentOpt = sysUserCurrentRoleRepository.findByUserId(userId);
if (currentOpt.isPresent()) {
@@ -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;
* 名称/描述/图标/排序 + 应用类型改名/排序做该校私有覆盖,主数据零侵入。<br>
* 读写口径:应用范围恒为「该(些)学校在 sys_group_app_rel 已开通的应用并集」;
* override 行字段为 null 表示沿用主表值(语义即「别名」)。<br>
* 仅管理端调用(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 接入):按<b>单一学校</b>覆盖 {@link BoaAppType}/{@link BoaApp}
* 的名称/图标/排序,override 字段非空才覆盖(无别名回落主表真名),语义与管理端
* {@link #mergeAppVo}/{@link #mergeCategoryList} 完全一致。<br>
* 排序:分类内应用按生效 orderNumber(override sortNo 覆盖后)升序、并列取 id 小;
* 分类间按生效 sortNo(override 优先,空回落 {@code categoryBaseSortNo} 主表值,再空视 999)、
* 并列取 id 小重排——BoaAppType 契约无排序字段,主表分类序由调用方以映射传入。<br>
* schoolId 为 null(用户无学校语境:平台/区域链账号)或该校两类覆盖行均不存在时原样返回;
* 非学校组 id 天然无覆盖行命中,等价原样返回。<b>原地修改并返回同一列表</b>(调用方为
* {@code listBoaAppType} 缓存方法内新构建的临时结构,无共享风险)。
*
* @param types 已组装的应用类型树
* @param categoryBaseSortNo categoryId → 主表分类 sortNo(分类重排兜底用;可为 null 视为空映射)
* @param schoolId 生效学校组 id(null=不合并)
*/
public List<BoaAppType> applyAlias(List<BoaAppType> types, Map<Integer, Integer> categoryBaseSortNo,
Integer schoolId) {
if (schoolId == null || types == null || types.isEmpty()) {
return types;
}
List<Integer> schools = Collections.singletonList(schoolId);
Map<Integer, SysSchoolAppOverride> appOverrides = appOverridesFor(schools);
Map<Integer, SysSchoolCategoryOverride> categoryOverrides = categoryOverridesFor(schools);
if (appOverrides.isEmpty() && categoryOverrides.isEmpty()) {
return types;
}
Map<Integer, Integer> 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<Integer, Integer> categoryBaseSortNo,
Map<Integer, SysSchoolCategoryOverride> 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。<br>
* 2026-09-10 起两张覆盖表已经 {@link #applyAlias} 接入 /oapi/app/list 读链,
* 本事件即读链别名即时性的保障(管理端改别名 → 门户最迟下次请求即见);
* 覆盖行与具体用户无法精确反推,维持「宁可失效宽泛」整清口径。
*/
private void publishAuthzEvent(String sourceOp) {
eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "SchoolAppOverrideService." + sourceOp));
}
// ===================== 辅助:学校下拉数据(多校管理员切换) =====================
@@ -742,6 +742,13 @@ public class SysGroupService implements ISysGroupService {
// ===================== 子树聚合 =====================
/**
* 自身 + 全部子孙 ID(BFS)。数据源为 {@link GroupSnapshotService} 本地快照(Caffeine 30min,
* GroupChangedEvent 驱逐,数据同源 sys_group 全表),消除此前每请求一次 {@code findAll()} 全表扫。<br>
* 调用方均为「事务写操作之前的边界校验」(assertWithinSchool/assertMoveWithinSchool/
* assertGroupWithinSchool/transferToSchool 前置校验/列表组范围解析),不存「同事务先写组后校验」路径,
* 吃快照安全;组域写事务内的环检测/边界校验仍直查库(见 GroupSnapshotService 只读约定)。
*/
@Override
public List<Integer> listSelfAndDescendantIds(Integer groupId) {
List<Integer> result = new ArrayList<>();
@@ -749,22 +756,20 @@ public class SysGroupService implements ISysGroupService {
return result;
}
result.add(groupId);
// parentId -> 直接子节点ID列表
Map<Integer, List<Integer>> 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<Integer, List<SysGroup>> childrenMap = groupSnapshotService.snapshot().getChildrenMap();
// 广度优先向下收集所有子孙
List<Integer> frontier = new ArrayList<>();
frontier.add(groupId);
while (!frontier.isEmpty()) {
List<Integer> next = new ArrayList<>();
for (Integer id : frontier) {
List<Integer> children = childrenMap.get(id);
List<SysGroup> 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;
@@ -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<Integer, Integer> stateMap = userGroupRelLoader.loadByUserId(userId);
Integer status = stateMap.get(groupId);
if (status == null) {
throw new DException("该用户不在此分组中");
}
return status;
}
@Override
@@ -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})。<br><br>
*
* 按 userId 缓存整张 groupId→status Map(<b>不做</b> (userId,groupId) 多维 key——changeGroups
* 整批重写组关系无法从事件反推旧 key 做精确驱逐,多维只能整清,写流量下会打穿缓存;
* 按用户整 Map 缓存则 UserChangedEvent 逐 key 精确驱逐,与 boaUser 的 {@code id:} 维度同一循环)。<br>
* status 为 NULL 的存量行归一为 0=正常;无组关系用户返回空 Map 恒非 null,sync=true 安全
* (穿透防护天然成立,无需空值哨兵)。<br>
* <b>注解要求经代理调用</b>(调用方 SysUserService.getUserGroupState 注入本类),
* 禁止同类内部自调用。<br>
* <b>失效责任</b>: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<Integer, Integer> loadByUserId(Integer userId) {
Map<Integer, Integer> 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;
}
}
@@ -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<Integer> backfilledUserIds = new ArrayList<>();
// 已有身份行的用户集合(幂等:重复执行不覆盖)
List<Integer> 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);
@@ -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 哨口完备性)。<br>
* 覆盖 AppRoleService(7 口)/ AppCategoryService(4 口)/ AppModuleService(3 口)/
* SchoolAppOverrideService(6 口):每个写口执行后必须恰好发布一次事件,sourceOp 可定位写口。<br>
* 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<AppAuthzChangedEvent> 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());
}
}
@@ -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)。<br>
* 契约(三级兜底):
* <ul>
* <li>显式 schoolId → assertSchoolId 锚点校验 + 用户归属校验后直接生效;锚点非法/非所属学校
* 抛 DException 且不进入构建与合并;</li>
* <li>未传 → 当前激活身份学校(IdentityService.getCurrent.schId);schId 为空继续降级;</li>
* <li>再兜底 → 主归属组解析学校(MainGroupResolver + resolveSchoolId);</li>
* <li>均无 → null 透传 applyAlias(不合并);</li>
* <li>categoryBaseSortNo 映射按 categoryId→主表 sortNo 传给合并口。</li>
* </ul>
*/
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<BoaAppType> 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<Map<Integer, Integer>> mapCap = ArgumentCaptor.forClass(Map.class);
verify(schoolAppOverrideService).applyAlias(anyList(), mapCap.capture(), any());
assertEquals(Integer.valueOf(1), mapCap.getValue().get(10));
}
// ===================== 辅助 =====================
private Integer capturedSchool() {
ArgumentCaptor<Integer> schoolCap = ArgumentCaptor.forClass(Integer.class);
verify(schoolAppOverrideService).applyAlias(anyList(), anyMap(), schoolCap.capture());
return schoolCap.getValue();
}
private static SysUserGroupRel groupRel(Integer groupId) {
SysUserGroupRel rel = new SysUserGroupRel();
rel.setGroupId(groupId);
rel.setIsMain(1);
return rel;
}
private static Map<Integer, Integer> schoolMap(Integer groupId, Integer schoolId) {
Map<Integer, Integer> map = new HashMap<>();
map.put(groupId, schoolId);
return map;
}
}
@@ -0,0 +1,222 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.entity.ApplicationInfo;
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;
import com.yunhai.userbase.repository.SysUserRepository;
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.cache.Cache;
import org.springframework.cache.CacheManager;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* CacheInvalidationListener 单元测试:四个失效事件 handler 的驱逐/整清动作断言。<br>
* 关键契约(oapi 三接口缓存方案 5.2/5.3):
* <ul>
* <li>UserChangedEvent → 逐 userId 驱逐 boaUser/boaUserNull/identityCurrent 的 id: 维度 +
* userGroupRel 的 u: 维度 + userAppList 的 16 条可枚举 key
* (8 个 client 维度 × {旧格式, :s:auto},2026-09-10 起 key 含学校维度;
* 显式学校后缀不可枚举,靠 TTL 自愈);</li>
* <li>GroupChangedEvent / AppAuthzChangedEvent / ApplicationInfoCacheChangedEvent → userAppList 整清;</li>
* <li>物理删除用户(DB 查无)→ boaUser 系整清兜底。</li>
* </ul>
*/
class CacheInvalidationListenerTest {
@InjectMocks
private CacheInvalidationListener listener;
@Mock
private CacheManager cacheManager;
@Mock
private SysUserRepository sysUserRepository;
private Cache boaUser;
private Cache boaUserNull;
private Cache identityCurrent;
private Cache userAppList;
private Cache userGroupRel;
private Cache groupSnapshot;
private Cache appInfo;
private Cache appKeyToId;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
boaUser = mock(Cache.class);
boaUserNull = mock(Cache.class);
identityCurrent = mock(Cache.class);
userAppList = mock(Cache.class);
userGroupRel = mock(Cache.class);
groupSnapshot = mock(Cache.class);
appInfo = mock(Cache.class);
appKeyToId = mock(Cache.class);
when(cacheManager.getCache(CacheNames.BOA_USER)).thenReturn(boaUser);
when(cacheManager.getCache(CacheNames.BOA_USER_NULL)).thenReturn(boaUserNull);
when(cacheManager.getCache(CacheNames.IDENTITY_CURRENT)).thenReturn(identityCurrent);
when(cacheManager.getCache(CacheNames.USER_APP_LIST)).thenReturn(userAppList);
when(cacheManager.getCache(CacheNames.USER_GROUP_REL)).thenReturn(userGroupRel);
when(cacheManager.getCache(CacheNames.GROUP_SNAPSHOT)).thenReturn(groupSnapshot);
when(cacheManager.getCache(CacheNames.APP_INFO)).thenReturn(appInfo);
when(cacheManager.getCache(CacheNames.APP_KEY_TO_ID)).thenReturn(appKeyToId);
}
// ===================== AppAuthzChangedEvent =====================
/** 应用授权域变更 → 仅整清 userAppList */
@Test
void onAppAuthzChanged_clearsUserAppListOnly() {
listener.onAppAuthzChanged(new AppAuthzChangedEvent(this, "GroupAuthzService.assignAppsToGroup"));
verify(userAppList).clear();
verify(boaUser, never()).clear();
verify(identityCurrent, never()).clear();
verify(userGroupRel, never()).clear();
}
// ===================== ApplicationInfoCacheChangedEvent =====================
/** 应用变更 → 清 appInfo/appKeyToId + 整清 userAppList(应用启停/排序/删除改变可见面) */
@Test
void onApplicationInfoChanged_clearsAppCachesAndUserAppList() {
ApplicationInfo app = new ApplicationInfo();
app.setAppKey(1001);
listener.onApplicationInfoChanged(ApplicationInfoCacheChangedEvent.upsert(this, app));
verify(appInfo).clear();
verify(appKeyToId).clear();
verify(userAppList).clear();
}
// ===================== GroupChangedEvent =====================
/** 组变更 → 驱逐快照单条 + 清 boaUser 系 + 整清 identityCurrent/userAppList/userGroupRel */
@Test
void onGroupChanged_evictsSnapshotAndClearsUserDerivedCaches() {
listener.onGroupChanged(new GroupChangedEvent(this, "move"));
verify(groupSnapshot).evict("all");
verify(boaUser).clear();
verify(boaUserNull).clear();
verify(identityCurrent).clear();
verify(userAppList).clear();
verify(userGroupRel).clear();
}
// ===================== UserChangedEvent =====================
/**
* 用户变更(换组/禁用/身份切换)→ 精确驱逐:
* id:{userId}(boaUser/boaUserNull/identityCurrent)+ u:{userId}(userGroupRel)+
* 16 条 userAppList key(8 个 client 维度 × {旧格式, :s:auto})+ 按回查的 account/phone
* 驱逐 login:/phone: 维度。
*/
@Test
void onUserChanged_evictsAllUserDimensionKeysIncludingClientAndSchoolAutoDimensions() {
SysUser user = new SysUser();
user.setId(1);
user.setAccount("zhang01");
user.setPhone("13800000000");
when(sysUserRepository.findById(1)).thenReturn(Optional.of(user));
listener.onUserChanged(new UserChangedEvent(this, Collections.singletonList(1), null));
verify(boaUser).evict("id:1");
verify(boaUserNull).evict("id:1");
verify(identityCurrent).evict("id:1");
verify(userGroupRel).evict("u:1");
// 16 条:('all' + ClientEnum 7 枚举名) × (旧格式 + ':s:auto')
ArgumentCaptor<Object> keyCaptor = ArgumentCaptor.forClass(Object.class);
verify(userAppList, times(16)).evict(keyCaptor.capture());
Set<String> appListKeys = new HashSet<>();
for (Object key : keyCaptor.getAllValues()) {
appListKeys.add(String.valueOf(key));
}
Set<String> expected = new HashSet<>();
expected.add("u:1:c:all");
expected.add("u:1:c:all:s:auto");
for (com.yunhai.platform.utils.enums.ClientEnum client : com.yunhai.platform.utils.enums.ClientEnum.values()) {
expected.add("u:1:c:" + client.name());
expected.add("u:1:c:" + client.name() + ":s:auto");
}
assertEquals(expected, appListKeys);
verify(boaUser).evict("login:zhang01");
verify(boaUserNull).evict("login:zhang01");
verify(boaUser).evict("phone:13800000000");
verify(boaUserNull).evict("phone:13800000000");
// 精确驱逐路径不整清
verify(boaUser, never()).clear();
}
/** 多用户:逐 userId 循环驱逐(一次变更影响 N 人) */
@Test
void onUserChanged_multipleUsers_evictsEachUser() {
when(sysUserRepository.findById(anyInt())).thenAnswer(inv -> {
SysUser u = new SysUser();
u.setId(inv.getArgument(0));
u.setAccount("acc" + inv.getArgument(0));
return Optional.of(u);
});
listener.onUserChanged(new UserChangedEvent(this, Arrays.asList(1, 2), null));
verify(identityCurrent).evict("id:1");
verify(identityCurrent).evict("id:2");
verify(userGroupRel).evict("u:1");
verify(userGroupRel).evict("u:2");
}
/** 物理删除用户(DB 查无该行)→ 无法反查账号手机号,boaUser 系整清兜底 */
@Test
void onUserChanged_deletedUser_fallsBackToClear() {
when(sysUserRepository.findById(1)).thenReturn(Optional.empty());
listener.onUserChanged(new UserChangedEvent(this, Collections.singletonList(1), null));
verify(boaUser).clear();
verify(boaUserNull).clear();
verify(identityCurrent).evict("id:1");
verify(userGroupRel).evict("u:1");
}
/** 改账号/改手机号的旧 key 提示 → staleKeyHints 精确驱逐 */
@Test
void onUserChanged_staleKeyHints_evictedOnBoaUserCaches() {
when(sysUserRepository.findById(1)).thenReturn(Optional.of(new SysUser()));
listener.onUserChanged(new UserChangedEvent(this, Collections.singletonList(1),
Collections.singleton("login:oldAcc")));
verify(boaUser).evict("login:oldAcc");
verify(boaUserNull).evict("login:oldAcc");
}
/** 缓存管理器返回 null 缓存(未注册)→ 静默跳过不抛错 */
@Test
void onAppAuthzChanged_cacheAbsent_silentlySkipped() {
lenient().when(cacheManager.getCache(CacheNames.USER_APP_LIST)).thenReturn(null);
listener.onAppAuthzChanged(new AppAuthzChangedEvent(this, "op"));
// 无异常即通过
}
}
@@ -10,7 +10,12 @@ import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.GroupSnapshot;
import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest;
import com.yunhai.userbase.model.groupauthz.GroupModuleAuthzItem;
import com.yunhai.userbase.model.groupauthz.GroupModulePermRequest;
import com.yunhai.userbase.model.groupauthz.SchoolAppAuthzRequest;
import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo;
import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo;
import com.yunhai.userbase.repository.AppCategoryRepository;
@@ -26,9 +31,11 @@ import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
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.Arrays;
import java.util.Collections;
@@ -42,6 +49,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
@@ -80,6 +88,8 @@ class GroupAuthzServiceTest {
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@Mock
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@BeforeEach
void setUp() {
@@ -446,6 +456,89 @@ class GroupAuthzServiceTest {
"超级管理员对任意模块都应返回 true");
}
// ===================== 写口事件发布(AppAuthzChangedEvent 哨口) =====================
/** assignAppsToGroup:全量覆盖保存后必须发布一次 AppAuthzChangedEvent */
@Test
void assignAppsToGroup_publishesAuthzEvent() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(applicationInfoRepository.existsById(100003)).thenReturn(true);
when(sysGroupAppRelRepository.findByGroupId(16)).thenReturn(Collections.emptyList());
GroupAppAssignRequest request = new GroupAppAssignRequest();
request.setGroupId(16);
request.setAppIds(Collections.singletonList(100003));
groupAuthzService.assignAppsToGroup(request);
ArgumentCaptor<AppAuthzChangedEvent> captor = ArgumentCaptor.forClass(AppAuthzChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals("GroupAuthzService.assignAppsToGroup", captor.getValue().getSourceOp());
}
/** saveModulePermissions:保存(或空操作)后必须发布一次 AppAuthzChangedEvent */
@Test
void saveModulePermissions_publishesAuthzEvent() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysGroupAppRelRepository.findByGroupIdIn(anyList()))
.thenReturn(Collections.singletonList(appRel(16, 100003)));
when(appModuleRepository.findByAppIdIn(anyList()))
.thenReturn(Collections.singletonList(module(8, 100003, "已收通知")));
when(sysGroupModulePermRepository.findByGroupId(16)).thenReturn(Collections.emptyList());
GroupModulePermRequest request = new GroupModulePermRequest();
request.setGroupId(16);
GroupModulePermRequest.ModulePermItem item = new GroupModulePermRequest.ModulePermItem();
item.setModuleId(8);
item.setInheritFlag(true);
request.setPerms(Collections.singletonList(item));
groupAuthzService.saveModulePermissions(request);
ArgumentCaptor<AppAuthzChangedEvent> captor = ArgumentCaptor.forClass(AppAuthzChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals("GroupAuthzService.saveModulePermissions", captor.getValue().getSourceOp());
}
/** setSchoolApps(追加模式):保存后必须发布一次 AppAuthzChangedEvent */
@Test
void setSchoolApps_publishesAuthzEvent() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(applicationInfoRepository.existsById(100003)).thenReturn(true);
when(sysGroupAppRelRepository.existsByGroupIdAndAppId(16, 100003)).thenReturn(false);
SchoolAppAuthzRequest request = new SchoolAppAuthzRequest();
request.setSchid(16);
request.setAppid(100003);
request.setMode(0); // APPEND
groupAuthzService.setSchoolApps(request);
ArgumentCaptor<AppAuthzChangedEvent> captor = ArgumentCaptor.forClass(AppAuthzChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals("GroupAuthzService.setSchoolApps", captor.getValue().getSourceOp());
}
/** setGroupModulePerms:保存后必须发布一次 AppAuthzChangedEvent */
@Test
void setGroupModulePerms_publishesAuthzEvent() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(applicationInfoRepository.existsById(100003)).thenReturn(true);
when(sysGroupAppRelRepository.findByGroupIdIn(anyList()))
.thenReturn(Collections.singletonList(appRel(16, 100003)));
when(appModuleRepository.findById(8)).thenReturn(Optional.of(module(8, 100003, "已收通知")));
when(sysGroupModulePermRepository.findByGroupIdAndModuleId(16, 8)).thenReturn(null);
GroupModuleAuthzItem item = new GroupModuleAuthzItem();
item.setGroupId(16);
item.setAppId(100003);
item.setModuleId(8);
item.setMode(0); // APPEND
item.setIsAll(1);
groupAuthzService.setGroupModulePerms(Collections.singletonList(item));
ArgumentCaptor<AppAuthzChangedEvent> captor = ArgumentCaptor.forClass(AppAuthzChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals("GroupAuthzService.setGroupModulePerms", captor.getValue().getSourceOp());
}
// ===================== helper =====================
private SysGroup group(Integer id, String name, Integer parentId, GroupTypeEnum type) {
@@ -23,6 +23,7 @@ import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@@ -134,4 +135,23 @@ class IdentityServiceTest {
void resolveMainIdentityByUser_emptyInput_returnsEmptyMap() {
assertTrue(identityService.resolveMainIdentityByUser(Collections.emptyList()).isEmpty());
}
// ===================== getCurrent 缓存注解契约 =====================
/**
* getCurrent 的 @Cacheable 契约(oapi 三接口缓存方案 4.2):
* identityCurrent 缓存、key='id:'+userId、unless 拦 null(无身份用户合法返回 null 不入缓存)、
* <b>不用 sync=true</b>(SD Redis 2.7 的 sync 路径对 null 无条件 put,disableCachingNullValues 下抛 IAE)。
*/
@Test
void getCurrent_cacheableAnnotation_contract() throws NoSuchMethodException {
org.springframework.cache.annotation.Cacheable anno = IdentityService.class
.getMethod("getCurrent", Integer.class)
.getAnnotation(org.springframework.cache.annotation.Cacheable.class);
assertEquals(1, anno.value().length);
assertEquals("identityCurrent", anno.value()[0]);
assertEquals("'id:' + #userId", anno.key());
assertEquals("#result == null", anno.unless());
assertFalse(anno.sync());
}
}
@@ -0,0 +1,245 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.entity.SysSchoolAppOverride;
import com.yunhai.userbase.entity.SysSchoolCategoryOverride;
import com.yunhai.userbase.repository.SysSchoolAppOverrideRepository;
import com.yunhai.userbase.repository.SysSchoolCategoryOverrideRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* SchoolAppOverrideService.applyAlias 单元测试(oapi 读链别名合并,2026-09-10)。<br>
* 契约:
* <ul>
* <li>schoolId=null 或该校两类覆盖行均不存在 → 原样返回(且不查覆盖表/不改动);</li>
* <li>覆盖字段非空才覆盖(名称/图标),空/空白回落主表真名——「无别名用真实名」;</li>
* <li>分类内应用按生效 orderNumber 重排、分类间按生效 sortNo(override 优先,回落主表映射,
* 再空视 999,并列取 id 小)重排——与管理端口径一致。</li>
* </ul>
*/
class SchoolAppOverrideServiceApplyAliasTest {
@InjectMocks
private SchoolAppOverrideService service;
@Mock
private SysSchoolAppOverrideRepository appOverrideRepository;
@Mock
private SysSchoolCategoryOverrideRepository categoryOverrideRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
// 默认无覆盖行(Mockito 对 List 返回默认空集合,此处显式声明意图)
when(appOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.emptyList());
when(categoryOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.emptyList());
}
// ===================== 不合并路径 =====================
/** schoolId=null(平台/区域链用户无学校语境)→ 原样返回且不查覆盖表 */
@Test
void schoolIdNull_returnsUnchangedWithoutQuery() {
List<BoaAppType> types = new ArrayList<>();
types.add(type(10, "办公管理", app(100, "通知公告", "icon-a", 1)));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1), null);
assertSame(types, result);
assertEquals("办公管理", result.get(0).getName());
verifyNoInteractions(appOverrideRepository, categoryOverrideRepository);
}
/** 该校无任何覆盖行 → 名称/顺序原样 */
@Test
void noOverrideRows_returnsUnchanged() {
List<BoaAppType> types = new ArrayList<>(Arrays.asList(
type(10, "办公管理", app(100, "通知公告", "icon-a", 1)),
type(11, "教务管理", app(101, "成绩管理", "icon-b", 2))));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1, 11, 2), 77);
assertEquals("办公管理", result.get(0).getName());
assertEquals("教务管理", result.get(1).getName());
assertEquals(Arrays.asList(10, 11), Arrays.asList(result.get(0).getId(), result.get(1).getId()));
}
// ===================== 名称/图标合并 =====================
/** 应用与分类别名同时命中:改名的应用图标不动;未覆盖的分类用真名 */
@Test
void namesOverridden_blankFieldsKeepMain() {
when(appOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
appOverride(77, 100, "通知公告lwj", null, null)));
when(categoryOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
categoryOverride(77, 10, "办公管理lwj", null)));
List<BoaAppType> types = new ArrayList<>(Arrays.asList(
type(10, "办公管理", app(100, "通知公告", "icon-a", 1)),
type(11, "教务管理", app(101, "成绩管理", "icon-b", 2))));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1, 11, 2), 77);
assertEquals("办公管理lwj", result.get(0).getName());
assertEquals("通知公告lwj", result.get(0).getApps().get(0).getName());
assertEquals("icon-a", result.get(0).getApps().get(0).getIcon());
assertEquals("教务管理", result.get(1).getName());
assertEquals("成绩管理", result.get(1).getApps().get(0).getName());
}
/** 覆盖行名称为空白串 → StringUtils.isNotBlank 拦截,回落主表真名 */
@Test
void blankOverrideName_fallsBackToMainName() {
when(appOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
appOverride(77, 100, " ", null, null)));
List<BoaAppType> types = new ArrayList<>(Collections.singletonList(
type(10, "办公管理", app(100, "通知公告", "icon-a", 1))));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1), 77);
assertEquals("通知公告", result.get(0).getApps().get(0).getName());
}
/** 图标覆盖只作用 pcIcon(BoaApp.icon),mobiIcon 不感知——与管理端 mergeAppVo 口径一致 */
@Test
void iconOverridden_appliesToPcIconOnly() {
when(appOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
appOverride(77, 100, null, "icon-lwj.png", null)));
BoaApp boa = app(100, "通知公告", "icon-a", 1);
boa.setMobiIcon("mobi-a");
List<BoaAppType> types = new ArrayList<>(Collections.singletonList(type(10, "办公管理", boa)));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1), 77);
BoaApp merged = result.get(0).getApps().get(0);
assertEquals("icon-lwj.png", merged.getIcon());
assertEquals("mobi-a", merged.getMobiIcon());
assertEquals("通知公告", merged.getName());
}
// ===================== 排序合并 =====================
/** 应用 sortNo 覆盖 → orderNumber 改写且分类内重排(102 提到首位) */
@Test
void appSortOverridden_reordersWithinCategory() {
when(appOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
appOverride(77, 102, null, null, 1)));
List<BoaAppType> types = new ArrayList<>(Collections.singletonList(
type(10, "办公管理", app(100, "A", "i", 2), app(101, "B", "i", 3), app(102, "C", "i", 4))));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1), 77);
List<BoaApp> apps = result.get(0).getApps();
assertEquals(Arrays.asList(102, 100, 101),
Arrays.asList(apps.get(0).getId(), apps.get(1).getId(), apps.get(2).getId()));
assertEquals(Integer.valueOf(1), apps.get(0).getOrderNumber());
}
/** 分类 sortNo 覆盖 → 分类间重排(12 提到首位),未覆盖分类回落主表 sortNo 映射 */
@Test
void categorySortOverridden_reordersTypes() {
when(categoryOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
categoryOverride(77, 12, null, 1)));
List<BoaAppType> types = new ArrayList<>(Arrays.asList(
type(10, "甲", app(100, "a", "i", 1)),
type(11, "乙", app(101, "b", "i", 1)),
type(12, "丙", app(102, "c", "i", 1))));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 2, 11, 3, 12, 4), 77);
assertEquals(Arrays.asList(12, 10, 11),
Arrays.asList(result.get(0).getId(), result.get(1).getId(), result.get(2).getId()));
}
/** 分类仅改名(sortNo 空)→ 顺序按主表映射保持 */
@Test
void categoryRenameOnly_keepsBaseOrder() {
when(categoryOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
categoryOverride(77, 11, "乙改名", null)));
List<BoaAppType> types = new ArrayList<>(Arrays.asList(
type(10, "甲", app(100, "a", "i", 1)),
type(11, "乙", app(101, "b", "i", 1))));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1, 11, 2), 77);
assertEquals(Arrays.asList(10, 11), Arrays.asList(result.get(0).getId(), result.get(1).getId()));
assertEquals("乙改名", result.get(1).getName());
}
/** 主表映射缺项(脏数据)→ 生效 sortNo 视 999 沉底,不抛错 */
@Test
void missingBaseSortEntry_treatedAsFallback() {
when(categoryOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
categoryOverride(77, 11, null, 500)));
List<BoaAppType> types = new ArrayList<>(Arrays.asList(
type(10, "甲", app(100, "a", "i", 1)),
type(11, "乙", app(101, "b", "i", 1))));
List<BoaAppType> result = service.applyAlias(types, new HashMap<>(), 77);
assertEquals(Integer.valueOf(11), result.get(0).getId());
assertEquals(Integer.valueOf(10), result.get(1).getId());
}
/** apps 为 null 的分类(无有权应用)→ 改名安全跳过 */
@Test
void nullAppsCategory_renamedSafely() {
when(categoryOverrideRepository.findBySchoolIdIn(anyList())).thenReturn(Collections.singletonList(
categoryOverride(77, 10, "办公管理lwj", null)));
BoaAppType empty = new BoaAppType();
empty.setId(10);
empty.setName("办公管理");
List<BoaAppType> types = new ArrayList<>(Collections.singletonList(empty));
List<BoaAppType> result = service.applyAlias(types, baseSort(10, 1), 77);
assertEquals("办公管理lwj", result.get(0).getName());
assertNull(result.get(0).getApps());
}
// ===================== 构造辅助 =====================
private static BoaApp app(int id, String name, String icon, Integer orderNumber) {
BoaApp boa = new BoaApp();
boa.setId(id);
boa.setName(name);
boa.setIcon(icon);
boa.setOrderNumber(orderNumber);
return boa;
}
private static BoaAppType type(int id, String name, BoaApp... apps) {
BoaAppType t = new BoaAppType();
t.setId(id);
t.setName(name);
t.setApps(new ArrayList<>(Arrays.asList(apps)));
return t;
}
private static SysSchoolAppOverride appOverride(int schoolId, int appId, String name, String icon, Integer sortNo) {
SysSchoolAppOverride o = new SysSchoolAppOverride();
o.setSchoolId(schoolId);
o.setAppId(appId);
o.setName(name);
o.setIcon(icon);
o.setSortNo(sortNo);
return o;
}
private static SysSchoolCategoryOverride categoryOverride(int schoolId, int categoryId, String name, Integer sortNo) {
SysSchoolCategoryOverride o = new SysSchoolCategoryOverride();
o.setSchoolId(schoolId);
o.setCategoryId(categoryId);
o.setName(name);
o.setSortNo(sortNo);
return o;
}
/** categoryId, sortNo 交替成对:baseSort(10,1,11,2) → {10:1, 11:2} */
private static Map<Integer, Integer> baseSort(Integer... pairs) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i + 1 < pairs.length; i += 2) {
map.put(pairs[i], pairs[i + 1]);
}
return map;
}
}
@@ -1,10 +1,13 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.event.UserChangedEvent;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
@@ -22,11 +25,14 @@ import org.springframework.context.ApplicationEventPublisher;
import java.util.Arrays;
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -57,6 +63,8 @@ class SysUserServiceTest {
private ApplicationEventPublisher eventPublisher;
@Mock
private IdentityService identityService;
@Mock
private UserGroupRelLoader userGroupRelLoader;
@BeforeEach
void setUp() {
@@ -177,4 +185,81 @@ class SysUserServiceTest {
// ③ 激活身份滞留清理:current 指向旧校 16(不在新 schoolSet {30})→ 删除
verify(sysUserCurrentRoleRepository).delete(current);
}
// ===================== groupState 写口事件补发 + loader 读路径 =====================
/** 组边界校验公共桩:学校 16,子树含 29/31 */
private void stubSchoolScope() {
SysGroup school = new SysGroup();
school.setId(16);
school.setType(GroupTypeEnum.SCHOOL_GROUP);
// assertSchoolId 是 void 直查(findById 属 SysGroupService 内部),此处仅 stub 范围查询
when(sysGroupService.listSelfAndDescendantIds(16)).thenReturn(Arrays.asList(16, 29, 30, 31));
}
/**
* changeUserGroupState(POST /oapi/user/groupState 写口):落库后必须发 UserChangedEvent
* 驱逐 userGroupRel 缓存;且只发缓存事件,不发 DataSync(推送行为不变)。
*/
@Test
void changeUserGroupState_publishesUserChangedEventOnly() {
stubSchoolScope();
when(sysUserRepository.existsById(1)).thenReturn(true);
SysUserGroupRel rel = rel(31, 0);
when(sysUserGroupRelRepository.findByUserIdAndGroupId(1, 31)).thenReturn(Optional.of(rel));
sysUserService.changeUserGroupState(1, 31, 16, 1);
ArgumentCaptor<UserChangedEvent> captor = ArgumentCaptor.forClass(UserChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals(Collections.singletonList(1), captor.getValue().getUserIds());
}
/** changeSortNo:宽泛失效纪律,只写 sort_no 也必须发 UserChangedEvent */
@Test
void changeSortNo_publishesUserChangedEvent() {
when(sysUserRepository.existsById(1)).thenReturn(true);
SysUserGroupRel rel = rel(31, 0);
when(sysUserGroupRelRepository.findByUserIdAndGroupId(1, 31)).thenReturn(Optional.of(rel));
sysUserService.changeSortNo(1, 31, 5);
ArgumentCaptor<UserChangedEvent> captor = ArgumentCaptor.forClass(UserChangedEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals(Collections.singletonList(1), captor.getValue().getUserIds());
}
/** getUserGroupState:走 userGroupRel loader,命中组返回归一后状态(含存量 null→0) */
@Test
void getUserGroupState_viaLoader_returnsNormalizedStatus() {
stubSchoolScope();
Map<Integer, Integer> stateMap = new HashMap<>();
stateMap.put(31, 1);
stateMap.put(29, 0);
when(userGroupRelLoader.loadByUserId(1)).thenReturn(stateMap);
assertEquals(Integer.valueOf(1), sysUserService.getUserGroupState(1, 31, 16));
assertEquals(Integer.valueOf(0), sysUserService.getUserGroupState(1, 29, 16));
}
/** getUserGroupState:组不在学校子树范围 → 仍抛「跨校」DException(校验链顺序不变) */
@Test
void getUserGroupState_groupOutsideSchool_throws() {
when(sysGroupService.listSelfAndDescendantIds(16)).thenReturn(Collections.singletonList(16));
DException ex = assertThrows(DException.class,
() -> sysUserService.getUserGroupState(1, 99, 16));
assertEquals("组不属于该学校范围,禁止跨校操作", ex.getMessage());
}
/** getUserGroupState:loader Map 无该 groupId → 抛同款「该用户不在此分组中」 */
@Test
void getUserGroupState_userNotInGroup_throws() {
stubSchoolScope();
when(userGroupRelLoader.loadByUserId(1)).thenReturn(new HashMap<>());
DException ex = assertThrows(DException.class,
() -> sysUserService.getUserGroupState(1, 31, 16));
assertEquals("该用户不在此分组中", ex.getMessage());
}
}
@@ -0,0 +1,82 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.cache.annotation.Cacheable;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
/**
* UserGroupRelLoader 单元测试:groupId→status Map 组装语义 + @Cacheable 注解属性契约。<br>
* 契约(oapi 三接口缓存方案 4.4):status NULL 归一 0=正常;无组关系返回空 Map 恒非 null
* (sync=true 安全、穿透防护天然成立);key='u:'+userId、value=[userGroupRel]。
*/
class UserGroupRelLoaderTest {
@InjectMocks
private UserGroupRelLoader loader;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private SysUserGroupRel rel(Integer groupId, UserGroupRelStatusEnum status) {
SysUserGroupRel r = new SysUserGroupRel();
r.setUserId(1);
r.setGroupId(groupId);
r.setStatus(status);
return r;
}
@Test
void loadByUserId_normalizesNullStatusToNormal() {
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Arrays.asList(
rel(29, null), // 存量行:status NULL
rel(31, UserGroupRelStatusEnum.TRANSFERRED_OUT),
rel(30, UserGroupRelStatusEnum.NORMAL)));
Map<Integer, Integer> map = loader.loadByUserId(1);
assertEquals(3, map.size());
assertEquals(Integer.valueOf(0), map.get(29));
assertEquals(Integer.valueOf(1), map.get(31));
assertEquals(Integer.valueOf(0), map.get(30));
}
@Test
void loadByUserId_noRels_returnsEmptyMapNotNull() {
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
Map<Integer, Integer> map = loader.loadByUserId(1);
assertTrue(map.isEmpty());
assertFalse(map == null);
}
/** @Cacheable 注解契约:userGroupRel 缓存、key='u:'+userId、sync=true(恒非 null 才允许 sync) */
@Test
void cacheableAnnotation_contract() throws NoSuchMethodException {
Cacheable anno = UserGroupRelLoader.class
.getMethod("loadByUserId", Integer.class)
.getAnnotation(Cacheable.class);
assertEquals(1, anno.value().length);
assertEquals("userGroupRel", anno.value()[0]);
assertEquals("'u:' + #userId", anno.key());
assertTrue(anno.sync());
}
}