diff --git a/src/main/java/com/yunhai/userbase/config/CacheNames.java b/src/main/java/com/yunhai/userbase/config/CacheNames.java index 702be50..760833d 100644 --- a/src/main/java/com/yunhai/userbase/config/CacheNames.java +++ b/src/main/java/com/yunhai/userbase/config/CacheNames.java @@ -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 源池)。
+ * key 规则:{@code 's:' + schoolId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())}—— + * 学校 id 不可枚举,用户级变更不涉及(学校层无用户),授权/组/应用/别名事件一律整清, + * 其余直改 DB 场景靠 TTL 自愈(同 USER_APP_LIST 显式学校后缀策略);
+ * TTL 60s(同 USER_APP_LIST 档位:脏了=流程中心显示过期应用池,短档足够);
+ * 存放:Redis;依赖表:sys_group_app_rel、app_category、application_info、 + * sys_school_app_override、sys_school_category_override;
+ * 失效责任:{@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=正常)。
* 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); } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index c467440..d59a6a7 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -124,4 +124,24 @@ public class AppController { : IEnum.getByKey(ClientEnum.class, clientType); return appCategoryService.listBoaAppType(userId, clientEnum, schoolId); } + + /** + * 按学校维度查询该校已开通的应用列表(按应用类型分组,已合并学校别名,2026-10-10)。
+ * 开通口径 = 学校组单点已开通(sys_group_app_rel where group_id=schoolId;开通不继承, + * 不并入子组/角色模块应用)——学校把应用只开给子组时本端点看不到,属口径自然结果。
+ * 基石应用(UserBase 自身 / BASE)不返回;不透出 appSecret。
+ * schoolId 必填且须为合法学校组锚点,否则 code=5。
+ * 消费方(flow-center 应用设置)可传 clientType=null 不过滤端,避免漏纯移动端应用。 + * + * @param schoolId 学校组ID(必填) + * @param clientType 客户端类型 key(可选;null=不按端过滤) + * @return 应用类型分组列表;未开通应用的类型 apps 为空数组 + */ + @GetMapping("list-by-school") + public List 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); + } } diff --git a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java index d36a284..f627c7f 100644 --- a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java @@ -55,4 +55,21 @@ public interface IAppCategoryService { */ List listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId); + /** + * 开放接口专用:按学校维度查询该校已开通的应用列表(按应用类型分组,2026-10-10 流程中心应用设置源池)。
+ * 规则:
+ * ① 开通口径:仅学校组自身已开通(sys_group_app_rel where group_id=schoolId,开通不继承、 + * 不并入子组/角色模块应用)——学校层无用户,这是"学校维度应用清单"的唯一定义;
+ * ② 基石应用过滤:同 {@link #listBoaAppType}(UserBase 自身 / BASE 应用不出现);
+ * ③ 客户端过滤:clientType 位掩码(null=不按端过滤);
+ * ④ 不透出 appSecret;
+ * ⑤ 学校别名:按该校合并 sys_school_app_override / sys_school_category_override(同 listBoaAppType ④)。
+ * schoolId 必填且须为合法学校锚点(assertSchoolId),非法抛 DException(code=5)。 + * + * @param schoolId 学校组ID(必填) + * @param clientType 客户端类型(null=不按端过滤) + * @return 应用类型 + 该校已开通应用列表(已合并学校别名) + */ + List listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType); + } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java index 03041bf..c0b9bf8 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java @@ -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 源池)。
+ * 开通口径 = 学校组单点已开通(findAuthorizedAppIds(schId),不沿祖先/不并角色模块): + * 学校层无用户,"学校开了什么"就是 sys_group_app_rel 的单点事实——学校把应用只开给子组时 + * 本端点看不到它,属口径自然结果(组开通继承的是模块权限池,不是学校清单)。
+ * 骨架复用 {@link #listBoaAppType}(分类分组/基石过滤/端过滤/convertToBoaApp 不透 secret/ + * applyAlias 别名合并),仅权限集合来源不同;返回前合并学校别名。
+ * 缓存 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 listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType) { + if (schoolId == null) { + throw new DException("schoolId 必填"); + } + sysGroupService.assertSchoolId(schoolId); + Set openedAppIds = new HashSet<>(groupAuthzService.findAuthorizedAppIds(schoolId)); + List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); + List apps = applicationInfoRepository.findAll(); + apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo))); + Map> appMap = new LinkedHashMap<>(); + for (ApplicationInfo app : apps) { + if (app.getCategoryId() == null) { + continue; + } + appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app); + } + Map categoryBaseSortNo = new HashMap<>(); + List result = new ArrayList<>(); + for (AppCategory category : categories) { + categoryBaseSortNo.put(category.getId(), category.getSortNo()); + BoaAppType type = new BoaAppType(); + type.setId(category.getId()); + type.setName(category.getName()); + List 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 生效学校三级兜底:
* ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组), diff --git a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java index 8e04417..9c73873 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java +++ b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java @@ -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 自愈兜底) =====================