diff --git a/pom.xml b/pom.xml index 4907139..4935f10 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,14 @@ spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + com.alibaba.cloud diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java index c06b8e0..2355501 100644 --- a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java +++ b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.common.interceptor; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.common.context.UserContext; import com.yunhai.userbase.service.impl.UserCenterService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -44,5 +45,7 @@ public class BoaUserContextInterceptor implements HandlerInterceptor { HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); + // 访问范围请求级缓存与用户上下文同生命周期,一并清理(防线程复用串档) + AccessScopeService.clearCache(); } } diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/ManageAccessInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/ManageAccessInterceptor.java new file mode 100644 index 0000000..794d5ec --- /dev/null +++ b/src/main/java/com/yunhai/userbase/common/interceptor/ManageAccessInterceptor.java @@ -0,0 +1,70 @@ +package com.yunhai.userbase.common.interceptor; + +import com.yunhai.userbase.enums.AccessLevelEnum; +import com.yunhai.userbase.service.resolve.AccessScope; +import com.yunhai.userbase.service.resolve.AccessScopeService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.List; + +/** + * 管理端统一防线:NONE 级(普通用户/未登录)一律拦截,HTTP 200 + {code:403, msg:"暂无权限"}。
+ * 与前端 alova 约定一致(status!=200 会被当作网络错误,业务码走 errorHandle default 分支弹 msg)。
+ * 白名单(仅登录即可访问):GetUserInfo / GetUserScope / GetSysDict / RedirectTo。
+ * 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。
+ * SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。 + * + * @author chenx + * @date 2026/9/4 + */ +@Component +public class ManageAccessInterceptor implements HandlerInterceptor { + + /** + * 白名单(Ant 风格,* 匹配 platform 前缀): + * 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理。 + */ + private static final List WHITELIST = Arrays.asList( + "/*/base/GetUserInfo", + "/*/base/GetUserScope", + "/*/base/GetSysDict", + "/*/base/RedirectTo/*"); + + private static final AntPathMatcher MATCHER = new AntPathMatcher(); + + @Autowired + private AccessScopeService accessScopeService; + + @Override + public boolean preHandle(HttpServletRequest request, + HttpServletResponse response, + Object handler) throws Exception { + String path = request.getRequestURI().substring(request.getContextPath().length()); + if (isWhitelisted(path)) { + return true; + } + AccessScope scope = accessScopeService.resolveCurrentScope(); + if (scope.getLevel() != AccessLevelEnum.NONE) { + return true; + } + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"msg\":\"暂无权限\",\"data\":null}"); + return false; + } + + private boolean isWhitelisted(String path) { + for (String pattern : WHITELIST) { + if (MATCHER.match(pattern, path)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/yunhai/userbase/config/CacheConfig.java b/src/main/java/com/yunhai/userbase/config/CacheConfig.java index 444784a..641584e 100644 --- a/src/main/java/com/yunhai/userbase/config/CacheConfig.java +++ b/src/main/java/com/yunhai/userbase/config/CacheConfig.java @@ -8,9 +8,16 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.Weigher; +import com.yunhai.userbase.config.CacheNames.Meta; import com.yunhai.userbase.utils.ConstantUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; @@ -18,56 +25,123 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; import org.springframework.data.redis.serializer.StringRedisSerializer; import java.time.Duration; import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +/** + * 缓存统一基建(Spring Cache 抽象)。

+ * + * 组成(缓存名单一定义、一注册、一路由,全部由 {@link CacheNames#REGISTRY} 驱动): + * + */ @EnableCaching @Configuration -public class CacheConfig { +public class CacheConfig implements CachingConfigurer { + private static final Logger log = LoggerFactory.getLogger(CacheConfig.class); + + /** 降级 Caffeine 的字节限重上限(按估算权重,防巨型条目绕过条数统计)。 */ + private static final long CAFFEINE_MAX_WEIGHT_BYTES = 64L * 1024 * 1024; + + /** groupSnapshot 本地实例的字节限重上限(快照约 4~8MB,留出翻倍余量)。 */ + private static final long SNAPSHOT_MAX_WEIGHT_BYTES = 64L * 1024 * 1024; + + /** appKeyToId 本地实例的条目上限(application_info 仅十几条,宽松限量)。 */ + private static final long APP_KEY_TO_ID_MAX_ENTRIES = 512; /** - * 一级缓存:Redis 分布式缓存管理器。 + * 一级缓存:Redis 分布式缓存管理器。
+ * 各缓存 TTL 由 {@link CacheNames#REGISTRY} 定价(配置型 1h~2h / 用户身份型 5min / 空值哨兵 30s), + * 未注册名兜底全局 5min;每次写入 TTL 加 ±10% 随机抖动。 */ @Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) { Jackson2JsonRedisSerializer jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); jacksonSerializer.setObjectMapper(newCacheObjectMapper()); - RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(ConstantUtil.CACHE_TTL) // 全局 5 分钟过期 - .serializeKeysWith(RedisSerializationContext.SerializationPair - .fromSerializer(new StringRedisSerializer())) // key 用 String - .serializeValuesWith(RedisSerializationContext.SerializationPair - .fromSerializer(jacksonSerializer)) // value 用 Jackson - .disableCachingNullValues(); // 禁止缓存 null(防穿透) + RedisCacheConfiguration baseConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(SerializationPair.fromSerializer(jacksonSerializer)) + .disableCachingNullValues(); - return RedisCacheManager.builder(factory) - .cacheDefaults(cacheConfig) + Map perCache = new HashMap<>(); + for (Meta meta : CacheNames.REGISTRY.values()) { + if (meta.getStore() == CacheNames.Store.REDIS) { + perCache.put(meta.getName(), baseConfig.entryTtl(meta.getTtl())); + } + } + + return RedisCacheManager.builder(new JitteringRedisCacheWriter(RedisCacheWriter.nonLockingRedisCacheWriter(factory))) + .cacheDefaults(baseConfig.entryTtl(ConstantUtil.CACHE_TTL)) + .withInitialCacheConfigurations(perCache) .build(); } /** - * 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。 + * 二级缓存:Caffeine 本地内存缓存管理器(Redis 降级兜底)。
+ * 按字节限重(原 maximumSize(10000) 条数统计会被巨型条目欺骗),权重为保守启发式估算。 */ @Bean public CaffeineCacheManager caffeineCacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() - .expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 过期保持一致 - .maximumSize(10000) // 限制本地容量,防止内存溢出 + .expireAfterWrite(5, TimeUnit.MINUTES) // 与 Redis 未注册名默认 TTL 保持一致 + .maximumWeight(CAFFEINE_MAX_WEIGHT_BYTES) + .weigher((Weigher) (key, value) -> (int) Math.min(Integer.MAX_VALUE, estimateWeight(value, 4))) .recordStats()); - manager.setAllowNullValues(false); // 与 Redis 一致:禁止缓存 null + manager.setAllowNullValues(false); + return manager; + } + + /** + * 本地专用缓存管理器:仅承载注册表 Store.LOCAL 的缓存(groupSnapshot / appKeyToId), + * 每缓存独立 Caffeine 实例(独立 TTL + 每条目抖动 Expiry + 独立命中率统计),静态模式不动态建缓存。 + */ + @Bean + public CaffeineCacheManager localCacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager(); + manager.registerCustomCache(CacheNames.GROUP_SNAPSHOT, Caffeine.newBuilder() + .maximumWeight(SNAPSHOT_MAX_WEIGHT_BYTES) + .weigher((Weigher) (key, value) -> (int) Math.min(Integer.MAX_VALUE, estimateWeight(value, 4))) + .expireAfter(jitterExpiry(Duration.ofMinutes(30))) + .recordStats() + .build()); + manager.registerCustomCache(CacheNames.APP_KEY_TO_ID, Caffeine.newBuilder() + .maximumSize(APP_KEY_TO_ID_MAX_ENTRIES) + .expireAfter(jitterExpiry(Duration.ofHours(2))) + .recordStats() + .build()); + manager.setCacheNames(java.util.Arrays.asList( + CacheNames.GROUP_SNAPSHOT, CacheNames.APP_KEY_TO_ID)); return manager; } @@ -89,24 +163,63 @@ public class CacheConfig { } /** - * 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。 - * 优先 Redis;Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。 + * 复合(故障转移)缓存管理器:作为全局默认 CacheManager 供 @Cacheable 使用。
+ * 注册表 LOCAL 存放的缓存恒路由本地实例;其余优先 Redis, + * Redis 不可达 / 未配置时自动降级 Caffeine;后台定时探活,恢复后自动切回。 */ @Primary @Bean public CacheManager cacheManager(RedisCacheManager redisCacheManager, CaffeineCacheManager caffeineCacheManager, + CaffeineCacheManager localCacheManager, RedisConnectionFactory redisConnectionFactory) { - return new FallbackCacheManager(redisCacheManager, caffeineCacheManager, redisConnectionFactory); + return new FallbackCacheManager(redisCacheManager, caffeineCacheManager, + localCacheManager, redisConnectionFactory); + } + + /** + * 缓存异常处理器(堵探活间隙):后台探活周期 10s,0~10s 内 redisUp 仍为 true 时 Redis 可能刚宕, + * 此时读写异常若上冒会让接口 500。这里统一降级:GET 失败视为 miss(穿透到 DB,慢但可用); + * PUT 失败跳过(本条不缓存);EVICT/CLEAR 失败打 error——脏数据将存活至 TTL 后自愈, + * 该日志频率是事件链路健康度的观测点(频繁出现必须排查)。 + */ + @Override + public CacheErrorHandler errorHandler() { + return new CacheErrorHandler() { + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("[Cache] GET 降级: cache={} key={} 视为未命中,穿透回源DB: {}", + cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("[Cache] PUT 降级: cache={} key={} 本次不缓存: {}", + cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.error("[Cache] EVICT 失败: cache={} key={} 驱逐异常,脏数据将存活至TTL后自愈,请检查事件失效链路: {}", + cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.error("[Cache] CLEAR 失败: cache={} 整体清空异常,脏数据将存活至TTL后自愈,请检查事件失效链路: {}", + cache.getName(), exception.getMessage()); + } + }; } /** * 轻量故障转移缓存管理器:Redis 健康时优先 Redis,故障时降级 Caffeine,无业务报错。 */ - static class FallbackCacheManager implements CacheManager { + public static class FallbackCacheManager implements CacheManager { private final RedisCacheManager redisManager; private final CaffeineCacheManager caffeineManager; + private final CaffeineCacheManager localManager; private final RedisConnectionFactory connectionFactory; private final ScheduledExecutorService healthScheduler = Executors.newSingleThreadScheduledExecutor(r -> { @@ -118,9 +231,11 @@ public class CacheConfig { FallbackCacheManager(RedisCacheManager redisManager, CaffeineCacheManager caffeineManager, + CaffeineCacheManager localManager, RedisConnectionFactory connectionFactory) { this.redisManager = redisManager; this.caffeineManager = caffeineManager; + this.localManager = localManager; this.connectionFactory = connectionFactory; if (connectionFactory != null) { // 启动即探活(delay=0),之后每 10s 探活一次(Redis 恢复后自动切回) @@ -152,8 +267,17 @@ public class CacheConfig { } } + /** Redis 是否健康(对账接口上报降级状态用)。 */ + public boolean isRedisUp() { + return redisUp; + } + @Override public Cache getCache(String name) { + // 本地专用缓存(groupSnapshot/appKeyToId)恒走本地实例,与 Redis 状态无关 + if (CacheNames.isLocalOnly(name)) { + return localManager.getCache(name); + } // AI-FIX: 不在请求线程做同步探活。redisUp 仅由后台定时探活(每 10s)维护。 // 否则 Redis 不可达时,每个 @Cacheable/@CacheEvict 都会同步阻塞一个连接超时(约 2s), // resetAppSecret 等多次访问缓存的接口会累计到 4s+。Caffeine 降级必须零阻塞。 @@ -162,10 +286,150 @@ public class CacheConfig { @Override public Collection getCacheNames() { - return redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames(); + Set names = new LinkedHashSet<>(); + names.addAll(localManager.getCacheNames()); + names.addAll(redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames()); + return names; } } + // ===================== TTL 随机抖动 ===================== + + /** + * RedisCacheWriter 装饰器:每次写入在缓存配置 TTL 基础上加 ±10% 随机抖动, + * 打散同一缓存所有条目的过期时刻,防「同时过期型雪崩」。无 TTL(不过期)条目原样透传。 + */ + static class JitteringRedisCacheWriter implements RedisCacheWriter { + + private final RedisCacheWriter delegate; + private final double jitterRatio; + + JitteringRedisCacheWriter(RedisCacheWriter delegate) { + this(delegate, 0.1); + } + + JitteringRedisCacheWriter(RedisCacheWriter delegate, double jitterRatio) { + this.delegate = delegate; + this.jitterRatio = jitterRatio; + } + + @Override + public void put(String name, byte[] key, byte[] value, Duration ttl) { + delegate.put(name, key, value, jitter(ttl)); + } + + @Override + public byte[] get(String name, byte[] key) { + return delegate.get(name, key); + } + + @Override + public byte[] putIfAbsent(String name, byte[] key, byte[] value, Duration ttl) { + return delegate.putIfAbsent(name, key, value, jitter(ttl)); + } + + @Override + public void remove(String name, byte[] key) { + delegate.remove(name, key); + } + + @Override + public void clean(String name, byte[] pattern) { + delegate.clean(name, pattern); + } + + @Override + public void clearStatistics(String name) { + delegate.clearStatistics(name); + } + + @Override + public org.springframework.data.redis.cache.CacheStatistics getCacheStatistics(String name) { + return delegate.getCacheStatistics(name); + } + + @Override + public RedisCacheWriter withStatisticsCollector( + org.springframework.data.redis.cache.CacheStatisticsCollector collector) { + return delegate.withStatisticsCollector(collector); + } + + private Duration jitter(Duration ttl) { + if (ttl == null || ttl.isZero() || ttl.isNegative()) { + return ttl; + } + return Duration.ofNanos(jitteredNanos(ttl.toNanos(), jitterRatio)); + } + } + + /** 均匀抖动到 [1-ratio, 1+ratio) 区间(ratio=0.1 即 ±10%)。 */ + static long jitteredNanos(long baseNanos, double ratio) { + double factor = 1 - ratio + 2 * ratio * ThreadLocalRandom.current().nextDouble(); + return Math.max(1L, (long) (baseNanos * factor)); + } + + /** Caffeine 变长过期:每条目在基础 TTL 上独立抖动(本地缓存同款防雪崩语义)。 */ + private static Expiry jitterExpiry(Duration base) { + long baseNanos = base.toNanos(); + return new Expiry() { + @Override + public long expireAfterCreate(Object key, Object value, long currentTime) { + return jitteredNanos(baseNanos, 0.1); + } + + @Override + public long expireAfterUpdate(Object key, Object value, long currentTime, long currentDuration) { + return jitteredNanos(baseNanos, 0.1); + } + + @Override + public long expireAfterRead(Object key, Object value, long currentTime, long currentDuration) { + return currentDuration; // 读不续期 + } + }; + } + + // ===================== 权重估算(Caffeine 按字节限重用) ===================== + + /** + * 保守的对象图字节估算(非精确值,只要量级正确即可完成「防巨型条目」使命): + * 字符串按 UTF-16 长度、集合/Map 递归求和(限深度)、单实体对象按 2KB 保底。 + */ + static long estimateWeight(Object value, int depth) { + if (value == null) { + return 128; + } + if (value instanceof String) { + return 64 + 2L * ((String) value).length(); + } + if (value instanceof byte[]) { + return 64 + ((byte[]) value).length; + } + if (value instanceof Collection) { + Collection c = (Collection) value; + if (depth <= 0) { + return 1024L * c.size(); + } + long w = 64; + for (Object o : c) { + w += estimateWeight(o, depth - 1); + } + return w; + } + if (value instanceof Map) { + Map m = (Map) value; + if (depth <= 0) { + return 2048L * m.size(); + } + long w = 64; + for (Map.Entry e : m.entrySet()) { + w += estimateWeight(e.getKey(), depth - 1) + estimateWeight(e.getValue(), depth - 1); + } + return w; + } + return 2048; + } + /** 统一构建带类型信息的 Jackson 序列化器(解决泛型/乱码),供 RedisTemplate 复用。 */ private Jackson2JsonRedisSerializer jacksonSerializer() { Jackson2JsonRedisSerializer jackson = new Jackson2JsonRedisSerializer<>(Object.class); diff --git a/src/main/java/com/yunhai/userbase/config/CacheNames.java b/src/main/java/com/yunhai/userbase/config/CacheNames.java new file mode 100644 index 0000000..f7a3d37 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/CacheNames.java @@ -0,0 +1,185 @@ +package com.yunhai.userbase.config; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 缓存名注册表(方案 C:代码即文档 + 运行时对账)。

+ * + * 本类是全工程所有 Spring Cache 缓存名的单一声明处: + *
    + *
  • {@code @Cacheable/@CacheEvict/@CachePut} 的 value 一律引用本类常量,禁止硬编码字符串;
  • + *
  • {@link CacheConfig} 依据 {@link #REGISTRY} 为每个缓存定制 TTL(含随机抖动)与存放路由 + * (Redis / 本地 Caffeine);
  • + *
  • 对账接口 {@code GET /api/cache/registry} 输出「注册表声明 × 运行时实际 × 命中率」对照, + * 注册表里声明但运行时从未出现 = 声明了没人用;运行时出现但注册表没有 = 有人硬编码缓存名。
  • + *
+ * + * 新增缓存流程(详见《userbase缓存决策方案.md》第七章): + * 在本类加常量与 Meta(javadoc 必须写明:内容、key 规则、TTL、存放、依赖表、谁负责失效)→ + * 在 {@link CacheConfig} 无需额外动作(配置由 REGISTRY 驱动)→ 读侧加 {@code @Cacheable(sync=true)} → + * 写侧事务提交后发失效事件 → 自检调对账接口。

+ * + * 禁止事项:不设 TTL(唯一特例 {@code sign:app:} 裸 Redis key,属 SignKeyCacheService + * 的跨服务验签契约,有每日对账兜底,不走 Spring Cache、不在本注册表管辖); + * 在 {@code @Transactional} 方法上用 {@code @CacheEvict/@CachePut}(驱逐发生在 commit 前, + * 有回源竞态,写失效一律走 AFTER_COMMIT 事件)。 + */ +public final class CacheNames { + + private CacheNames() { + } + + // ===================== 缓存名常量 ===================== + + /** + * application_info 单条记录(按主键 id)。
+ * key 规则:{@code #id};TTL 1h(配置型:变更以月计,脏了只是旧名字/旧图标,事件失效保即时性);
+ * 存放:Redis;依赖表:application_info;
+ * 失效责任:{@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent} + * (ApplicationInfoService 的 add/edit/delete/resetAppSecret/save/savePushSetting/sort 发布, + * {@link com.yunhai.userbase.service.impl.CacheInvalidationListener} AFTER_COMMIT 清整缓存)。 + */ + public static final String APP_INFO = "appInfo"; + + /** + * BoaUser 完整用户(用户中心最高频读路径:CAS 会话、开放接口 byLoginName/byPhone/byId、认证链路)。
+ * key 规则(三个维度各自成 key):{@code 'login:' + account}、{@code 'phone:' + phone}、{@code 'id:' + userId};
+ * TTL 5min(用户身份型:脏 currentRole = 用户以错误身份操作,权限事故级);
+ * 存放:Redis;依赖表:sys_user、sys_user_group_rel、sys_user_role_rel、sys_user_current_role、sys_group;
+ * 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent} + * (SysUserService 全部写口 + IdentityService.switchIdentity 身份切换——身份切换必须立即失效; + * 监听器按 id/login/phone 精确驱逐,用户被物理删除时整体 clear 兜底)。 + */ + public static final String BOA_USER = "boaUser"; + + /** + * boaUser 的空值哨兵(byLoginName/byPhone/byId 查无此人时的穿透防护)。
+ * key 规则:与 {@link #BOA_USER} 相同的三维 key;值是哨兵对象,消费侧只判「key 存在」、绝不读值;
+ * TTL 30s(反向哨兵:脏「不存在」会阻新用户使用,新注册用户须尽快可查);
+ * 存放:Redis;失效责任:同 {@link #BOA_USER}(UserChangedEvent,随真实 key 一起驱逐)。
+ * 设计说明:{@code unless} 在 {@code sync=true} 下被 Spring 忽略,无法用注解把哨兵与真实值分流到 + * 不同 TTL 的缓存,故哨兵缓存由 UserCenterService 手动 put(30s 短 TTL 由本注册表驱动配置)。 + */ + public static final String BOA_USER_NULL = "boaUserNull"; + + /** + * sys_group 全表快照(单条目:不可变 List + 派生 byId/childrenMap 索引)。
+ * key 规则:固定单 key {@code 'all'}(整体驱逐+重建,不做增量维护);
+ * TTL 30min(组织架构型:变更以天/周计,脏数据影响祖先链→开通判定;组写频次极低,命中率 >99%);
+ * 存放:Caffeine 仅本地(约 1 万行 ≈ 序列化 2MB/JVM 4~8MB,进 Redis 则每次读取网络传输 2MB, + * 权限计算一次请求读 N 次,无意义;本地=对象引用传递,零序列化零网络。单副本前提,多副本时需改两层结构或广播);
+ * 依赖表:sys_group;
+ * 失效责任:{@link com.yunhai.userbase.event.GroupChangedEvent} + * (SysGroupService 组域写口 add/batchAdd/edit/move/delete/sortChildren 发布, + * 监听器驱逐整条快照,下次读取 sync=true 排队重建)。 + */ + public static final String GROUP_SNAPSHOT = "groupSnapshot"; + + /** + * appKey→appId 映射(application_info 十几条,消除 /oapi/user/modules 每请求一次 findByAppKey)。
+ * key 规则:{@code #appKey};TTL 2h(配置型);
+ * 存放:Caffeine 仅本地;依赖表:application_info;
+ * 失效责任:搭车 {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent} + * (监听器整缓存 clear,条目仅十几条,clear 成本可忽略)。 + */ + public static final String APP_KEY_TO_ID = "appKeyToId"; + + // ===================== 注册表元数据(驱动 CacheConfig 与对账接口) ===================== + + /** 存放策略:Redis(跨重启、单条小总量大)或 LOCAL(Caffeine 仅本地,单条大/读频极高/配置型小数据)。 */ + public enum Store { + REDIS, LOCAL + } + + /** + * 单个缓存的注册元数据:TTL、抖动比例、存放、key 规则、失效责任方、依赖表。 + * javadoc 语义见各常量声明处,这里保留机器可读字段供 CacheConfig / 对账接口消费。 + */ + public static final class Meta { + + private final String name; + private final Store store; + private final Duration ttl; + /** TTL 随机抖动比例(±10% = 0.1),每次写入打散过期点,防同时过期型雪崩。 */ + private final double jitterRatio; + private final String keyRule; + private final String invalidationOwner; + private final String dependsOn; + + Meta(String name, Store store, Duration ttl, double jitterRatio, + String keyRule, String invalidationOwner, String dependsOn) { + this.name = name; + this.store = store; + this.ttl = ttl; + this.jitterRatio = jitterRatio; + this.keyRule = keyRule; + this.invalidationOwner = invalidationOwner; + this.dependsOn = dependsOn; + } + + public String getName() { + return name; + } + + public Store getStore() { + return store; + } + + public Duration getTtl() { + return ttl; + } + + public double getJitterRatio() { + return jitterRatio; + } + + public String getKeyRule() { + return keyRule; + } + + public String getInvalidationOwner() { + return invalidationOwner; + } + + public String getDependsOn() { + return dependsOn; + } + } + + /** 注册表本体:保持声明顺序(对账接口按此顺序输出)。不可变。 */ + public static final Map REGISTRY; + + static { + Map m = new LinkedHashMap<>(); + m.put(APP_INFO, new Meta(APP_INFO, Store.REDIS, Duration.ofHours(1), 0.1, + "#id", + "ApplicationInfoCacheChangedEvent → CacheInvalidationListener", + "application_info")); + m.put(BOA_USER, new Meta(BOA_USER, Store.REDIS, Duration.ofMinutes(5), 0.1, + "'login:'+account | 'phone:'+phone | 'id:'+userId", + "UserChangedEvent → CacheInvalidationListener", + "sys_user, sys_user_group_rel, sys_user_role_rel, sys_user_current_role, sys_group")); + m.put(BOA_USER_NULL, new Meta(BOA_USER_NULL, Store.REDIS, Duration.ofSeconds(30), 0.1, + "同 boaUser 三维 key(值=哨兵对象,只判存在)", + "UserChangedEvent → CacheInvalidationListener(随真实 key 一起驱逐)", + "sys_user")); + m.put(GROUP_SNAPSHOT, new Meta(GROUP_SNAPSHOT, Store.LOCAL, Duration.ofMinutes(30), 0.1, + "固定单 key 'all'(整体驱逐+重建)", + "GroupChangedEvent → CacheInvalidationListener", + "sys_group")); + m.put(APP_KEY_TO_ID, new Meta(APP_KEY_TO_ID, Store.LOCAL, Duration.ofHours(2), 0.1, + "#appKey", + "ApplicationInfoCacheChangedEvent → CacheInvalidationListener(搭车 clear)", + "application_info")); + REGISTRY = Collections.unmodifiableMap(m); + } + + /** 是否本地专用缓存(FallbackCacheManager 据此路由到本地 CacheManager,永不进 Redis)。 */ + public static boolean isLocalOnly(String cacheName) { + Meta meta = REGISTRY.get(cacheName); + return meta != null && meta.store == Store.LOCAL; + } +} diff --git a/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java b/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java index ebb16e9..9732d49 100644 --- a/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java +++ b/src/main/java/com/yunhai/userbase/config/DWebMvcConfig.java @@ -1,6 +1,7 @@ package com.yunhai.userbase.config; import com.yunhai.userbase.common.interceptor.BoaUserContextInterceptor; +import com.yunhai.userbase.common.interceptor.ManageAccessInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; @@ -14,6 +15,8 @@ public class DWebMvcConfig implements WebMvcConfigurer private BoaUserContextInterceptor boaUserContextInterceptor; @Autowired private OapiCallerAppInterceptor oapiCallerAppInterceptor; + @Autowired + private ManageAccessInterceptor manageAccessInterceptor; @Override public void addCorsMappings(CorsRegistry registry) @@ -30,8 +33,18 @@ public class DWebMvcConfig implements WebMvcConfigurer // oapi 调用方来源注入:仅命中 /oapi/**,供推送来源排除使用 registry.addInterceptor(oapiCallerAppInterceptor) .addPathPatterns("/oapi/**"); + // 用户上下文必须先于访问范围拦截器注册(后者依赖 UserContext 判定 NONE) registry.addInterceptor(boaUserContextInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**"); + // 管理端统一防线:NONE 级 403(白名单见拦截器内)。 + // /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。 + // /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚); + // /v3/api-docs、/swagger-ui:接口文档工具链。 + registry.addInterceptor(manageAccessInterceptor) + .addPathPatterns("/{platform}/**") + .excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**", + "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html", + "/webjars/**"); } } \ No newline at end of file diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java index f25da78..a10c6d1 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppRoleController.java @@ -14,6 +14,7 @@ import com.yunhai.userbase.model.approle.SysAppRoleRequest; import com.yunhai.userbase.model.approle.SysAppRoleVo; import com.yunhai.userbase.model.approle.UserAppRoleBindRequest; import com.yunhai.userbase.service.IAppRoleService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import com.yunhai.userbase.utils.PageUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Sort; @@ -41,12 +42,16 @@ public class AppRoleController { @Autowired private IAppRoleService appRoleService; + @Autowired + private AccessScopeService accessScopeService; + /** * 角色分页列表 :name 模糊,appId/allAppFlag/disabled 精确;普通角色回填所属应用名称 */ @GetMapping("/RoleList") public BasePageResult roleList(SysAppRoleQuery query, BasePageQuery pageQuery, @SortDefault(sort = "id", direction = Sort.Direction.DESC) Sort sort) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 return PageUtils.toResult(appRoleService.list(query, PageUtils.toPageable(pageQuery, sort))); } @@ -57,6 +62,7 @@ public class AppRoleController { @OperationLog("新增角色") @PostMapping("RoleAdd") public void roleAdd(@RequestBody @Validated(ValidationGroups.Create.class) SysAppRoleRequest request) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.add(request); } @@ -67,6 +73,7 @@ public class AppRoleController { @PostMapping("RoleEdit/{id}") public void roleEdit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysAppRoleRequest request) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.edit(id, request); } @@ -76,6 +83,7 @@ public class AppRoleController { @OperationLog("删除角色") @PostMapping("RoleDelete/{id}") public void roleDelete(@PathVariable Integer id) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.delete(id); } @@ -85,6 +93,7 @@ public class AppRoleController { */ @GetMapping("/RoleModuleTree") public List roleModuleTree(@RequestParam Integer roleId) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 return appRoleService.findRoleModuleTree(roleId); } @@ -94,6 +103,7 @@ public class AppRoleController { @OperationLog("保存角色模块授权") @PostMapping("SaveRoleModules") public void saveRoleModules(@RequestBody @Validated AppRoleModuleAssignRequest request) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.saveRoleModules(request); } @@ -102,6 +112,7 @@ public class AppRoleController { */ @GetMapping("/RoleUsers") public List roleUsers(@RequestParam Integer roleId) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 return appRoleService.findRoleUsers(roleId); } @@ -111,6 +122,7 @@ public class AppRoleController { @OperationLog("保存角色用户绑定") @PostMapping("SaveRoleUsers") public void saveRoleUsers(@RequestBody @Validated AppRoleUserBindRequest request) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.saveRoleUsers(request); } @@ -120,6 +132,7 @@ public class AppRoleController { @OperationLog("保存用户角色绑定") @PostMapping("SaveUserRoles") public void saveUserRoles(@RequestBody @Validated UserAppRoleBindRequest request) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.saveUserRoles(request); } @@ -130,6 +143,7 @@ public class AppRoleController { @OperationLog("移除角色用户绑定") @PostMapping("RemoveRoleUser") public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) { + accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险 appRoleService.removeRoleUser(roleId, userId); } } diff --git a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java index cc3e9d6..fbd2e42 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/AppSettingsController.java @@ -4,6 +4,7 @@ import com.yunhai.userbase.common.annotation.OperationLog; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.enums.AccessLevelEnum; import com.yunhai.userbase.model.BasePageQuery; import com.yunhai.userbase.model.BasePageResult; import com.yunhai.userbase.model.appauthz.AppAuthzCategoryVo; @@ -22,7 +23,10 @@ import com.yunhai.userbase.model.appmodule.AppModuleVo; import com.yunhai.userbase.service.IAppCategoryService; import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IAppModuleService; +import com.yunhai.userbase.service.impl.SchoolAppOverrideService; import com.yunhai.userbase.service.impl.SignKeyCacheService; +import com.yunhai.userbase.service.resolve.AccessScope; +import com.yunhai.userbase.service.resolve.AccessScopeService; import com.yunhai.userbase.utils.PageUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Sort; @@ -30,10 +34,20 @@ import org.springframework.data.web.SortDefault; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +/** + * 应用管理 + 学校私有覆盖(管理端权限分级改造 §3.4)。
+ * 访问矩阵:
+ * - 仅超管:新增/删除应用、密钥、推送设置、模块管理、新增/删除类型;
+ * - 超管+局端:AppAuthzList(应用开通页数据源);
+ * - 学校管理员:AppList/AppTypeList/AppSortList/EditApp/AppSort/EditType/AppTypeSort 走覆盖表 + * (仅本校已开通应用,名称/描述/图标/排序,均只对该校生效);
+ * - 局端不开放应用维护(局端的应用动作是「开通」,在应用开通页),落到写接口一律 requireSuper。 + */ @RestController @RequestMapping(value = Config.API_PATH + "/app/") public class AppSettingsController { @@ -50,67 +64,120 @@ public class AppSettingsController { @Autowired private SignKeyCacheService signKeyCacheService; + @Autowired + private AccessScopeService accessScopeService; + @Autowired + private SchoolAppOverrideService schoolAppOverrideService; + + /** 当前 scope 是否学校管理员(覆盖表通道) */ + private boolean isSchoolLevel() { + return accessScopeService.resolveCurrentScope().getLevel() == AccessLevelEnum.SCHOOL; + } @OperationLog("新增应用类型") @PostMapping("AddType") public void addType(@RequestBody @Validated(ValidationGroups.Create.class) AppCategoryRequest request) { + accessScopeService.requireSuper(); appCategoryService.add(request); } @OperationLog("编辑应用类型") @PostMapping("EditType/{id}") - public void editType(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request) { + public void editType(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) AppCategoryRequest request, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + if (isSchoolLevel()) { + // 学校:仅改名,写分类覆盖表(该校私有) + schoolAppOverrideService.editCategoryForSchool(id, request.getName(), + accessScopeService.resolveCurrentScope(), schoolId); + return; + } + // 主表维护仅超管(局端不开放应用管理) + accessScopeService.requireSuper(); appCategoryService.edit(id, request); } @OperationLog("删除应用类型") @PostMapping("DeleteType/{id}") public void deleteType(@PathVariable Integer id) { + accessScopeService.requireSuper(); + // 级联清理该类型在所有学校的覆盖行 + schoolAppOverrideService.deleteCategoryOverrides(id); appCategoryService.delete(id); } @GetMapping("/AppTypeList") - public List appTypeList(AppCategoryQuery query) { - return appCategoryService.list(query); + public List appTypeList(AppCategoryQuery query, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + List base = appCategoryService.list(query); + if (isSchoolLevel()) { + // 合并学校私有改名/排序 + return schoolAppOverrideService.mergeCategoryList(base, + accessScopeService.resolveCurrentScope(), schoolId); + } + return base; } @OperationLog("应用类型排序") @PostMapping("/AppTypeSort") - public void appTypeSort(@RequestBody List ids) { + public void appTypeSort(@RequestBody List ids, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + if (isSchoolLevel()) { + schoolAppOverrideService.saveCategorySortForSchool(ids, + accessScopeService.resolveCurrentScope(), schoolId); + return; + } + accessScopeService.requireSuper(); appCategoryService.sort(ids); } /** * 应用授权用:获取所有应用类型及其类型下的应用(每个应用含 id/name/icon)。
- * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。 + * 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。
+ * 应用开通页数据源,仅超管+局端。 */ @GetMapping("/AppAuthzList") public List appAuthzList() { + accessScopeService.requireSuperOrBureau(); return appCategoryService.listWithApps(); } @OperationLog("新增应用") @PostMapping("AddApp") public void addApp(@RequestBody @Validated(ValidationGroups.Create.class) ApplicationInfoRequest request) { + accessScopeService.requireSuper(); applicationInfoService.add(request); } @OperationLog("编辑应用") @PostMapping("EditApp/{id}") - public void editApp(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request) { + public void editApp(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) ApplicationInfoRequest request, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + if (isSchoolLevel()) { + // 学校:只接受 名称/描述/图标 三字段 upsert 覆盖表,appKey/回调/推送等一律不可动 + schoolAppOverrideService.editAppForSchool(id, request, + accessScopeService.resolveCurrentScope(), schoolId); + return; + } + accessScopeService.requireSuper(); applicationInfoService.edit(id, request); } @OperationLog("删除应用") @PostMapping("DeleteApp/{id}") public void deleteApp(@PathVariable Integer id) { + accessScopeService.requireSuper(); + // 级联清理该应用在所有学校的覆盖行 + schoolAppOverrideService.deleteAppOverrides(id); applicationInfoService.delete(id); } @OperationLog("重置应用密钥") @PostMapping("ResetAppSecret/{id}") public String resetAppSecret(@PathVariable Integer id) { + accessScopeService.requireSuper(); applicationInfoService.resetAppSecret(id); ApplicationInfo info= applicationInfoService.getById(id); return info.getAppSecret(); @@ -119,6 +186,7 @@ public class AppSettingsController { @OperationLog("全量刷新应用签名密钥到Redis") @PostMapping("RefreshSignKeys") public Map refreshSignKeys() { + accessScopeService.requireSuper(); int count = signKeyCacheService.refreshAll(); Map result = new LinkedHashMap<>(); result.put("refreshed", count); @@ -127,52 +195,93 @@ public class AppSettingsController { @GetMapping("/AppList") public BasePageResult appList(ApplicationInfoQuery query, BasePageQuery pageQuery, - @SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort) { + @SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + if (isSchoolLevel()) { + // 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校) + AccessScope scope = accessScopeService.resolveCurrentScope(); + return PageUtils.toResult( + schoolAppOverrideService.listSchoolApps(query, PageUtils.toPageable(pageQuery, sort), scope, schoolId)); + } return PageUtils.toResult(applicationInfoService.list(query, PageUtils.toPageable(pageQuery, sort))); } @OperationLog("应用排序") @PostMapping("/AppSort") - public void appSort(@RequestBody List ids) { + public void appSort(@RequestBody List ids, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + if (isSchoolLevel()) { + // 学校:写覆盖表 sort_no(整校快照,仅该校生效) + schoolAppOverrideService.saveAppSortForSchool(ids, + accessScopeService.resolveCurrentScope(), schoolId); + return; + } + accessScopeService.requireSuper(); applicationInfoService.sort(ids); } @GetMapping("/AppSortList") - public List appSortList(@RequestParam Integer categoryId) { + public List appSortList(@RequestParam Integer categoryId, + @RequestParam(value = "schoolId", required = false) Integer schoolId) { + if (isSchoolLevel()) { + // 学校:仅返回本校已开通应用(拖拽排序数据源,override 生效排序) + return schoolAppOverrideService.schoolSortList(categoryId, + accessScopeService.resolveCurrentScope(), schoolId); + } return applicationInfoService.findListByCategoryId(categoryId); } + /** + * 可管理学校列表(多校管理员应用管理页切换下拉用):{id, name}。
+ * 仅 SCHOOL 级返回数据;SUPER/BUREAU 返回空数组(前端不渲染下拉)。 + */ + @GetMapping("/SchoolList") + public List> schoolList() { + accessScopeService.requireAdmin(); + AccessScope scope = accessScopeService.resolveCurrentScope(); + if (scope.getLevel() != AccessLevelEnum.SCHOOL) { + return Collections.emptyList(); + } + return schoolAppOverrideService.manageableSchools(scope); + } + @OperationLog("新增模块") @PostMapping("AddModule") public void addModule(@RequestBody @Validated(ValidationGroups.Create.class) AppModuleRequest request) { + accessScopeService.requireSuper(); appModuleService.add(request); } @OperationLog("编辑模块") @PostMapping("EditModule/{id}") public void editModule(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) AppModuleRequest request) { + accessScopeService.requireSuper(); appModuleService.edit(id, request); } @OperationLog("删除模块") @PostMapping("DeleteModule/{id}") public void deleteModule(@PathVariable Integer id) { + accessScopeService.requireSuper(); appModuleService.delete(id); } @GetMapping("/ModuleList") public List moduleList(AppModuleQuery query) { + accessScopeService.requireSuper(); return appModuleService.list(query); } @OperationLog("保存应用推送设置") @PostMapping("SavePushSetting/{id}") public void savePushSetting(@PathVariable Integer id, @RequestBody PushSettingRequest request) { + accessScopeService.requireSuper(); applicationInfoService.savePushSetting(id, request); } @GetMapping("GetPushSetting/{id}") public PushSettingVo getPushSetting(@PathVariable Integer id) { + accessScopeService.requireSuper(); return applicationInfoService.getPushSetting(id); } } diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index 3cc1135..ad6ef0a 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -6,6 +6,9 @@ import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.platform.apistd.ApiResponse; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.enums.*; +import com.yunhai.userbase.model.other.UserScopeVo; +import com.yunhai.userbase.service.resolve.AccessScope; +import com.yunhai.userbase.service.resolve.AccessScopeService; import com.yunhai.userbase.utils.*; import com.yunhai.platform.utils.exception.DException; import org.springframework.web.multipart.MultipartFile; @@ -25,6 +28,9 @@ public class BaseController { @Autowired private RedisUtil redisUtil; + @Autowired + private AccessScopeService accessScopeService; + /** * 一般hashMap初始化大小 */ @@ -41,6 +47,22 @@ public class BaseController { return authUserWithDept; } + /** + * 当前登录用户的管理端访问范围(四级访问控制)。
+ * 不复用 GetUserInfo 的原因:BoaUser 是 yh-utils-core 外部类不可加字段,且 scope 规则 + * (身份行 × 组类型)由后端单点计算,前端只消费结果、不重复实现判定逻辑,避免双端规则漂移。
+ * 在 NONE 拦截器白名单内(登录即可访问),NONE 用户靠它拿到 level=4 后前端整体渲染「暂无权限」。 + */ + @GetMapping("GetUserScope") + public UserScopeVo getUserScope(BoaUser authUserWithDept) { + AccessScope scope = accessScopeService.resolveCurrentScope(); + UserScopeVo vo = new UserScopeVo(); + vo.setLevel(scope.getLevel().getKey()); + vo.setBureauGroupId(scope.getBureauGroupId()); + vo.setSchoolGroupIds(scope.schoolGroupIds().isEmpty() ? null : scope.schoolGroupIds()); + return vo; + } + //返回系统枚举2 @GetMapping("GetSysDict") public Map getSysDict(BoaUser authUserWithDept){ @@ -56,11 +78,13 @@ public class BaseController { } /** - * 手动管理 Redis Hash 缓存的示例(@Cacheable 注解无法满足的场景)。
+ * 手动管理 Redis Hash 的示例(@Cacheable 注解无法满足的场景)。
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。
- * 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。 + * 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。
+ * 缓存统一管理后本端点仅为 Hash 结构用法演示(《userbase缓存决策方案.md》存量收敛: + * 业务缓存一律走 Spring Cache + CacheNames 注册表,RedisUtil 已 @Deprecated,勿以本端点为新缓存范本)。 * * @return 本次演示各步骤的结果快照 */ diff --git a/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java b/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java new file mode 100644 index 0000000..bbf29c8 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/api/CacheRegistryController.java @@ -0,0 +1,123 @@ +package com.yunhai.userbase.controller.api; + +import com.github.benmanes.caffeine.cache.stats.CacheStats; +import com.yunhai.userbase.config.CacheConfig; +import com.yunhai.userbase.config.CacheNames; +import com.yunhai.userbase.config.CacheNames.Meta; +import com.yunhai.userbase.config.Config; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * 缓存注册表对账接口(方案 C 的运行时可观测面)。

+ * + * {@code GET /{platform}/cache/registry} 输出三块内容:
+ *
    + *
  • caches:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表) + * × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);
  • + *
  • unregisteredRuntimeCaches:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);
  • + *
  • declaredButUnusedCaches:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。
  • + *
+ * 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」—— + * 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。
+ * 注:Redis 侧不提供条目数/命中率(无 stats),TTL 抖动效果用 redis-cli TTL 命令核对(验收清单 8.1)。 + */ +@RestController +@RequestMapping(Config.API_PATH + "/cache") +public class CacheRegistryController { + + @Autowired + private CacheManager cacheManager; + + /** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */ + @Autowired + @Qualifier("caffeineCacheManager") + private CaffeineCacheManager caffeineCacheManager; + + /** 本地专用实例(groupSnapshot / appKeyToId,恒为权威数据源)。 */ + @Autowired + @Qualifier("localCacheManager") + private CaffeineCacheManager localCacheManager; + + @Operation(summary = "缓存注册表对账(声明 × 运行时 × 命中率)", operationId = "cacheRegistry") + @GetMapping("registry") + public Map registry() { + boolean redisUp = !(cacheManager instanceof CacheConfig.FallbackCacheManager) + || ((CacheConfig.FallbackCacheManager) cacheManager).isRedisUp(); + + // 运行时实际出现的缓存名:复合管理器 = 本地专用名 + 当前活跃侧(Redis 或降级 Caffeine)名 + Set runtimeNames = new TreeSet<>(cacheManager.getCacheNames()); + + List> caches = new ArrayList<>(); + for (Meta meta : CacheNames.REGISTRY.values()) { + Map item = new LinkedHashMap<>(); + item.put("name", meta.getName()); + item.put("declaredStore", meta.getStore().name()); + item.put("ttl", meta.getTtl().toString()); + item.put("jitter", "±" + Math.round(meta.getJitterRatio() * 100) + "%"); + item.put("keyRule", meta.getKeyRule()); + item.put("invalidationOwner", meta.getInvalidationOwner()); + item.put("dependsOn", meta.getDependsOn()); + item.put("runtimePresent", runtimeNames.contains(meta.getName())); + // 命中率:本地专用缓存恒有意义;Redis 型缓存仅降级期条目落在 Caffeine,正常期为 null + item.put("caffeineStats", statsOf(meta.getName())); + caches.add(item); + } + + Set unregistered = new TreeSet<>(runtimeNames); + unregistered.removeAll(CacheNames.REGISTRY.keySet()); + Set unused = new TreeSet<>(CacheNames.REGISTRY.keySet()); + unused.removeAll(runtimeNames); + + Map result = new LinkedHashMap<>(); + result.put("degraded", !redisUp); + result.put("degradedNote", redisUp ? null + : "Redis 探活失败已降级 Caffeine:Redis 侧缓存名不可见属预期,恢复后自动切回"); + result.put("caches", caches); + result.put("unregisteredRuntimeCaches", unregistered); + result.put("declaredButUnusedCaches", unused); + return result; + } + + /** 读取缓存对应的 Caffeine 实例统计;缓存尚未在任何 Caffeine 实例中出现时返回 null(不主动创建)。 */ + private Map statsOf(String name) { + CaffeineCache cache = findExistingCaffeineCache(name); + if (cache == null) { + return null; + } + CacheStats stats = cache.getNativeCache().stats(); + Map m = new LinkedHashMap<>(); + m.put("hitCount", stats.hitCount()); + m.put("missCount", stats.missCount()); + m.put("hitRate", stats.hitRate()); + m.put("evictionCount", stats.evictionCount()); + m.put("estimatedSize", cache.getNativeCache().estimatedSize()); + return m; + } + + /** 只读查找(getCache 会按需创建空缓存,污染运行时名单,必须先判 getCacheNames)。 */ + private CaffeineCache findExistingCaffeineCache(String name) { + if (localCacheManager.getCacheNames().contains(name)) { + return (CaffeineCache) localCacheManager.getCache(name); + } + if (caffeineCacheManager.getCacheNames().contains(name)) { + return (CaffeineCache) caffeineCacheManager.getCache(name); + } + return null; + } +} diff --git a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java index 3297b28..a2dbaa7 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java @@ -14,6 +14,7 @@ import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo; import com.yunhai.userbase.model.groupauthz.UserRoleVo; import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import javax.validation.Valid; @@ -24,13 +25,17 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; /** * @author chenx * @date 2026/7/23 * @description 组应用权限管理(应用开通 + 组授权 + 权限判定)。
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。
+ * 访问控制:应用开通类 = 超管+局端;组授权/权限一览 = 管理员(数据范围内组); + * HasModulePerm 保留登录即可(判定接口,自身已有超管直通)。 */ @RestController @RequestMapping(value = Config.API_PATH + "/groupauthz/") @@ -39,11 +44,17 @@ public class GroupAuthzController { @Autowired private IGroupAuthzService groupAuthzService; + @Autowired + private AccessScopeService accessScopeService; + /** * 功能1回显:点击左侧组后,返回系统全量应用列表,已开通的 checked=true。 */ @GetMapping("/GroupAppList") public List groupAppList(@RequestParam Integer groupId) { + // 应用开通动作仅超管+局端(学校无此菜单);局端目标组须在本区域范围内 + accessScopeService.requireSuperOrBureau(); + accessScopeService.requireGroupInScope(groupId); return groupAuthzService.listGroupApps(groupId); } @@ -53,6 +64,9 @@ public class GroupAuthzController { @OperationLog("保存组应用开通") @PostMapping("AssignApps") public void assignApps(@RequestBody @Validated GroupAppAssignRequest request) { + // 局端目标组须在本区域范围内(验收矩阵:局端仅可开通本区域学校) + accessScopeService.requireSuperOrBureau(); + accessScopeService.requireGroupInScope(request.getGroupId()); groupAuthzService.assignAppsToGroup(request); } @@ -61,6 +75,7 @@ public class GroupAuthzController { */ @GetMapping("/GroupModuleTree") public List groupModuleTree(@RequestParam Integer groupId) { + accessScopeService.requireGroupInScope(groupId); return groupAuthzService.findGroupAppsWithModules(groupId); } @@ -70,6 +85,7 @@ public class GroupAuthzController { */ @GetMapping("/GroupModuleTreeByCategory") public List groupModuleTreeByCategory(@RequestParam Integer groupId) { + accessScopeService.requireGroupInScope(groupId); return groupAuthzService.findGroupAppsWithModulesByCategory(groupId); } @@ -79,6 +95,7 @@ public class GroupAuthzController { @OperationLog("保存组模块权限") @PostMapping("SaveModulePerms") public void saveModulePerms(@RequestBody @Validated GroupModulePermRequest request) { + accessScopeService.requireGroupInScope(request.getGroupId()); groupAuthzService.saveModulePermissions(request); } @@ -95,26 +112,37 @@ public class GroupAuthzController { */ @GetMapping("/GroupUsers") public List groupUsers(@RequestParam Integer groupId) { + accessScopeService.requireGroupInScope(groupId); return groupAuthzService.findGroupUsers(groupId); } /** * 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。
- * 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。 + * 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。
+ * 非超管结果按数据范围过滤:仅返回归属组与范围有交集的用户。 */ @GetMapping("/SearchUsers") public List searchUsers(@RequestParam String keyword) { - return groupAuthzService.searchUsers(keyword); + return groupAuthzService.searchUsers(keyword, accessScopeService.scopeGroupIds()); } /** * 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。
- * 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。 + * 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。
+ * 非超管:目标用户归属组与数据范围无交集时返回空集合(只看得到本校/本区域用户)。 */ @GetMapping("/UserPermissionTree") public List userPermissionTree(@RequestParam Integer userId, @RequestParam(required = false) Integer groupId) { + // 组上下文模式:显式传入的 groupId 也须在数据范围内(防借在范围用户推演范围外组的权限数据) + if (groupId != null) { + accessScopeService.requireGroupInScope(groupId); + } + if (!accessScopeService.resolveCurrentScope().isSuper() + && !accessScopeService.userIntersectsScope(userId)) { + return Collections.emptyList(); + } return groupAuthzService.findUserPermissionTree(groupId, userId); } @@ -124,15 +152,24 @@ public class GroupAuthzController { */ @GetMapping("/UserRoles") public List userRoles(@RequestParam Integer userId) { + if (!accessScopeService.resolveCurrentScope().isSuper() + && !accessScopeService.userIntersectsScope(userId)) { + return Collections.emptyList(); + } return groupAuthzService.findUserRoles(userId); } /** * 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
- * 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。 + * 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
+ * 非超管:目标用户归属组与数据范围无交集时返回空集合。 */ @GetMapping("/UserGroupTree") public List userGroupTree(@RequestParam Integer userId) { + if (!accessScopeService.resolveCurrentScope().isSuper() + && !accessScopeService.userIntersectsScope(userId)) { + return Collections.emptyList(); + } return groupAuthzService.findUserGroupTree(userId); } @@ -146,6 +183,9 @@ public class GroupAuthzController { */ @GetMapping("/GetSchoolAppId") public List getSchoolAppId(@RequestParam Integer nodeId) { + // 应用开通动作仅超管+局端;局端目标节点须在本区域范围内 + accessScopeService.requireSuperOrBureau(); + accessScopeService.requireGroupInScope(nodeId); return groupAuthzService.findAuthorizedAppIds(nodeId); } @@ -159,6 +199,9 @@ public class GroupAuthzController { @OperationLog("设置组应用开通") @PostMapping("SetSchoolApp") public void setSchoolApp(@RequestBody @Valid SchoolAppAuthzRequest request) { + // 应用开通动作仅超管+局端;局端目标学校须在本区域范围内 + accessScopeService.requireSuperOrBureau(); + accessScopeService.requireGroupInScope(request.getSchid()); groupAuthzService.setSchoolApps(request); } @@ -174,6 +217,10 @@ public class GroupAuthzController { @OperationLog("设置组模块权限") @PostMapping("SetGroupModule") public void setGroupModule(@RequestBody @Valid List items) { + // 增量授权逐条校验目标组均在数据范围内 + accessScopeService.requireGroupsInScope( + items == null ? Collections.emptyList() + : items.stream().map(GroupModuleAuthzItem::getGroupId).collect(Collectors.toList())); groupAuthzService.setGroupModulePerms(items); } } diff --git a/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java b/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java index b16850d..31ae1ad 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/PushSubscriptionController.java @@ -8,6 +8,7 @@ import com.yunhai.userbase.model.push.PushRecordQuery; import com.yunhai.userbase.model.push.PushTestRequest; import com.yunhai.userbase.service.push.PushDispatcher; import com.yunhai.userbase.service.push.PushRecordService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import com.yunhai.userbase.utils.PageUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -41,9 +42,13 @@ public class PushSubscriptionController { @Autowired private PushRecordService pushRecordService; + @Autowired + private AccessScopeService accessScopeService; + /** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */ @PostMapping("Test") public Map test(@RequestBody PushTestRequest request) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds()); Map preview = pushDispatcher.previewManual( request.getAppId(), request.getInterfacePath(), request.getTargetIds()); @@ -58,12 +63,14 @@ public class PushSubscriptionController { @GetMapping("RecordList") public BasePageResult recordList(PushRecordQuery query, BasePageQuery pageQuery, @SortDefault(sort = "createdAt", direction = Sort.Direction.DESC) Sort sort) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 return PageUtils.toResult(pushRecordService.query(query, PageUtils.toPageable(pageQuery, sort))); } /** 手动重试一条失败的推送记录。 */ @PostMapping("Retry/{id}") public void retry(@PathVariable Long id) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 pushRecordService.retry(id); } @@ -74,6 +81,7 @@ public class PushSubscriptionController { */ @GetMapping("/Download/{id}") public ResponseEntity download(@PathVariable Long id) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 String data = pushRecordService.getPayload(id); if (data == null) { data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。"; diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java index 82fbeed..b0f03f6 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysGroupController.java @@ -10,17 +10,21 @@ import com.yunhai.userbase.model.sysgroup.SysGroupRequest; import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; import com.yunhai.userbase.model.sysgroup.SysGroupVo; import com.yunhai.userbase.service.ISysGroupService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import io.swagger.v3.oas.annotations.Operation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * @author chenx * @date 2026/7/20 16:00 - * @description 系统组管理(组织架构树) + * @description 系统组管理(组织架构树)——管理端四级访问控制 guard 层(S+B+C)。
+ * 范围裁剪与越权拦截在此层完成,Service 保持与 oapi 共享不受影响;SUPER 行为与改造前完全一致。 */ @RestController @RequestMapping(value = Config.API_PATH + "/sysgroup/") @@ -29,10 +33,15 @@ public class SysGroupController { @Autowired private ISysGroupService sysGroupService; + @Autowired + private AccessScopeService accessScopeService; + @OperationLog("新增组") @Operation(operationId = "sysGroupAdd") @PostMapping("Add") public void add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) { + // pid 为 null/0 表示挂根下(仅超管);其余必须落在数据范围内(学校不可跳出本校建组) + accessScopeService.requireCreatePointInScope(request.getParentId()); sysGroupService.add(request); } @@ -40,6 +49,9 @@ public class SysGroupController { @Operation(operationId = "sysGroupEdit") @PostMapping("Edit/{id}") public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) { + accessScopeService.requireGroupInScope(id); + // SCHOOL 禁止编辑学校组本身(防改名/改类型拆校);BUREAU 禁止编辑区域组本身 + accessScopeService.requireGroupModifiable(id); sysGroupService.edit(id, request); } @@ -47,30 +59,41 @@ public class SysGroupController { @Operation(operationId = "sysGroupRemove") @PostMapping("Delete/{id}") public void delete(@PathVariable Integer id) { + accessScopeService.requireGroupInScope(id); + // 学校组/区域组本身不可删,子组可管理 + accessScopeService.requireGroupModifiable(id); sysGroupService.delete(id); } @Operation(operationId = "sysGroupList") @GetMapping("/List") public List list(SysGroupQuery query) { - return sysGroupService.list(query); + List result = sysGroupService.list(query); + // 防御性收紧:结果按数据范围过滤(当前前端未直用本接口) + Set scope = accessScopeService.scopeGroupIds(); + if (scope == null) { + return result; + } + return result.stream().filter(vo -> scope.contains(vo.getId())).collect(Collectors.toList()); } /** - * 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 + * 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。
+ * 按访问级别裁剪:SUPER 全量;BUREAU 以区域组为根的子树;SCHOOL 各学校子树(多校 = 多根数组)。 */ @GetMapping("GetTree") public List getTree() { - return sysGroupService.getTree(); + return sysGroupService.getTree(accessScopeService.resolveCurrentScope()); } /** * 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;
- * 子树中无学校组的分支整体剪掉;根节点始终保留。 + * 子树中无学校组的分支整体剪掉;根节点始终保留。
+ * BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。 */ @GetMapping("GetSchoolTree") public List getSchoolTree() { - return sysGroupService.getSchoolTree(); + return sysGroupService.getSchoolTree(accessScopeService.resolveCurrentScope()); } /** @@ -79,6 +102,7 @@ public class SysGroupController { @OperationLog("批量新增子组") @PostMapping("BatchAdd") public void batchAdd(@RequestBody @Validated SysGroupBatchRequest request) { + accessScopeService.requireCreatePointInScope(request.getPid()); sysGroupService.batchAdd(request); } @@ -88,15 +112,19 @@ public class SysGroupController { @OperationLog("子组排序") @PostMapping("SortChildren") public void sortChildren(@Validated SysGroupSortRequest request) { + accessScopeService.requireGroupInScope(request.getPid()); sysGroupService.sortChildren(request); } /** - * 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。 + * 移动组:将 moveId 移动到 pid 下(不支持移动到自身或自身后代)。
+ * moveId 与目标 pid 均须落在数据范围内(SCHOOL 级天然禁止跨校移动,与 oapi move 语义一致)。 */ @OperationLog("移动组") @PostMapping("Move") public void move(@Validated SysGroupMoveRequest request) { + accessScopeService.requireGroupInScope(request.getMoveId()); + accessScopeService.requireCreatePointInScope(request.getPid()); sysGroupService.move(request); } } diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java b/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java index 46ffd0f..3137368 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysOapiLogController.java @@ -6,6 +6,7 @@ import com.yunhai.userbase.model.BasePageResult; import com.yunhai.userbase.model.oapilog.SysOapiLogListVo; import com.yunhai.userbase.model.oapilog.SysOapiLogQuery; import com.yunhai.userbase.service.ISysOapiLogService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import io.swagger.v3.oas.annotations.Operation; import lombok.extern.slf4j.Slf4j; @@ -37,12 +38,16 @@ public class SysOapiLogController { @Autowired private ISysOapiLogService sysOapiLogService; + @Autowired + private AccessScopeService accessScopeService; + /** * 分页查询开放接口日志列表(不含响应结果)。 */ @Operation(summary = "开放接口日志分页查询", operationId = "oapiLogList") @GetMapping("/List") public BasePageResult list(SysOapiLogQuery query, BasePageQuery pageQuery) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 return sysOapiLogService.pageQuery(query, pageQuery); } @@ -54,6 +59,7 @@ public class SysOapiLogController { @Operation(summary = "下载开放接口日志响应原文", operationId = "oapiLogDownload") @GetMapping("/Download/{id}") public ResponseEntity download(@PathVariable Long id) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 String data = sysOapiLogService.getResponseData(id); if (data == null) { data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。"; diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java b/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java index e980c23..b3371f0 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysOperationLogController.java @@ -6,6 +6,7 @@ import com.yunhai.userbase.model.BasePageResult; import com.yunhai.userbase.model.oplog.SysOperationLogListVo; import com.yunhai.userbase.model.oplog.SysOperationLogQuery; import com.yunhai.userbase.service.ISysOperationLogService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import io.swagger.v3.oas.annotations.Operation; import lombok.extern.slf4j.Slf4j; @@ -37,12 +38,16 @@ public class SysOperationLogController { @Autowired private ISysOperationLogService sysOperationLogService; + @Autowired + private AccessScopeService accessScopeService; + /** * 分页查询操作日志列表(不含响应结果)。 */ @Operation(summary = "操作日志分页查询", operationId = "operationLogList") @GetMapping("/List") public BasePageResult list(SysOperationLogQuery query, BasePageQuery pageQuery) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 return sysOperationLogService.pageQuery(query, pageQuery); } @@ -54,6 +59,7 @@ public class SysOperationLogController { @Operation(summary = "下载日志响应原文", operationId = "operationLogDownload") @GetMapping("/Download/{id}") public ResponseEntity download(@PathVariable Long id) { + accessScopeService.requireSuper(); // 日志/推送管理仅超管 String data = sysOperationLogService.getResponseData(id); if (data == null) { data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。"; diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index b48ea0f..84dd582 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -17,15 +17,17 @@ import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserVo; import com.yunhai.userbase.model.sysuser.UserIdentityVo; import com.yunhai.userbase.service.ISysUserService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import com.yunhai.userbase.utils.PageUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageImpl; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -40,17 +42,34 @@ public class SysUserController { @Autowired private ISysUserService sysUserService; + @Autowired + private AccessScopeService accessScopeService; + + /** 非超管必须携带 groupId(null = 全平台导出/列表),且该组须落在数据范围内 */ + private void requireQueryGroupInScope(Integer groupId) { + if (accessScopeService.resolveCurrentScope().isSuper()) { + return; + } + if (groupId == null) { + throw new DException("请先选择组织节点"); + } + accessScopeService.requireGroupInScope(groupId); + } + @OperationLog("新增用户") @PostMapping("Add") public Integer add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { - List data=new ArrayList<>(); - + accessScopeService.requireGroupInScope(request.getGroupId()); return sysUserService.add(request); } @OperationLog("编辑用户") @PostMapping("Edit/{id}") public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) { + // 关键防线:目标用户本身必须与数据范围有交集。request.groupId 只是前端上下文, + // 伪造 groupId(范围内任一组)+ 任意 id 可跨校改他人资料/重置密码(账号接管)。 + accessScopeService.requireUserInScope(id); + accessScopeService.requireGroupInScope(request.getGroupId()); sysUserService.edit(id, request); } @@ -60,6 +79,7 @@ public class SysUserController { @OperationLog("从组中删除用户") @PostMapping("DeleteFromGroup/{userId}/{groupId}") public void deleteFromGroup(@PathVariable Integer userId, @PathVariable Integer groupId) { + accessScopeService.requireGroupInScope(groupId); sysUserService.deleteFromGroup(userId, groupId); } @@ -69,6 +89,7 @@ public class SysUserController { @OperationLog("批量从组中删除用户") @PostMapping("BatchDelete") public void batchDelete(@RequestBody @Validated BatchDeleteRequest request) { + accessScopeService.requireGroupInScope(request.getGroupId()); sysUserService.batchDeleteFromGroup(request.getUserIds(), request.getGroupId()); } @@ -76,6 +97,8 @@ public class SysUserController { @OperationLog("禁用用户") @PostMapping("Disable/{id}") public void disable(@PathVariable Integer id) { + // 入参只有 userId:校验目标用户归属组与数据范围有交集,防止跨校禁用他人账号 + accessScopeService.requireUserInScope(id); sysUserService.changeStatus(id, true); } @@ -83,6 +106,7 @@ public class SysUserController { @OperationLog("启用用户") @PostMapping("Enable/{id}") public void enable(@PathVariable Integer id) { + accessScopeService.requireUserInScope(id); sysUserService.changeStatus(id, false); } @@ -90,11 +114,21 @@ public class SysUserController { @OperationLog("批量设置用户状态") @PostMapping("BatchChangeStatus") public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) { + accessScopeService.requireUsersInScope(request.getUserIds()); sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled()); } @GetMapping("/List") public BasePageResult list(SysUserQuery query, BasePageQuery pageQuery) { + // 前端点树节点必带 groupId;非超管 null groupId(树未选中时的首载请求)返回空页而非报错, + // 越界组拒绝(null = 全平台用户,仅超管语义) + if (!accessScopeService.resolveCurrentScope().isSuper()) { + if (query.getGroupId() == null) { + return PageUtils.toResult(new PageImpl(Collections.emptyList(), + PageUtils.toPageable(pageQuery, null), 0)); + } + accessScopeService.requireGroupInScope(query.getGroupId()); + } // 排序字段在关联表,无法走 Pageable.sort,统一由 Service.Specification 控制 // (根节点按 id 升序,具体组按组内 sort_no 升序)。此处不传 Sort,避免覆盖 Spec 的 orderBy。 return PageUtils.toResult(sysUserService.list(query, PageUtils.toPageable(pageQuery, null))); @@ -105,12 +139,17 @@ public class SysUserController { @PostMapping("ChangeSortNo/{userId}") public void changeSortNo(@PathVariable Integer userId, @RequestBody @Validated ChangeSortNoRequest request) { + accessScopeService.requireGroupInScope(request.getGroupId()); sysUserService.changeSortNo(userId, request.getGroupId(), request.getSortNo()); } @OperationLog("调整组") @PostMapping("ChangeGroups") public void changeGroups(@RequestBody @Validated ChangeGroupsRequest request) { + // 目标组全部须落在数据范围内(学校不可把用户调出本校); + // 被移动用户本身也须在范围内(防把外校用户拉进本校) + accessScopeService.requireUsersInScope(request.getUserIds()); + accessScopeService.requireGroupsInScope(request.getGroupIds()); sysUserService.changeGroups(request.getUserIds(), request.getGroupIds(), request.getMainGroupId()); } @@ -125,12 +164,14 @@ public class SysUserController { */ @GetMapping("UserIdentity") public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) { + accessScopeService.requireGroupInScope(groupId); return sysUserService.getUserIdentity(userId, groupId); } @OperationLog("批量导入用户") @PostMapping("BatchImport") public BatchImportResult batchImport(@RequestParam("file") MultipartFile file) { + // 涉及的全部组 id ∈ scope 的校验在 Service 解析 Excel 后逐行执行(越界行按行失败上报) return sysUserService.batchImport(file); } @@ -140,6 +181,7 @@ public class SysUserController { @GetMapping("DownloadTemplate") public void downloadTemplate(@RequestParam(value = "groupId", required = false) Integer groupId, HttpServletResponse response) throws IOException { + requireQueryGroupInScope(groupId); sysUserService.downloadImportTemplate(groupId, response); } @@ -152,6 +194,7 @@ public class SysUserController { @RequestParam(value = "withSub", defaultValue = "1") Integer withSub, @RequestParam(value = "name", required = false) String name, HttpServletResponse response) throws IOException { + requireQueryGroupInScope(groupId); sysUserService.exportUsers(groupId, withSub, name, response); } } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 716cee0..f4b5625 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -10,7 +10,6 @@ import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.common.annotation.OapiLog; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; -import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest; import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; @@ -19,7 +18,7 @@ import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; import com.yunhai.userbase.model.sysuser.SysUserPasswordRequest; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserTransferSchoolRequest; -import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.service.impl.UserCenterService; @@ -67,7 +66,7 @@ public class UserController { private IGroupAuthzService groupAuthzService; @Autowired - private ApplicationInfoRepository applicationInfoRepository; + private IApplicationInfoService applicationInfoService; @GetMapping("byId") public BoaUser byId(@RequestParam("id") Integer id) { @@ -122,7 +121,7 @@ public class UserController { } /** - * 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id。
+ * 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id(appKeyToId 本地缓存,TTL 2h)。
* 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。 */ private Integer resolveCallerAppId(HttpServletRequest request) { @@ -144,13 +143,13 @@ public class UserController { log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI()); return null; } - ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey); - if (app == null) { + Integer appId = applicationInfoService.findIdByAppKey(appKey); + if (appId == null) { log.warn("[oapi:user:modules] appKey={} not found in application_info, uri={}", appKey, request.getRequestURI()); return null; } - return app.getId(); + return appId; } diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java index 6d17c06..5510d6d 100644 --- a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java @@ -64,8 +64,7 @@ public class ApplicationInfo extends BaseEntity { //应用前端类型 @Convert(converter = ListIntegerJsonConverter.class) -// @Column(name = "clients", columnDefinition = "json") - @Column(name = "clients", columnDefinition = "TEXT") + @Column(name = "clients", columnDefinition = "json") private List clients; /** diff --git a/src/main/java/com/yunhai/userbase/entity/SysSchoolAppOverride.java b/src/main/java/com/yunhai/userbase/entity/SysSchoolAppOverride.java new file mode 100644 index 0000000..e5abc5c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysSchoolAppOverride.java @@ -0,0 +1,64 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotNull; + +/** + * 学校应用私有覆盖表:学校管理员对「本校已开通应用」的私有别名(名称/描述/图标/排序)。
+ * 为什么不改 application_info 加 school_id 多行:appKey 是全局身份(oapi 签名 / 推送订阅 / + * 应用全链路引用),按学校插行要么复制 appKey 破坏唯一性、要么新发 appKey 导致一个应用裂变, + * 覆盖表对主数据零侵入。override 语义即「别名」,主表改名不自动同步属预期行为; + * 删除应用时级联清理本表覆盖行。 + * + * @author chenx + * @date 2026/9/4 + */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "sys_school_app_override", + indexes = { + @Index(name = "idx_school_app_override_app", columnList = "app_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_school_app", columnNames = {"school_id", "app_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysSchoolAppOverride extends BaseEntity { + + /** 学校组ID(逻辑外键 → SysGroup.id,且组类型为学校组) */ + @NotNull + @Column(name = "school_id", nullable = false) + private Integer schoolId; + + /** 应用ID(逻辑外键 → ApplicationInfo.id,该应用须为本校已开通应用) */ + @NotNull + @Column(name = "app_id", nullable = false) + private Integer appId; + + /** 应用别名(null = 沿用主表名称) */ + @Column(name = "name", length = 64) + private String name; + + /** 应用描述覆盖(null = 沿用主表描述) */ + @Column(name = "description", length = 255) + private String description; + + /** PC 图标覆盖(null = 沿用主表 pcIcon) */ + @Column(name = "icon", length = 255) + private String icon; + + /** 排序号覆盖(null = 沿用主表 sortNo;学校排序快照按 1..n 写入) */ + @Column(name = "sort_no") + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysSchoolCategoryOverride.java b/src/main/java/com/yunhai/userbase/entity/SysSchoolCategoryOverride.java new file mode 100644 index 0000000..d037cc0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/SysSchoolCategoryOverride.java @@ -0,0 +1,53 @@ +package com.yunhai.userbase.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.validation.constraints.NotNull; + +/** + * 学校应用类型(分类)私有覆盖表:学校管理员对应用类型的私有改名 + 排序,仅对该校生效。
+ * 与 {@link SysSchoolAppOverride} 同语义;删除应用类型时级联清理覆盖行。 + * + * @author chenx + * @date 2026/9/4 + */ +@NoArgsConstructor +@Data +@Entity +@Table( + name = "sys_school_category_override", + indexes = { + @Index(name = "idx_school_category_override_cat", columnList = "category_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_school_category", columnNames = {"school_id", "category_id"}) + } +) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysSchoolCategoryOverride extends BaseEntity { + + /** 学校组ID(逻辑外键 → SysGroup.id,且组类型为学校组) */ + @NotNull + @Column(name = "school_id", nullable = false) + private Integer schoolId; + + /** 应用类型ID(逻辑外键 → AppCategory.id) */ + @NotNull + @Column(name = "category_id", nullable = false) + private Integer categoryId; + + /** 类型别名(null = 沿用主表名称) */ + @Column(name = "name", length = 64) + private String name; + + /** 排序号覆盖(null = 沿用主表 sortNo;学校排序快照按 1..n 写入) */ + @Column(name = "sort_no") + private Integer sortNo; +} diff --git a/src/main/java/com/yunhai/userbase/entity/SysUser.java b/src/main/java/com/yunhai/userbase/entity/SysUser.java index 6a1c6ed..bb3adfa 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUser.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUser.java @@ -52,8 +52,7 @@ public class SysUser extends BaseEntity { * 取值对应 {@link com.yunhai.userbase.enums.SysRoleEnum} 的 key,例如 [1,2,3] */ @Convert(converter = ListIntegerJsonConverter.class) -// @Column(name = "roles", columnDefinition = "json") - @Column(name = "roles",columnDefinition = "TEXT") + @Column(name = "roles", columnDefinition = "json") private List roles; // 一对多:关联用户分组中间表,实现多对多业务 diff --git a/src/main/java/com/yunhai/userbase/enums/AccessLevelEnum.java b/src/main/java/com/yunhai/userbase/enums/AccessLevelEnum.java new file mode 100644 index 0000000..7100617 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/enums/AccessLevelEnum.java @@ -0,0 +1,36 @@ +package com.yunhai.userbase.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author chenx + * @date 2026/9/4 + * @description 管理端访问级别(四级访问控制,判定优先级 SUPER > SCHOOL > BUREAU > NONE)。
+ * key 与前端 UserRoleEnum 对齐(1/2/3/4),GetUserScope 直接下发该数值。
+ * 判定规则权威实现见 {@code AccessScopeService}(方案《管理端权限分级改造方案》§1)。 + */ +@Getter +@AllArgsConstructor +public enum AccessLevelEnum { + + SUPER(1, "超级管理员"), + BUREAU(2, "局端账户"), + SCHOOL(3, "学校管理员"), + NONE(4, "普通用户"); + + private Integer key; + private String value; + + public static AccessLevelEnum getByKey(Integer key) { + if (key == null) { + return null; + } + for (AccessLevelEnum e : values()) { + if (e.key.equals(key)) { + return e; + } + } + return null; + } +} diff --git a/src/main/java/com/yunhai/userbase/event/GroupChangedEvent.java b/src/main/java/com/yunhai/userbase/event/GroupChangedEvent.java new file mode 100644 index 0000000..2f33536 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/event/GroupChangedEvent.java @@ -0,0 +1,26 @@ +package com.yunhai.userbase.event; + +import org.springframework.context.ApplicationEvent; + +/** + * 组域数据变更事件:sys_group 任意写操作(add/batchAdd/edit/move/delete/sortChildren)事务提交后发布。
+ * 消费方:{@code CacheInvalidationListener} 驱逐 {@code groupSnapshot} 本地快照 + * (下次读取 sync=true 排队重建),并整清 {@code boaUser}(BoaUser 组织链内嵌组名/组类型, + * 组改名后 5min TTL 内的旧值不可接受,组写频次极低、clear 成本可忽略)。
+ * 粒度:单一事件类型——对所有消费方语义均为「组数据变了,重建」,将来出现需要精确到组的缓存再细化。
+ * 注意与 {@link DataSyncEvent}(下游推送)互不影响:推送面向外部应用,本事件面向本机缓存失效。 + */ +public class GroupChangedEvent extends ApplicationEvent { + + /** 触发本次变更的写口(add/batchAdd/edit/move/delete/sortChildren),仅用于日志定位。 */ + private final String sourceOp; + + public GroupChangedEvent(Object source, String sourceOp) { + super(source); + this.sourceOp = sourceOp; + } + + public String getSourceOp() { + return sourceOp; + } +} diff --git a/src/main/java/com/yunhai/userbase/event/UserChangedEvent.java b/src/main/java/com/yunhai/userbase/event/UserChangedEvent.java new file mode 100644 index 0000000..a27f3c9 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/event/UserChangedEvent.java @@ -0,0 +1,48 @@ +package com.yunhai.userbase.event; + +import org.springframework.context.ApplicationEvent; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 用户域数据变更事件:sys_user / sys_user_group_rel / sys_user_role_rel / sys_user_current_role + * 任意写操作(新增/编辑/换组/身份切换/禁用/跨校转入/密码/头像/删除等)事务提交后发布。
+ * 消费方:{@code CacheInvalidationListener} 按 {@code id:userId} 精确驱逐 boaUser 缓存, + * 并回查当前 account/phone 驱逐 {@code login:}/{@code phone:} 维度 key(含空值哨兵缓存)。
+ * 硬约束:身份切换(IdentityService.switchIdentity)后 boaUser.currentRole 必须立即失效, + * 否则用户以旧身份操作(权限事故级)。
+ * 旧 key 提示:改账号/改手机号场景,DB 里已查不到旧值,发布方须把旧 key(如 + * {@code login:oldAccount})放入 {@link #staleKeyHints},监听器据此精确驱逐,避免旧 key 残留至 TTL。 + */ +public class UserChangedEvent extends ApplicationEvent { + + /** 发生变更的用户ID列表。 */ + private final List userIds; + + /** + * 无法由 userId 回查的旧维度 key 提示(带前缀,如 "login:zhangsan01"、"phone:138...")。 + * 典型场景:edit 改账号、删除用户(sys_user 行已删,account/phone 无处可查)。 + */ + private final Set staleKeyHints; + + public UserChangedEvent(Object source, Collection userIds, Collection staleKeyHints) { + super(source); + this.userIds = userIds == null ? Collections.emptyList() : Collections.unmodifiableList( + new java.util.ArrayList<>(userIds)); + this.staleKeyHints = staleKeyHints == null || staleKeyHints.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(new LinkedHashSet<>(staleKeyHints)); + } + + public List getUserIds() { + return userIds; + } + + public Set getStaleKeyHints() { + return staleKeyHints; + } +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/LoadTestMetricsConfig.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/LoadTestMetricsConfig.java new file mode 100644 index 0000000..7ca1495 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/LoadTestMetricsConfig.java @@ -0,0 +1,36 @@ +package com.yunhai.userbase.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +/** + * [LOADTEST-METRICS] 埋点装配:注册 oapi 指标过滤器。
+ * 顺序说明(与方案 §2.1 的差异):方案原文写 +12(RequireAppSignFilter 之后), + * 但 +11 的 401 短路发生在 chain.doFilter 之前,+12 根本不会执行—— + * 「401 验签失败也能统计」这一目标就落空了。故取 +10(与 AppAuthFilter 同级、 + * 先于 +11 闸门):401 请求全量可统计;appKey 在 AppAuth 先执行时取权威 attr, + * 同序竞争落后时降级 header(签名场景两者取值一致,无实际偏差)。 + */ +@Configuration +public class LoadTestMetricsConfig { + + @Bean + public FilterRegistrationBean oapiMetricsFilter(OapiMetricsCollector collector) { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new OapiMetricsFilter(collector)); + bean.addUrlPatterns("/oapi/*"); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); + bean.setName("oapiMetricsFilter"); + return bean; + } + +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsCollector.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsCollector.java new file mode 100644 index 0000000..c85482c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsCollector.java @@ -0,0 +1,142 @@ +package com.yunhai.userbase.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * [LOADTEST-METRICS] oapi 指标内存累加器(方案 §2.2)。
+ * 按 (uri, method, appKey, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加, + * 不走队列、不做同步 IO——压测高并发下不丢数据、不影响吞吐。
+ * 每 60s 定时快照落库 oapi_metrics(getAndSet 原子清零);落库失败把数值加回, + * 最多顺延到下个窗口,不静默丢失。
+ * Nacos 可下发 oapi.metrics.enabled=false 整体停用(Filter 变纯透传,定时器跳过)。 + */ +@Slf4j +@Component +public class OapiMetricsCollector { + + /** 聚合维度 key(uri×method×appKey×压测标记),基数 = 路由数×调用方数,预估数百级 */ + private static final class Dim { + final String uri; + final String method; + final String appKey; + final boolean loadTest; + + Dim(String uri, String method, String appKey, boolean loadTest) { + this.uri = uri; + this.method = method; + this.appKey = appKey; + this.loadTest = loadTest; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Dim)) { + return false; + } + Dim d = (Dim) o; + return loadTest == d.loadTest && uri.equals(d.uri) && method.equals(d.method) && appKey.equals(d.appKey); + } + + @Override + public int hashCode() { + return Objects.hash(uri, method, appKey, loadTest); + } + } + + /** 单维度三指标:次数 / 耗时累计 / 错误数 */ + private static final class Counter { + final AtomicLong cnt = new AtomicLong(); + final AtomicLong cost = new AtomicLong(); + final AtomicLong err = new AtomicLong(); + } + + private final ConcurrentHashMap counters = new ConcurrentHashMap<>(); + + @Autowired + private OapiMetricsRepository repository; + + /** 埋点总开关(Nacos 可下发紧急关停) */ + @Value("${oapi.metrics.enabled:true}") + private boolean enabled; + + /** Filter 层调用:请求结束(含异常)后记录一笔 */ + public void record(String uri, String method, String appKey, long costMs, boolean error, boolean loadTest) { + if (!enabled) { + return; + } + Counter c = counters.computeIfAbsent(new Dim(uri, method, appKey, loadTest), k -> new Counter()); + c.cnt.incrementAndGet(); + c.cost.addAndGet(costMs); + if (error) { + c.err.incrementAndGet(); + } + } + + /** 快照窗口(ms),对齐方案 60s;仅本地/紧急调整用,常量语义 */ + @Scheduled(fixedDelayString = "${oapi.metrics.flush-interval-ms:60000}") + public void flush() { + if (!enabled || counters.isEmpty()) { + return; + } + LocalDateTime sliceTime = LocalDateTime.now(); + List rows = new ArrayList<>(); + List snapshot = new ArrayList<>(counters.size()); + for (ConcurrentHashMap.Entry e : counters.entrySet()) { + long cnt = e.getValue().cnt.getAndSet(0); + long cost = e.getValue().cost.getAndSet(0); + long err = e.getValue().err.getAndSet(0); + if (cnt <= 0) { + continue; + } + snapshot.add(new Object[]{e.getKey(), cnt, cost, err}); + OapiMetricsEntity row = new OapiMetricsEntity(); + row.setStatTime(sliceTime); + row.setUri(e.getKey().uri); + row.setMethod(e.getKey().method); + row.setAppKey(e.getKey().appKey); + row.setCnt(cnt); + row.setTotalCost(cost); + row.setErrCnt(err); + row.setIsLoadTest(e.getKey().loadTest ? 1 : 0); + rows.add(row); + } + if (rows.isEmpty()) { + return; + } + try { + repository.saveAll(rows); + } catch (Exception ex) { + // 落库失败:数值加回内存,并入下个窗口,不静默丢数 + log.warn("[LOADTEST-METRICS] oapi_metrics 落库失败,{} 行并入下个窗口: {}", rows.size(), ex.getMessage()); + for (Object[] s : snapshot) { + Dim dim = (Dim) s[0]; + Counter c = counters.computeIfAbsent(dim, k -> new Counter()); + c.cnt.addAndGet((Long) s[1]); + c.cost.addAndGet((Long) s[2]); + c.err.addAndGet((Long) s[3]); + } + } + } + +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java new file mode 100644 index 0000000..3364831 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsController.java @@ -0,0 +1,150 @@ +package com.yunhai.userbase.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一(方案 §2.3) + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import com.yunhai.userbase.config.Config; +import io.swagger.v3.oas.annotations.Operation; +import lombok.Data; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * [LOADTEST-METRICS] oapi 热度报告查询(方案 §2.3)。
+ * 默认只统计线上流量(is_load_test=0),压测流量单独给合计数,两者严格隔离。
+ * 例:GET /api/oapimetrics/TopN?dimension=both&days=7&top=10 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/oapimetrics/") +public class OapiMetricsController { + + @Autowired + private OapiMetricsRepository repository; + + /** + * TopN 热度报告:Top 接口 + appKey 分层分布。 + */ + @Operation(summary = "[LOADTEST-METRICS] oapi热度TopN报告", operationId = "oapiMetricsTopN") + @GetMapping("/TopN") + public ReportVo topN( + @RequestParam(value = "dimension", defaultValue = "both") String dimension, + @RequestParam(value = "days", defaultValue = "1") Integer days, + @RequestParam(value = "top", defaultValue = "10") Integer top) { + + int d = clamp(days == null ? 1 : days, 1, 30); + int n = clamp(top == null ? 10 : top, 1, 100); + LocalDateTime since = LocalDateTime.now().minusDays(d); + ReportVo vo = new ReportVo(); + vo.setGeneratedAt(LocalDateTime.now().toString()); + vo.setDays(d); + vo.setTop(n); + + Object[] total = repository.aggregateTotal(since, 0); + vo.setTotalCnt(asLong(total, 0)); + vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1))); + vo.setTotalErrCnt(asLong(total, 2)); + + if ("uri".equals(dimension) || "both".equals(dimension)) { + List rows = new ArrayList<>(); + for (Object[] r : repository.aggregateByUri(since, 0)) { + rows.add(toUriRow(r)); + if (rows.size() >= n) { + break; + } + } + vo.setTopUris(rows); + } + if ("appkey".equals(dimension) || "both".equals(dimension)) { + List rows = new ArrayList<>(); + for (Object[] r : repository.aggregateByAppKey(since, 0)) { + AppKeyRow row = new AppKeyRow(); + row.setAppKey((String) r[0]); + row.setCnt(asLong(r, 1)); + row.setAvgCostMs(avg(asLong(r, 1), asLong(r, 2))); + row.setErrCnt(asLong(r, 3)); + rows.add(row); + if (rows.size() >= n) { + break; + } + } + vo.setAppKeys(rows); + } + + // 压测流量合计(单列口径,不与线上热度混算) + Object[] lt = repository.aggregateTotal(since, 1); + vo.setLoadTestCnt(asLong(lt, 0)); + return vo; + } + + private UriRow toUriRow(Object[] r) { + UriRow row = new UriRow(); + row.setUri((String) r[0]); + row.setMethod((String) r[1]); + row.setCnt(asLong(r, 2)); + row.setAvgCostMs(avg(asLong(r, 2), asLong(r, 3))); + row.setErrCnt(asLong(r, 4)); + return row; + } + + private long asLong(Object[] arr, int i) { + if (arr == null || i >= arr.length || arr[i] == null) { + return 0L; + } + return ((Number) arr[i]).longValue(); + } + + private double avg(long cnt, long total) { + return cnt <= 0 ? 0.0 : Math.round(total * 100.0 / cnt) / 100.0; + } + + private int clamp(int v, int min, int max) { + return v < min ? min : (v > max ? max : v); + } + + /** 报告 VO(嵌套声明,压测结束随包整体删除) */ + @Data + public static class ReportVo { + private String generatedAt; + private Integer days; + private Integer top; + /** 线上总请求数 */ + private Long totalCnt; + private Double totalAvgCostMs; + private Long totalErrCnt; + /** Top 接口热度 */ + private List topUris; + /** appKey 分层分布 */ + private List appKeys; + /** 压测流量请求数(is_load_test=1 单列,不混入上面各项) */ + private Long loadTestCnt; + } + + @Data + public static class UriRow { + private String uri; + private String method; + private Long cnt; + private Double avgCostMs; + private Long errCnt; + } + + @Data + public static class AppKeyRow { + private String appKey; + private Long cnt; + private Double avgCostMs; + private Long errCnt; + } + +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsEntity.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsEntity.java new file mode 100644 index 0000000..4eb8c11 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsEntity.java @@ -0,0 +1,77 @@ +package com.yunhai.userbase.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * [LOADTEST-METRICS] oapi 接口访问频次指标(60s 切片聚合)。
+ * 区别于 sys_oapi_log(逐请求审计日志):本表是方案 §2.2 的轻量统计层—— + * Filter 层内存累加、定时快照,GET/401 全覆盖,不丢数据、不膨胀。
+ * 表结构由 ddl-auto=update 自动创建;生产 DBA 手工建表脚本见 + * resources/sql/oapi_metrics_loadtest.sql。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "oapi_metrics", indexes = { + @Index(name = "idx_time", columnList = "stat_time"), + @Index(name = "idx_appkey_time", columnList = "app_key, stat_time") +}) +public class OapiMetricsEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 统计切片时间(该 60s 窗口的结束时刻) */ + @Column(name = "stat_time", nullable = false, columnDefinition = "DATETIME") + private LocalDateTime statTime; + + /** 规范化路由(去 query、纯数字路径段归一为 {id}),最长 200 */ + @Column(name = "uri", nullable = false, length = 200) + private String uri; + + /** GET/POST/PUT/DELETE */ + @Column(name = "method", nullable = false, length = 10) + private String method; + + /** 调用方 appKey:验签成功为权威值,未验签(401)为 X-App-Key header 原值,无 header 为 "-" */ + @Column(name = "app_key", nullable = false, length = 32) + private String appKey; + + /** 窗口内请求次数 */ + @Column(name = "cnt", nullable = false) + private Long cnt; + + /** 窗口内耗时累计 ms */ + @Column(name = "total_cost", nullable = false) + private Long totalCost; + + /** 窗口内 HTTP 非 200 次数(Filter 层口径,不含响应体 code:5 细化) */ + @Column(name = "err_cnt", nullable = false) + private Long errCnt; + + /** 压测流量单列:请求头 X-Load-Test: 1 → 1,不污染线上热度 */ + @Column(name = "is_load_test", nullable = false, columnDefinition = "TINYINT(1)") + private Integer isLoadTest; + +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsFilter.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsFilter.java new file mode 100644 index 0000000..c846165 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsFilter.java @@ -0,0 +1,138 @@ +package com.yunhai.userbase.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一(方案 §2.1) + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import com.yunhai.platform.appauth.AppAuthFilter; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * [LOADTEST-METRICS] oapi 访问频次采集过滤器。
+ * 只在 Filter 层记 HTTP 状态(方案 §2.1),不解响应体;业务错误(HTTP 200 + code:5) + * 细化继续由 sys_oapi_log 补充分析。
+ * 注册见 {@link LoadTestMetricsConfig}(order=HIGHEST_PRECEDENCE+10,先于 401 闸门, + * 保证 401 验签失败请求也能统计)。appKey 取值优先级:
+ * 1. {@code AppAuthFilter.APP_AUTH_ATTR}(验签后权威值);
+ * 2. 降级 {@code X-App-Key} header(401 请求,标记未验签的 header 原值);
+ * 3. 均无 → "-"(无签名头,将被 RequireAppSignFilter 401)。 + */ +public class OapiMetricsFilter implements Filter { + + /** 压测流量标记头(方案 §2.4,k6 全部请求携带) */ + public static final String HEADER_LOAD_TEST = "X-Load-Test"; + + /** appKey 降级取值的 header(yh-app-auth 签名三件套之一) */ + public static final String HEADER_APP_KEY = "X-App-Key"; + + private static final int MAX_URI_LEN = 200; + private static final int MAX_APP_KEY_LEN = 32; + + private final OapiMetricsCollector collector; + + public OapiMetricsFilter(OapiMetricsCollector collector) { + this.collector = collector; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + + String uri = normalizeUri(req); + String method = req.getMethod(); + String appKey = resolveAppKey(req); + boolean loadTest = isLoadTest(req); + + long start = System.currentTimeMillis(); + boolean error = false; + try { + chain.doFilter(request, response); + error = res.getStatus() != 200; + } catch (IOException | ServletException e) { + error = true; + throw e; + } finally { + collector.record(uri, method, appKey, System.currentTimeMillis() - start, error, loadTest); + } + } + + /** + * 规范化路由:去 context-path、去 query、纯数字路径段归一为 {id} + * (如 /oapi/user/edit/123 → /oapi/user/edit/{id}),防止 PathVariable 撑爆维度基数。 + */ + private String normalizeUri(HttpServletRequest req) { + String uri = req.getRequestURI(); + String ctx = req.getContextPath(); + if (ctx != null && ctx.length() > 1 && uri.startsWith(ctx)) { + uri = uri.substring(ctx.length()); + } + if (uri.indexOf('/') >= 0) { + String[] segs = uri.split("/"); + StringBuilder sb = new StringBuilder(uri.length()); + for (String seg : segs) { + if (seg.length() > 0 && isDigits(seg)) { + seg = "{id}"; + } + sb.append('/').append(seg); + } + uri = sb.toString(); + } + if (uri.length() > MAX_URI_LEN) { + uri = uri.substring(0, MAX_URI_LEN); + } + return uri; + } + + private boolean isDigits(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isDigit(s.charAt(i))) { + return false; + } + } + return true; + } + + /** appKey:验签 attr 权威值 → X-App-Key header(401 未验签)→ "-" */ + private String resolveAppKey(HttpServletRequest req) { + Object attr = req.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + if (attr != null) { + String v = String.valueOf(attr).trim(); + if (!v.isEmpty()) { + return truncate(v, MAX_APP_KEY_LEN); + } + } + String header = req.getHeader(HEADER_APP_KEY); + if (header != null && !header.trim().isEmpty()) { + return truncate(header.trim(), MAX_APP_KEY_LEN); + } + return "-"; + } + + /** 压测标记:X-Load-Test 为 1/true/非 0 值即认为压测流量 */ + private boolean isLoadTest(HttpServletRequest req) { + String v = req.getHeader(HEADER_LOAD_TEST); + if (v == null || v.trim().isEmpty()) { + return false; + } + v = v.trim(); + return !"0".equals(v) && !"false".equalsIgnoreCase(v); + } + + private String truncate(String s, int max) { + return s.length() > max ? s.substring(0, max) : s; + } + +} diff --git a/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsRepository.java b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsRepository.java new file mode 100644 index 0000000..885bfcb --- /dev/null +++ b/src/main/java/com/yunhai/userbase/loadtestmetrics/OapiMetricsRepository.java @@ -0,0 +1,37 @@ +package com.yunhai.userbase.loadtestmetrics; + +/* ============================================================================= + * [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段一 + * 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml + * 中 [LOADTEST-METRICS] 标记段 + DROP TABLE oapi_metrics。 + * 移除步骤详见仓库根《埋点移除清单.md》。 + * ============================================================================= */ + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** [LOADTEST-METRICS] oapi_metrics 聚合查询(TopN 报告用)。 */ +public interface OapiMetricsRepository extends JpaRepository { + + /** 按 uri+method 聚合(热度 TopN),按总次数降序 */ + @Query("select e.uri, e.method, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e " + + "where e.statTime >= :since and e.isLoadTest = :isLoadTest " + + "group by e.uri, e.method order by sum(e.cnt) desc") + List aggregateByUri(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest); + + /** 按 appKey 聚合(调用方分层分布),按总次数降序 */ + @Query("select e.appKey, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e " + + "where e.statTime >= :since and e.isLoadTest = :isLoadTest " + + "group by e.appKey order by sum(e.cnt) desc") + List aggregateByAppKey(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest); + + /** 全量汇总(总次数/总耗时/总错误) */ + @Query("select sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from OapiMetricsEntity e " + + "where e.statTime >= :since and e.isLoadTest = :isLoadTest") + Object[] aggregateTotal(@Param("since") LocalDateTime since, @Param("isLoadTest") Integer isLoadTest); + +} diff --git a/src/main/java/com/yunhai/userbase/model/GroupSnapshot.java b/src/main/java/com/yunhai/userbase/model/GroupSnapshot.java new file mode 100644 index 0000000..4bb6f51 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/GroupSnapshot.java @@ -0,0 +1,90 @@ +package com.yunhai.userbase.model; + +import com.yunhai.userbase.entity.SysGroup; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * sys_group 全表不可变快照({@code groupSnapshot} 缓存的值类型,单条目装整个快照)。

+ * + * 形态:不可变 List + 两个派生索引(byId / childrenMap),整体驱逐 + 重建, + * 不做增量维护(避免在缓存层重复实现树结构逻辑)。
+ * 存放:Caffeine 仅本地(约 1 万行 ≈ JVM 4~8MB;进 Redis 则每次读取网络传输 2MB, + * 权限计算一次请求读 N 次,无意义流量),消费方拿到的是对象引用,零序列化零网络。
+ * 失效:{@code GroupChangedEvent} AFTER_COMMIT 驱逐整条,下次读取排队重建。
+ * 消费方约定:只读,严禁改动快照内的实体对象(快照被全请求共享,写一个等于污染全部)。 + */ +public class GroupSnapshot { + + /** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */ + private static final int ROOT_PARENT_ID = 0; + + /** 全表记录(构造时已定序,顺序与派生索引一致)。 */ + private final List all; + /** id → 组。 */ + private final Map byId; + /** parentId(null 归一为 0)→ 直接子节点列表(同层按 sort 升序、再按 id 升序)。 */ + private final Map> childrenMap; + /** 行级字节粗估(Caffeine 按字节限重的 weigher 用,量级正确即可)。 */ + private final long estimatedBytes; + + public GroupSnapshot(List groups) { + List list = new ArrayList<>(); + Map idMap = new HashMap<>(); + Map> childMap = new HashMap<>(); + if (groups != null) { + for (SysGroup g : groups) { + if (g == null) { + continue; + } + list.add(g); + idMap.put(g.getId(), g); + Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId(); + childMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g); + } + } + Comparator siblingOrder = (a, b) -> { + int sa = a.getSort() == null ? 999 : a.getSort(); + int sb = b.getSort() == null ? 999 : b.getSort(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }; + for (List siblings : childMap.values()) { + siblings.sort(siblingOrder); + } + this.all = Collections.unmodifiableList(list); + this.byId = Collections.unmodifiableMap(idMap); + this.childrenMap = Collections.unmodifiableMap(childMap); + // 行均按 512B 保守估算(名称/类型/审计字段等实际 150~250B,翻倍留余量) + this.estimatedBytes = 512L * list.size() + 65536L; + } + + public List getAll() { + return all; + } + + public Map getById() { + return byId; + } + + public Map> getChildrenMap() { + return childrenMap; + } + + /** 是否存在任何组(空表时 false)。 */ + public boolean isEmpty() { + return all.isEmpty(); + } + + /** Caffeine weigher 用的字节粗估值。 */ + public long getEstimatedBytes() { + return estimatedBytes; + } +} diff --git a/src/main/java/com/yunhai/userbase/model/other/UserScopeVo.java b/src/main/java/com/yunhai/userbase/model/other/UserScopeVo.java new file mode 100644 index 0000000..3a963a0 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/model/other/UserScopeVo.java @@ -0,0 +1,26 @@ +package com.yunhai.userbase.model.other; + +import lombok.Data; + +import java.util.List; + +/** + * GetUserScope 返回体:当前登录用户的管理端访问范围(后端单点计算,前端不重复实现判定逻辑)。
+ * level 取 {@link com.yunhai.userbase.enums.AccessLevelEnum} 的 key(1=SUPER/2=BUREAU/3=SCHOOL/4=NONE), + * 与前端 UserRoleEnum 数值对齐。 + * + * @author chenx + * @date 2026/9/4 + */ +@Data +public class UserScopeVo { + + /** 访问级别(1=超管 2=局端 3=学校管理员 4=普通用户) */ + private Integer level; + + /** BUREAU 级:区域组 id;其余级别为 null */ + private Integer bureauGroupId; + + /** SCHOOL 级:可管理学校组 id 列表(多校管理员多元素);其余级别为 null/空 */ + private List schoolGroupIds; +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysSchoolAppOverrideRepository.java b/src/main/java/com/yunhai/userbase/repository/SysSchoolAppOverrideRepository.java new file mode 100644 index 0000000..221f6a4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysSchoolAppOverrideRepository.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysSchoolAppOverride; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +/** + * 学校应用私有覆盖仓储。 + * + * @author chenx + * @date 2026/9/4 + */ +public interface SysSchoolAppOverrideRepository extends JpaRepository { + + /** 指定学校集合的全部应用覆盖行(AppList/AppSortList 合并用) */ + List findBySchoolIdIn(Collection schoolIds); + + SysSchoolAppOverride findBySchoolIdAndAppId(Integer schoolId, Integer appId); + + /** 删除应用时级联清理该应用在所有学校的覆盖行 */ + @Modifying + @Query("DELETE FROM SysSchoolAppOverride o WHERE o.appId = :appId") + void deleteByAppIdBulk(@Param("appId") Integer appId); +} diff --git a/src/main/java/com/yunhai/userbase/repository/SysSchoolCategoryOverrideRepository.java b/src/main/java/com/yunhai/userbase/repository/SysSchoolCategoryOverrideRepository.java new file mode 100644 index 0000000..dc715a4 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/SysSchoolCategoryOverrideRepository.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.repository; + +import com.yunhai.userbase.entity.SysSchoolCategoryOverride; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +/** + * 学校应用类型私有覆盖仓储。 + * + * @author chenx + * @date 2026/9/4 + */ +public interface SysSchoolCategoryOverrideRepository extends JpaRepository { + + /** 指定学校集合的全部类型覆盖行(AppTypeList 合并用) */ + List findBySchoolIdIn(Collection schoolIds); + + SysSchoolCategoryOverride findBySchoolIdAndCategoryId(Integer schoolId, Integer categoryId); + + /** 删除应用类型时级联清理该类型在所有学校的覆盖行 */ + @Modifying + @Query("DELETE FROM SysSchoolCategoryOverride o WHERE o.categoryId = :categoryId") + void deleteByCategoryIdBulk(@Param("categoryId") Integer categoryId); +} diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java index 08d31c0..c6c5fc6 100644 --- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java @@ -23,6 +23,11 @@ public interface IApplicationInfoService { */ ApplicationInfo getById(Integer id); + /** + * 根据 appKey 查询应用主键 id(appKeyToId 本地缓存,消除开放接口每请求一次 findByAppKey)。 + */ + Integer findIdByAppKey(Integer appKey); + /** * 保存/更新应用信息(写后同步更新缓存)。 */ diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index 95ef9c6..e2305ee 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -84,6 +84,12 @@ public interface IGroupAuthzService { */ List searchUsers(String keyword); + /** + * 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回归属组与范围有交集的行; + * null 表示不过滤(超管/内部调用)。 + */ + List searchUsers(String keyword, java.util.Set scopeGroupIds); + /** * 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。
diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index 524c795..9013b4c 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -8,6 +8,7 @@ import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.model.sysgroup.SysGroupVo; +import com.yunhai.userbase.service.resolve.AccessScope; import java.util.Collection; import java.util.List; @@ -85,6 +86,12 @@ public interface ISysGroupService { /** 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。 */ List getTree(); + /** + * 获取组织架构树(按访问范围裁剪):SUPER 原样全量;BUREAU 以区域组为根的子树; + * SCHOOL 各学校子树(多校 = 多根数组)。scope 为 null 或 SUPER 级时行为与 {@link #getTree()} 完全一致。 + */ + List getTree(AccessScope scope); + /** * 获取「学校组」剪枝树:末端叶子一定是学校组。
* 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);
@@ -92,6 +99,12 @@ public interface ISysGroupService { */ List getSchoolTree(); + /** + * 获取「学校组」剪枝树(按访问范围裁剪):BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。 + * scope 为 null 或 SUPER 级时行为与 {@link #getSchoolTree()} 完全一致。 + */ + List getSchoolTree(AccessScope scope); + /** 在指定父组下按规则批量生成子组。 */ List batchAdd(SysGroupBatchRequest request); diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index a24150a..9794e8b 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -12,9 +12,8 @@ import com.yunhai.userbase.repository.SysGroupModulePermRepository; import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.config.CacheNames; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -72,19 +71,37 @@ public class ApplicationInfoService implements IApplicationInfoService { private ApplicationEventPublisher eventPublisher; /** - * 查询缓存:sync = true 防击穿;底层优先 Redis,Redis 故障时自动降级 Caffeine。 + * 查询缓存:底层优先 Redis,Redis 故障时自动降级 Caffeine。
+ * 不存在或不存在的 id 返回 null 不入缓存(unless 拦截)——不能用 sync=true:SD Redis 2.7 的 + * sync 路径对 null 返回值会无条件 put 并在禁 null 缓存下抛 IllegalArgumentException(查不存在=500)。
+ * 失效走 ApplicationInfoCacheChangedEvent(AFTER_COMMIT 清整缓存,见 CacheInvalidationListener), + * 不再用 @CachePut/@CacheEvict——事务方法上注解驱逐发生在 commit 前,存在回源旧值竞态(方案 5.3 禁令)。 */ @Override - @Cacheable(value = "appInfo", key = "#id", sync = true) + @Cacheable(value = CacheNames.APP_INFO, key = "#id", unless = "#result == null") public ApplicationInfo getById(Integer id) { return applicationInfoRepository.findById(id).orElse(null); } /** - * 更新缓存:方法返回即写入缓存(与 @Cacheable 同一 cacheName/key,保证一致)。 + * appKey → appId 映射(appKeyToId 本地缓存,TTL 2h):消除 /oapi/user/modules 等 + * 每请求一次 findByAppKey 查库;应用增删改经 ApplicationInfoCacheChangedEvent 搭车整清。 + * 查无此 appKey 返回 null 不入缓存(无效 appKey 已被验签拦截,穿透可忽略)。 + */ + @Override + @Cacheable(value = CacheNames.APP_KEY_TO_ID, key = "#appKey", unless = "#result == null") + public Integer findIdByAppKey(Integer appKey) { + if (appKey == null) { + return null; + } + ApplicationInfo app = applicationInfoRepository.findByAppKey(appKey); + return app == null ? null : app.getId(); + } + + /** + * 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。 */ @Override - @CachePut(value = "appInfo", key = "#info.id") public ApplicationInfo save(ApplicationInfo info) { ApplicationInfo saved = applicationInfoRepository.save(info); eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); @@ -92,10 +109,9 @@ public class ApplicationInfoService implements IApplicationInfoService { } /** - * 删除缓存:删除数据时同步清除对应缓存项。 + * 按主键删除:删除成功后发布变更事件(含 appKey),监听器清缓存 + 摘除签名密钥。 */ @Override - @CacheEvict(value = "appInfo", key = "#id") public void deleteById(Integer id) { ApplicationInfo existing = applicationInfoRepository.findById(id).orElse(null); applicationInfoRepository.deleteById(id); @@ -143,8 +159,8 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override @Transactional - // AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理),并清除 appInfo 缓存 - @CacheEvict(value = "appInfo", key = "#id") + // AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理); + // appInfo 缓存失效统一走 AFTER_COMMIT 事件(原 @CacheEvict 在事务方法上驱逐时机早于 commit,存在竞态,已移除) public void delete(Integer id) { Objects.requireNonNull(id, "应用ID不能为空"); ApplicationInfo existing = applicationInfoRepository.findById(id) @@ -226,8 +242,7 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override @Transactional - // AI-FIX: 重置密钥后必须清除 appInfo 缓存,否则控制器随后 getById 命中旧缓存、返回旧密钥 - @CacheEvict(value = "appInfo", key = "#id") + // AI-FIX: 重置密钥后 appInfo 缓存由 AFTER_COMMIT 事件统一清空(原 @CacheEvict 驱逐早于 commit,存在回源旧密钥竞态,已移除) public void resetAppSecret(Integer id) { Objects.requireNonNull(id, "应用ID不能为空"); ApplicationInfo entity = applicationInfoRepository.findById(id) @@ -256,6 +271,11 @@ public class ApplicationInfoService implements IApplicationInfoService { } applicationInfoRepository.updateSortNoById(id, i + 1); } + // 排序结果缓存于 appInfo(实体含 sortNo),提交后发布变更事件统一失效 + ApplicationInfo first = applicationInfoRepository.findById(ids.get(0)).orElse(null); + if (first != null) { + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, first)); + } } private ApplicationInfoVo convertToVo(ApplicationInfo entity) { @@ -271,7 +291,6 @@ public class ApplicationInfoService implements IApplicationInfoService { @Override @Transactional - @CachePut(value = "appInfo", key = "#id") public ApplicationInfo savePushSetting(Integer id, PushSettingRequest request) { Objects.requireNonNull(id, "应用ID不能为空"); Objects.requireNonNull(request, "推送设置入参不能为空"); @@ -296,7 +315,10 @@ public class ApplicationInfoService implements IApplicationInfoService { if (request.getPushSecret() != null) { entity.setPushSecret(request.getPushSecret()); } - return applicationInfoRepository.save(entity); + ApplicationInfo saved = applicationInfoRepository.save(entity); + // 推送配置写入实体字段,提交后发布变更事件统一失效 appInfo/appKeyToId(原 @CachePut 驱逐早于 commit,已移除) + eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); + return saved; } @Override diff --git a/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java b/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java new file mode 100644 index 0000000..c39f649 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/BoaUserLoader.java @@ -0,0 +1,255 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.enums.IEnum; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.config.CacheNames; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserCurrentRole; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.entity.SysUserRoleRel; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.enums.SysRoleEnum; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.repository.SysUserRoleRelRepository; +import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; +import com.yunhai.userbase.service.resolve.MainGroupResolver; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * BoaUser 缓存加载器({@code boaUser} 缓存的唯一回源入口,注册表见 {@link CacheNames#BOA_USER})。

+ * + * 三个查询维度各自成 key({@code login:}/{@code phone:}/{@code id:}),查无此人返回 null 不入缓存 + * ({@code unless} 拦截;穿透防护由 UserCenterService 的 30s 空值哨兵缓存承担)。
+ * 注解不使用 sync=true 的原因:spring-data-redis 2.7 的 sync 路径对 loader 返回 null 会无条件 + * put(key, null),在 {@code disableCachingNullValues} 下直接抛 IllegalArgumentException(查无此人=500), + * 无法与空值保护共存;单 JVM 排队回源(防击穿)由 UserCenterService 门面的条带锁承担,语义等价。
+ * BoaUser 组装逻辑(组织链/身份推导)自 UserCenterService 平移至此; + * UserCenterService 作为对外门面保留原方法签名,内部委托本类(调用必须经代理,禁止同类自调用)。
+ * 失效责任:UserChangedEvent AFTER_COMMIT 精确驱逐(身份切换必须立即失效 currentRole)。 + */ +@Service +public class BoaUserLoader { + + /** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */ + private static final int ROOT_PARENT_ID = 0; + + @Autowired + private SysUserRepository sysUserRepository; + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Autowired + private SysGroupRepository sysGroupRepository; + @Autowired + private SysUserRoleRelRepository sysUserRoleRelRepository; + @Autowired + private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; + @Autowired + private MainGroupResolver mainGroupResolver; + @Autowired + private IdentityService identityService; + + /** 按账号加载(缓存 key = login:{account})。 */ + @Cacheable(value = CacheNames.BOA_USER, key = "'login:' + #account", unless = "#result == null") + public BoaUser byAccount(String account) { + return resolve(sysUserRepository.findByAccount(account)); + } + + /** 按手机号加载(缓存 key = phone:{phone})。 */ + @Cacheable(value = CacheNames.BOA_USER, key = "'phone:' + #phone", unless = "#result == null") + public BoaUser byPhone(String phone) { + return resolve(sysUserRepository.findByPhone(phone)); + } + + /** 按用户ID加载(缓存 key = id:{userId})。 */ + @Cacheable(value = CacheNames.BOA_USER, key = "'id:' + #id", unless = "#result == null") + public BoaUser byId(Integer id) { + return resolve(sysUserRepository.findById(id).orElse(null)); + } + + private BoaUser resolve(SysUser user) { + if (user == null) { + return null; + } + BoaUser boa = new BoaUser(); + boa.setId(user.getId()); + boa.setAccount(user.getAccount()); + boa.setName(user.getRealName()); + boa.setIdcard(user.getIdcard()); + boa.setPhone(user.getPhone()); + boa.setProfilePicture(user.getProfilePicture()); + // 用户表无 email / gender 列,置 null + boa.setEmail(null); + boa.setGender(null); + boa.setType(user.getType() != null ? user.getType().getKey() : null); + // 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用) + boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0); + // 角色列表:从 sys_user_role_rel(平台身份角色权威源)取正常态 roleId 去重,不再读 sys_user.roles + List roleIds = sysUserRoleRelRepository + .findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL); + boa.setRoleIds(roleIds); + + List groups = buildGroups(user.getId()); + boa.setGroups(groups); + // 部门/教育局:仍从组织链推导(与主组/区域组语义绑定) + deriveOrg(groups, boa); + // 学校/当前激活身份/可选学校:从身份关联表推导(school 语义=当前激活学校) + deriveIdentity(user.getId(), boa); + return boa; + } + + /** + * 组装用户组织链:取主归属组(isMain=1 取 id 最小,无主兜底 id 最小),沿 parentId 向上回溯到根, + * 顺序为「主组 -> ... -> 根方向」(近 -> 远)。 + */ + private List buildGroups(Integer userId) { + List rels = sysUserGroupRelRepository.findByUserId(userId); + if (rels == null || rels.isEmpty()) { + return new ArrayList<>(); + } + SysUserGroupRel mainRel = mainGroupResolver.resolve(rels); + Integer groupId = mainRel != null ? mainRel.getGroupId() : null; + if (groupId == null) { + return new ArrayList<>(); + } + + List chain = new ArrayList<>(); + Integer cursor = groupId; + Set seen = new HashSet<>(); + while (cursor != null && cursor != ROOT_PARENT_ID && seen.add(cursor)) { + SysGroup g = sysGroupRepository.findById(cursor).orElse(null); + if (g == null) { + break; + } + BoaGroup bg = new BoaGroup(); + bg.setId(g.getId()); + bg.setName(g.getName()); + bg.setParentId(g.getParentId()); + bg.setType(g.getType() != null ? g.getType().getKey() : null); + chain.add(bg); + cursor = g.getParentId(); + } + return chain; + } + + /** 从组织链推导 school / bureau / dept。 */ + private void deriveOrg(List groups, BoaUser boa) { + if (groups == null || groups.isEmpty()) { + return; + } + BoaGroup school = null; + BoaGroup bureau = null; + for (BoaGroup g : groups) { + if (school == null && Objects.equals(g.getType(), GroupTypeEnum.SCHOOL_GROUP.getKey())) { + school = g; + } + if (bureau == null && Objects.equals(g.getType(), GroupTypeEnum.AREA_GROUP.getKey())) { + bureau = g; + } + } + boa.setSchool(school); + boa.setBureau(bureau); + // 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校 + boa.setDept(groups.get(0)); + } + + /** + * 从身份关联推导当前激活身份与可选学校。school 字段语义=当前激活学校:
+ * ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);
+ * ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行, + * 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);
+ * ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);
+ * ④ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合组装 BoaGroup 列表(本期补码)。
+ * school 类型守卫:仅当身份 schoolId 对应组类型=SCHOOL_GROUP 时回填 boa.school, + * 区域组/根直属用户保持 null——等价旧约定「填 0 不回填」的行为,避免子应用读到 school=区域组 对象。 + */ + private void deriveIdentity(Integer userId, BoaUser boa) { + Integer currentRoleId = null; + Integer currentSchoolId = null; + String currentSchoolName = null; + + SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null); + if (current != null && current.getRoleId() != null) { + currentRoleId = current.getRoleId(); + currentSchoolId = current.getSchoolId(); + currentSchoolName = current.getSchoolName(); + } else { + // 兜底:主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行), + // 而不是「主组+主角色」组合(避免组合出 B 学校老师的非法身份) + SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId); + if (mainRel != null) { + currentRoleId = mainRel.getRoleId(); + currentSchoolId = mainRel.getSchoolId(); + if (currentSchoolId != null && currentSchoolId > 0) { + SysGroup sch = sysGroupRepository.findById(currentSchoolId).orElse(null); + currentSchoolName = sch != null ? sch.getName() : null; + } + } + } + + // 当前激活学校:仅当对应组为学校组时回填(区域组/根直属保持 null) + if (currentSchoolId != null && currentSchoolId > 0 && isSchoolGroup(currentSchoolId)) { + boa.setSchool(toBoaGroup(currentSchoolId, currentSchoolName)); + } + // 当前激活身份(优先切换记录,兜底主身份)——组装为 BoaRoleInfo 富对象(childId/childName 本期不做,恒空) + BoaRoleInfo currentRole = new BoaRoleInfo(); + currentRole.setRoleId(currentRoleId); + currentRole.setRoleName(resolveRoleName(currentRoleId)); + currentRole.setSchId(currentSchoolId); + currentRole.setSchName(currentSchoolName); + boa.setCurrentRole(currentRole); + + // 可选学校集合:全部正常态身份行的 schoolId 去重(>0 过滤,恒为真实组 id) + List schoolIds = sysUserRoleRelRepository + .findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL); + if (schoolIds != null && !schoolIds.isEmpty()) { + List schools = schoolIds.stream() + .filter(sid -> sid != null && sid > 0) + .map(sid -> toBoaGroup(sid, null)) + .collect(Collectors.toList()); + boa.setSchools(schools); + } + } + + /** 该组是否为学校组(SCHOOL_GROUP);组不存在视为 false。 */ + private boolean isSchoolGroup(Integer groupId) { + SysGroup g = sysGroupRepository.findById(groupId).orElse(null); + return g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP; + } + + /** 由角色 id 反查角色名(SysRoleEnum.value);未知角色返回 null。 */ + private String resolveRoleName(Integer roleId) { + if (roleId == null) { + return null; + } + SysRoleEnum role = IEnum.getByKey(SysRoleEnum.class, roleId); + return role != null ? role.getValue() : null; + } + + /** 组装一个 BoaGroup(仅填充 id 与 name;name 为 null 时回源查 sys_group) */ + private BoaGroup toBoaGroup(Integer groupId, String fallbackName) { + BoaGroup bg = new BoaGroup(); + bg.setId(groupId); + if (fallbackName != null) { + bg.setName(fallbackName); + } else { + SysGroup g = sysGroupRepository.findById(groupId).orElse(null); + bg.setName(g != null ? g.getName() : null); + } + return bg; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java new file mode 100644 index 0000000..bcfc9e2 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/CacheInvalidationListener.java @@ -0,0 +1,138 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.config.CacheNames; +import com.yunhai.userbase.entity.SysUser; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +/** + * 缓存失效统一监听器(写失效唯一通道:AFTER_COMMIT 事件驱动)。

+ * + * 订阅三个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:
+ *
    + *
  • {@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId;
  • + *
  • {@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照(下次读取排队重建)+ + * 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名,组改名后不能吃 5min 旧值);
  • + *
  • {@link UserChangedEvent} → 按 id:{userId} 精确驱逐,并回查当前 account/phone 驱逐 + * login:/phone: 维度;改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时 + * 整清兜底(无法反查 account/phone,删除是罕见操作)。
  • + *
+ * + * 护栏(方案 5.2):所有监听器 {@code fallbackExecution=true}——发布方不在事务中时事件 + * 不被静默吞掉;每次驱逐自带 try-catch + error 日志(含 key 与「TTL 后自愈」说明)—— + * CacheErrorHandler 只覆盖注解驱动的缓存操作,手动驱逐路径必须自兜底;该 error 日志的 + * 出现频率是事件链路健康度的观测点,频繁出现必须排查。 + */ +@Service +public class CacheInvalidationListener { + + private static final Logger log = LoggerFactory.getLogger(CacheInvalidationListener.class); + + /** groupSnapshot 的固定单 key(与 GroupSnapshotService 的 @Cacheable key 一致)。 */ + private static final String SNAPSHOT_KEY = "all"; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private SysUserRepository sysUserRepository; + + // ===================== 应用域 ===================== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onApplicationInfoChanged(ApplicationInfoCacheChangedEvent event) { + clearQuietly(CacheNames.APP_INFO); + clearQuietly(CacheNames.APP_KEY_TO_ID); + log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId", + event.getAppKey(), event.getType()); + } + + // ===================== 组域 ===================== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onGroupChanged(GroupChangedEvent event) { + evictQuietly(CacheNames.GROUP_SNAPSHOT, SNAPSHOT_KEY); + clearQuietly(CacheNames.BOA_USER); + clearQuietly(CacheNames.BOA_USER_NULL); + log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser", event.getSourceOp()); + } + + // ===================== 用户域 ===================== + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onUserChanged(UserChangedEvent event) { + List userIds = event.getUserIds(); + if (userIds != null) { + for (Integer userId : userIds) { + if (userId == null) { + continue; + } + evictQuietly(CacheNames.BOA_USER, "id:" + userId); + evictQuietly(CacheNames.BOA_USER_NULL, "id:" + userId); + // 回查当前 account/phone:绝大多数写口(换组/禁用/身份切换等)不改变账号手机号, + // 按当前值驱逐即可精确覆盖 login:/phone: 维度 + SysUser user = sysUserRepository.findById(userId).orElse(null); + if (user != null) { + if (user.getAccount() != null) { + evictQuietly(CacheNames.BOA_USER, "login:" + user.getAccount()); + evictQuietly(CacheNames.BOA_USER_NULL, "login:" + user.getAccount()); + } + if (user.getPhone() != null && !user.getPhone().isEmpty()) { + evictQuietly(CacheNames.BOA_USER, "phone:" + user.getPhone()); + evictQuietly(CacheNames.BOA_USER_NULL, "phone:" + user.getPhone()); + } + } else { + // 用户行已不存在(物理删除):无法反查 account/phone,整清兜底(罕见操作) + clearQuietly(CacheNames.BOA_USER); + clearQuietly(CacheNames.BOA_USER_NULL); + log.warn("[Cache失效] userId={} 已不存在(物理删除) → 整清 boaUser 兜底", userId); + } + } + } + // 改账号/改手机号场景的旧 key(DB 已查不到),由发布方通过 staleKeyHints 精确指示 + for (String hint : event.getStaleKeyHints()) { + evictQuietly(CacheNames.BOA_USER, hint); + evictQuietly(CacheNames.BOA_USER_NULL, hint); + } + log.info("[Cache失效] 用户变更 userIds={} hints={} → boaUser 精确驱逐完成", + userIds, event.getStaleKeyHints()); + } + + // ===================== 驱逐工具(error 可观测,TTL 自愈兜底) ===================== + + private void evictQuietly(String cacheName, Object key) { + try { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.evict(key); + } + } catch (Exception e) { + log.error("[Cache失效] 驱逐失败 cache={} key={} 脏数据将存活至TTL后自愈,若频繁出现请排查事件失效链路", + cacheName, key, e); + } + } + + private void clearQuietly(String cacheName) { + try { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.clear(); + } + } catch (Exception e) { + log.error("[Cache失效] 整清失败 cache={} 脏数据将存活至TTL后自愈,若频繁出现请排查事件失效链路", + cacheName, e); + } + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 56b9792..4981543 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -32,6 +32,7 @@ import com.yunhai.userbase.repository.SysAppRoleRepository; import com.yunhai.userbase.repository.SysUserAppRoleRelRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.impl.UserCenterService; import com.yunhai.userbase.service.resolve.MainGroupResolver; @@ -83,6 +84,10 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private ApplicationInfoRepository applicationInfoRepository; + /** 开放接口读路径经缓存 service 取应用(appInfo 缓存 1h):单条存在性校验与图标直读。 */ + @Autowired + private IApplicationInfoService applicationInfoService; + @Autowired private AppCategoryRepository appCategoryRepository; @@ -107,6 +112,9 @@ public class GroupAuthzService implements IGroupAuthzService { @Autowired private SysUserAppRoleRelRepository sysUserAppRoleRelRepository; + @Autowired + private GroupSnapshotService groupSnapshotService; + // ===================== 新增:沿祖先链收集「本组+所有祖先组开通的应用ID并集」(应用池联动GetSchoolAppId) // ===================== @@ -506,8 +514,9 @@ public class GroupAuthzService implements IGroupAuthzService { public List findUserModulesForApp(Integer appId, Integer schId, Integer userId) { Objects.requireNonNull(appId, "应用ID不能为空"); Objects.requireNonNull(userId, "用户ID不能为空"); - // 1. 调用方应用本身存在性校验(数据库过滤第一层:appId 必须真实存在,否则直接空) - ApplicationInfo app = applicationInfoRepository.findById(appId).orElse(null); + // 1. 调用方应用本身存在性校验(数据库过滤第一层:appId 必须真实存在,否则直接空); + // 经 appInfo 缓存 service 读取(命中零 SQL,应用信息属低频变更、事件失效保即时性) + ApplicationInfo app = applicationInfoService.getById(appId); if (app == null) { return Collections.emptyList(); } @@ -763,6 +772,15 @@ public class GroupAuthzService implements IGroupAuthzService { @Override public List searchUsers(String keyword) { + return searchUsers(keyword, null); + } + + /** + * 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回「归属组与范围有交集」的行, + * 无组关系的用户行一并滤除;scopeGroupIds 为 null(超管)行为与 {@link #searchUsers(String)} 一致。 + */ + @Override + public List searchUsers(String keyword, Set scopeGroupIds) { if (keyword == null) { keyword = ""; } @@ -793,7 +811,11 @@ public class GroupAuthzService implements IGroupAuthzService { for (SysUser user : users) { List userRels = relsByUser.get(user.getId()); if (userRels == null || userRels.isEmpty()) { - // 无组关系的用户仍出一行(组路径空),保持可选中查看权限 + // 无组关系的用户仍出一行(组路径空),保持可选中查看权限; + // 范围过滤模式下无交集则整行丢弃 + if (scopeGroupIds != null) { + continue; + } UserPermissionUserVo vo = new UserPermissionUserVo(); vo.setId(user.getId()); vo.setAccount(user.getAccount()); @@ -803,6 +825,10 @@ public class GroupAuthzService implements IGroupAuthzService { continue; } for (SysUserGroupRel rel : userRels) { + if (scopeGroupIds != null + && (rel.getGroupId() == null || !scopeGroupIds.contains(rel.getGroupId()))) { + continue; + } UserPermissionUserVo vo = new UserPermissionUserVo(); vo.setId(user.getId()); vo.setAccount(user.getAccount()); @@ -1380,12 +1406,13 @@ public class GroupAuthzService implements IGroupAuthzService { }; } + /** + * 全表组索引(id → 组):取自 groupSnapshot 本地快照(30min TTL + 组写事件驱逐), + * 消除权限链路(collectAppIdsForAuthzScope / findUserModulesForApp / 权限一览等)中 + * 按用户归属组循环重复执行的全表 findAll。仅限只读路径调用。 + */ private Map buildAllGroupMap() { - Map map = new HashMap<>(); - for (SysGroup g : sysGroupRepository.findAll()) { - map.put(g.getId(), g); - } - return map; + return groupSnapshotService.snapshot().getById(); } private String buildGroupPath(Integer groupId, Map allGroupMap) { @@ -1449,11 +1476,8 @@ public class GroupAuthzService implements IGroupAuthzService { if (userRels.isEmpty()) { return false; } - // 2. 全部组载入内存(树深度有限),供祖先链回溯 - Map allGroupMap = new HashMap<>(); - for (SysGroup g : sysGroupRepository.findAll()) { - allGroupMap.put(g.getId(), g); - } + // 2. 全部组载入内存(树深度有限,取自快照),供祖先链回溯 + Map allGroupMap = buildAllGroupMap(); // 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 就近来源模块权限 ∩ 已开通应用); // 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询 Map> chainMap = new HashMap<>(); diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupSnapshotService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupSnapshotService.java new file mode 100644 index 0000000..049b974 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupSnapshotService.java @@ -0,0 +1,32 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.config.CacheNames; +import com.yunhai.userbase.model.GroupSnapshot; +import com.yunhai.userbase.repository.SysGroupRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +/** + * sys_group 全表快照读取服务({@code groupSnapshot} 缓存的唯一入口)。

+ * + * 消除权限链路中按用户归属组循环重复执行的全表 {@code findAll()}: + * 一次 findAll 建索引(几十毫秒)后,同一快照内所有祖先链判定都是内存 Map 操作。
+ * TTL 30min + GroupChangedEvent 事务提交后驱逐;{@code sync=true} 保证驱逐后并发读只重建一次。
+ * 仅限只读路径使用:组域写操作的事务内校验(环检测/边界校验)必须直查库,不能吃旧快照。 + */ +@Service +public class GroupSnapshotService { + + @Autowired + private SysGroupRepository sysGroupRepository; + + /** + * 取全表快照(整体驱逐+重建,固定单 key)。 + * 调用方通过本方法(经代理)访问才会命中缓存,禁止同类内部自调用。 + */ + @Cacheable(value = CacheNames.GROUP_SNAPSHOT, key = "'all'", sync = true) + public GroupSnapshot snapshot() { + return new GroupSnapshot(sysGroupRepository.findAll()); + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java index 0abc224..5d16ad7 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -13,9 +13,11 @@ import com.yunhai.userbase.repository.SysGroupRepository; import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.repository.SysUserRoleRelRepository; +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.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -53,6 +55,8 @@ public class IdentityService { private MainGroupResolver mainGroupResolver; @Autowired private ISysGroupService sysGroupService; + @Autowired + private ApplicationEventPublisher eventPublisher; /** * 用户全部可选身份(正常态),用于「切换身份」页渲染。 @@ -120,6 +124,11 @@ public class IdentityService { current.setSwitchTime(LocalDateTime.now()); sysUserCurrentRoleRepository.save(current); + // 身份切换后 boaUser.currentRole 必须立即失效(缓存失效硬约束:脏 currentRole = 用户以错误身份操作)。 + // 本方法无 @Transactional(单条 save 自动提交),监听器 fallbackExecution=true 保证事件不被吞。 + eventPublisher.publishEvent(new UserChangedEvent(this, + Collections.singletonList(userId), null)); + return toBoaRoleInfo(roleId, schoolId, current.getSchoolName()); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java new file mode 100644 index 0000000..8d157f7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/impl/SchoolAppOverrideService.java @@ -0,0 +1,443 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.exception.DException; +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.model.appcategory.AppCategoryVo; +import com.yunhai.userbase.model.application.ApplicationInfoQuery; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.model.application.ApplicationInfoSortVo; +import com.yunhai.userbase.model.application.ApplicationInfoVo; +import com.yunhai.userbase.repository.AppCategoryRepository; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.SysGroupAppRelRepository; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysSchoolAppOverrideRepository; +import com.yunhai.userbase.repository.SysSchoolCategoryOverrideRepository; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import javax.transaction.Transactional; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 学校私有应用覆盖服务(管理端权限分级改造 §3.4):学校管理员对「本校已开通应用」的 + * 名称/描述/图标/排序 + 应用类型改名/排序做该校私有覆盖,主数据零侵入。
+ * 读写口径:应用范围恒为「该(些)学校在 sys_group_app_rel 已开通的应用并集」; + * override 行字段为 null 表示沿用主表值(语义即「别名」)。
+ * 仅管理端调用(AppSettingsController),oapi / 认证链路不感知本服务。 + * + * @author chenx + * @date 2026/9/4 + */ +@Service +public class SchoolAppOverrideService { + + /** 排序兜底值(与既有应用排序口径一致:sortNo 空视为999) */ + private static final int SORT_NULL_FALLBACK = 999; + + @Autowired + private SysSchoolAppOverrideRepository appOverrideRepository; + + @Autowired + private SysSchoolCategoryOverrideRepository categoryOverrideRepository; + + @Autowired + private SysGroupAppRelRepository sysGroupAppRelRepository; + + @Autowired + private ApplicationInfoRepository applicationInfoRepository; + + @Autowired + private AppCategoryRepository appCategoryRepository; + + @Autowired + private SysGroupRepository sysGroupRepository; + + // ===================== 学校解析 ===================== + + /** 目标学校集合:schoolId 指定则单校(必须 ∈ 可管理学校),否则全部可管理学校(并集模式) */ + private List resolveSchools(AccessScope scope, Integer schoolId) { + List schools = scope.schoolGroupIds(); + if (schools.isEmpty()) { + throw new DException("暂无权限"); + } + if (schoolId == null) { + return schools; + } + if (!schools.contains(schoolId)) { + throw new DException("暂无权限访问该学校"); + } + return Collections.singletonList(schoolId); + } + + /** + * 写操作的目标学校:必须唯一。schoolId 缺省且仅有一所可管理学校时取该校; + * 多校管理员未指定时无法确定写入目标,直接拒绝(前端多校时提供切换下拉)。 + */ + private Integer resolveSingleSchool(AccessScope scope, Integer schoolId) { + if (schoolId != null) { + if (!scope.schoolGroupIds().contains(schoolId)) { + throw new DException("暂无权限访问该学校"); + } + return schoolId; + } + if (scope.schoolGroupIds().size() == 1) { + return scope.schoolGroupIds().get(0); + } + throw new DException("您管理多所学校,请先选择学校"); + } + + /** 学校集合已开通应用并集 */ + private Set openedAppIds(List schoolIds) { + Set appIds = new LinkedHashSet<>(); + for (SysGroupAppRel rel : sysGroupAppRelRepository.findByGroupIdIn(schoolIds)) { + if (rel.getAppId() != null) { + appIds.add(rel.getAppId()); + } + } + return appIds; + } + + // ===================== 覆盖合并(读) ===================== + + /** + * appId → 覆盖行(多校并集模式取 sortNo 最小的学校行,空 sortNo 沉底;并列取 schoolId 小者, + * 结果确定)。无覆盖的应用不在 Map 中。 + */ + private Map appOverridesFor(List schoolIds) { + Map result = new HashMap<>(); + for (SysSchoolAppOverride o : appOverrideRepository.findBySchoolIdIn(schoolIds)) { + if (o.getAppId() == null) { + continue; + } + SysSchoolAppOverride cur = result.get(o.getAppId()); + if (cur == null || appOverrideOrder(o) < appOverrideOrder(cur)) { + result.put(o.getAppId(), o); + } + } + return result; + } + + private long appOverrideOrder(SysSchoolAppOverride o) { + int sortNo = o.getSortNo() == null ? SORT_NULL_FALLBACK : o.getSortNo(); + return sortNo * 100000L + (o.getSchoolId() == null ? 0 : o.getSchoolId()); + } + + private Map categoryOverridesFor(List schoolIds) { + Map result = new HashMap<>(); + for (SysSchoolCategoryOverride o : categoryOverrideRepository.findBySchoolIdIn(schoolIds)) { + if (o.getCategoryId() == null) { + continue; + } + SysSchoolCategoryOverride cur = result.get(o.getCategoryId()); + if (cur == null || categoryOverrideOrder(o) < categoryOverrideOrder(cur)) { + result.put(o.getCategoryId(), o); + } + } + return result; + } + + private long categoryOverrideOrder(SysSchoolCategoryOverride o) { + int sortNo = o.getSortNo() == null ? SORT_NULL_FALLBACK : o.getSortNo(); + return sortNo * 100000L + (o.getSchoolId() == null ? 0 : o.getSchoolId()); + } + + /** VO 合并:override 优先(null 字段沿用主表) */ + private ApplicationInfoVo mergeAppVo(ApplicationInfo app, Map overrides) { + ApplicationInfoVo vo = new ApplicationInfoVo(); + BeanUtils.copyProperties(app, vo); + SysSchoolAppOverride o = overrides.get(app.getId()); + if (o != null) { + if (StringUtils.isNotBlank(o.getName())) { + vo.setName(o.getName()); + } + if (StringUtils.isNotBlank(o.getDescription())) { + vo.setDesc(o.getDescription()); + } + if (StringUtils.isNotBlank(o.getIcon())) { + vo.setPcIcon(o.getIcon()); + } + if (o.getSortNo() != null) { + vo.setSortNo(o.getSortNo()); + } + } + return vo; + } + + /** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */ + private Comparator effectiveAppOrder(Map overrides) { + return (a, b) -> { + int sa = effectiveSort(a.getId(), a.getSortNo(), overrides); + int sb = effectiveSort(b.getId(), b.getSortNo(), overrides); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId() == null ? 0 : a.getId(), b.getId() == null ? 0 : b.getId()); + }; + } + + private int effectiveSort(Integer appId, Integer mainSortNo, Map overrides) { + SysSchoolAppOverride o = appId == null ? null : overrides.get(appId); + if (o != null && o.getSortNo() != null) { + return o.getSortNo(); + } + return mainSortNo == null ? SORT_NULL_FALLBACK : mainSortNo; + } + + // ===================== AppList / AppSortList(SCHOOL) ===================== + + /** + * 学校应用分页列表:仅「本校已开通应用」,override 合并 名称/描述/图标/排序。
+ * 已开通应用为小集合(校内几十个),全量载入内存过滤合并后按生效排序内存分页, + * 保证 override 排序在翻页间一致。 + */ + public Page listSchoolApps(ApplicationInfoQuery query, Pageable pageable, + AccessScope scope, Integer schoolId) { + List schools = resolveSchools(scope, schoolId); + Set opened = openedAppIds(schools); + if (opened.isEmpty()) { + return new PageImpl<>(Collections.emptyList(), pageable, 0); + } + List apps = applicationInfoRepository.findAllById(opened); + // 与主表列表同口径的查询条件:name 关键词(名称模糊 / appKey 精确)、categoryId 精确 + String kw = query != null && StringUtils.isNotEmpty(query.getName()) ? query.getName().trim() : null; + Integer categoryId = query != null ? query.getCategoryId() : null; + List filtered = new ArrayList<>(); + for (ApplicationInfo app : apps) { + if (categoryId != null && !Objects.equals(categoryId, app.getCategoryId())) { + continue; + } + if (kw != null && !matchKeyword(app, kw)) { + continue; + } + filtered.add(app); + } + Map overrides = appOverridesFor(schools); + List vos = filtered.stream() + .map(app -> mergeAppVo(app, overrides)) + .sorted(effectiveAppOrder(overrides)) + .collect(Collectors.toList()); + return pageInMemory(vos, pageable); + } + + private boolean matchKeyword(ApplicationInfo app, String kw) { + if (app.getName() != null && app.getName().contains(kw)) { + return true; + } + return kw.matches("\\d+") && app.getAppKey() != null && app.getAppKey().toString().equals(kw); + } + + /** 拖拽排序数据源:该分类下本校已开通应用(override 合并名称与生效排序) */ + public List schoolSortList(Integer categoryId, AccessScope scope, Integer schoolId) { + List schools = resolveSchools(scope, schoolId); + Set opened = openedAppIds(schools); + if (opened.isEmpty()) { + return Collections.emptyList(); + } + Map overrides = appOverridesFor(schools); + return applicationInfoRepository.findAllById(opened).stream() + .filter(app -> Objects.equals(categoryId, app.getCategoryId())) + .map(app -> { + ApplicationInfoVo merged = mergeAppVo(app, overrides); + ApplicationInfoSortVo vo = new ApplicationInfoSortVo(); + vo.setId(merged.getId()); + vo.setName(merged.getName()); + vo.setSortNo(merged.getSortNo()); + return vo; + }) + .sorted((a, b) -> { + int sa = a.getSortNo() == null ? SORT_NULL_FALLBACK : a.getSortNo(); + int sb = b.getSortNo() == null ? SORT_NULL_FALLBACK : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }) + .collect(Collectors.toList()); + } + + private Page pageInMemory(List list, Pageable pageable) { + if (pageable == null) { + return new PageImpl<>(list); + } + int from = (int) Math.min(pageable.getOffset(), list.size()); + int to = Math.min(from + pageable.getPageSize(), list.size()); + return new PageImpl<>(new ArrayList<>(list.subList(from, to)), pageable, list.size()); + } + + // ===================== AppTypeList(SCHOOL) ===================== + + /** 应用类型列表合并学校覆盖:改名 + 排序(仅该校生效),按生效排序重排 */ + public List mergeCategoryList(List baseList, AccessScope scope, Integer schoolId) { + List schools = resolveSchools(scope, schoolId); + Map overrides = categoryOverridesFor(schools); + List result = new ArrayList<>(); + for (AppCategoryVo vo : baseList) { + SysSchoolCategoryOverride o = vo.getId() == null ? null : overrides.get(vo.getId()); + if (o != null) { + if (StringUtils.isNotBlank(o.getName())) { + vo.setName(o.getName()); + } + if (o.getSortNo() != null) { + vo.setSortNo(o.getSortNo()); + } + } + result.add(vo); + } + result.sort((a, b) -> { + int sa = a.getSortNo() == null ? SORT_NULL_FALLBACK : a.getSortNo(); + int sb = b.getSortNo() == null ? SORT_NULL_FALLBACK : b.getSortNo(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId() == null ? 0 : a.getId(), b.getId() == null ? 0 : b.getId()); + }); + return result; + } + + // ===================== 写操作(SCHOOL) ===================== + + /** + * 学校编辑应用:仅接受 name/description/icon 三字段 upsert 覆盖表; + * appKey、回调、推送等其余字段一律不可动(入参其余字段忽略)。应用须为本校已开通。 + */ + @Transactional + public void editAppForSchool(Integer appId, ApplicationInfoRequest request, + AccessScope scope, Integer schoolId) { + Integer school = resolveSingleSchool(scope, schoolId); + if (!applicationInfoRepository.existsById(appId)) { + throw new DException("应用不存在: " + appId); + } + if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(school, appId)) { + throw new DException("该应用未在本校开通,无法编辑"); + } + SysSchoolAppOverride override = appOverrideRepository.findBySchoolIdAndAppId(school, appId); + if (override == null) { + override = new SysSchoolAppOverride(); + override.setSchoolId(school); + override.setAppId(appId); + } + override.setName(StringUtils.trimToNull(request.getName())); + override.setDescription(StringUtils.trimToNull(request.getDesc())); + override.setIcon(StringUtils.trimToNull(request.getPcIcon())); + appOverrideRepository.save(override); + } + + /** 学校应用排序:写覆盖表 sort_no(覆盖保存整份顺序快照;纯 upsert,不动名称等其它覆盖字段) */ + @Transactional + public void saveAppSortForSchool(List appIds, AccessScope scope, Integer schoolId) { + Integer school = resolveSingleSchool(scope, schoolId); + if (appIds == null || appIds.isEmpty()) { + throw new DException("排序应用ID列表不能为空"); + } + Set opened = openedAppIds(Collections.singletonList(school)); + for (int i = 0; i < appIds.size(); i++) { + Integer appId = appIds.get(i); + if (!opened.contains(appId)) { + throw new DException("应用未在本校开通,无法排序: " + appId); + } + SysSchoolAppOverride override = appOverrideRepository.findBySchoolIdAndAppId(school, appId); + if (override == null) { + override = new SysSchoolAppOverride(); + override.setSchoolId(school); + override.setAppId(appId); + } + override.setSortNo(i + 1); + appOverrideRepository.save(override); + } + } + + /** 学校编辑应用类型:仅改名,写分类覆盖表 */ + @Transactional + public void editCategoryForSchool(Integer categoryId, String name, AccessScope scope, Integer schoolId) { + Integer school = resolveSingleSchool(scope, schoolId); + if (!appCategoryRepository.existsById(categoryId)) { + throw new DException("应用类型不存在: " + categoryId); + } + SysSchoolCategoryOverride override = categoryOverrideRepository.findBySchoolIdAndCategoryId(school, categoryId); + if (override == null) { + override = new SysSchoolCategoryOverride(); + override.setSchoolId(school); + override.setCategoryId(categoryId); + } + override.setName(StringUtils.trimToNull(name)); + categoryOverrideRepository.save(override); + } + + /** 学校类型排序:写分类覆盖表 sort_no(覆盖保存整份顺序快照) */ + @Transactional + public void saveCategorySortForSchool(List categoryIds, AccessScope scope, Integer schoolId) { + Integer school = resolveSingleSchool(scope, schoolId); + if (categoryIds == null || categoryIds.isEmpty()) { + throw new DException("排序类型ID列表不能为空"); + } + for (int i = 0; i < categoryIds.size(); i++) { + Integer categoryId = categoryIds.get(i); + if (!appCategoryRepository.existsById(categoryId)) { + throw new DException("应用类型不存在: " + categoryId); + } + SysSchoolCategoryOverride override = + categoryOverrideRepository.findBySchoolIdAndCategoryId(school, categoryId); + if (override == null) { + override = new SysSchoolCategoryOverride(); + override.setSchoolId(school); + override.setCategoryId(categoryId); + } + override.setSortNo(i + 1); + categoryOverrideRepository.save(override); + } + } + + // ===================== 级联清理(删除应用/类型时) ===================== + + /** 删除应用时级联清理该应用在所有学校的覆盖行 */ + @Transactional + public void deleteAppOverrides(Integer appId) { + appOverrideRepository.deleteByAppIdBulk(appId); + } + + /** 删除应用类型时级联清理该类型在所有学校的覆盖行 */ + @Transactional + public void deleteCategoryOverrides(Integer categoryId) { + categoryOverrideRepository.deleteByCategoryIdBulk(categoryId); + } + + // ===================== 辅助:学校下拉数据(多校管理员切换) ===================== + + /** 可管理学校 {id, name} 列表(多校下拉用);仅返回仍存在且为学校组的项 */ + public List> manageableSchools(AccessScope scope) { + List> result = new ArrayList<>(); + for (SysGroup school : sysGroupRepository.findAllById(scope.schoolGroupIds())) { + if (school.getType() != GroupTypeEnum.SCHOOL_GROUP) { + continue; + } + Map item = new HashMap<>(4); + item.put("id", school.getId()); + item.put("name", school.getName()); + result.add(item); + } + result.sort(Comparator.comparing(m -> String.valueOf(m.get("name")))); + return result; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java index 3463459..4b65330 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java @@ -64,9 +64,11 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex /** * 事务提交后的安全订阅:只有 ApplicationInfoService 对应事务真正 commit 后, - * 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。 + * 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。
+ * fallbackExecution=true:发布方不在事务中时(如非事务的通用 save/deleteById)事件不被静默吞掉, + * 而是立即执行(缓存失效护栏,见《userbase缓存决策方案.md》5.2)。 */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void onCacheChanged(ApplicationInfoCacheChangedEvent event) { if (event.getType() == ApplicationInfoCacheChangedEvent.Type.DELETE) { removeOne(event.getAppKey()); diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 0e57a3d..3f052e3 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -3,11 +3,15 @@ package com.yunhai.userbase.service.impl; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.userbase.enums.AccessLevelEnum; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.event.DataSyncEvent; +import com.yunhai.userbase.event.GroupChangedEvent; import com.yunhai.userbase.common.context.CallerAppContext; +import com.yunhai.userbase.model.GroupSnapshot; import com.yunhai.userbase.model.push.PushInterfaceType; +import com.yunhai.userbase.service.resolve.AccessScope; import org.springframework.context.ApplicationEventPublisher; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest; @@ -122,6 +126,9 @@ public class SysGroupService implements ISysGroupService { @Autowired private ApplicationEventPublisher eventPublisher; + @Autowired + private GroupSnapshotService groupSnapshotService; + // ===================== 基础增删改(含根节点保护) ===================== @Override @@ -292,23 +299,35 @@ public class SysGroupService implements ISysGroupService { @Override public List getTree() { // 根节点由 SysGroupRootInitializer 在启动时幂等创建,此处直接读取组装 - List all = sysGroupRepository.findAll(); - if (all.isEmpty()) { + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + if (snapshot.isEmpty()) { return new ArrayList<>(); } - Map> childrenMap = buildSortedChildrenMap(all); + Map> childrenMap = snapshot.getChildrenMap(); // 从根(pid=0)开始组装树 List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList()); } @Override - public List getSchoolTree() { - List all = sysGroupRepository.findAll(); - if (all.isEmpty()) { + public List getTree(AccessScope scope) { + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + if (snapshot.isEmpty()) { return new ArrayList<>(); } - Map> childrenMap = buildSortedChildrenMap(all); + if (scope == null || scope.isSuper()) { + return getTree(); + } + return buildTrimmedTree(scope, snapshot.getAll(), false); + } + + @Override + public List getSchoolTree() { + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + if (snapshot.isEmpty()) { + return new ArrayList<>(); + } + Map> childrenMap = snapshot.getChildrenMap(); List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); List result = new ArrayList<>(); for (SysGroup root : roots) { @@ -322,6 +341,94 @@ public class SysGroupService implements ISysGroupService { return result; } + @Override + public List getSchoolTree(AccessScope scope) { + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + if (snapshot.isEmpty()) { + return new ArrayList<>(); + } + if (scope == null || scope.isSuper()) { + return getSchoolTree(); + } + return buildTrimmedTree(scope, snapshot.getAll(), true); + } + + /** + * 按访问范围裁剪组树(getTree/getSchoolTree 的 scope 版本共用)。
+ * 范围内节点重建 childrenMap;「父节点不在范围内」的节点提升为树根—— + * BUREAU 得到以区域组为根的子树,SCHOOL 得到各学校子树(多校 = 多根数组)。
+ * schoolPruned=true 时按「学校组叶子」剪枝(GetSchoolTree 语义)。
+ * 注意不能用 childrenMap.get(ROOT_PARENT_ID) 取根:范围内不存在 pid=0 的节点 + * (平台根不属于任何区域/学校子树),提升孤儿节点才是正确的根集合。 + */ + private List buildTrimmedTree(AccessScope scope, List all, boolean schoolPruned) { + Set keep = scopedKeepIds(scope, all); + Map> childrenMap = buildSortedChildrenMap( + all.stream().filter(g -> keep.contains(g.getId())).collect(Collectors.toList())); + List roots = new ArrayList<>(); + for (SysGroup g : all) { + if (!keep.contains(g.getId())) { + continue; + } + Integer parentId = g.getParentId(); + if (parentId == null || parentId == ROOT_PARENT_ID || !keep.contains(parentId)) { + roots.add(g); + } + } + roots.sort((a, b) -> { + int sa = a.getSort() == null ? 999 : a.getSort(); + int sb = b.getSort() == null ? 999 : b.getSort(); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return Integer.compare(a.getId(), b.getId()); + }); + List result = new ArrayList<>(); + for (SysGroup root : roots) { + if (schoolPruned) { + SysGroupVo vo = buildSchoolNode(root, childrenMap); + if (vo == null) { + vo = convertToVo(root); + } + result.add(vo); + } else { + result.add(buildNode(root, childrenMap)); + } + } + return result; + } + + /** + * 计算访问范围内的保留组 id 集合:BUREAU = 区域组子树;SCHOOL = 各学校子树并集。
+ * SUPER/NONE 不应走到此处(调用方已分流);NONE 返回空集合(整树裁空)。 + */ + private Set scopedKeepIds(AccessScope scope, List all) { + Map> childrenMap = buildSortedChildrenMap(all); + Set keep = new HashSet<>(); + List anchors = new ArrayList<>(); + if (scope.getLevel() == AccessLevelEnum.BUREAU && scope.getBureauGroupId() != null) { + anchors.add(scope.getBureauGroupId()); + } else if (scope.getLevel() == AccessLevelEnum.SCHOOL) { + anchors.addAll(scope.schoolGroupIds()); + } + // 广度优先展开各锚点的整棵子树(含锚点自身) + while (!anchors.isEmpty()) { + List next = new ArrayList<>(); + for (Integer id : anchors) { + if (keep.add(id)) { + List children = childrenMap.get(id); + if (children != null) { + for (SysGroup child : children) { + next.add(child.getId()); + } + } + } + } + anchors = next; + } + return keep; + } + /** * 递归组装「学校组」剪枝节点。
* 学校组:直接作为叶子返回(不再向下递归,其下所有组不显示);
@@ -387,11 +494,11 @@ public class SysGroupService implements ISysGroupService { */ @Override public BoaGroup getGroups() { - List all = sysGroupRepository.findAll(); - if (all.isEmpty()) { + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + if (snapshot.isEmpty()) { return null; } - Map> childrenMap = buildSortedChildrenMap(all); + Map> childrenMap = snapshot.getChildrenMap(); List roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); if (roots.isEmpty()) { return null; @@ -412,16 +519,16 @@ public class SysGroupService implements ISysGroupService { if (groupId == null) { return null; } - SysGroup entity = sysGroupRepository.findById(groupId).orElse(null); + // 只读路径:节点与子孙均取自快照,保证同请求内树结构一致 + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + SysGroup entity = snapshot.getById().get(groupId); if (entity == null) { return null; } if (!Boolean.TRUE.equals(withSub)) { return convertToBoaGroup(entity); } - List all = sysGroupRepository.findAll(); - Map> childrenMap = buildSortedChildrenMap(all); - return buildBoaNode(entity, childrenMap, true); + return buildBoaNode(entity, snapshot.getChildrenMap(), true); } /** @@ -435,10 +542,9 @@ public class SysGroupService implements ISysGroupService { return new ArrayList<>(); } boolean expand = Boolean.TRUE.equals(withSub); - List all = sysGroupRepository.findAll(); - Map allMap = all.stream() - .collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a)); - Map> childrenMap = buildSortedChildrenMap(all); + GroupSnapshot snapshot = groupSnapshotService.snapshot(); + Map allMap = snapshot.getById(); + Map> childrenMap = snapshot.getChildrenMap(); List result = new ArrayList<>(); for (Integer id : idList) { SysGroup g = allMap.get(id); @@ -530,6 +636,8 @@ public class SysGroupService implements ISysGroupService { childMap.get(ids.get(i)).setSort(i + 1); } sysGroupRepository.saveAll(children); + // 排序改变兄弟节点顺序 → 快照 childrenMap 内容变化,须驱逐(缓存失效事件,不发推送) + eventPublisher.publishEvent(new GroupChangedEvent(this, "sortChildren")); } // ===================== 移动组 ===================== @@ -844,6 +952,8 @@ public class SysGroupService implements ISysGroupService { } // ===================== 推送事件(AFTER_COMMIT 由 PushDispatcher 消费,push_enabled 默认 0 不触发) ===================== + // 同一写口同时发布 GroupChangedEvent(缓存失效,CacheInvalidationListener 消费): + // 组域五写口 add/batchAdd/edit/move/delete(含 schoolId 边界重载)+ sortChildren 全部经此收口。 private void publishGroupEvent(PushInterfaceType type, Integer groupId) { DataSyncEvent e = new DataSyncEvent(); @@ -851,6 +961,7 @@ public class SysGroupService implements ISysGroupService { e.setGroupIds(Collections.singletonList(groupId)); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); + eventPublisher.publishEvent(new GroupChangedEvent(this, type.name())); } private void publishGroupEvent(PushInterfaceType type, List groupIds) { @@ -862,5 +973,6 @@ public class SysGroupService implements ISysGroupService { e.setGroupIds(groupIds); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); + eventPublisher.publishEvent(new GroupChangedEvent(this, type.name())); } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index ffedd89..4d959fb 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -30,8 +30,10 @@ import com.yunhai.userbase.repository.SysUserRoleRelRepository; import com.yunhai.userbase.service.ISysGroupService; import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.service.impl.IdentityService; +import com.yunhai.userbase.service.resolve.AccessScopeService; import com.yunhai.userbase.service.resolve.MainGroupResolver; import com.yunhai.userbase.event.DataSyncEvent; +import com.yunhai.userbase.event.UserChangedEvent; import com.yunhai.userbase.common.context.CallerAppContext; import com.yunhai.userbase.model.push.PushInterfaceType; import org.springframework.context.ApplicationEventPublisher; @@ -125,6 +127,9 @@ public class SysUserService implements ISysUserService { @Autowired private IdentityService identityService; + @Autowired + private AccessScopeService accessScopeService; + @Autowired private PasswordEncoder passwordEncoder; @@ -205,6 +210,15 @@ public class SysUserService implements ISysUserService { SysUser entity = sysUserRepository.findById(id) .orElseThrow(() -> new DException("系统用户不存在")); validateProfile(request); + // 改账号/改手机号后 DB 查不到旧值,boaUser 旧维度 key 须随事件显式驱逐(否则残留至 5min TTL) + java.util.Set staleKeyHints = new HashSet<>(); + if (entity.getAccount() != null && !entity.getAccount().equals(request.getAccount())) { + staleKeyHints.add("login:" + entity.getAccount()); + } + if (entity.getPhone() != null && !entity.getPhone().isEmpty() + && !entity.getPhone().equals(request.getPhone())) { + staleKeyHints.add("phone:" + entity.getPhone()); + } entity.setAccount(request.getAccount()); // 密码非空才修改:需两次一致 + 弱密码校验 + BCrypt 加密 @@ -227,7 +241,7 @@ public class SysUserService implements ISysUserService { } entity.setUpdateUser(SYSTEM_USER_ID); sysUserRepository.save(entity); - publishUserEvent(PushInterfaceType.UPDATE_USER, id); + publishUserEvent(PushInterfaceType.UPDATE_USER, id, staleKeyHints); // 身份按 schoolId 全量覆盖保存(编辑请求总是携带 {schoolId, roleIds},未修改也提交原值); // 未携带 schoolId 的调用(如旧版 oapi edit)跳过身份写库,保持原身份不变 @@ -274,6 +288,12 @@ public class SysUserService implements ISysUserService { if (users.size() != userIds.size()) { throw new DException("存在无效用户"); } + Map userById = new HashMap<>(); + for (SysUser u : users) { + userById.put(u.getId(), u); + } + // 被物理删除用户的 account/phone 事后无处回查,缓存旧 key 在删除时现场捕获随事件驱逐 + Set deletedKeyHints = new HashSet<>(); for (Integer userId : userIds) { // 删除当前组关联 @@ -284,11 +304,20 @@ public class SysUserService implements ISysUserService { sysUserRoleRelRepository.deleteByUserIdBulk(userId); sysUserCurrentRoleRepository.deleteByUserIdBulk(userId); sysUserRepository.deleteById(userId); + SysUser gone = userById.get(userId); + if (gone != null) { + if (gone.getAccount() != null) { + deletedKeyHints.add("login:" + gone.getAccount()); + } + if (StringUtils.isNotBlank(gone.getPhone())) { + deletedKeyHints.add("phone:" + gone.getPhone()); + } + } } } // 业务语义:从组移除 = 对子应用即删除(不论是否真删账户),统一推送 DELETE_USER // 因是「解除组关系」,需携带 (userId, groupId) 供子应用精确处理 - publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId); + publishUserEvent(PushInterfaceType.DELETE_USER, userIds, groupId, deletedKeyHints); } @Override @@ -864,6 +893,8 @@ public class SysUserService implements ISysUserService { } try { SysUserRequest request = parseImportRow(row); + // 管理端范围校验:该行落组必须在当前操作者数据范围内(越界按行失败上报,不中断整表) + accessScopeService.requireGroupInScope(request.getGroupId()); createUser(request, false); result.addSuccess(); } catch (DException e) { @@ -1223,6 +1254,9 @@ public class SysUserService implements ISysUserService { entity.setPassword(passwordEncoder.encode(newPassword)); entity.setUpdateUser(SYSTEM_USER_ID); sysUserRepository.save(entity); + // 密码不在 BoaUser 内,按方案写口清单仍发缓存失效事件(不发推送,保持既有推送语义) + eventPublisher.publishEvent(new UserChangedEvent(this, + Collections.singletonList(userId), null)); } /** 头像大小上限(与前端限制一致:20MB) */ @@ -1257,6 +1291,9 @@ public class SysUserService implements ISysUserService { entity.setProfilePicture(url); entity.setUpdateUser(SYSTEM_USER_ID); sysUserRepository.save(entity); + // 头像 URL 内嵌 BoaUser.profilePicture,事务提交后驱逐缓存(不发推送,保持既有推送语义) + eventPublisher.publishEvent(new UserChangedEvent(this, + Collections.singletonList(userId), null)); return url; } @@ -1517,29 +1554,38 @@ public class SysUserService implements ISysUserService { } } - // ===================== 推送事件 ===================== + // ===================== 推送事件 + 缓存失效事件 ===================== + // 用户域全部写口在此收口:DataSyncEvent 由 PushDispatcher 消费(push_enabled 默认 0 不触发); + // UserChangedEvent 由 CacheInvalidationListener AFTER_COMMIT 消费,按 id/login/phone + // 精确驱逐 boaUser 与空值哨兵(staleKeyHints 携带改名/物理删除后无法回查的旧 key)。 private void publishUserEvent(PushInterfaceType type, Integer userId) { + publishUserEvent(type, userId, null); + } + + /** 带缓存旧 key 提示的单用户版本(改账号/改手机号场景用)。 */ + private void publishUserEvent(PushInterfaceType type, Integer userId, java.util.Set staleKeyHints) { DataSyncEvent e = new DataSyncEvent(); e.setType(type); e.setUserIds(Collections.singletonList(userId)); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); + eventPublisher.publishEvent(new UserChangedEvent(this, + Collections.singletonList(userId), staleKeyHints)); } private void publishUserEvent(PushInterfaceType type, List userIds) { - if (userIds == null || userIds.isEmpty()) { - return; - } - DataSyncEvent e = new DataSyncEvent(); - e.setType(type); - e.setUserIds(userIds); - e.setSourceAppKey(CallerAppContext.getAppKey()); - eventPublisher.publishEvent(e); + publishUserEvent(type, userIds, null, null); } /** 组维度的用户事件(如 DELETE_USER=解除组关系),额外携带组 id */ private void publishUserEvent(PushInterfaceType type, List userIds, Integer groupId) { + publishUserEvent(type, userIds, groupId, null); + } + + /** 组维度 + 缓存旧 key 提示的完整版本(物理删除用户后 account/phone 无处回查,须显式给出旧 key)。 */ + private void publishUserEvent(PushInterfaceType type, List userIds, Integer groupId, + java.util.Set staleKeyHints) { if (userIds == null || userIds.isEmpty()) { return; } @@ -1549,5 +1595,6 @@ public class SysUserService implements ISysUserService { e.setGroupId(groupId); e.setSourceAppKey(CallerAppContext.getAppKey()); eventPublisher.publishEvent(e); + eventPublisher.publishEvent(new UserChangedEvent(this, userIds, staleKeyHints)); } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java index 8d91adb..64e7ce8 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java @@ -1,253 +1,141 @@ package com.yunhai.userbase.service.impl; -import com.yunhai.platform.utils.enums.IEnum; -import com.yunhai.platform.utils.model.BoaGroup; -import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; -import com.yunhai.userbase.entity.SysGroup; -import com.yunhai.userbase.entity.SysUser; -import com.yunhai.userbase.entity.SysUserCurrentRole; -import com.yunhai.userbase.entity.SysUserGroupRel; -import com.yunhai.userbase.entity.SysUserRoleRel; -import com.yunhai.userbase.enums.GroupTypeEnum; -import com.yunhai.userbase.enums.StatusEnum; -import com.yunhai.userbase.enums.SysRoleEnum; -import com.yunhai.userbase.repository.SysGroupRepository; -import com.yunhai.userbase.repository.SysUserGroupRelRepository; -import com.yunhai.userbase.repository.SysUserRepository; -import com.yunhai.userbase.repository.SysUserRoleRelRepository; -import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; -import com.yunhai.userbase.service.impl.IdentityService; -import com.yunhai.userbase.service.resolve.MainGroupResolver; +import com.yunhai.userbase.config.CacheNames; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.function.Function; +import java.util.regex.Pattern; /** * 用户中心核心服务:把 UserBase 自身的 sys_user / sys_group 组装成基础平台统一的 * {@link BoaUser}(含部门/学校/教育局多级组织)。

* 这是「UserBase 由子应用升级为用户中心」的关键:平台侧 {@code UserDetailsProvider} * ({@code UserBaseUserDetailsProvider})与开放接口({@code OpenBaseController})都复用本服务, - * 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。 + * 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。

+ * + * 缓存编排(boaUser + 空值哨兵):真实值经 {@link BoaUserLoader} 的 + * {@code @Cacheable} 三维入口(login:/phone:/id:)缓存,TTL 5min; + * 查无此人时向 {@code boaUserNull} 哨兵缓存写入标记对象(TTL 30s,新注册用户可感知性优先), + * 消费侧只判哨兵 key 存在、绝不把哨兵对象当业务值返回。条带锁承担同 key 排队回源 + * (SD Redis 2.7 的 sync=true 与 null 返回值不兼容,详见 BoaUserLoader 类注释)。 + * 哨兵 put/get 为手动路径(CacheErrorHandler 不覆盖),自带 try-catch 降级,Redis 抖动时不影响主流程。
+ * 失效:UserChangedEvent AFTER_COMMIT 驱逐(身份切换必须立即失效,见 CacheInvalidationListener)。 */ @Service public class UserCenterService { - /** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */ - private static final int ROOT_PARENT_ID = 0; + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 空值哨兵标记:值本体无业务含义(消费侧只判 key 存在),字段仅用于序列化与排查识别。 */ + static final BoaUser NULL_SENTINEL = buildNullSentinel(); + + /** + * 按条带加锁的互斥对象(单 JVM 排队回源,防击穿):boaUser 加载器注解不能用 sync=true + * (SD Redis 2.7 sync 路径对 null 返回值会抛 IllegalArgumentException),由本门面等价承担。 + */ + private static final int LOCK_STRIPES = 32; + private final Object[] keyLocks = new Object[LOCK_STRIPES]; + + { + for (int i = 0; i < LOCK_STRIPES; i++) { + keyLocks[i] = new Object(); + } + } + + private static BoaUser buildNullSentinel() { + BoaUser sentinel = new BoaUser(); + sentinel.setId(0); + sentinel.setAccount("__NULL_SENTINEL__"); + return sentinel; + } @Autowired - private SysUserRepository sysUserRepository; + private BoaUserLoader boaUserLoader; + + /** @Primary 复合管理器:Redis 优先/降级 Caffeine,boaUserNull 恒按注册表路由。 */ @Autowired - private SysUserGroupRelRepository sysUserGroupRelRepository; - @Autowired - private SysGroupRepository sysGroupRepository; - @Autowired - private SysUserRoleRelRepository sysUserRoleRelRepository; - @Autowired - private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; - @Autowired - private MainGroupResolver mainGroupResolver; - @Autowired - private IdentityService identityService; + private CacheManager cacheManager; + + /** + * 入参格式前置校验(方案 3.2 穿透对策第三条):格式非法的 loginName/phone 直接返回 null, + * 不进查询逻辑也不写空值哨兵——否则恶意/异常调用方可用无限随机串把哨兵缓存灌成无界 key 空间。 + */ + private static final Pattern ACCOUNT_PATTERN = Pattern.compile("^[A-Za-z0-9_@.\\-]{1,64}$"); + private static final Pattern PHONE_PATTERN = Pattern.compile("^\\d{5,20}$"); public BoaUser getUserInfoWithDeptByAccount(String account) { - if (account == null) { + if (account == null || account.trim().isEmpty()) { return null; } - return resolve(sysUserRepository.findByAccount(account.trim())); + String trimmed = account.trim(); + if (!ACCOUNT_PATTERN.matcher(trimmed).matches()) { + return null; + } + return resolveCached("login:" + trimmed, key -> boaUserLoader.byAccount(trimmed)); } public BoaUser getUserInfoWithDeptByPhone(String phone) { - if (phone == null) { + if (phone == null || phone.trim().isEmpty()) { return null; } - return resolve(sysUserRepository.findByPhone(phone.trim())); + String trimmed = phone.trim(); + if (!PHONE_PATTERN.matcher(trimmed).matches()) { + return null; + } + return resolveCached("phone:" + trimmed, key -> boaUserLoader.byPhone(trimmed)); } public BoaUser getUserInfoWithDeptById(Integer id) { - if (id == null) { + if (id == null || id <= 0) { return null; } - return resolve(sysUserRepository.findById(id).orElse(null)); - } - - private BoaUser resolve(SysUser user) { - if (user == null) { - return null; - } - BoaUser boa = new BoaUser(); - boa.setId(user.getId()); - boa.setAccount(user.getAccount()); - boa.setName(user.getRealName()); - boa.setIdcard(user.getIdcard()); - boa.setPhone(user.getPhone()); - boa.setProfilePicture(user.getProfilePicture()); - // 用户表无 email / gender 列,置 null - boa.setEmail(null); - boa.setGender(null); - boa.setType(user.getType() != null ? user.getType().getKey() : null); - // 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用) - boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0); - // 角色列表:从 sys_user_role_rel(平台身份角色权威源)取正常态 roleId 去重,不再读 sys_user.roles - List roleIds = sysUserRoleRelRepository - .findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL); - boa.setRoleIds(roleIds); - - List groups = buildGroups(user.getId()); - boa.setGroups(groups); - // 部门/教育局:仍从组织链推导(与主组/区域组语义绑定) - deriveOrg(groups, boa); - // 学校/当前激活身份/可选学校:从身份关联表推导(school 语义=当前激活学校) - deriveIdentity(user.getId(), boa); - return boa; + return resolveCached("id:" + id, key -> boaUserLoader.byId(id)); } /** - * 组装用户组织链:取主归属组(isMain=1 取 id 最小,无主兜底 id 最小),沿 parentId 向上回溯到根, - * 顺序为「主组 -> ... -> 根方向」(近 -> 远)。 + * 三维 key 统一编排:条带锁内「查哨兵 → 走缓存加载 → miss 则写哨兵」。 + * 同 key 并发未命中时排队回源(等价 sync=true),跨 key 仅按条带弱互斥,无全局串行。 */ - private List buildGroups(Integer userId) { - List rels = sysUserGroupRelRepository.findByUserId(userId); - if (rels == null || rels.isEmpty()) { - return new ArrayList<>(); - } - SysUserGroupRel mainRel = mainGroupResolver.resolve(rels); - Integer groupId = mainRel != null ? mainRel.getGroupId() : null; - if (groupId == null) { - return new ArrayList<>(); - } - - List chain = new ArrayList<>(); - Integer cursor = groupId; - Set seen = new HashSet<>(); - while (cursor != null && cursor != ROOT_PARENT_ID && seen.add(cursor)) { - SysGroup g = sysGroupRepository.findById(cursor).orElse(null); - if (g == null) { - break; + private BoaUser resolveCached(String key, Function loader) { + Object lock = keyLocks[Math.floorMod(key.hashCode(), LOCK_STRIPES)]; + synchronized (lock) { + if (hitNullSentinel(key)) { + return null; } - BoaGroup bg = new BoaGroup(); - bg.setId(g.getId()); - bg.setName(g.getName()); - bg.setParentId(g.getParentId()); - bg.setType(g.getType() != null ? g.getType().getKey() : null); - chain.add(bg); - cursor = g.getParentId(); - } - return chain; - } - - /** 从组织链推导 school / bureau / dept。 */ - private void deriveOrg(List groups, BoaUser boa) { - if (groups == null || groups.isEmpty()) { - return; - } - BoaGroup school = null; - BoaGroup bureau = null; - for (BoaGroup g : groups) { - if (school == null && Objects.equals(g.getType(), GroupTypeEnum.SCHOOL_GROUP.getKey())) { - school = g; + BoaUser boa = loader.apply(key); + if (boa == null) { + putNullSentinel(key); } - if (bureau == null && Objects.equals(g.getType(), GroupTypeEnum.AREA_GROUP.getKey())) { - bureau = g; + return boa; + } + } + + /** 是否命中空值哨兵(key 存在即查无此人)。异常视为未命中,继续走真实缓存/DB。 */ + private boolean hitNullSentinel(String key) { + try { + Cache nullCache = cacheManager.getCache(CacheNames.BOA_USER_NULL); + return nullCache != null && nullCache.get(key) != null; + } catch (Exception e) { + log.warn("[boaUser] 空值哨兵读失败,视为未命中 key={}: {}", key, e.getMessage()); + return false; + } + } + + /** 写入空值哨兵(30s TTL 由注册表驱动配置)。失败仅告警,不影响返回 null。 */ + private void putNullSentinel(String key) { + try { + Cache nullCache = cacheManager.getCache(CacheNames.BOA_USER_NULL); + if (nullCache != null) { + nullCache.put(key, NULL_SENTINEL); } + } catch (Exception e) { + log.warn("[boaUser] 空值哨兵写失败 key={}: {}", key, e.getMessage()); } - boa.setSchool(school); - boa.setBureau(bureau); - // 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校 - boa.setDept(groups.get(0)); - } - - /** - * 从身份关联推导当前激活身份与可选学校。school 字段语义=当前激活学校:
- * ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);
- * ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行, - * 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);
- * ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);
- * ④ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合组装 BoaGroup 列表(本期补码)。
- * school 类型守卫:仅当身份 schoolId 对应组类型=SCHOOL_GROUP 时回填 boa.school, - * 区域组/根直属用户保持 null——等价旧约定「填 0 不回填」的行为,避免子应用读到 school=区域组 对象。 - */ - private void deriveIdentity(Integer userId, BoaUser boa) { - Integer currentRoleId = null; - Integer currentSchoolId = null; - String currentSchoolName = null; - - SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null); - if (current != null && current.getRoleId() != null) { - currentRoleId = current.getRoleId(); - currentSchoolId = current.getSchoolId(); - currentSchoolName = current.getSchoolName(); - } else { - // 兜底:主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行), - // 而不是「主组+主角色」组合(避免组合出 B 学校老师的非法身份) - SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId); - if (mainRel != null) { - currentRoleId = mainRel.getRoleId(); - currentSchoolId = mainRel.getSchoolId(); - if (currentSchoolId != null && currentSchoolId > 0) { - SysGroup sch = sysGroupRepository.findById(currentSchoolId).orElse(null); - currentSchoolName = sch != null ? sch.getName() : null; - } - } - } - - // 当前激活学校:仅当对应组为学校组时回填(区域组/根直属保持 null) - if (currentSchoolId != null && currentSchoolId > 0 && isSchoolGroup(currentSchoolId)) { - boa.setSchool(toBoaGroup(currentSchoolId, currentSchoolName)); - } - // 当前激活身份(优先切换记录,兜底主身份)——组装为 BoaRoleInfo 富对象(childId/childName 本期不做,恒空) - BoaRoleInfo currentRole = new BoaRoleInfo(); - currentRole.setRoleId(currentRoleId); - currentRole.setRoleName(resolveRoleName(currentRoleId)); - currentRole.setSchId(currentSchoolId); - currentRole.setSchName(currentSchoolName); - boa.setCurrentRole(currentRole); - - // 可选学校集合:全部正常态身份行的 schoolId 去重(>0 过滤,恒为真实组 id) - List schoolIds = sysUserRoleRelRepository - .findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL); - if (schoolIds != null && !schoolIds.isEmpty()) { - List schools = schoolIds.stream() - .filter(sid -> sid != null && sid > 0) - .map(sid -> toBoaGroup(sid, null)) - .collect(Collectors.toList()); - boa.setSchools(schools); - } - } - - /** 该组是否为学校组(SCHOOL_GROUP);组不存在视为 false。 */ - private boolean isSchoolGroup(Integer groupId) { - SysGroup g = sysGroupRepository.findById(groupId).orElse(null); - return g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP; - } - - /** 由角色 id 反查角色名(SysRoleEnum.value);未知角色返回 null。 */ - private String resolveRoleName(Integer roleId) { - if (roleId == null) { - return null; - } - SysRoleEnum role = IEnum.getByKey(SysRoleEnum.class, roleId); - return role != null ? role.getValue() : null; - } - - /** 组装一个 BoaGroup(仅填充 id 与 name;name 为 null 时回源查 sys_group) */ - private BoaGroup toBoaGroup(Integer groupId, String fallbackName) { - BoaGroup bg = new BoaGroup(); - bg.setId(groupId); - if (fallbackName != null) { - bg.setName(fallbackName); - } else { - SysGroup g = sysGroupRepository.findById(groupId).orElse(null); - bg.setName(g != null ? g.getName() : null); - } - return bg; } } diff --git a/src/main/java/com/yunhai/userbase/service/resolve/AccessScope.java b/src/main/java/com/yunhai/userbase/service/resolve/AccessScope.java new file mode 100644 index 0000000..85995f3 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/resolve/AccessScope.java @@ -0,0 +1,64 @@ +package com.yunhai.userbase.service.resolve; + +import com.yunhai.userbase.enums.AccessLevelEnum; +import lombok.Data; + +import java.util.Collections; +import java.util.List; + +/** + * 管理端访问范围(一次请求内的权威判定结果)。
+ * 不可变值对象:由 {@link AccessScopeService#resolveCurrentScope()} 计算, + * level 与范围组字段(bureauGroupId / schoolGroupIds)互斥——BUREAU 级只填 bureauGroupId, + * SCHOOL 级只填 schoolGroupIds,SUPER/NONE 均为空。
+ * 数据范围展开(scopeGroupIds = 范围组整棵子树)不在此对象内,由 AccessScopeService 惰性计算并缓存。 + * + * @author chenx + * @date 2026/9/4 + */ +@Data +public class AccessScope { + + /** 访问级别(后端权威判定) */ + private AccessLevelEnum level; + + /** BUREAU 级:区域组 id(范围 = 该区域组整棵子树) */ + private Integer bureauGroupId; + + /** SCHOOL 级:可管理学校组 id 列表(范围 = 各学校子树并集,多校管理员多棵子树) */ + private List schoolGroupIds; + + public static AccessScope of(AccessLevelEnum level) { + AccessScope scope = new AccessScope(); + scope.setLevel(level); + return scope; + } + + public static AccessScope superAdmin() { + return of(AccessLevelEnum.SUPER); + } + + public static AccessScope bureau(Integer bureauGroupId) { + AccessScope scope = of(AccessLevelEnum.BUREAU); + scope.setBureauGroupId(bureauGroupId); + return scope; + } + + public static AccessScope school(List schoolGroupIds) { + AccessScope scope = of(AccessLevelEnum.SCHOOL); + scope.setSchoolGroupIds(Collections.unmodifiableList(schoolGroupIds)); + return scope; + } + + public List schoolGroupIds() { + return schoolGroupIds == null ? Collections.emptyList() : schoolGroupIds; + } + + public boolean isSuper() { + return level == AccessLevelEnum.SUPER; + } + + public boolean isNone() { + return level == AccessLevelEnum.NONE; + } +} diff --git a/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java b/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java new file mode 100644 index 0000000..6dd5cec --- /dev/null +++ b/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java @@ -0,0 +1,392 @@ +package com.yunhai.userbase.service.resolve; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.common.context.UserContext; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.entity.SysUserRoleRel; +import com.yunhai.userbase.enums.AccessLevelEnum; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.enums.SysRoleEnum; +import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.repository.SysUserRoleRelRepository; +import com.yunhai.userbase.utils.DicUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 管理端访问范围统一解析器(四级访问控制的唯一权威判定入口)。
+ * 级别判定规则(《管理端权限分级改造方案》§1,优先级 SUPER > SCHOOL > BUREAU > NONE): + *
    + *
  1. SUPER:sys_user.type=1(内置超管账号)或基石账号 id=100000,行为与现状完全一致;
  2. + *
  3. SCHOOL:sys_user_role_rel 存在正常态身份行 roleId∈{2 校领导, 7 管理员} 且其 schoolId + * 对应组类型为学校组(schoolId 恒为真实组 id,局管理员的 schoolId=区域组,必须校验组类型 + * 防止误判)。与当前激活身份无关,不看 sys_user_current_role;
  4. + *
  5. BUREAU:主归属组链(主组 → 根)含区域组且不含学校组,不限角色;
  6. + *
  7. NONE:其余(登录用户但无任何管理面)。
  8. + *
+ * 请求级缓存:一次请求多次调用只算一次(scope 与展开后的 scopeGroupIds 各自惰性缓存), + * {@code BoaUserContextInterceptor.afterCompletion} 统一 clear。
+ * 不加接口(参照 IdentityService 特例):仅管理端 Controller 调用,oapi 链路不感知本类。 + * + * @author chenx + * @date 2026/9/4 + */ +@Service +public class AccessScopeService { + + /** 虚拟父节点:根节点的 parentId(与 SysGroupService/GroupAuthzService 约定一致) */ + private static final int ROOT_PARENT_ID = 0; + + /** 学校管理员身份行:2=校领导 7=管理员(SysRoleEnum) */ + private static final Set SCHOOL_ADMIN_ROLE_IDS = new HashSet<>(java.util.Arrays.asList( + SysRoleEnum.SCHOOL_LEADER.getKey(), SysRoleEnum.ADMIN.getKey())); + + /** 请求级 scope 缓存(含 NONE,避免重复判定) */ + private static final ThreadLocal SCOPE_CACHE = new ThreadLocal<>(); + + /** 请求级范围组集合缓存(scopeGroupIds 的展开结果;SUPER 为 null 表示不限制) */ + private static final ThreadLocal> SCOPE_GROUP_CACHE = new ThreadLocal<>(); + + @Autowired + private SysUserRepository sysUserRepository; + + @Autowired + private SysUserRoleRelRepository sysUserRoleRelRepository; + + @Autowired + private SysUserGroupRelRepository sysUserGroupRelRepository; + + @Autowired + private SysGroupRepository sysGroupRepository; + + @Autowired + private MainGroupResolver mainGroupResolver; + + // ===================== 请求级缓存 ===================== + + /** 清空请求级缓存(BoaUserContextInterceptor.afterCompletion 统一调用) */ + public static void clearCache() { + SCOPE_CACHE.remove(); + SCOPE_GROUP_CACHE.remove(); + } + + /** + * 解析当前登录用户的访问范围(请求级 ThreadLocal 惰性缓存)。
+ * 未登录(UserContext 为空)按 NONE 处理——管理端所有非白名单接口将被拦截。 + */ + public AccessScope resolveCurrentScope() { + AccessScope cached = SCOPE_CACHE.get(); + if (cached != null) { + return cached; + } + AccessScope scope = computeScope(UserContext.getCurrentUser()); + SCOPE_CACHE.set(scope); + return scope; + } + + /** 无缓存强制重算(仅供测试与内部 compute 复用) */ + AccessScope computeScope(Integer userId) { + if (userId == null) { + return AccessScope.of(AccessLevelEnum.NONE); + } + SysUser user = sysUserRepository.findById(userId).orElse(null); + if (user == null) { + return AccessScope.of(AccessLevelEnum.NONE); + } + // 1) SUPER:type=1 或基石账号(与 GroupAuthzService.isSuperAdmin 口径一致) + if (isSuperAdmin(user, userId)) { + return AccessScope.superAdmin(); + } + Map groupMap = null; + // 2) SCHOOL:正常态身份行 roleId∈{2,7} 且 schoolId 为学校组 + List roleRels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); + List schoolGroupIds = new ArrayList<>(); + for (SysUserRoleRel rel : roleRels) { + if (rel.getRoleId() == null || rel.getSchoolId() == null + || !SCHOOL_ADMIN_ROLE_IDS.contains(rel.getRoleId())) { + continue; + } + if (groupMap == null) { + groupMap = buildAllGroupMap(); + } + SysGroup school = groupMap.get(rel.getSchoolId()); + if (school != null && school.getType() == GroupTypeEnum.SCHOOL_GROUP + && !schoolGroupIds.contains(rel.getSchoolId())) { + schoolGroupIds.add(rel.getSchoolId()); + } + } + if (!schoolGroupIds.isEmpty()) { + return AccessScope.school(schoolGroupIds); + } + // 3) BUREAU:主归属组链含区域组且不含学校组(不限角色) + SysUserGroupRel mainRel = mainGroupResolver.resolve(userId).orElse(null); + if (mainRel != null && mainRel.getGroupId() != null) { + if (groupMap == null) { + groupMap = buildAllGroupMap(); + } + List chain = ancestorChain(mainRel.getGroupId(), groupMap); + boolean chainHasSchool = false; + Integer areaId = null; + for (Integer gid : chain) { + SysGroup g = groupMap.get(gid); + if (g == null) { + continue; + } + if (g.getType() == GroupTypeEnum.SCHOOL_GROUP) { + chainHasSchool = true; + break; + } + if (areaId == null && g.getType() == GroupTypeEnum.AREA_GROUP) { + areaId = g.getId(); + } + } + if (!chainHasSchool && areaId != null) { + return AccessScope.bureau(areaId); + } + } + // 4) 其余:NONE + return AccessScope.of(AccessLevelEnum.NONE); + } + + private boolean isSuperAdmin(SysUser user, Integer userId) { + if (user.getType() != null && UserTypeEnum.SUPER_ADMIN.getKey().equals(user.getType().getKey())) { + return true; + } + return DicUtil.SUPER_ADMIN_ID.equals(userId); + } + + // ===================== 范围展开 ===================== + + /** + * 当前用户的数据范围组集合 = bureauGroupId / schoolGroupIds 各自展开的整棵子树(含自身)并集。
+ * SUPER 返回 null(约定「不限制」,调用方据此跳过过滤);NONE 返回空集合(任何组都不在范围内)。
+ * 请求级缓存:与 scope 同生命周期,避免同请求内重复 findAll。 + */ + public Set scopeGroupIds() { + AccessScope scope = resolveCurrentScope(); + if (scope.isSuper()) { + return null; + } + Set cached = SCOPE_GROUP_CACHE.get(); + if (cached != null) { + return cached; + } + Set result = new LinkedHashSet<>(); + if (scope.getLevel() == AccessLevelEnum.BUREAU && scope.getBureauGroupId() != null) { + result.addAll(listSelfAndDescendantIds(scope.getBureauGroupId())); + } else if (scope.getLevel() == AccessLevelEnum.SCHOOL) { + for (Integer schoolId : scope.schoolGroupIds()) { + result.addAll(listSelfAndDescendantIds(schoolId)); + } + } + result = Collections.unmodifiableSet(result); + SCOPE_GROUP_CACHE.set(result); + return result; + } + + /** + * 组自身 + 全部子孙 id(findAll 一次载入内存展开,MySQL5.7 无递归 CTE, + * 与 SysGroupService.listSelfAndDescendantIds 同套路,此处独立实现避免循环依赖)。 + */ + private List listSelfAndDescendantIds(Integer groupId) { + List result = new ArrayList<>(); + if (groupId == null) { + return result; + } + Map> 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()); + } + result.add(groupId); + List frontier = new ArrayList<>(); + frontier.add(groupId); + while (!frontier.isEmpty()) { + List next = new ArrayList<>(); + for (Integer id : frontier) { + List children = childrenMap.get(id); + if (children != null) { + result.addAll(children); + next.addAll(children); + } + } + frontier = next; + } + return result; + } + + // ===================== Guard 方法(管理端 Controller 调用) ===================== + + /** 仅超管:其余级别抛「暂无权限」 */ + public void requireSuper() { + if (!resolveCurrentScope().isSuper()) { + throw new DException("暂无权限"); + } + } + + /** 超管 + 局端(应用开通等局端动作):SCHOOL/NONE 抛「暂无权限」 */ + public void requireSuperOrBureau() { + AccessScope scope = resolveCurrentScope(); + if (scope.getLevel() != AccessLevelEnum.SUPER && scope.getLevel() != AccessLevelEnum.BUREAU) { + throw new DException("暂无权限"); + } + } + + /** 管理员(SUPER/BUREAU/SCHOOL):NONE 抛「暂无权限」(NONE 已被拦截器挡住,双保险) */ + public void requireAdmin() { + if (resolveCurrentScope().isNone()) { + throw new DException("暂无权限"); + } + } + + /** 目标组必须在当前用户数据范围内(SUPER 不限制);groupId 为空视为非法 */ + public void requireGroupInScope(Integer groupId) { + if (groupId == null) { + throw new DException("组ID不能为空"); + } + Set scope = scopeGroupIds(); + if (scope == null || scope.contains(groupId)) { + return; + } + throw new DException("暂无权限访问该组织"); + } + + /** + * 编辑/删除边界:SCHOOL 禁止操作学校组本身(防改名/改类型拆校),子组可管理; + * BUREAU 禁止操作区域组本身。与 {@link #requireGroupInScope(Integer)} 配套使用。 + */ + public void requireGroupModifiable(Integer groupId) { + AccessScope scope = resolveCurrentScope(); + if (scope.getLevel() == AccessLevelEnum.SCHOOL && scope.schoolGroupIds().contains(groupId)) { + throw new DException("学校组本身不可编辑或删除"); + } + if (scope.getLevel() == AccessLevelEnum.BUREAU && groupId.equals(scope.getBureauGroupId())) { + throw new DException("区域组本身不可编辑或删除"); + } + } + + /** + * 建组落点边界:非超管 pid 必填且必须落在数据范围内。
+ * pid 为 null/0 表示挂平台根下——仅超管可用(学校不能跳出本校建组、局端不能跳出区域建组, + * scopeGroupIds 天然不含范围外父级,requireGroupInScope 即可挡住)。 + */ + public void requireCreatePointInScope(Integer pid) { + if (resolveCurrentScope().isSuper()) { + return; + } + if (pid == null || pid == ROOT_PARENT_ID) { + throw new DException("暂无权限在根节点下创建组"); + } + requireGroupInScope(pid); + } + + /** 目标组集合必须全部在当前用户数据范围内(SUPER 不限制);空集合直接放行由既有参数校验兜底 */ + public void requireGroupsInScope(Collection groupIds) { + if (groupIds == null || groupIds.isEmpty()) { + return; + } + for (Integer groupId : groupIds) { + requireGroupInScope(groupId); + } + } + + /** + * 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。
+ * 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。 + */ + public void requireUsersInScope(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return; + } + Set scope = scopeGroupIds(); + if (scope == null) { + return; + } + Set distinct = new LinkedHashSet<>(userIds); + distinct.remove(null); + if (distinct.isEmpty()) { + return; + } + // 一次 IN 查询收集「归属组命中 scope」的用户,全部命中才放行 + Set inScope = new HashSet<>(); + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserIdIn(new ArrayList<>(distinct))) { + if (rel.getGroupId() != null && scope.contains(rel.getGroupId())) { + inScope.add(rel.getUserId()); + } + } + distinct.removeAll(inScope); + if (!distinct.isEmpty()) { + throw new DException("暂无权限操作该用户"); + } + } + + /** 单个用户是否存在至少一个归属组落在 scope 内(无组关系 = false) */ + public boolean userIntersectsScope(Integer userId) { + Set scope = scopeGroupIds(); + if (scope == null) { + return true; + } + if (userId == null) { + return false; + } + for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) { + if (rel.getGroupId() != null && scope.contains(rel.getGroupId())) { + return true; + } + } + return false; + } + + /** 目标用户必须在范围内(单个),否则抛「暂无权限操作该用户」 */ + public void requireUserInScope(Integer userId) { + Set scope = scopeGroupIds(); + if (scope == null) { + return; + } + if (!userIntersectsScope(userId)) { + throw new DException("暂无权限操作该用户"); + } + } + + // ===================== 私有工具 ===================== + + private Map buildAllGroupMap() { + Map map = new HashMap<>(); + for (SysGroup g : sysGroupRepository.findAll()) { + map.put(g.getId(), g); + } + return map; + } + + /** 祖先链(自身 → 根,含自身),带 visited 防环(与 GroupAuthzService.ancestorChain 同套路) */ + private List ancestorChain(Integer groupId, Map allGroupMap) { + List chain = new ArrayList<>(); + Set visited = new HashSet<>(); + Integer cursor = groupId; + while (cursor != null && cursor != ROOT_PARENT_ID && visited.add(cursor)) { + SysGroup g = allGroupMap.get(cursor); + if (g == null) { + break; + } + chain.add(cursor); + cursor = g.getParentId(); + } + return chain; + } +} diff --git a/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java b/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java index e357906..c5b51b7 100644 --- a/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java +++ b/src/main/java/com/yunhai/userbase/utils/CacheKitUtils.java @@ -6,8 +6,11 @@ import java.util.function.Supplier; * @author chenx * @date 2026/1/6 20:54 * @description 通用缓存封装 + * @deprecated 手动 getOrLoad 缓存风格已废弃(三套缓存风格并存问题,《userbase缓存决策方案.md》第六章存量收敛)。 + * 新缓存一律走 Spring Cache 注解 + {@link com.yunhai.userbase.config.CacheNames} 注册表; + * 本类当前无生产调用方,仅为历史保留。 */ - +@Deprecated public final class CacheKitUtils { private CacheKitUtils() {} diff --git a/src/main/java/com/yunhai/userbase/utils/RedisUtil.java b/src/main/java/com/yunhai/userbase/utils/RedisUtil.java index b1abccc..88abe91 100644 --- a/src/main/java/com/yunhai/userbase/utils/RedisUtil.java +++ b/src/main/java/com/yunhai/userbase/utils/RedisUtil.java @@ -14,8 +14,13 @@ import java.util.concurrent.TimeUnit; /** * Redis工具类 + * + * @deprecated 作为「缓存」用途已废弃(《userbase缓存决策方案.md》第六章存量收敛):新缓存一律走 + * Spring Cache 注解 + {@link com.yunhai.userbase.config.CacheNames} 注册表(统一 TTL/失效/对账), + * 禁止绕过注册表新增手动缓存。仅剩 BaseController 的 Hash 结构演示端点在用,属非缓存类 + * Hash/按字段读写场景;确有此类需求时注入 RedisTemplate 自行管理。 */ - +@Deprecated @Component public final class RedisUtil { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 53b14d4..f540524 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -96,7 +96,10 @@ management: endpoints: web: exposure: - include: health + # [LOADTEST-METRICS-BEGIN] 临时埋点:追加 prometheus 端点供压测观测(方案 §2.5), + # 压测结束后把 include 改回 health。见《埋点移除清单.md》。 + include: health,prometheus + # [LOADTEST-METRICS-END] endpoint: health: show-details: never diff --git a/src/main/resources/sql/oapi_metrics_loadtest.sql b/src/main/resources/sql/oapi_metrics_loadtest.sql new file mode 100644 index 0000000..7799d11 --- /dev/null +++ b/src/main/resources/sql/oapi_metrics_loadtest.sql @@ -0,0 +1,25 @@ +-- ============================================================================= +-- [LOADTEST-METRICS] 临时埋点表 —— 《压测与oapi埋点统计方案.md》阶段一 +-- 压测结束后移除:DROP TABLE(见文末);代码侧删除 loadtestmetrics 包。 +-- 清单见仓库根《埋点移除清单.md》。 +-- ============================================================================= + +-- 本地 dev 由 Hibernate(ddl-auto=update) 依据 OapiMetricsEntity 自动建表; +-- 生产 / 独立压测环境由 DBA 手工执行本脚本(与实体定义保持一致)。 + +CREATE TABLE IF NOT EXISTS oapi_metrics ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + stat_time DATETIME NOT NULL COMMENT '统计切片时间(60s 窗口结束时刻)', + uri VARCHAR(200) NOT NULL COMMENT '规范化路由(去 query、数字段归一为 {id})', + method VARCHAR(10) NOT NULL COMMENT 'GET/POST/PUT/DELETE', + app_key VARCHAR(32) NOT NULL COMMENT '调用方 appKey,未验签为 header 原值,无 header 为 -', + cnt BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内请求次数', + total_cost BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内耗时累计 ms', + err_cnt BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内 HTTP 非 200 次数', + is_load_test TINYINT(1) NOT NULL DEFAULT 0 COMMENT '压测流量单列(X-Load-Test: 1)', + KEY idx_time (stat_time), + KEY idx_appkey_time (app_key, stat_time) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '[LOADTEST-METRICS] oapi 访问频次统计(临时,压测结束后删除)'; + +-- 压测结束、热度报告导出后执行: +-- DROP TABLE IF EXISTS oapi_metrics; diff --git a/src/main/resources/sql/school_app_override_init.sql b/src/main/resources/sql/school_app_override_init.sql new file mode 100644 index 0000000..4f59512 --- /dev/null +++ b/src/main/resources/sql/school_app_override_init.sql @@ -0,0 +1,33 @@ +-- 学校私有应用覆盖表初始化脚本(备案用;实际由 ddl-auto=update 自动建表) +-- 《管理端权限分级改造方案》§5:仅两张覆盖表,无数据迁移、无存量数据回填。 +-- 时间:2026-09-04 + +CREATE TABLE IF NOT EXISTS sys_school_app_override ( + id INT AUTO_INCREMENT PRIMARY KEY, + create_user INT NOT NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_user INT NULL, + update_time DATETIME(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + school_id INT NOT NULL, + app_id INT NOT NULL, + name VARCHAR(64) NULL, + description VARCHAR(255) NULL, + icon VARCHAR(255) NULL, + sort_no INT NULL, + UNIQUE KEY uk_school_app (school_id, app_id), + KEY idx_school_app_override_app (app_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sys_school_category_override ( + id INT AUTO_INCREMENT PRIMARY KEY, + create_user INT NOT NULL, + create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + update_user INT NULL, + update_time DATETIME(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + school_id INT NOT NULL, + category_id INT NOT NULL, + name VARCHAR(64) NULL, + sort_no INT NULL, + UNIQUE KEY uk_school_category (school_id, category_id), + KEY idx_school_category_override_cat (category_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java index 25ef956..0c8ce70 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java @@ -10,6 +10,7 @@ import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.model.GroupSnapshot; import com.yunhai.userbase.model.groupauthz.UserPermissionCategoryVo; import com.yunhai.userbase.model.groupauthz.UserPermissionModuleVo; import com.yunhai.userbase.repository.AppCategoryRepository; @@ -47,13 +48,16 @@ import static org.mockito.Mockito.when; * GroupAuthzService 权限计算单元测试。 * 重点验证「就近原则 + 子组继承闸门」:沿组的祖先链向上,取最近的有显式模块配置的组作为该应用唯一权限来源, * 只返回该来源组的模块集合(不向上合并更远的祖先);且来源组若为祖先,必须勾选子组继承(inheritFlag=true)才下放 - * (修复「子组只勾1个却继承学校全部」「返回3个而非最近祖先的2个」「父级未勾子组继承仍被继承」三类 bug)。 + * (修复「子组只勾1个却继承学校全部」「返回3个而非最近祖先的2个」「父级未勾子组继承仍被继承」三类 bug)。
+ * 组全量数据现经 groupSnapshot 快照提供(buildAllGroupMap 改造),故统一 stub snapshot 而非 repository.findAll。 */ class GroupAuthzServiceTest { @InjectMocks private GroupAuthzService groupAuthzService; + @Mock + private GroupSnapshotService groupSnapshotService; @Mock private SysGroupAppRelRepository sysGroupAppRelRepository; @Mock @@ -93,7 +97,7 @@ class GroupAuthzServiceTest { * └─ 30 学生组 */ private void initGroups() { - when(sysGroupRepository.findAll()).thenReturn(Arrays.asList( + List groups = Arrays.asList( group(5, "教育云中心", 0, GroupTypeEnum.NORMAL_GROUP), group(27, "信阳教育局", 5, GroupTypeEnum.AREA_GROUP), group(16, "云海中学", 27, GroupTypeEnum.SCHOOL_GROUP), @@ -101,7 +105,8 @@ class GroupAuthzServiceTest { group(29, "教师组", 16, GroupTypeEnum.TEACHER_GROUP), group(30, "学生组", 16, GroupTypeEnum.STUDENT_GROUP), group(31, "英语组", 29, GroupTypeEnum.NORMAL_GROUP) - )); + ); + when(groupSnapshotService.snapshot()).thenReturn(new GroupSnapshot(groups)); } @Test