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

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

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