From 3421851b6f46146ff681b03e49e164e5fb7bbb72 Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 10 Sep 2026 11:13:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20oapi=20/oapi/app/list=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=AD=A6=E6=A0=A1=E5=88=AB=E5=90=8D=20+=20userbase=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E4=BA=8B=E4=BB=B6=E5=A4=B1=E6=95=88=E5=9F=BA?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 别名接入(本次): - 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 等用例 --- .../yunhai/userbase/config/CacheNames.java | 60 ++++ .../controller/oapi/AppController.java | 12 +- .../userbase/event/AppAuthzChangedEvent.java | 29 ++ .../OapiMetricsController.java | 15 +- .../userbase/service/IAppCategoryService.java | 16 +- .../service/impl/AppCategoryService.java | 105 +++++- .../service/impl/AppModuleService.java | 17 + .../userbase/service/impl/AppRoleService.java | 20 ++ .../impl/CacheInvalidationListener.java | 64 +++- .../service/impl/GroupAuthzService.java | 17 + .../service/impl/IdentityService.java | 10 +- .../impl/SchoolAppOverrideService.java | 100 +++++- .../service/impl/SysGroupService.java | 23 +- .../userbase/service/impl/SysUserService.java | 22 +- .../service/impl/UserGroupRelLoader.java | 50 +++ .../userbase/task/IdentityBackfillTask.java | 12 + .../impl/AppAuthzEventPublishTest.java | 312 ++++++++++++++++++ .../AppCategoryServiceSchoolAliasTest.java | 226 +++++++++++++ .../impl/CacheInvalidationListenerTest.java | 222 +++++++++++++ .../service/impl/GroupAuthzServiceTest.java | 93 ++++++ .../service/impl/IdentityServiceTest.java | 20 ++ ...choolAppOverrideServiceApplyAliasTest.java | 245 ++++++++++++++ .../service/impl/SysUserServiceTest.java | 85 +++++ .../service/impl/UserGroupRelLoaderTest.java | 82 +++++ 24 files changed, 1819 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java create mode 100644 src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/CacheInvalidationListenerTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceApplyAliasTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/UserGroupRelLoaderTest.java diff --git a/src/main/java/com/yunhai/userbase/config/CacheNames.java b/src/main/java/com/yunhai/userbase/config/CacheNames.java index f7a3d37..702be50 100644 --- a/src/main/java/com/yunhai/userbase/config/CacheNames.java +++ b/src/main/java/com/yunhai/userbase/config/CacheNames.java @@ -87,6 +87,50 @@ public final class CacheNames { */ public static final String APP_KEY_TO_ID = "appKeyToId"; + /** + * 用户当前激活身份(GET /oapi/identity/current 读路径,压测占比 40%)。
+ * key 规则:{@code 'id:' + userId};TTL 5min(用户身份型:脏 currentRole = 权限事故,与 boaUser 同档同事件);
+ * 存放:Redis;依赖表:sys_user_current_role、sys_user_role_rel、sys_user_group_rel、sys_group;
+ * 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent} + * (身份切换/换组/回填等全部用户域写口 AFTER_COMMIT 精确驱逐)+ + * {@link com.yunhai.userbase.event.GroupChangedEvent}(结果内嵌校名,组改名后不能吃 5min 旧值)。
+ * 设计说明:不用 sync=true——方法对「连主身份都没有」的用户合法返回 null(unless 拦截不入缓存), + * SD Redis 2.7 的 sync 路径对 null 会无条件 put 并在 {@code disableCachingNullValues} 下抛 IAE + * (同 {@link com.yunhai.userbase.service.impl.BoaUserLoader} 既有纪律)。 + */ + public static final String IDENTITY_CURRENT = "identityCurrent"; + + /** + * 用户可见应用列表(GET /oapi/app/list 读路径,压测占比 20%,每请求 ~8 条 SQL)。
+ * key 规则:{@code 'u:' + userId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name()) + * + ':s:' + (schoolId 为 null ? 'auto' : schoolId)}——client 维度共 1+7=8 个; + * 学校维度:未显式传校归一为字面量 'auto'(生效学校随 userId 由身份/主归属推导,可精确枚举驱逐), + * 显式传校后缀为学校 id(不可枚举,用户自身变更场景靠 TTL 自愈,别名/授权/组/应用变更均整清);
+ * TTL 60s(结果敏感型短档:脏了=用户看到不该看的菜单;叠加全哨口事件后 TTL 仅兜直改 DB);
+ * 存放:Redis;依赖表:sys_group_app_rel、sys_group_module_perm、sys_user_app_role_rel、sys_app_role、 + * sys_app_role_module_rel、app_module、app_category、application_info、sys_group、 + * sys_school_app_override、sys_school_category_override(2026-09-10 起后两者经 + * SchoolAppOverrideService.applyAlias 正式在读链上:返回前合并学校私有别名/图标/排序);
+ * 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(应用授权域 24 写口整清, + * 含学校别名覆盖全部写口)+ + * {@link com.yunhai.userbase.event.UserChangedEvent}(按用户 16 条可枚举 key 精确驱逐)+ + * {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent} + * (组移动改变继承开通面 / 应用启停排序删除整清)。 + */ + public static final String USER_APP_LIST = "userAppList"; + + /** + * 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。
+ * key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups + * 整批重写组关系无法从事件反推旧 key,多维只能整清,写流量下会打穿缓存);
+ * TTL 5min(用户身份型;全部写口有 UserChangedEvent,TTL 仅兜直改 DB);
+ * 存放:Redis;依赖表:sys_user_group_rel;
+ * 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}(含 groupState 写口本次补发事件)+ + * {@link com.yunhai.userbase.event.GroupChangedEvent}(组删除级联的卫生性整清)。
+ * 无组关系用户为空 Map 恒非 null,sync=true 安全(穿透防护天然成立)。 + */ + public static final String USER_GROUP_REL = "userGroupRel"; + // ===================== 注册表元数据(驱动 CacheConfig 与对账接口) ===================== /** 存放策略:Redis(跨重启、单条小总量大)或 LOCAL(Caffeine 仅本地,单条大/读频极高/配置型小数据)。 */ @@ -174,6 +218,22 @@ public final class CacheNames { "#appKey", "ApplicationInfoCacheChangedEvent → CacheInvalidationListener(搭车 clear)", "application_info")); + m.put(IDENTITY_CURRENT, new Meta(IDENTITY_CURRENT, Store.REDIS, Duration.ofMinutes(5), 0.1, + "'id:'+userId", + "UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener", + "sys_user_current_role, sys_user_role_rel, sys_user_group_rel, sys_group")); + m.put(USER_APP_LIST, new Meta(USER_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1, + "'u:'+userId+':c:'+(clientType?.name()?:'all')+':s:'+(schoolId?:'auto')(8 client × auto/显式校)", + "AppAuthzChangedEvent(整清,含学校别名写口)+ UserChangedEvent(按用户 16 条可枚举 key 驱逐," + + "显式学校后缀 TTL 自愈)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent(整清)" + + " → CacheInvalidationListener", + "sys_group_app_rel, sys_group_module_perm, sys_user_app_role_rel, sys_app_role," + + " sys_app_role_module_rel, app_module, app_category, application_info, sys_group," + + " sys_school_app_override, sys_school_category_override(2026-09-10 起在读链:applyAlias 合并别名)")); + m.put(USER_GROUP_REL, new Meta(USER_GROUP_REL, Store.REDIS, Duration.ofMinutes(5), 0.1, + "'u:'+userId(整张 groupId→status Map)", + "UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener", + "sys_user_group_rel")); REGISTRY = Collections.unmodifiableMap(m); } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index efc3ebb..4971bc0 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -55,20 +55,24 @@ public class AppController { } /** - * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。
+ * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用), * 超级管理员返回全部;不透出 appSecret。
- * clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。 + * clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
+ * schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5); + * 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。 * * @param userId 用户ID(必填) * @param clientType 客户端类型 key(可选) + * @param schoolId 学校上下文(可选;null 走身份/主归属兜底) * @return 应用类型分组列表;无权限时各类型 apps 为空数组 */ @GetMapping("list") public List list(@RequestParam("userId") Integer userId, - @RequestParam(value = "clientType", required = false) Integer clientType) { + @RequestParam(value = "clientType", required = false) Integer clientType, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { ClientEnum clientEnum = (clientType == null) ? null : IEnum.getByKey(ClientEnum.class, clientType); - return appCategoryService.listBoaAppType(userId, clientEnum); + return appCategoryService.listBoaAppType(userId, clientEnum, schoolId); } } diff --git a/src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java b/src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java new file mode 100644 index 0000000..07a14f1 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/event/AppAuthzChangedEvent.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.event; + +import org.springframework.context.ApplicationEvent; + +/** + * 应用授权域数据变更事件:组应用开通(sys_group_app_rel)、组模块授权(sys_group_module_perm)、 + * 业务角色及其模块/用户绑定(sys_app_role / sys_app_role_module_rel / sys_user_app_role_rel)、 + * 应用分类(app_category)、模块(app_module)、学校别名覆盖(sys_school_app_override / + * sys_school_category_override)任意写操作事务提交后发布。
+ * 消费方:{@code CacheInvalidationListener} 整清 {@code userAppList} + * (/oapi/app/list 的组开通/角色授权输入面广,无法从事件反推受影响用户,按「宁可失效宽泛」原则整缓存 clear)。
+ * 宽泛口径说明:部分发布哨口(组模块授权、学校别名覆盖)经查不影响 /oapi/app/list 当前返回, + * 按失效总原则仍纳入——多清无害,防未来读链演化漏驱。
+ * 粒度:单一事件类型——所有发布方语义均为「应用授权面变了,重建列表」,不做精确到用户/组的最小化裁剪。 + */ +public class AppAuthzChangedEvent extends ApplicationEvent { + + /** 触发本次变更的写口(assignAppsToGroup/saveRoleModules/editCategory 等),仅用于日志定位。 */ + private final String sourceOp; + + public AppAuthzChangedEvent(Object source, String sourceOp) { + super(source); + this.sourceOp = sourceOp; + } + + public String getSourceOp() { + return sourceOp; + } +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java index 3364831..5fcb9de 100644 --- a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java @@ -50,7 +50,7 @@ public class OapiMetricsController { vo.setDays(d); vo.setTop(n); - Object[] total = repository.aggregateTotal(since, 0); + Object[] total = firstRow(repository.aggregateTotal(since, 0)); vo.setTotalCnt(asLong(total, 0)); vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1))); vo.setTotalErrCnt(asLong(total, 2)); @@ -82,7 +82,7 @@ public class OapiMetricsController { } // 压测流量合计(单列口径,不与线上热度混算) - Object[] lt = repository.aggregateTotal(since, 1); + Object[] lt = firstRow(repository.aggregateTotal(since, 1)); vo.setLoadTestCnt(asLong(lt, 0)); return vo; } @@ -97,6 +97,17 @@ public class OapiMetricsController { return row; } + /** + * Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构 + * (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。 + */ + private Object[] firstRow(Object[] arr) { + if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) { + return (Object[]) arr[0]; + } + return arr; + } + private long asLong(Object[] arr, int i) { if (arr == null || i >= arr.length || arr[i] == null) { return 0L; diff --git a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java index 13772b4..d36a284 100644 --- a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java @@ -36,17 +36,23 @@ public interface IAppCategoryService { List listWithApps(); /** - * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组)。
+ * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组), + * 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。
* 规则:
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);
- * ③ 不透出 appSecret。
- * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无有权应用的类型 apps 为空数组。 + * ③ 不透出 appSecret;
+ * ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖, + * 覆盖字段为空回落主表真名(无别名=真实名);生效学校三级兜底: + * 显式 schoolId(须合法学校锚点且用户属该校,违规抛 DException)→ 当前激活身份学校 + * (sys_user_current_role)→ 主归属学校;均解析不到则不合并。
+ * 分类间/分类内均按生效排序升序(空视 999、并列取 id 小);无有权应用的类型 apps 为空数组。 * * @param userId 用户ID * @param clientType 客户端类型(null=不按端过滤) - * @return 应用类型 + 有权应用列表 + * @param schoolId 学校上下文(可选;null 走身份/主归属学校兜底) + * @return 应用类型 + 有权应用列表(已合并学校别名) */ - List listBoaAppType(Integer userId, ClientEnum clientType); + List listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java index 0fbbb21..c678d92 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java @@ -5,8 +5,12 @@ import com.yunhai.platform.utils.enums.ClientEnum; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaApp; import com.yunhai.platform.utils.model.BoaAppType; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.userbase.config.CacheNames; import com.yunhai.userbase.entity.AppCategory; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.model.appauthz.AppAuthzAppRow; import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; import com.yunhai.userbase.model.appcategory.AppCategoryQuery; @@ -14,10 +18,15 @@ import com.yunhai.userbase.model.appcategory.AppCategoryRequest; import com.yunhai.userbase.model.appcategory.AppCategoryVo; import com.yunhai.userbase.repository.AppCategoryRepository; import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.service.IAppCategoryService; import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.ISysGroupService; +import com.yunhai.userbase.service.resolve.MainGroupResolver; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -49,6 +58,24 @@ public class AppCategoryService implements IAppCategoryService { @Autowired private IGroupAuthzService groupAuthzService; + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Autowired + private SchoolAppOverrideService schoolAppOverrideService; + + @Autowired + private IdentityService identityService; + + @Autowired + private MainGroupResolver mainGroupResolver; + + @Autowired + private ISysGroupService sysGroupService; + + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Override @Transactional @@ -68,6 +95,7 @@ public class AppCategoryService implements IAppCategoryService { } appCategoryRepository.save(entity); + publishAuthzEvent("add"); } @Override @@ -84,6 +112,7 @@ public class AppCategoryService implements IAppCategoryService { entity.setSortNo(request.getSortNo()); } appCategoryRepository.save(entity); + publishAuthzEvent("edit"); } @Override @@ -93,6 +122,7 @@ public class AppCategoryService implements IAppCategoryService { throw new RuntimeException("该类别下存在应用,无法删除"); } appCategoryRepository.deleteById(id); + publishAuthzEvent("delete"); } @Override @@ -115,6 +145,7 @@ public class AppCategoryService implements IAppCategoryService { } appCategoryRepository.updateSortNoById(ids.get(i), i + 1); } + publishAuthzEvent("sort"); } @Override @@ -146,8 +177,26 @@ public class AppCategoryService implements IAppCategoryService { return vo; } + /** + * /oapi/app/list 读路径缓存({@link CacheNames#USER_APP_LIST}, + * key='u:'+userId+':c:'+client+':s:'+school 维度,TTL 60s)。恒返回非 null 列表 + * (无分类/无权限时为空列表),sync=true 白得同 key 排队回源; + * clientType 入参已由 Controller 层 IEnum.getByKey 归一(非法/缺失 → null → key 维度 'all'), + * 用枚举 name() 而非原始参数做 key 天然防垃圾维度。
+ * 学校维度 key 规则:未显式传 schoolId 时归一为字面量 'auto'(结果按用户当前身份/主归属学校 + * 推导,随 userId 确定),可随用户精确枚举驱逐;显式传入则后缀学校 id——该维度不可枚举, + * 用户自身变更场景靠 60s TTL 自愈(别名/授权/组/应用变更均整清,见失效责任注册表)。
+ * 返回前经 {@link SchoolAppOverrideService#applyAlias} 合并学校私有别名/图标/排序 + * (无别名回落主表真名),合并结果入缓存。失效责任见 CacheNames 注册表(全哨口事件)。 + */ @Override - public List listBoaAppType(Integer userId, ClientEnum clientType) { + @Cacheable(value = CacheNames.USER_APP_LIST, + key = "'u:' + #userId + ':c:' + (#clientType == null ? 'all' : #clientType.name())" + + " + ':s:' + (#schoolId == null ? 'auto' : #schoolId)", + sync = true) + public List listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) { + // 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并 + Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId); Set authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId)); List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); List apps = applicationInfoRepository.findAll(); @@ -161,8 +210,11 @@ public class AppCategoryService implements IAppCategoryService { } appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app); } + // categoryId -> 主表分类 sortNo(BoaAppType 契约无排序字段,供别名合并后分类重排兜底) + Map categoryBaseSortNo = new HashMap<>(); List result = new ArrayList<>(); for (AppCategory category : categories) { + categoryBaseSortNo.put(category.getId(), category.getSortNo()); BoaAppType type = new BoaAppType(); type.setId(category.getId()); type.setName(category.getName()); @@ -181,7 +233,48 @@ public class AppCategoryService implements IAppCategoryService { type.setApps(boaApps); result.add(type); } - return result; + return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool); + } + + /** + * oapi 生效学校三级兜底:
+ * ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组), + * 并校验用户至少一条组关系解析到该校(与 move/groupState 的 schoolId 子树纪律一致),违规抛 + * DException(→code=5);
+ * ② 当前激活身份学校({@link IdentityService#getCurrent},即 sys_user_current_role, + * UserHome 选校/切换身份已写该表,故调用方不传参也能跟随所选学校);
+ * ③ 主归属学校({@link MainGroupResolver} 主组 → resolveSchoolId 向上最近学校组)。
+ * 均解析不到(平台账号/区域链用户)返回 null → 不合并别名;③ 兜底解析到区域/根 id 时 + * 天然无覆盖行命中,等价不合并,无需二次判型。 + */ + private Integer resolveEffectiveSchool(Integer userId, Integer schoolId) { + if (schoolId != null) { + sysGroupService.assertSchoolId(schoolId); + assertUserInSchool(userId, schoolId); + return schoolId; + } + BoaRoleInfo current = identityService.getCurrent(userId); + if (current != null && current.getSchId() != null) { + return current.getSchId(); + } + SysUserGroupRel main = mainGroupResolver.resolve(userId).orElse(null); + if (main == null || main.getGroupId() == null) { + return null; + } + return sysGroupService.resolveSchoolId(main.getGroupId()); + } + + /** 显式学校上下文边界:用户至少一条组关系沿祖先链解析到该学校,否则拒绝(防任意传校窥探他校别名) */ + private void assertUserInSchool(Integer userId, Integer schoolId) { + List groupIds = new ArrayList<>(); + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) { + if (rel.getGroupId() != null) { + groupIds.add(rel.getGroupId()); + } + } + if (groupIds.isEmpty() || !sysGroupService.resolveSchoolIds(groupIds).containsValue(schoolId)) { + throw new DException("用户不属于该学校"); + } } /** @@ -237,6 +330,14 @@ public class AppCategoryService implements IAppCategoryService { return false; } + /** + * 分类写口统一发 {@link AppAuthzChangedEvent}:app_category 不在 app/list 的用户过滤面内, + * 但分类名/排序直接出现在 app/list 返回结构中,必须即时失效(AFTER_COMMIT + fallback 兜底)。 + */ + private void publishAuthzEvent(String sourceOp) { + eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppCategoryService." + sourceOp)); + } + } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java index d3ebd9e..8809fb3 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppModuleService.java @@ -2,6 +2,7 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.model.appmodule.AppModuleQuery; import com.yunhai.userbase.model.appmodule.AppModuleRequest; import com.yunhai.userbase.model.appmodule.AppModuleVo; @@ -12,6 +13,7 @@ import com.yunhai.userbase.repository.SysGroupModulePermRepository; import com.yunhai.userbase.service.IAppModuleService; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import javax.transaction.Transactional; @@ -46,6 +48,9 @@ public class AppModuleService implements IAppModuleService { @Autowired private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + @Override @Transactional public void add(AppModuleRequest request) { @@ -62,6 +67,7 @@ public class AppModuleService implements IAppModuleService { entity.setLevel(resolveLevel(request.getAppId(), request.getParentId())); // sortNo 默认为空(由前端编辑时带入),不自动递增 appModuleRepository.save(entity); + publishAuthzEvent("add"); } @Override @@ -85,6 +91,7 @@ public class AppModuleService implements IAppModuleService { } else { appModuleRepository.save(entity); } + publishAuthzEvent("edit"); } @Override @@ -102,6 +109,7 @@ public class AppModuleService implements IAppModuleService { // AI-FIX(角色授权): 删除模块时级联清理各业务角色对该模块的授权(逻辑外键无物理 FK) sysAppRoleModuleRelRepository.deleteByModuleIdInBulk(Collections.singletonList(id)); appModuleRepository.deleteById(id); + publishAuthzEvent("delete"); } @Override @@ -274,4 +282,13 @@ public class AppModuleService implements IAppModuleService { BeanUtils.copyProperties(entity, vo); return vo; } + + /** + * 模块域写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList + * (模块增改删改变模块授权面与 cascade 清理记录,宽泛纳入;moveSubtree 为 edit 的私有内部路径, + * 由 edit 统一发布,不重复发)。 + */ + private void publishAuthzEvent(String sourceOp) { + eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppModuleService." + sourceOp)); + } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java index f1023d9..a95fa00 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java @@ -7,6 +7,7 @@ import com.yunhai.userbase.entity.SysAppRole; import com.yunhai.userbase.entity.SysAppRoleModuleRel; import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserAppRoleRel; +import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo; import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest; @@ -26,6 +27,7 @@ import com.yunhai.userbase.service.IAppRoleService; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -77,6 +79,9 @@ public class AppRoleService implements IAppRoleService { @Autowired private SysUserRepository sysUserRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + // ===================== 角色 CRUD ===================== @Override @@ -123,6 +128,7 @@ public class AppRoleService implements IAppRoleService { entity.setRemark(request.getRemark()); entity.setDisabled(Boolean.TRUE.equals(request.getDisabled())); sysAppRoleRepository.save(entity); + publishAuthzEvent("add"); } @Override @@ -153,6 +159,7 @@ public class AppRoleService implements IAppRoleService { entity.setDisabled(request.getDisabled()); } sysAppRoleRepository.save(entity); + publishAuthzEvent("edit"); } @Override @@ -166,6 +173,7 @@ public class AppRoleService implements IAppRoleService { sysAppRoleModuleRelRepository.deleteByRoleIdBulk(id); sysUserAppRoleRelRepository.deleteByRoleIdBulk(id); sysAppRoleRepository.deleteById(id); + publishAuthzEvent("delete"); } // ===================== 角色模块授权 ===================== @@ -253,6 +261,7 @@ public class AppRoleService implements IAppRoleService { if (!batch.isEmpty()) { sysAppRoleModuleRelRepository.saveAll(batch); } + publishAuthzEvent("saveRoleModules"); } // ===================== 用户角色绑定 ===================== @@ -318,6 +327,7 @@ public class AppRoleService implements IAppRoleService { if (!batch.isEmpty()) { sysUserAppRoleRelRepository.saveAll(batch); } + publishAuthzEvent("saveRoleUsers"); } @Override @@ -348,6 +358,7 @@ public class AppRoleService implements IAppRoleService { if (!batch.isEmpty()) { sysUserAppRoleRelRepository.saveAll(batch); } + publishAuthzEvent("saveUserRoles"); } @Override @@ -362,6 +373,15 @@ public class AppRoleService implements IAppRoleService { throw new DException("用户不存在: " + userId); } sysUserAppRoleRelRepository.deleteByRoleIdAndUserId(roleId, userId); + publishAuthzEvent("removeRoleUser"); + } + + /** + * 业务角色域写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList + * (角色模块授权是 app/list 授权并集的一侧输入,无法精确反推受影响用户,宽泛整清)。 + */ + private void publishAuthzEvent(String sourceOp) { + eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "AppRoleService." + sourceOp)); } // ===================== 私有工具 ===================== diff --git a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java index bcfc9e2..8e04417 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java +++ b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java @@ -1,7 +1,9 @@ package com.yunhai.userbase.service.impl; +import com.yunhai.platform.utils.enums.ClientEnum; import com.yunhai.userbase.config.CacheNames; import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent; import com.yunhai.userbase.event.GroupChangedEvent; import com.yunhai.userbase.event.UserChangedEvent; @@ -20,14 +22,20 @@ import java.util.List; /** * 缓存失效统一监听器(写失效唯一通道:AFTER_COMMIT 事件驱动)。

* - * 订阅三个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:
+ * 订阅四个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:
*
    - *
  • {@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId;
  • - *
  • {@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照(下次读取排队重建)+ - * 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名,组改名后不能吃 5min 旧值);
  • - *
  • {@link UserChangedEvent} → 按 id:{userId} 精确驱逐,并回查当前 account/phone 驱逐 - * login:/phone: 维度;改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时 - * 整清兜底(无法反查 account/phone,删除是罕见操作)。
  • + *
  • {@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId + + * 整清 userAppList(应用启停/排序/删除改变用户可见应用面);
  • + *
  • {@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照 + + * 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名)+ 整清 identityCurrent(内嵌校名)+ + * userAppList(组移动/删除改变继承开通面)+ userGroupRel(组删除级联的卫生性整清);
  • + *
  • {@link UserChangedEvent} → 按 id:{userId} 精确驱逐(boaUser/identityCurrent/userGroupRel), + * 并回查当前 account/phone 驱逐 login:/phone: 维度 + userAppList 的 16 条可枚举 key + * (8 client 维度 × {旧格式, :s:auto};显式学校后缀维度靠 60s TTL 自愈); + * 改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时整清兜底 + * (无法反查 account/phone,删除是罕见操作);
  • + *
  • {@link AppAuthzChangedEvent} → 整清 userAppList(组开通/角色授权/分类/模块/学校别名等 + * 应用授权域写口,输入面广无法精确反推用户,按「宁可失效宽泛」原则整清)。
  • *
* * 护栏(方案 5.2):所有监听器 {@code fallbackExecution=true}——发布方不在事务中时事件 @@ -55,7 +63,8 @@ public class CacheInvalidationListener { public void onApplicationInfoChanged(ApplicationInfoCacheChangedEvent event) { clearQuietly(CacheNames.APP_INFO); clearQuietly(CacheNames.APP_KEY_TO_ID); - log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId", + clearQuietly(CacheNames.USER_APP_LIST); + log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList", event.getAppKey(), event.getType()); } @@ -66,7 +75,11 @@ public class CacheInvalidationListener { evictQuietly(CacheNames.GROUP_SNAPSHOT, SNAPSHOT_KEY); clearQuietly(CacheNames.BOA_USER); clearQuietly(CacheNames.BOA_USER_NULL); - log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser", event.getSourceOp()); + clearQuietly(CacheNames.IDENTITY_CURRENT); + clearQuietly(CacheNames.USER_APP_LIST); + clearQuietly(CacheNames.USER_GROUP_REL); + log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/userGroupRel", + event.getSourceOp()); } // ===================== 用户域 ===================== @@ -81,6 +94,10 @@ public class CacheInvalidationListener { } evictQuietly(CacheNames.BOA_USER, "id:" + userId); evictQuietly(CacheNames.BOA_USER_NULL, "id:" + userId); + // 身份/组关系缓存与 boaUser 同源同事件:切换身份/换组/转出后即时失效 + evictQuietly(CacheNames.IDENTITY_CURRENT, "id:" + userId); + evictQuietly(CacheNames.USER_GROUP_REL, "u:" + userId); + evictUserAppListByUser(userId); // 回查当前 account/phone:绝大多数写口(换组/禁用/身份切换等)不改变账号手机号, // 按当前值驱逐即可精确覆盖 login:/phone: 维度 SysUser user = sysUserRepository.findById(userId).orElse(null); @@ -106,12 +123,39 @@ public class CacheInvalidationListener { evictQuietly(CacheNames.BOA_USER, hint); evictQuietly(CacheNames.BOA_USER_NULL, hint); } - log.info("[Cache失效] 用户变更 userIds={} hints={} → boaUser 精确驱逐完成", + log.info("[Cache失效] 用户变更 userIds={} hints={} → boaUser/identityCurrent/userGroupRel/userAppList 精确驱逐完成", userIds, event.getStaleKeyHints()); } + // ===================== 应用授权域 ===================== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onAppAuthzChanged(AppAuthzChangedEvent event) { + clearQuietly(CacheNames.USER_APP_LIST); + log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList", event.getSourceOp()); + } + // ===================== 驱逐工具(error 可观测,TTL 自愈兜底) ===================== + /** + * 按用户驱逐 userAppList 的全部可枚举 key(2026-09-10 起 key 含 ':s:' 学校维度): + * 每个 client 维度({@code all} + 7 个 {@link ClientEnum#name()})驱逐两条—— + * 无后缀旧格式(发布过渡期存量,TTL 内自然清零后可移除)与 {@code :s:auto} + * (schoolId 未显式传入时归一为字面量 auto,结果随 userId 确定,可精确枚举)。
+ * 显式学校后缀维度({@code :s:{schoolId}})不可枚举:schoolId 由调用方任意传入, + * 用户自身变更(换组/身份切换)时无法反推全部后缀,该维度靠 60s TTL 自愈—— + * 别名/授权/组/应用变更均走整清已全覆盖,仅 UserChangedEvent 一路存在 ≤TTL 的窗口, + * 符合本类「精确驱逐尽力、整清兜底、TTL 自愈」纪律。 + */ + private void evictUserAppListByUser(Integer userId) { + evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:all"); + evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:all:s:auto"); + for (ClientEnum client : ClientEnum.values()) { + evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:" + client.name()); + evictQuietly(CacheNames.USER_APP_LIST, "u:" + userId + ":c:" + client.name() + ":s:auto"); + } + } + private void evictQuietly(String cacheName, Object key) { try { Cache cache = cacheManager.getCache(cacheName); diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 4981543..713420f 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -7,6 +7,7 @@ import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.enums.SysModeEnum; import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.model.groupauthz.GroupAppAssignRequest; import com.yunhai.userbase.model.groupauthz.GroupAppVo; import com.yunhai.userbase.model.groupauthz.GroupAuthzAppVo; @@ -38,6 +39,7 @@ import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.resolve.MainGroupResolver; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; @@ -115,6 +117,9 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private GroupSnapshotService groupSnapshotService; + @Autowired + private ApplicationEventPublisher eventPublisher; + // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) // ===================== @@ -209,6 +214,7 @@ public class GroupAuthzService implements IGroupAuthzService { if (!batch.isEmpty()) { sysGroupAppRelRepository.saveAll(batch); } + publishAuthzEvent("assignAppsToGroup"); } // ===================== 功能2:组授权 ===================== @@ -377,6 +383,7 @@ public class GroupAuthzService implements IGroupAuthzService { if (!toDelete.isEmpty()) { sysGroupModulePermRepository.deleteAll(toDelete); } + publishAuthzEvent("saveModulePermissions"); } // ===================== 新增:树节点式增量授权 ===================== @@ -436,6 +443,7 @@ public class GroupAuthzService implements IGroupAuthzService { sysGroupModulePermRepository.deleteByGroupIdAndModuleIdInBulk(schId, moduleIds); } } + publishAuthzEvent("setSchoolApps"); } @Override @@ -483,6 +491,15 @@ public class GroupAuthzService implements IGroupAuthzService { } } } + publishAuthzEvent("setGroupModulePerms"); + } + + /** + * 组应用开通/组模块授权写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList + * (组开通面与模块授权直接决定用户可见应用,无法精确反推受影响用户,宽泛整清)。 + */ + private void publishAuthzEvent(String sourceOp) { + eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "GroupAuthzService." + sourceOp)); } /** diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java index 5d16ad7..342b6a2 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.userbase.config.CacheNames; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysUserCurrentRole; import com.yunhai.userbase.entity.SysUserGroupRel; @@ -17,6 +18,7 @@ import com.yunhai.userbase.event.UserChangedEvent; import com.yunhai.userbase.service.ISysGroupService; import com.yunhai.userbase.service.resolve.MainGroupResolver; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -80,8 +82,14 @@ public class IdentityService { /** * 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。 - * 连主身份都没有时返回 null(前端引导创建身份)。 + * 连主身份都没有时返回 null(前端引导创建身份)。

+ * + * 缓存({@link CacheNames#IDENTITY_CURRENT},key='id:'+userId):仅经 oapi 代理调用(IdentityController), + * 结果只由 userId 决定。不使用 sync=true——对无身份用户合法返回 null(unless 拦截不入缓存), + * SD Redis 2.7 的 sync 路径对 null 会无条件 put 并抛 IAE(BoaUserLoader 既有纪律); + * 失效责任:UserChangedEvent 精确驱逐 + GroupChangedEvent 整清(结果内嵌校名)。 */ + @Cacheable(value = CacheNames.IDENTITY_CURRENT, key = "'id:' + #userId", unless = "#result == null") public BoaRoleInfo getCurrent(Integer userId) { Optional currentOpt = sysUserCurrentRoleRepository.findByUserId(userId); if (currentOpt.isPresent()) { diff --git a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java index 8d157f7..e0df1d9 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java @@ -1,12 +1,15 @@ package com.yunhai.userbase.service.impl; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaApp; +import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysGroupAppRel; import com.yunhai.userbase.entity.SysSchoolAppOverride; import com.yunhai.userbase.entity.SysSchoolCategoryOverride; import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.model.appcategory.AppCategoryVo; import com.yunhai.userbase.model.application.ApplicationInfoQuery; import com.yunhai.userbase.model.application.ApplicationInfoRequest; @@ -22,6 +25,7 @@ import com.yunhai.userbase.service.resolve.AccessScope; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -45,7 +49,9 @@ import java.util.stream.Collectors; * 名称/描述/图标/排序 + 应用类型改名/排序做该校私有覆盖,主数据零侵入。
* 读写口径:应用范围恒为「该(些)学校在 sys_group_app_rel 已开通的应用并集」; * override 行字段为 null 表示沿用主表值(语义即「别名」)。
- * 仅管理端调用(AppSettingsController),oapi / 认证链路不感知本服务。 + * 调用方:管理端(AppSettingsController 读写)+ oapi 读链({@code AppCategoryService.listBoaAppType} + * → {@link #applyAlias},2026-09-10 接入,兑现写口事件「防未来读链合并别名时漏驱」预留钩子); + * 认证链路不感知本服务。 * * @author chenx * @date 2026/9/4 @@ -74,6 +80,9 @@ public class SchoolAppOverrideService { @Autowired private SysGroupRepository sysGroupRepository; + @Autowired + private ApplicationEventPublisher eventPublisher; + // ===================== 学校解析 ===================== /** 目标学校集合:schoolId 指定则单校(必须 ∈ 可管理学校),否则全部可管理学校(并集模式) */ @@ -316,6 +325,79 @@ public class SchoolAppOverrideService { return result; } + // ===================== oapi 读链合并(/oapi/app/list) ===================== + + /** + * oapi 读链别名合并(2026-09-10 接入):按单一学校覆盖 {@link BoaAppType}/{@link BoaApp} + * 的名称/图标/排序,override 字段非空才覆盖(无别名回落主表真名),语义与管理端 + * {@link #mergeAppVo}/{@link #mergeCategoryList} 完全一致。
+ * 排序:分类内应用按生效 orderNumber(override sortNo 覆盖后)升序、并列取 id 小; + * 分类间按生效 sortNo(override 优先,空回落 {@code categoryBaseSortNo} 主表值,再空视 999)、 + * 并列取 id 小重排——BoaAppType 契约无排序字段,主表分类序由调用方以映射传入。
+ * schoolId 为 null(用户无学校语境:平台/区域链账号)或该校两类覆盖行均不存在时原样返回; + * 非学校组 id 天然无覆盖行命中,等价原样返回。原地修改并返回同一列表(调用方为 + * {@code listBoaAppType} 缓存方法内新构建的临时结构,无共享风险)。 + * + * @param types 已组装的应用类型树 + * @param categoryBaseSortNo categoryId → 主表分类 sortNo(分类重排兜底用;可为 null 视为空映射) + * @param schoolId 生效学校组 id(null=不合并) + */ + public List applyAlias(List types, Map categoryBaseSortNo, + Integer schoolId) { + if (schoolId == null || types == null || types.isEmpty()) { + return types; + } + List schools = Collections.singletonList(schoolId); + Map appOverrides = appOverridesFor(schools); + Map categoryOverrides = categoryOverridesFor(schools); + if (appOverrides.isEmpty() && categoryOverrides.isEmpty()) { + return types; + } + Map baseSort = categoryBaseSortNo == null ? Collections.emptyMap() : categoryBaseSortNo; + for (BoaAppType type : types) { + SysSchoolCategoryOverride co = type.getId() == null ? null : categoryOverrides.get(type.getId()); + if (co != null && StringUtils.isNotBlank(co.getName())) { + type.setName(co.getName()); + } + if (type.getApps() == null || type.getApps().isEmpty()) { + continue; + } + for (BoaApp app : type.getApps()) { + SysSchoolAppOverride ao = app.getId() == null ? null : appOverrides.get(app.getId()); + if (ao == null) { + continue; + } + if (StringUtils.isNotBlank(ao.getName())) { + app.setName(ao.getName()); + } + if (StringUtils.isNotBlank(ao.getIcon())) { + app.setIcon(ao.getIcon()); + } + if (ao.getSortNo() != null) { + app.setOrderNumber(ao.getSortNo()); + } + } + type.getApps().sort(Comparator + .comparingInt((BoaApp a) -> a.getOrderNumber() == null ? SORT_NULL_FALLBACK : a.getOrderNumber()) + .thenComparing(a -> a.getId() == null ? 0 : a.getId())); + } + types.sort(Comparator + .comparingInt((BoaAppType t) -> effectiveCategorySort(t.getId(), baseSort, categoryOverrides)) + .thenComparing(t -> t.getId() == null ? 0 : t.getId())); + return types; + } + + /** 分类生效排序:override sortNo 优先,空回落主表 sortNo(categoryBaseSortNo),再空视 999 */ + private int effectiveCategorySort(Integer categoryId, Map categoryBaseSortNo, + Map overrides) { + SysSchoolCategoryOverride o = categoryId == null ? null : overrides.get(categoryId); + if (o != null && o.getSortNo() != null) { + return o.getSortNo(); + } + Integer base = categoryId == null ? null : categoryBaseSortNo.get(categoryId); + return base == null ? SORT_NULL_FALLBACK : base; + } + // ===================== 写操作(SCHOOL) ===================== /** @@ -342,6 +424,7 @@ public class SchoolAppOverrideService { override.setDescription(StringUtils.trimToNull(request.getDesc())); override.setIcon(StringUtils.trimToNull(request.getPcIcon())); appOverrideRepository.save(override); + publishAuthzEvent("editAppForSchool"); } /** 学校应用排序:写覆盖表 sort_no(覆盖保存整份顺序快照;纯 upsert,不动名称等其它覆盖字段) */ @@ -366,6 +449,7 @@ public class SchoolAppOverrideService { override.setSortNo(i + 1); appOverrideRepository.save(override); } + publishAuthzEvent("saveAppSortForSchool"); } /** 学校编辑应用类型:仅改名,写分类覆盖表 */ @@ -383,6 +467,7 @@ public class SchoolAppOverrideService { } override.setName(StringUtils.trimToNull(name)); categoryOverrideRepository.save(override); + publishAuthzEvent("editCategoryForSchool"); } /** 学校类型排序:写分类覆盖表 sort_no(覆盖保存整份顺序快照) */ @@ -407,6 +492,7 @@ public class SchoolAppOverrideService { override.setSortNo(i + 1); categoryOverrideRepository.save(override); } + publishAuthzEvent("saveCategorySortForSchool"); } // ===================== 级联清理(删除应用/类型时) ===================== @@ -415,12 +501,24 @@ public class SchoolAppOverrideService { @Transactional public void deleteAppOverrides(Integer appId) { appOverrideRepository.deleteByAppIdBulk(appId); + publishAuthzEvent("deleteAppOverrides"); } /** 删除应用类型时级联清理该类型在所有学校的覆盖行 */ @Transactional public void deleteCategoryOverrides(Integer categoryId) { categoryOverrideRepository.deleteByCategoryIdBulk(categoryId); + publishAuthzEvent("deleteCategoryOverrides"); + } + + /** + * 学校别名覆盖写口统一发 {@link AppAuthzChangedEvent}:整清 userAppList。
+ * 2026-09-10 起两张覆盖表已经 {@link #applyAlias} 接入 /oapi/app/list 读链, + * 本事件即读链别名即时性的保障(管理端改别名 → 门户最迟下次请求即见); + * 覆盖行与具体用户无法精确反推,维持「宁可失效宽泛」整清口径。 + */ + private void publishAuthzEvent(String sourceOp) { + eventPublisher.publishEvent(new AppAuthzChangedEvent(this, "SchoolAppOverrideService." + sourceOp)); } // ===================== 辅助:学校下拉数据(多校管理员切换) ===================== diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 3f052e3..2f469d7 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -742,6 +742,13 @@ public class SysGroupService implements ISysGroupService { // ===================== 子树聚合 ===================== + /** + * 自身 + 全部子孙 ID(BFS)。数据源为 {@link GroupSnapshotService} 本地快照(Caffeine 30min, + * GroupChangedEvent 驱逐,数据同源 sys_group 全表),消除此前每请求一次 {@code findAll()} 全表扫。
+ * 调用方均为「事务写操作之前的边界校验」(assertWithinSchool/assertMoveWithinSchool/ + * assertGroupWithinSchool/transferToSchool 前置校验/列表组范围解析),不存「同事务先写组后校验」路径, + * 吃快照安全;组域写事务内的环检测/边界校验仍直查库(见 GroupSnapshotService 只读约定)。 + */ @Override public List listSelfAndDescendantIds(Integer groupId) { List result = new ArrayList<>(); @@ -749,22 +756,20 @@ public class SysGroupService implements ISysGroupService { return result; } result.add(groupId); - // parentId -> 直接子节点ID列表 - Map> childrenMap = new HashMap<>(); - for (SysGroup g : sysGroupRepository.findAll()) { - Integer parent = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId(); - childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(g.getId()); - } + // parentId(null 归一 0)→ 直接子节点列表,取自本地快照 + Map> childrenMap = groupSnapshotService.snapshot().getChildrenMap(); // 广度优先向下收集所有子孙 List frontier = new ArrayList<>(); frontier.add(groupId); while (!frontier.isEmpty()) { List next = new ArrayList<>(); for (Integer id : frontier) { - List children = childrenMap.get(id); + List children = childrenMap.get(id); if (children != null) { - result.addAll(children); - next.addAll(children); + for (SysGroup child : children) { + result.add(child.getId()); + next.add(child.getId()); + } } } frontier = next; diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 4d959fb..a423bc2 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -130,6 +130,9 @@ public class SysUserService implements ISysUserService { @Autowired private AccessScopeService accessScopeService; + @Autowired + private UserGroupRelLoader userGroupRelLoader; + @Autowired private PasswordEncoder passwordEncoder; @@ -404,6 +407,9 @@ public class SysUserService implements ISysUserService { .orElseThrow(() -> new DException("用户不在该组,无法设置排序号")); rel.setSortNo(sortNo); sysUserGroupRelRepository.save(rel); + // 宽泛失效:sort_no 不影响缓存值(userGroupRel 只存 status),按「凡写口必发事件」纪律补发; + // 本方法此前是 sys_user_group_rel 唯二无事件写口之一(另一为 changeUserGroupState),本行顺带使其补齐 + eventPublisher.publishEvent(new UserChangedEvent(this, Collections.singletonList(userId), null)); } @Override @@ -422,15 +428,23 @@ public class SysUserService implements ISysUserService { .orElseThrow(() -> new DException("该用户不在此分组中")); rel.setStatus(state == 1 ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL); sysUserGroupRelRepository.save(rel); + // 转出状态是 userGroupRel 缓存的直接数据源(oapi groupState 写口),必须发 UserChangedEvent + // 即时驱逐;只发缓存事件,不动 DataSync 下游推送行为(推送语义见移动组方案:状态非成员语义) + eventPublisher.publishEvent(new UserChangedEvent(this, Collections.singletonList(userId), null)); } @Override public Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId) { assertGroupWithinSchool(groupId, schoolId); - SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId) - .orElseThrow(() -> new DException("该用户不在此分组中")); - // 存量数据 status 为 NULL,按正常返回 - return rel.getStatus() == null ? UserGroupRelStatusEnum.NORMAL.getKey() : rel.getStatus().getKey(); + // 数据源走 userGroupRel 缓存(按 userId 整张 Map,TTL 5min,UserChangedEvent 即时驱逐): + // 校验链顺序与异常语义原样保留——组不在学校范围抛 DException;Map 无该 groupId 抛同款「不在此分组中」; + // 异常路径不入缓存(loader 的 @Cacheable 仅对正常返回生效)。status 已由 loader 归一(null→0=正常) + Map stateMap = userGroupRelLoader.loadByUserId(userId); + Integer status = stateMap.get(groupId); + if (status == null) { + throw new DException("该用户不在此分组中"); + } + return status; } @Override diff --git a/src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java b/src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java new file mode 100644 index 0000000..837d972 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/UserGroupRelLoader.java @@ -0,0 +1,50 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.config.CacheNames; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.enums.UserGroupRelStatusEnum; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * 用户组关系状态缓存加载器({@code userGroupRel} 缓存的唯一回源入口, + * 注册表见 {@link CacheNames#USER_GROUP_REL})。

+ * + * 按 userId 缓存整张 groupId→status Map(不做 (userId,groupId) 多维 key——changeGroups + * 整批重写组关系无法从事件反推旧 key 做精确驱逐,多维只能整清,写流量下会打穿缓存; + * 按用户整 Map 缓存则 UserChangedEvent 逐 key 精确驱逐,与 boaUser 的 {@code id:} 维度同一循环)。
+ * status 为 NULL 的存量行归一为 0=正常;无组关系用户返回空 Map 恒非 null,sync=true 安全 + * (穿透防护天然成立,无需空值哨兵)。
+ * 注解要求经代理调用(调用方 SysUserService.getUserGroupState 注入本类), + * 禁止同类内部自调用。
+ * 失效责任:UserChangedEvent 按 {@code u:{userId}} 精确驱逐(含 groupState 写口)+ + * GroupChangedEvent 卫生性整清。 + */ +@Service +public class UserGroupRelLoader { + + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + + /** + * 加载用户全部组关系状态(缓存 key = u:{userId}):groupId → status(null 归一 0=正常)。 + */ + @Cacheable(value = CacheNames.USER_GROUP_REL, key = "'u:' + #userId", sync = true) + public Map loadByUserId(Integer userId) { + Map result = new HashMap<>(); + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) { + if (rel.getGroupId() != null) { + int status = rel.getStatus() == null + ? UserGroupRelStatusEnum.NORMAL.getKey() + : rel.getStatus().getKey(); + result.put(rel.getGroupId(), status); + } + } + return result; + } +} diff --git a/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java b/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java index ab6c313..ad613a8 100644 --- a/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java +++ b/src/main/java/com/yunhai/userbase/task/IdentityBackfillTask.java @@ -4,6 +4,7 @@ import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.entity.SysUserRoleRel; import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.event.UserChangedEvent; import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.repository.SysUserRepository; import com.yunhai.userbase.repository.SysUserRoleRelRepository; @@ -14,6 +15,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -53,6 +55,8 @@ public class IdentityBackfillTask implements ApplicationRunner { private ISysGroupService sysGroupService; @Autowired private MainGroupResolver mainGroupResolver; + @Autowired + private ApplicationEventPublisher eventPublisher; @org.springframework.beans.factory.annotation.Value("${userbase.identity-backfill.enabled:false}") private boolean enabled; @@ -69,6 +73,8 @@ public class IdentityBackfillTask implements ApplicationRunner { int skippedNoRoles = 0; int skippedNoGroup = 0; int skippedExisting = 0; + // 本次实际写入身份行的用户:回填完成后统一发 UserChangedEvent 驱逐其缓存(boaUser/identityCurrent 等) + List backfilledUserIds = new ArrayList<>(); // 已有身份行的用户集合(幂等:重复执行不覆盖) List existingUserIds = sysUserRoleRelRepository.findAll().stream() @@ -133,10 +139,16 @@ public class IdentityBackfillTask implements ApplicationRunner { } sysUserRoleRelRepository.saveAll(rows); inserted++; + backfilledUserIds.add(user.getId()); log.info("[IdentityBackfill] 用户 {}({})→ schoolId={}(主组 {}「{}」)roles={}", user.getAccount(), user.getId(), schoolId, mainRel.getGroupId(), groupNameMap.getOrDefault(mainRel.getGroupId(), "?"), roles); } + // 回填写 sys_user_role_rel 后按写口纪律发缓存事件:boaUser(角色列表/身份推导)、identityCurrent + // (主身份兜底)等随 UserChangedEvent 精确驱逐。纯保险——默认关的一次性幂等迁移。 + if (!backfilledUserIds.isEmpty()) { + eventPublisher.publishEvent(new UserChangedEvent(this, backfilledUserIds, null)); + } log.info("[IdentityBackfill] 回填完成:写入 {} 人,跳过(roles 为空){} 人,跳过(无组关系){} 人," + "跳过(已有身份行){} 人。请核对上方日志主组与身份归属一致。", inserted, skippedNoRoles, skippedNoGroup, skippedExisting); diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java new file mode 100644 index 0000000..4ac40cd --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/AppAuthzEventPublishTest.java @@ -0,0 +1,312 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.AppCategory; +import com.yunhai.userbase.entity.AppModule; +import com.yunhai.userbase.entity.SysAppRole; +import com.yunhai.userbase.entity.SysGroupAppRel; +import com.yunhai.userbase.event.AppAuthzChangedEvent; +import com.yunhai.userbase.model.appcategory.AppCategoryRequest; +import com.yunhai.userbase.model.appmodule.AppModuleRequest; +import com.yunhai.userbase.model.approle.AppRoleModuleAssignRequest; +import com.yunhai.userbase.model.approle.AppRoleUserBindRequest; +import com.yunhai.userbase.model.approle.SysAppRoleRequest; +import com.yunhai.userbase.model.approle.UserAppRoleBindRequest; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.repository.AppCategoryRepository; +import com.yunhai.userbase.repository.AppModuleRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; +import com.yunhai.userbase.repository.SysAppRoleRepository; +import com.yunhai.userbase.repository.SysGroupAppRelRepository; +import com.yunhai.userbase.repository.SysGroupModulePermRepository; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysSchoolAppOverrideRepository; +import com.yunhai.userbase.repository.SysSchoolCategoryOverrideRepository; +import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.enums.AccessLevelEnum; +import com.yunhai.userbase.service.resolve.AccessScope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 应用授权域写口 → {@link AppAuthzChangedEvent} 发布断言(oapi 三接口缓存方案 5.2 哨口完备性)。
+ * 覆盖 AppRoleService(7 口)/ AppCategoryService(4 口)/ AppModuleService(3 口)/ + * SchoolAppOverrideService(6 口):每个写口执行后必须恰好发布一次事件,sourceOp 可定位写口。
+ * GroupAuthzService 的 4 口断言在其自身测试类中。 + */ +class AppAuthzEventPublishTest { + + // ===================== AppRoleService(7 口) ===================== + + @InjectMocks + private AppRoleService appRoleService; + + @Mock + private SysAppRoleRepository sysAppRoleRepository; + @Mock + private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository; + @Mock + private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + @Mock + private ApplicationInfoRepository applicationInfoRepository; + @Mock + private AppModuleRepository appModuleRepository; + @Mock + private SysUserRepository sysUserRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + + // AppCategoryService + @InjectMocks + private AppCategoryService appCategoryService; + @Mock + private AppCategoryRepository appCategoryRepository; + + // AppModuleService + @InjectMocks + private AppModuleService appModuleService; + @Mock + private SysGroupModulePermRepository sysGroupModulePermRepository; + + // SchoolAppOverrideService + @InjectMocks + private SchoolAppOverrideService schoolAppOverrideService; + @Mock + private SysSchoolAppOverrideRepository appOverrideRepository; + @Mock + private SysSchoolCategoryOverrideRepository categoryOverrideRepository; + @Mock + private SysGroupAppRelRepository sysGroupAppRelRepository; + @Mock + private SysGroupRepository sysGroupRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + private AppAuthzChangedEvent capturedOp() { + ArgumentCaptor captor = ArgumentCaptor.forClass(AppAuthzChangedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + return captor.getValue(); + } + + // ===================== AppRoleService ===================== + + @Test + void appRole_add_publishes() { + when(sysAppRoleRepository.existsByNameAndAllAppFlagTrue("r")).thenReturn(false); + SysAppRoleRequest request = new SysAppRoleRequest(); + request.setName("r"); + request.setAllAppFlag(true); + appRoleService.add(request); + assertEquals("AppRoleService.add", capturedOp().getSourceOp()); + } + + @Test + void appRole_edit_publishes() { + SysAppRole role = new SysAppRole(); + role.setId(1); + role.setAllAppFlag(true); + when(sysAppRoleRepository.findById(1)).thenReturn(Optional.of(role)); + when(sysAppRoleRepository.existsByNameAndAllAppFlagTrueAndIdNot("r2", 1)).thenReturn(false); + SysAppRoleRequest request = new SysAppRoleRequest(); + request.setName("r2"); + request.setAllAppFlag(true); + appRoleService.edit(1, request); + assertEquals("AppRoleService.edit", capturedOp().getSourceOp()); + } + + @Test + void appRole_delete_publishes() { + when(sysAppRoleRepository.existsById(1)).thenReturn(true); + appRoleService.delete(1); + assertEquals("AppRoleService.delete", capturedOp().getSourceOp()); + } + + @Test + void appRole_saveRoleModules_publishes() { + SysAppRole role = new SysAppRole(); + role.setId(1); + role.setAllAppFlag(true); + when(sysAppRoleRepository.findById(1)).thenReturn(Optional.of(role)); + AppRoleModuleAssignRequest request = new AppRoleModuleAssignRequest(); + request.setRoleId(1); + request.setModuleIds(Collections.emptyList()); + appRoleService.saveRoleModules(request); + assertEquals("AppRoleService.saveRoleModules", capturedOp().getSourceOp()); + } + + @Test + void appRole_saveRoleUsers_publishes() { + when(sysAppRoleRepository.existsById(1)).thenReturn(true); + when(sysUserRepository.existsById(2)).thenReturn(true); + AppRoleUserBindRequest request = new AppRoleUserBindRequest(); + request.setRoleId(1); + request.setUserIds(Collections.singletonList(2)); + appRoleService.saveRoleUsers(request); + assertEquals("AppRoleService.saveRoleUsers", capturedOp().getSourceOp()); + } + + @Test + void appRole_saveUserRoles_publishes() { + when(sysUserRepository.existsById(2)).thenReturn(true); + when(sysAppRoleRepository.existsById(1)).thenReturn(true); + UserAppRoleBindRequest request = new UserAppRoleBindRequest(); + request.setUserId(2); + request.setRoleIds(Collections.singletonList(1)); + appRoleService.saveUserRoles(request); + assertEquals("AppRoleService.saveUserRoles", capturedOp().getSourceOp()); + } + + @Test + void appRole_removeRoleUser_publishes() { + when(sysAppRoleRepository.existsById(1)).thenReturn(true); + when(sysUserRepository.existsById(2)).thenReturn(true); + appRoleService.removeRoleUser(1, 2); + assertEquals("AppRoleService.removeRoleUser", capturedOp().getSourceOp()); + } + + // ===================== AppCategoryService ===================== + + @Test + void appCategory_add_publishes() { + when(appCategoryRepository.existsByName("分类")).thenReturn(false); + when(appCategoryRepository.findMaxSortNo()).thenReturn(null); + AppCategoryRequest request = new AppCategoryRequest(); + request.setName("分类"); + appCategoryService.add(request); + assertEquals("AppCategoryService.add", capturedOp().getSourceOp()); + } + + @Test + void appCategory_edit_publishes() { + AppCategory entity = new AppCategory(); + entity.setId(1); + when(appCategoryRepository.findById(1)).thenReturn(Optional.of(entity)); + when(appCategoryRepository.existsByNameAndIdNot("分类2", 1)).thenReturn(false); + AppCategoryRequest request = new AppCategoryRequest(); + request.setName("分类2"); + appCategoryService.edit(1, request); + assertEquals("AppCategoryService.edit", capturedOp().getSourceOp()); + } + + @Test + void appCategory_delete_publishes() { + when(applicationInfoRepository.countByCategoryId(5)).thenReturn(0); + appCategoryService.delete(5); + assertEquals("AppCategoryService.delete", capturedOp().getSourceOp()); + } + + @Test + void appCategory_sort_publishes() { + when(appCategoryRepository.existsById(5)).thenReturn(true); + appCategoryService.sort(Collections.singletonList(5)); + assertEquals("AppCategoryService.sort", capturedOp().getSourceOp()); + } + + // ===================== AppModuleService ===================== + + @Test + void appModule_add_publishes() { + when(applicationInfoRepository.existsById(10)).thenReturn(true); + when(appModuleRepository.existsByAppIdAndCustomId(10, "m1")).thenReturn(false); + AppModuleRequest request = new AppModuleRequest(); + request.setAppId(10); + request.setName("模块1"); + request.setCustomId("m1"); + appModuleService.add(request); + assertEquals("AppModuleService.add", capturedOp().getSourceOp()); + } + + @Test + void appModule_edit_publishes() { + AppModule entity = new AppModule(); + entity.setId(1); + entity.setAppId(10); + entity.setParentId(null); + when(appModuleRepository.findById(1)).thenReturn(Optional.of(entity)); + when(appModuleRepository.existsByAppIdAndCustomIdAndIdNot(10, "m1x", 1)).thenReturn(false); + AppModuleRequest request = new AppModuleRequest(); + request.setParentId(null); + request.setCustomId("m1x"); + appModuleService.edit(1, request); + assertEquals("AppModuleService.edit", capturedOp().getSourceOp()); + } + + @Test + void appModule_delete_publishes() { + when(appModuleRepository.existsById(1)).thenReturn(true); + when(appModuleRepository.countByParentId(1)).thenReturn(0L); + appModuleService.delete(1); + assertEquals("AppModuleService.delete", capturedOp().getSourceOp()); + } + + // ===================== SchoolAppOverrideService(宽泛口径哨口) ===================== + + private AccessScope schoolScope() { + return AccessScope.school(Collections.singletonList(16)); + } + + @Test + void schoolOverride_editAppForSchool_publishes() { + when(applicationInfoRepository.existsById(100)).thenReturn(true); + when(sysGroupAppRelRepository.existsByGroupIdAndAppId(16, 100)).thenReturn(true); + when(appOverrideRepository.findBySchoolIdAndAppId(16, 100)).thenReturn(null); + schoolAppOverrideService.editAppForSchool(100, new ApplicationInfoRequest(), schoolScope(), 16); + assertEquals("SchoolAppOverrideService.editAppForSchool", capturedOp().getSourceOp()); + } + + @Test + void schoolOverride_saveAppSortForSchool_publishes() { + SysGroupAppRel opened = new SysGroupAppRel(); + opened.setGroupId(16); + opened.setAppId(100); + when(sysGroupAppRelRepository.findByGroupIdIn(Collections.singletonList(16))) + .thenReturn(Collections.singletonList(opened)); + when(appOverrideRepository.findBySchoolIdAndAppId(16, 100)).thenReturn(null); + schoolAppOverrideService.saveAppSortForSchool(Collections.singletonList(100), schoolScope(), 16); + assertEquals("SchoolAppOverrideService.saveAppSortForSchool", capturedOp().getSourceOp()); + } + + @Test + void schoolOverride_editCategoryForSchool_publishes() { + when(appCategoryRepository.existsById(5)).thenReturn(true); + when(categoryOverrideRepository.findBySchoolIdAndCategoryId(16, 5)).thenReturn(null); + schoolAppOverrideService.editCategoryForSchool(5, "别名", schoolScope(), 16); + assertEquals("SchoolAppOverrideService.editCategoryForSchool", capturedOp().getSourceOp()); + } + + @Test + void schoolOverride_saveCategorySortForSchool_publishes() { + when(appCategoryRepository.existsById(5)).thenReturn(true); + when(categoryOverrideRepository.findBySchoolIdAndCategoryId(16, 5)).thenReturn(null); + schoolAppOverrideService.saveCategorySortForSchool(Collections.singletonList(5), schoolScope(), 16); + assertEquals("SchoolAppOverrideService.saveCategorySortForSchool", capturedOp().getSourceOp()); + } + + @Test + void schoolOverride_deleteAppOverrides_publishes() { + schoolAppOverrideService.deleteAppOverrides(100); + assertEquals("SchoolAppOverrideService.deleteAppOverrides", capturedOp().getSourceOp()); + } + + @Test + void schoolOverride_deleteCategoryOverrides_publishes() { + schoolAppOverrideService.deleteCategoryOverrides(5); + assertEquals("SchoolAppOverrideService.deleteCategoryOverrides", capturedOp().getSourceOp()); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java new file mode 100644 index 0000000..3bab450 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java @@ -0,0 +1,226 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.model.BoaAppType; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.userbase.entity.AppCategory; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.repository.AppCategoryRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.ISysGroupService; +import com.yunhai.userbase.service.resolve.MainGroupResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * AppCategoryService.listBoaAppType 生效学校解析单元测试(oapi 别名接入,2026-09-10)。
+ * 契约(三级兜底): + *
    + *
  • 显式 schoolId → assertSchoolId 锚点校验 + 用户归属校验后直接生效;锚点非法/非所属学校 + * 抛 DException 且不进入构建与合并;
  • + *
  • 未传 → 当前激活身份学校(IdentityService.getCurrent.schId);schId 为空继续降级;
  • + *
  • 再兜底 → 主归属组解析学校(MainGroupResolver + resolveSchoolId);
  • + *
  • 均无 → null 透传 applyAlias(不合并);
  • + *
  • categoryBaseSortNo 映射按 categoryId→主表 sortNo 传给合并口。
  • + *
+ */ +class AppCategoryServiceSchoolAliasTest { + + @InjectMocks + private AppCategoryService appCategoryService; + + @Mock + private AppCategoryRepository appCategoryRepository; + + @Mock + private ApplicationInfoRepository applicationInfoRepository; + + @Mock + private IGroupAuthzService groupAuthzService; + + @Mock + private SchoolAppOverrideService schoolAppOverrideService; + + @Mock + private IdentityService identityService; + + @Mock + private MainGroupResolver mainGroupResolver; + + @Mock + private ISysGroupService sysGroupService; + + @Mock + private SysUserGroupRelRepository sysUserGroupRelRepository; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + // 最小数据集:1 分类(10,"办公管理",sort 1) + 1 应用(100,"通知公告",sort 1),用户 1 有权 + AppCategory category = new AppCategory(); + category.setId(10); + category.setName("办公管理"); + category.setSortNo(1); + when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category)); + ApplicationInfo app = new ApplicationInfo(); + app.setId(100); + app.setCategoryId(10); + app.setName("通知公告"); + app.setSortNo(1); + when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app)); + when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(100)); + // 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest) + when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any())) + .thenAnswer(inv -> inv.getArgument(0)); + } + + // ===================== ① 显式 schoolId ===================== + + /** 显式且合法(锚点通过 + 用户属该校)→ 原样生效 */ + @Test + void explicitSchool_valid_passesThrough() { + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(groupRel(5))); + when(sysGroupService.resolveSchoolIds(anyList())).thenReturn(schoolMap(5, 77)); + + List result = appCategoryService.listBoaAppType(1, null, 77); + + verify(sysGroupService).assertSchoolId(77); + assertEquals(77, capturedSchool()); + assertEquals("办公管理", result.get(0).getName()); + verifyNoInteractions(identityService, mainGroupResolver); + } + + /** 锚点非法(非学校组/不存在)→ assertSchoolId 抛 DException,不构建不合并 */ + @Test + void explicitSchool_invalidAnchor_throws() { + doThrow(new DException("学校不存在")).when(sysGroupService).assertSchoolId(999); + + assertThrows(DException.class, () -> appCategoryService.listBoaAppType(1, null, 999)); + verify(schoolAppOverrideService, never()).applyAlias(anyList(), anyMap(), any()); + verifyNoInteractions(groupAuthzService); + } + + /** 用户不属于该校(组关系均解析到他校)→ 拒绝,防任意传校窥探别名 */ + @Test + void explicitSchool_userNotInSchool_throws() { + when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(groupRel(5))); + when(sysGroupService.resolveSchoolIds(anyList())).thenReturn(schoolMap(5, 88)); + + assertThrows(DException.class, () -> appCategoryService.listBoaAppType(1, null, 77)); + verify(schoolAppOverrideService, never()).applyAlias(anyList(), anyMap(), any()); + } + + // ===================== ② 当前身份档 ===================== + + /** 未传校 + 有当前激活身份 → 身份学校生效,不再走主归属兜底 */ + @Test + void noParam_usesCurrentIdentitySchool() { + BoaRoleInfo current = new BoaRoleInfo(); + current.setSchId(66); + when(identityService.getCurrent(1)).thenReturn(current); + + appCategoryService.listBoaAppType(1, null, null); + + assertEquals(66, capturedSchool()); + verifyNoInteractions(mainGroupResolver, sysUserGroupRelRepository); + } + + /** 身份存在但 schId 为空(脏数据/未落校)→ 继续降级主归属 */ + @Test + void identityWithoutSchool_fallsBackToMainGroup() { + when(identityService.getCurrent(1)).thenReturn(new BoaRoleInfo()); + when(mainGroupResolver.resolve(1)).thenReturn(Optional.of(groupRel(5))); + when(sysGroupService.resolveSchoolId(5)).thenReturn(55); + + appCategoryService.listBoaAppType(1, null, null); + + assertEquals(55, capturedSchool()); + } + + // ===================== ③ 主归属档 / 无学校语境 ===================== + + /** 无身份记录 → 主归属组向上解析学校 */ + @Test + void noIdentity_fallsBackToMainGroupSchool() { + when(identityService.getCurrent(1)).thenReturn(null); + when(mainGroupResolver.resolve(1)).thenReturn(Optional.of(groupRel(5))); + when(sysGroupService.resolveSchoolId(5)).thenReturn(55); + + appCategoryService.listBoaAppType(1, null, null); + + assertEquals(55, capturedSchool()); + } + + /** 平台账号(无身份、无组关系)→ null 透传,不合并 */ + @Test + void noSchoolContext_passesNull() { + when(identityService.getCurrent(1)).thenReturn(null); + when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty()); + + appCategoryService.listBoaAppType(1, null, null); + + assertNull(capturedSchool()); + } + + // ===================== 合并入参 ===================== + + /** categoryBaseSortNo 按 categoryId→主表 sortNo 传给合并口 */ + @Test + void passesCategoryBaseSortMap() { + when(identityService.getCurrent(1)).thenReturn(null); + when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty()); + + appCategoryService.listBoaAppType(1, null, null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> mapCap = ArgumentCaptor.forClass(Map.class); + verify(schoolAppOverrideService).applyAlias(anyList(), mapCap.capture(), any()); + assertEquals(Integer.valueOf(1), mapCap.getValue().get(10)); + } + + // ===================== 辅助 ===================== + + private Integer capturedSchool() { + ArgumentCaptor 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 schoolMap(Integer groupId, Integer schoolId) { + Map map = new HashMap<>(); + map.put(groupId, schoolId); + return map; + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/CacheInvalidationListenerTest.java b/src/test/java/com/yunhai/userbase/service/impl/CacheInvalidationListenerTest.java new file mode 100644 index 0000000..9c80456 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/CacheInvalidationListenerTest.java @@ -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 的驱逐/整清动作断言。
+ * 关键契约(oapi 三接口缓存方案 5.2/5.3): + *
    + *
  • UserChangedEvent → 逐 userId 驱逐 boaUser/boaUserNull/identityCurrent 的 id: 维度 + + * userGroupRel 的 u: 维度 + userAppList 的 16 条可枚举 key + * (8 个 client 维度 × {旧格式, :s:auto},2026-09-10 起 key 含学校维度; + * 显式学校后缀不可枚举,靠 TTL 自愈);
  • + *
  • GroupChangedEvent / AppAuthzChangedEvent / ApplicationInfoCacheChangedEvent → userAppList 整清;
  • + *
  • 物理删除用户(DB 查无)→ boaUser 系整清兜底。
  • + *
+ */ +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 keyCaptor = ArgumentCaptor.forClass(Object.class); + verify(userAppList, times(16)).evict(keyCaptor.capture()); + Set appListKeys = new HashSet<>(); + for (Object key : keyCaptor.getAllValues()) { + appListKeys.add(String.valueOf(key)); + } + Set 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")); + // 无异常即通过 + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java index 0c8ce70..4c50aba 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java @@ -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 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 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 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 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) { diff --git a/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java index f62a3af..bda2cdd 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/IdentityServiceTest.java @@ -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 不入缓存)、 + * 不用 sync=true(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()); + } } diff --git a/src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceApplyAliasTest.java b/src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceApplyAliasTest.java new file mode 100644 index 0000000..7962353 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SchoolAppOverrideServiceApplyAliasTest.java @@ -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)。
+ * 契约: + *
    + *
  • schoolId=null 或该校两类覆盖行均不存在 → 原样返回(且不查覆盖表/不改动);
  • + *
  • 覆盖字段非空才覆盖(名称/图标),空/空白回落主表真名——「无别名用真实名」;
  • + *
  • 分类内应用按生效 orderNumber 重排、分类间按生效 sortNo(override 优先,回落主表映射, + * 再空视 999,并列取 id 小)重排——与管理端口径一致。
  • + *
+ */ +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 types = new ArrayList<>(); + types.add(type(10, "办公管理", app(100, "通知公告", "icon-a", 1))); + List result = service.applyAlias(types, baseSort(10, 1), null); + assertSame(types, result); + assertEquals("办公管理", result.get(0).getName()); + verifyNoInteractions(appOverrideRepository, categoryOverrideRepository); + } + + /** 该校无任何覆盖行 → 名称/顺序原样 */ + @Test + void noOverrideRows_returnsUnchanged() { + List types = new ArrayList<>(Arrays.asList( + type(10, "办公管理", app(100, "通知公告", "icon-a", 1)), + type(11, "教务管理", app(101, "成绩管理", "icon-b", 2)))); + List 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 types = new ArrayList<>(Arrays.asList( + type(10, "办公管理", app(100, "通知公告", "icon-a", 1)), + type(11, "教务管理", app(101, "成绩管理", "icon-b", 2)))); + List 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 types = new ArrayList<>(Collections.singletonList( + type(10, "办公管理", app(100, "通知公告", "icon-a", 1)))); + List 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 types = new ArrayList<>(Collections.singletonList(type(10, "办公管理", boa))); + List 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 types = new ArrayList<>(Collections.singletonList( + type(10, "办公管理", app(100, "A", "i", 2), app(101, "B", "i", 3), app(102, "C", "i", 4)))); + List result = service.applyAlias(types, baseSort(10, 1), 77); + List 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 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 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 types = new ArrayList<>(Arrays.asList( + type(10, "甲", app(100, "a", "i", 1)), + type(11, "乙", app(101, "b", "i", 1)))); + List 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 types = new ArrayList<>(Arrays.asList( + type(10, "甲", app(100, "a", "i", 1)), + type(11, "乙", app(101, "b", "i", 1)))); + List 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 types = new ArrayList<>(Collections.singletonList(empty)); + List 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 baseSort(Integer... pairs) { + Map map = new HashMap<>(); + for (int i = 0; i + 1 < pairs.length; i += 2) { + map.put(pairs[i], pairs[i + 1]); + } + return map; + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java index 73c91bc..e325717 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java @@ -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 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 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 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()); + } } diff --git a/src/test/java/com/yunhai/userbase/service/impl/UserGroupRelLoaderTest.java b/src/test/java/com/yunhai/userbase/service/impl/UserGroupRelLoaderTest.java new file mode 100644 index 0000000..518bd4b --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/UserGroupRelLoaderTest.java @@ -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 注解属性契约。
+ * 契约(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 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 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()); + } +}