feat(oapi): 学校维度应用清单端点 GET /oapi/app/list-by-school(流程中心应用设置源池)
build / build-push (push) Successful in 54s

- 开通口径=学校组单点已开通(sys_group_app_rel where group_id=schId,不沿祖先/不并子树角色,拍板 D7);
  基石应用过滤/端掩码过滤/convertToBoaApp 不透 secret/applyAlias 学校别名合并,骨架复用 listBoaAppType
- 新缓存 schoolAppList('s:'+schId+':c:'+clientType,60s,学校 id 不可枚举→授权/组/应用/别名事件整清)
- AppAuthzChangedEvent/GroupChangedEvent/ApplicationInfoCacheChangedEvent 三事件挂接整清
- 仅含本功能 5 文件;工作区其余未提交改动(icon-map/channel-bind 实验线)未纳入
This commit is contained in:
chenxing
2026-10-10 21:50:50 +08:00
parent 96855a8565
commit 34750c03ac
5 changed files with 121 additions and 3 deletions
@@ -119,6 +119,20 @@ public final class CacheNames {
*/
public static final String USER_APP_LIST = "userAppList";
/**
* 学校维度应用清单(GET /oapi/app/list-by-school 读路径,2026-10-10;流程中心 flw_app 源池)。<br>
* key 规则:{@code 's:' + schoolId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())}——
* 学校 id 不可枚举,用户级变更不涉及(学校层无用户),授权/组/应用/别名事件一律整清,
* 其余直改 DB 场景靠 TTL 自愈(同 USER_APP_LIST 显式学校后缀策略);<br>
* TTL 60s(同 USER_APP_LIST 档位:脏了=流程中心显示过期应用池,短档足够);<br>
* 存放:Redis;依赖表:sys_group_app_rel、app_category、application_info、
* sys_school_app_override、sys_school_category_override;<br>
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(授权域写口整清,含学校别名)+
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
* (组开通面变化 / 应用启停排序删除整清)。
*/
public static final String SCHOOL_APP_LIST = "schoolAppList";
/**
* 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br>
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
@@ -234,6 +248,12 @@ public final class CacheNames {
"'u:'+userId(整张 groupId→status Map)",
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
"sys_user_group_rel"));
m.put(SCHOOL_APP_LIST, new Meta(SCHOOL_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
"'s:'+schoolId+':c:'+(clientType?.name()?:'all')(8 client × 学校 id,不可枚举→整清)",
"AppAuthzChangedEvent(整清,含学校别名写口)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent"
+ "(整清)→ CacheInvalidationListener;直改 DB 靠 TTL 自愈",
"sys_group_app_rel, app_category, application_info, sys_group,"
+ " sys_school_app_override, sys_school_category_override"));
REGISTRY = Collections.unmodifiableMap(m);
}
@@ -124,4 +124,24 @@ public class AppController {
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
}
/**
* 按学校维度查询该校已开通的应用列表(按应用类型分组,已合并学校别名,2026-10-10)。<br>
* 开通口径 = 学校组<b>单点</b>已开通(sys_group_app_rel where group_id=schoolId;开通不继承,
* 不并入子组/角色模块应用)——学校把应用只开给子组时本端点看不到,属口径自然结果。<br>
* 基石应用(UserBase 自身 / BASE)不返回;不透出 appSecret。<br>
* schoolId 必填且须为合法学校组锚点,否则 code=5。<br>
* 消费方(flow-center 应用设置)可传 clientType=null 不过滤端,避免漏纯移动端应用。
*
* @param schoolId 学校组ID(必填)
* @param clientType 客户端类型 key(可选;null=不按端过滤)
* @return 应用类型分组列表;未开通应用的类型 apps 为空数组
*/
@GetMapping("list-by-school")
public List<BoaAppType> listBySchool(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "clientType", required = false) Integer clientType) {
ClientEnum clientEnum = (clientType == null) ? null
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
}
}
@@ -55,4 +55,21 @@ public interface IAppCategoryService {
*/
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId);
/**
* 开放接口专用:按学校维度查询该校已开通的应用列表(按应用类型分组,2026-10-10 流程中心应用设置源池)。<br>
* 规则:<br>
* ① 开通口径:<b>仅学校组自身已开通</b>(sys_group_app_rel where group_id=schoolId,开通不继承、
* 不并入子组/角色模块应用)——学校层无用户,这是"学校维度应用清单"的唯一定义;<br>
* ② 基石应用过滤:同 {@link #listBoaAppType}(UserBase 自身 / BASE 应用不出现);<br>
* ③ 客户端过滤:clientType 位掩码(null=不按端过滤);<br>
* ④ 不透出 appSecret;<br>
* ⑤ 学校别名:按该校合并 sys_school_app_override / sys_school_category_override(同 listBoaAppType ④)。<br>
* schoolId 必填且须为合法学校锚点(assertSchoolId),非法抛 DException(code=5)。
*
* @param schoolId 学校组ID(必填)
* @param clientType 客户端类型(null=不按端过滤)
* @return 应用类型 + 该校已开通应用列表(已合并学校别名)
*/
List<BoaAppType> listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType);
}
@@ -245,6 +245,64 @@ public class AppCategoryService implements IAppCategoryService {
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool);
}
/**
* 学校维度应用清单(2026-10-10,GET /oapi/app/list-by-school 读路径;流程中心 flw_app 源池)。<br>
* 开通口径 = 学校组<b>单点</b>已开通(findAuthorizedAppIds(schId),不沿祖先/不并角色模块):
* 学校层无用户,"学校开了什么"就是 sys_group_app_rel 的单点事实——学校把应用只开给子组时
* 本端点看不到它,属口径自然结果(组开通继承的是模块权限池,不是学校清单)。<br>
* 骨架复用 {@link #listBoaAppType}(分类分组/基石过滤/端过滤/convertToBoaApp 不透 secret/
* applyAlias 别名合并),仅权限集合来源不同;返回前合并学校别名。<br>
* 缓存 key 规则:{@code 's:'+schoolId+':c:'+(clientType?'all')}——学校 id 不可枚举,
* 一律整清(授权/组/应用/别名事件)+ 60s TTL 自愈(同 USER_APP_LIST 显式学校后缀策略)。
*/
@Override
@Cacheable(value = CacheNames.SCHOOL_APP_LIST,
key = "'s:' + #schoolId + ':c:' + (#clientType == null ? 'all' : #clientType.name())",
sync = true)
public List<BoaAppType> listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType) {
if (schoolId == null) {
throw new DException("schoolId 必填");
}
sysGroupService.assertSchoolId(schoolId);
Set<Integer> openedAppIds = new HashSet<>(groupAuthzService.findAuthorizedAppIds(schoolId));
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo)));
Map<Integer, List<ApplicationInfo>> appMap = new LinkedHashMap<>();
for (ApplicationInfo app : apps) {
if (app.getCategoryId() == null) {
continue;
}
appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app);
}
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());
List<BoaApp> boaApps = new ArrayList<>();
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
if (AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
continue;
}
// 学校组单点开通过滤(与用户维度的权限过滤唯一差异)
if (!openedAppIds.contains(app.getId())) {
continue;
}
if (clientType != null && !containsClient(app.getClients(), clientType.getKey())) {
continue;
}
boaApps.add(convertToBoaApp(app));
}
type.setApps(boaApps);
result.add(type);
}
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, schoolId);
}
/**
* oapi 生效学校三级兜底:<br>
* ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组),
@@ -64,7 +64,8 @@ public class CacheInvalidationListener {
clearQuietly(CacheNames.APP_INFO);
clearQuietly(CacheNames.APP_KEY_TO_ID);
clearQuietly(CacheNames.USER_APP_LIST);
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList",
clearQuietly(CacheNames.SCHOOL_APP_LIST);
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList/schoolAppList",
event.getAppKey(), event.getType());
}
@@ -77,8 +78,9 @@ public class CacheInvalidationListener {
clearQuietly(CacheNames.BOA_USER_NULL);
clearQuietly(CacheNames.IDENTITY_CURRENT);
clearQuietly(CacheNames.USER_APP_LIST);
clearQuietly(CacheNames.SCHOOL_APP_LIST);
clearQuietly(CacheNames.USER_GROUP_REL);
log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/userGroupRel",
log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/schoolAppList/userGroupRel",
event.getSourceOp());
}
@@ -132,7 +134,8 @@ public class CacheInvalidationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onAppAuthzChanged(AppAuthzChangedEvent event) {
clearQuietly(CacheNames.USER_APP_LIST);
log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList", event.getSourceOp());
clearQuietly(CacheNames.SCHOOL_APP_LIST);
log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList/schoolAppList", event.getSourceOp());
}
// ===================== 驱逐工具(error 可观测,TTL 自愈兜底) =====================