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 自愈兜底) =====================