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