后端缓存管理

This commit is contained in:
chenxing
2026-09-07 09:33:10 +08:00
parent 79b1d99db9
commit 1047d2387f
56 changed files with 3786 additions and 331 deletions
+8
View File
@@ -82,6 +82,14 @@
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点:暴露 /actuator/prometheus 供压测观测(方案 §2.5)。
压测结束后整段删除,并同步回退 application.yml 的 management 段。见《埋点移除清单.md》。 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-END] -->
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
@@ -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();
}
}
@@ -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:"暂无权限"}。<br>
* 与前端 alova 约定一致(status!=200 会被当作网络错误,业务码走 errorHandle default 分支弹 msg)。<br>
* 白名单(仅登录即可访问):GetUserInfo / GetUserScope / GetSysDict / RedirectTo。<br>
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
*
* @author chenx
* @date 2026/9/4
*/
@Component
public class ManageAccessInterceptor implements HandlerInterceptor {
/**
* 白名单(Ant 风格,* 匹配 platform 前缀):
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理。
*/
private static final List<String> 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;
}
}
@@ -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 抽象)。<br><br>
*
* 组成(缓存名单一定义、一注册、一路由,全部由 {@link CacheNames#REGISTRY} 驱动):
* <ul>
* <li><b>redisCacheManager</b>:注册表中 REDIS 存放的缓存按名定制 TTL(30s~2h 分级定价),
* 写入经抖动装饰器打散过期点(±10%,防同时过期型雪崩);未注册名兜底 5min;</li>
* <li><b>caffeineCacheManager</b>:Redis 降级兜底,按字节限重(maximumWeight + 启发式估算,
* 防巨型条目绕过条数限制撑爆内存);</li>
* <li><b>localCacheManager</b>:LOCAL 存放专用(groupSnapshot / appKeyToId),Caffeine 独立实例,
* 永不进 Redis;</li>
* <li><b>FallbackCacheManager</b>(@Primary):本地专用名恒路由 localCacheManager,
* 其余 Redis 健康走 Redis、故障降级 Caffeine,后台 10s 探活自动切回;</li>
* <li><b>CacheErrorHandler</b>:堵探活间隙——Redis 读写异常降级为 log+miss 而非上冒 500,
* 驱逐失败打 error(脏数据存活至 TTL 后自愈)。</li>
* </ul>
*/
@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 分布式缓存管理器。<br>
* 各缓存 TTL 由 {@link CacheNames#REGISTRY} 定价(配置型 1h~2h / 用户身份型 5min / 空值哨兵 30s),
* 未注册名兜底全局 5min;每次写入 TTL 加 ±10% 随机抖动。
*/
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer<Object> 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<String, RedisCacheConfiguration> 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 降级兜底)。<br>
* 按字节限重(原 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<Object, Object>) (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<Object, Object>) (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 使用。<br>
* 注册表 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<String> getCacheNames() {
return redisUp ? redisManager.getCacheNames() : caffeineManager.getCacheNames();
Set<String> 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<Object, Object> jitterExpiry(Duration base) {
long baseNanos = base.toNanos();
return new Expiry<Object, Object>() {
@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<Object> jacksonSerializer() {
Jackson2JsonRedisSerializer<Object> jackson = new Jackson2JsonRedisSerializer<>(Object.class);
@@ -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:代码即文档 + 运行时对账)。<br><br>
*
* <b>本类是全工程所有 Spring Cache 缓存名的单一声明处</b>:
* <ul>
* <li>{@code @Cacheable/@CacheEvict/@CachePut} 的 value 一律引用本类常量,禁止硬编码字符串;</li>
* <li>{@link CacheConfig} 依据 {@link #REGISTRY} 为每个缓存定制 TTL(含随机抖动)与存放路由
* (Redis / 本地 Caffeine);</li>
* <li>对账接口 {@code GET /api/cache/registry} 输出「注册表声明 × 运行时实际 × 命中率」对照,
* 注册表里声明但运行时从未出现 = 声明了没人用;运行时出现但注册表没有 = 有人硬编码缓存名。</li>
* </ul>
*
* <b>新增缓存流程</b>(详见《userbase缓存决策方案.md》第七章):
* 在本类加常量与 Meta(javadoc 必须写明:内容、key 规则、TTL、存放、依赖表、<b>谁负责失效</b>)→
* 在 {@link CacheConfig} 无需额外动作(配置由 REGISTRY 驱动)→ 读侧加 {@code @Cacheable(sync=true)} →
* 写侧事务提交后发失效事件 → 自检调对账接口。<br><br>
*
* <b>禁止事项</b>:不设 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)。<br>
* key 规则:{@code #id};TTL 1h(配置型:变更以月计,脏了只是旧名字/旧图标,事件失效保即时性);<br>
* 存放:Redis;依赖表:application_info;<br>
* 失效责任:{@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、认证链路)。<br>
* key 规则(三个维度各自成 key):{@code 'login:' + account}、{@code 'phone:' + phone}、{@code 'id:' + userId};<br>
* TTL 5min(用户身份型:脏 currentRole = 用户以错误身份操作,权限事故级);<br>
* 存放:Redis;依赖表:sys_user、sys_user_group_rel、sys_user_role_rel、sys_user_current_role、sys_group;<br>
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
* (SysUserService 全部写口 + IdentityService.switchIdentity 身份切换——<b>身份切换必须立即失效</b>;
* 监听器按 id/login/phone 精确驱逐,用户被物理删除时整体 clear 兜底)。
*/
public static final String BOA_USER = "boaUser";
/**
* boaUser 的<b>空值哨兵</b>(byLoginName/byPhone/byId 查无此人时的穿透防护)。<br>
* key 规则:与 {@link #BOA_USER} 相同的三维 key;值是哨兵对象,消费侧只判「key 存在」、绝不读值;<br>
* TTL 30s(反向哨兵:脏「不存在」会阻新用户使用,新注册用户须尽快可查);<br>
* 存放:Redis;失效责任:同 {@link #BOA_USER}(UserChangedEvent,随真实 key 一起驱逐)。<br>
* 设计说明:{@code unless} 在 {@code sync=true} 下被 Spring 忽略,无法用注解把哨兵与真实值分流到
* 不同 TTL 的缓存,故哨兵缓存由 UserCenterService 手动 put(30s 短 TTL 由本注册表驱动配置)。
*/
public static final String BOA_USER_NULL = "boaUserNull";
/**
* sys_group 全表快照(单条目:不可变 List + 派生 byId/childrenMap 索引)。<br>
* key 规则:固定单 key {@code 'all'}(整体驱逐+重建,不做增量维护);<br>
* TTL 30min(组织架构型:变更以天/周计,脏数据影响祖先链→开通判定;组写频次极低,命中率 &gt;99%);<br>
* 存放:<b>Caffeine 仅本地</b>(约 1 万行 ≈ 序列化 2MB/JVM 4~8MB,进 Redis 则每次读取网络传输 2MB,
* 权限计算一次请求读 N 次,无意义;本地=对象引用传递,零序列化零网络。单副本前提,多副本时需改两层结构或广播);<br>
* 依赖表:sys_group;<br>
* 失效责任:{@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)。<br>
* key 规则:{@code #appKey};TTL 2h(配置型);<br>
* 存放:Caffeine 仅本地;依赖表:application_info;<br>
* 失效责任:搭车 {@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<String, Meta> REGISTRY;
static {
Map<String, Meta> 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;
}
}
@@ -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/**");
}
}
@@ -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<SysAppRoleVo> 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<AppRoleAuthzAppVo> 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<AppRoleUserVo> 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);
}
}
@@ -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)。<br>
* 访问矩阵:<br>
* - 仅超管:新增/删除应用、密钥、推送设置、模块管理、新增/删除类型;<br>
* - 超管+局端:AppAuthzList(应用开通页数据源);<br>
* - 学校管理员:AppList/AppTypeList/AppSortList/EditApp/AppSort/EditType/AppTypeSort 走覆盖表
* (仅本校已开通应用,名称/描述/图标/排序,均只对该校生效);<br>
* - 局端不开放应用维护(局端的应用动作是「开通」,在应用开通页),落到写接口一律 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<AppCategoryVo> appTypeList(AppCategoryQuery query) {
return appCategoryService.list(query);
public List<AppCategoryVo> appTypeList(AppCategoryQuery query,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
List<AppCategoryVo> base = appCategoryService.list(query);
if (isSchoolLevel()) {
// 合并学校私有改名/排序
return schoolAppOverrideService.mergeCategoryList(base,
accessScopeService.resolveCurrentScope(), schoolId);
}
return base;
}
@OperationLog("应用类型排序")
@PostMapping("/AppTypeSort")
public void appTypeSort(@RequestBody List<Integer> ids) {
public void appTypeSort(@RequestBody List<Integer> 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)。<br>
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。
* 类型按 sortNo 升序,类型内应用按 sortNo、id 升序;无应用的类型 app 为空数组。<br>
* 应用开通页数据源,仅超管+局端。
*/
@GetMapping("/AppAuthzList")
public List<AppAuthzCategoryVo> 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<String, Object> refreshSignKeys() {
accessScopeService.requireSuper();
int count = signKeyCacheService.refreshAll();
Map<String, Object> result = new LinkedHashMap<>();
result.put("refreshed", count);
@@ -127,52 +195,93 @@ public class AppSettingsController {
@GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> 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<Integer> ids) {
public void appSort(@RequestBody List<Integer> 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<ApplicationInfoSortVo> appSortList(@RequestParam Integer categoryId) {
public List<ApplicationInfoSortVo> 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}。<br>
* 仅 SCHOOL 级返回数据;SUPER/BUREAU 返回空数组(前端不渲染下拉)。
*/
@GetMapping("/SchoolList")
public List<Map<String, Object>> 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<AppModuleVo> 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);
}
}
@@ -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;
}
/**
* 当前登录用户的管理端访问范围(四级访问控制)。<br>
* 不复用 GetUserInfo 的原因:BoaUser 是 yh-utils-core 外部类不可加字段,且 scope 规则
* (身份行 × 组类型)由后端单点计算,前端只消费结果、不重复实现判定逻辑,避免双端规则漂移。<br>
* 在 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 注解无法满足的场景)。<br>
* 手动管理 Redis Hash 的示例(@Cacheable 注解无法满足的场景)。<br>
* 适用场景:需要把一个对象的多个字段按 Hash 结构存储,<br>
* 可单独读写/更新某个字段,而不像 @Cacheable 那样把整个对象序列化成一个大 value。<br>
* 访问路径:GET {Config.API_PATH}/base/ManualHashCacheDemo。<br>
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。
* 注:手动缓存依赖 Redis;Redis 不可用时 RedisUtil 已内部捕获异常,操作失败但不影响接口本身。<br>
* <b>缓存统一管理后本端点仅为 Hash 结构用法演示</b>(《userbase缓存决策方案.md》存量收敛:
* 业务缓存一律走 Spring Cache + CacheNames 注册表,RedisUtil 已 @Deprecated,勿以本端点为新缓存范本)。
*
* @return 本次演示各步骤的结果快照
*/
@@ -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 的运行时可观测面)。<br><br>
*
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
* <ul>
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
* </ul>
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
* 注: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<String, Object> registry() {
boolean redisUp = !(cacheManager instanceof CacheConfig.FallbackCacheManager)
|| ((CacheConfig.FallbackCacheManager) cacheManager).isRedisUp();
// 运行时实际出现的缓存名:复合管理器 = 本地专用名 + 当前活跃侧(Redis 或降级 Caffeine)名
Set<String> runtimeNames = new TreeSet<>(cacheManager.getCacheNames());
List<Map<String, Object>> caches = new ArrayList<>();
for (Meta meta : CacheNames.REGISTRY.values()) {
Map<String, Object> 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<String> unregistered = new TreeSet<>(runtimeNames);
unregistered.removeAll(CacheNames.REGISTRY.keySet());
Set<String> unused = new TreeSet<>(CacheNames.REGISTRY.keySet());
unused.removeAll(runtimeNames);
Map<String, Object> 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<String, Object> statsOf(String name) {
CaffeineCache cache = findExistingCaffeineCache(name);
if (cache == null) {
return null;
}
CacheStats stats = cache.getNativeCache().stats();
Map<String, Object> 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;
}
}
@@ -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 组应用权限管理(应用开通 + 组授权 + 权限判定)。<br>
* 左侧组树复用 /sysgroup/GetTree;本控制器提供右侧数据加载与保存接口。<br>
* 访问控制:应用开通类 = 超管+局端;组授权/权限一览 = 管理员(数据范围内组);
* 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<GroupAppVo> 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<GroupAuthzAppVo> groupModuleTree(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupAppsWithModules(groupId);
}
@@ -70,6 +85,7 @@ public class GroupAuthzController {
*/
@GetMapping("/GroupModuleTreeByCategory")
public List<GroupAuthzCategoryVo> 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<UserPermissionUserVo> groupUsers(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupUsers(groupId);
}
/**
* 权限一览:全局搜索用户(按账号/姓名/身份证模糊、手机号精确匹配),不分页。<br>
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。
* 返回用户及其主归属部门/组织路径,供左侧用户搜索形态展示。<br>
* 非超管结果按数据范围过滤:仅返回归属组与范围有交集的用户。
*/
@GetMapping("/SearchUsers")
public List<UserPermissionUserVo> searchUsers(@RequestParam String keyword) {
return groupAuthzService.searchUsers(keyword);
return groupAuthzService.searchUsers(keyword, accessScopeService.scopeGroupIds());
}
/**
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
* 只返回有授权模块的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。
* 角色授权并集:结果 = 组授权模块 ∪ 启用角色授权模块(角色模块不要求组先开通应用)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合(只看得到本校/本区域用户)。
*/
@GetMapping("/UserPermissionTree")
public List<UserPermissionCategoryVo> 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<UserRoleVo> userRoles(@RequestParam Integer userId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserRoles(userId);
}
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合。
*/
@GetMapping("/UserGroupTree")
public List<UserGroupNodeVo> 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<Integer> 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<GroupModuleAuthzItem> items) {
// 增量授权逐条校验目标组均在数据范围内
accessScopeService.requireGroupsInScope(
items == null ? Collections.emptyList()
: items.stream().map(GroupModuleAuthzItem::getGroupId).collect(Collectors.toList()));
groupAuthzService.setGroupModulePerms(items);
}
}
@@ -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<String, Object> test(@RequestBody PushTestRequest request) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
Map<String, Object> preview = pushDispatcher.previewManual(
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
@@ -58,12 +63,14 @@ public class PushSubscriptionController {
@GetMapping("RecordList")
public BasePageResult<PushRecordListVo> 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<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = pushRecordService.getPayload(id);
if (data == null) {
data = "该条推送记录未记录请求体(payload 为空),无法下载原始内容。";
@@ -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)。<br>
* 范围裁剪与越权拦截在此层完成,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<SysGroupVo> list(SysGroupQuery query) {
return sysGroupService.list(query);
List<SysGroupVo> result = sysGroupService.list(query);
// 防御性收紧:结果按数据范围过滤(当前前端未直用本接口)
Set<Integer> scope = accessScopeService.scopeGroupIds();
if (scope == null) {
return result;
}
return result.stream().filter(vo -> scope.contains(vo.getId())).collect(Collectors.toList());
}
/**
* 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。
* 获取组织架构树;表为空时自动创建根节点「教育云中心」(pid=0)。<br>
* 按访问级别裁剪:SUPER 全量;BUREAU 以区域组为根的子树;SCHOOL 各学校子树(多校 = 多根数组)。
*/
@GetMapping("GetTree")
public List<SysGroupVo> getTree() {
return sysGroupService.getTree();
return sysGroupService.getTree(accessScopeService.resolveCurrentScope());
}
/**
* 获取「学校组」剪枝树:末端叶子一定是学校组;学校组下的组不显示;<br>
* 子树中无学校组的分支整体剪掉;根节点始终保留。
* 子树中无学校组的分支整体剪掉;根节点始终保留。<br>
* BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
*/
@GetMapping("GetSchoolTree")
public List<SysGroupVo> 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 下(不支持移动到自身或自身后代)。<br>
* 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);
}
}
@@ -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<SysOapiLogListVo> 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<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = sysOapiLogService.getResponseData(id);
if (data == null) {
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
@@ -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<SysOperationLogListVo> 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<InputStreamResource> download(@PathVariable Long id) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
String data = sysOperationLogService.getResponseData(id);
if (data == null) {
data = "该条日志未记录响应数据(saveResponse=false、或接口响应为空、或响应序列化失败),无法下载原始内容。";
@@ -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<String> 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<SysUserVo> list(SysUserQuery query, BasePageQuery pageQuery) {
// 前端点树节点必带 groupId;非超管 null groupId(树未选中时的首载请求)返回空页而非报错,
// 越界组拒绝(null = 全平台用户,仅超管语义)
if (!accessScopeService.resolveCurrentScope().isSuper()) {
if (query.getGroupId() == null) {
return PageUtils.toResult(new PageImpl<SysUserVo>(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);
}
}
@@ -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。<br>
* 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id(appKeyToId 本地缓存,TTL 2h)。<br>
* 映射失败返回 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;
}
@@ -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<Integer> clients;
/**
@@ -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;
/**
* 学校应用私有覆盖表:学校管理员对「本校已开通应用」的私有别名(名称/描述/图标/排序)。<br>
* 为什么不改 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;
}
@@ -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;
/**
* 学校应用类型(分类)私有覆盖表:学校管理员对应用类型的私有改名 + 排序,仅对该校生效。<br>
* 与 {@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;
}
@@ -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<Integer> roles;
// 一对多:关联用户分组中间表,实现多对多业务
@@ -0,0 +1,36 @@
package com.yunhai.userbase.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/9/4
* @description 管理端访问级别(四级访问控制,判定优先级 SUPER &gt; SCHOOL &gt; BUREAU &gt; NONE)。<br>
* key 与前端 UserRoleEnum 对齐(1/2/3/4),GetUserScope 直接下发该数值。<br>
* 判定规则权威实现见 {@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;
}
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.event;
import org.springframework.context.ApplicationEvent;
/**
* 组域数据变更事件:sys_group 任意写操作(add/batchAdd/edit/move/delete/sortChildren)事务提交后发布。<br>
* <b>消费方</b>:{@code CacheInvalidationListener} 驱逐 {@code groupSnapshot} 本地快照
* (下次读取 sync=true 排队重建),并整清 {@code boaUser}(BoaUser 组织链内嵌组名/组类型,
* 组改名后 5min TTL 内的旧值不可接受,组写频次极低、clear 成本可忽略)。<br>
* <b>粒度</b>:单一事件类型——对所有消费方语义均为「组数据变了,重建」,将来出现需要精确到组的缓存再细化。<br>
* 注意与 {@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;
}
}
@@ -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
* 任意写操作(新增/编辑/换组/身份切换/禁用/跨校转入/密码/头像/删除等)事务提交后发布。<br>
* <b>消费方</b>:{@code CacheInvalidationListener} 按 {@code id:userId} 精确驱逐 boaUser 缓存,
* 并回查当前 account/phone 驱逐 {@code login:}/{@code phone:} 维度 key(含空值哨兵缓存)。<br>
* <b>硬约束</b>:身份切换(IdentityService.switchIdentity)后 boaUser.currentRole 必须立即失效,
* 否则用户以旧身份操作(权限事故级)。<br>
* <b>旧 key 提示</b>:改账号/改手机号场景,DB 里已查不到旧值,发布方须把旧 key(如
* {@code login:oldAccount})放入 {@link #staleKeyHints},监听器据此精确驱逐,避免旧 key 残留至 TTL。
*/
public class UserChangedEvent extends ApplicationEvent {
/** 发生变更的用户ID列表。 */
private final List<Integer> userIds;
/**
* 无法由 userId 回查的旧维度 key 提示(带前缀,如 "login:zhangsan01"、"phone:138...")。
* 典型场景:edit 改账号、删除用户(sys_user 行已删,account/phone 无处可查)。
*/
private final Set<String> staleKeyHints;
public UserChangedEvent(Object source, Collection<Integer> userIds, Collection<String> 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<Integer> getUserIds() {
return userIds;
}
public Set<String> getStaleKeyHints() {
return staleKeyHints;
}
}
@@ -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 指标过滤器。<br>
* <b>顺序说明(与方案 §2.1 的差异)</b>:方案原文写 +12(RequireAppSignFilter 之后),
* 但 +11 的 401 短路发生在 chain.doFilter 之前,+12 根本不会执行——
* 「401 验签失败也能统计」这一目标就落空了。故取 +10(与 AppAuthFilter 同级、
* 先于 +11 闸门):401 请求全量可统计;appKey 在 AppAuth 先执行时取权威 attr,
* 同序竞争落后时降级 header(签名场景两者取值一致,无实际偏差)。
*/
@Configuration
public class LoadTestMetricsConfig {
@Bean
public FilterRegistrationBean<OapiMetricsFilter> oapiMetricsFilter(OapiMetricsCollector collector) {
FilterRegistrationBean<OapiMetricsFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new OapiMetricsFilter(collector));
bean.addUrlPatterns("/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
bean.setName("oapiMetricsFilter");
return bean;
}
}
@@ -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)。<br>
* 按 (uri, method, appKey, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加,
* 不走队列、不做同步 IO——压测高并发下不丢数据、不影响吞吐。<br>
* 每 60s 定时快照落库 oapi_metrics(getAndSet 原子清零);落库失败把数值加回,
* 最多顺延到下个窗口,不静默丢失。<br>
* 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<Dim, Counter> 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<OapiMetricsEntity> rows = new ArrayList<>();
List<Object[]> snapshot = new ArrayList<>(counters.size());
for (ConcurrentHashMap.Entry<Dim, Counter> 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]);
}
}
}
}
@@ -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)。<br>
* 默认只统计线上流量(is_load_test=0),压测流量单独给合计数,两者严格隔离。<br>
* 例:GET /api/oapimetrics/TopN?dimension=both&amp;days=7&amp;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<UriRow> 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<AppKeyRow> 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<UriRow> topUris;
/** appKey 分层分布 */
private List<AppKeyRow> 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;
}
}
@@ -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 切片聚合)。<br>
* 区别于 sys_oapi_log(逐请求审计日志):本表是方案 §2.2 的轻量统计层——
* Filter 层内存累加、定时快照,GET/401 全覆盖,不丢数据、不膨胀。<br>
* 表结构由 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;
}
@@ -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 访问频次采集过滤器。<br>
* 只在 Filter 层记 HTTP 状态(方案 §2.1),不解响应体;业务错误(HTTP 200 + code:5)
* 细化继续由 sys_oapi_log 补充分析。<br>
* 注册见 {@link LoadTestMetricsConfig}(order=HIGHEST_PRECEDENCE+10,先于 401 闸门,
* 保证 401 验签失败请求也能统计)。appKey 取值优先级:<br>
* 1. {@code AppAuthFilter.APP_AUTH_ATTR}(验签后权威值);<br>
* 2. 降级 {@code X-App-Key} header(401 请求,标记未验签的 header 原值);<br>
* 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;
}
}
@@ -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<OapiMetricsEntity, Long> {
/** 按 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<Object[]> 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<Object[]> 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);
}
@@ -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} 缓存的值类型,单条目装整个快照)。<br><br>
*
* 形态:不可变 List + 两个派生索引(byId / childrenMap),整体驱逐 + 重建,
* <b>不做增量维护</b>(避免在缓存层重复实现树结构逻辑)。<br>
* 存放:Caffeine 仅本地(约 1 万行 ≈ JVM 4~8MB;进 Redis 则每次读取网络传输 2MB,
* 权限计算一次请求读 N 次,无意义流量),消费方拿到的是对象引用,零序列化零网络。<br>
* 失效:{@code GroupChangedEvent} AFTER_COMMIT 驱逐整条,下次读取排队重建。<br>
* <b>消费方约定:只读,严禁改动快照内的实体对象</b>(快照被全请求共享,写一个等于污染全部)。
*/
public class GroupSnapshot {
/** 虚拟根节点 parentId(与 SysGroupService 约定一致)。 */
private static final int ROOT_PARENT_ID = 0;
/** 全表记录(构造时已定序,顺序与派生索引一致)。 */
private final List<SysGroup> all;
/** id → 组。 */
private final Map<Integer, SysGroup> byId;
/** parentId(null 归一为 0)→ 直接子节点列表(同层按 sort 升序、再按 id 升序)。 */
private final Map<Integer, List<SysGroup>> childrenMap;
/** 行级字节粗估(Caffeine 按字节限重的 weigher 用,量级正确即可)。 */
private final long estimatedBytes;
public GroupSnapshot(List<SysGroup> groups) {
List<SysGroup> list = new ArrayList<>();
Map<Integer, SysGroup> idMap = new HashMap<>();
Map<Integer, List<SysGroup>> 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<SysGroup> 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<SysGroup> 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<SysGroup> getAll() {
return all;
}
public Map<Integer, SysGroup> getById() {
return byId;
}
public Map<Integer, List<SysGroup>> getChildrenMap() {
return childrenMap;
}
/** 是否存在任何组(空表时 false)。 */
public boolean isEmpty() {
return all.isEmpty();
}
/** Caffeine weigher 用的字节粗估值。 */
public long getEstimatedBytes() {
return estimatedBytes;
}
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.model.other;
import lombok.Data;
import java.util.List;
/**
* GetUserScope 返回体:当前登录用户的管理端访问范围(后端单点计算,前端不重复实现判定逻辑)。<br>
* 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<Integer> schoolGroupIds;
}
@@ -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<SysSchoolAppOverride, Integer> {
/** 指定学校集合的全部应用覆盖行(AppList/AppSortList 合并用) */
List<SysSchoolAppOverride> findBySchoolIdIn(Collection<Integer> schoolIds);
SysSchoolAppOverride findBySchoolIdAndAppId(Integer schoolId, Integer appId);
/** 删除应用时级联清理该应用在所有学校的覆盖行 */
@Modifying
@Query("DELETE FROM SysSchoolAppOverride o WHERE o.appId = :appId")
void deleteByAppIdBulk(@Param("appId") Integer appId);
}
@@ -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<SysSchoolCategoryOverride, Integer> {
/** 指定学校集合的全部类型覆盖行(AppTypeList 合并用) */
List<SysSchoolCategoryOverride> findBySchoolIdIn(Collection<Integer> schoolIds);
SysSchoolCategoryOverride findBySchoolIdAndCategoryId(Integer schoolId, Integer categoryId);
/** 删除应用类型时级联清理该类型在所有学校的覆盖行 */
@Modifying
@Query("DELETE FROM SysSchoolCategoryOverride o WHERE o.categoryId = :categoryId")
void deleteByCategoryIdBulk(@Param("categoryId") Integer categoryId);
}
@@ -23,6 +23,11 @@ public interface IApplicationInfoService {
*/
ApplicationInfo getById(Integer id);
/**
* 根据 appKey 查询应用主键 id(appKeyToId 本地缓存,消除开放接口每请求一次 findByAppKey)。
*/
Integer findIdByAppKey(Integer appKey);
/**
* 保存/更新应用信息(写后同步更新缓存)。
*/
@@ -84,6 +84,12 @@ public interface IGroupAuthzService {
*/
List<UserPermissionUserVo> searchUsers(String keyword);
/**
* 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回归属组与范围有交集的行;
* null 表示不过滤(超管/内部调用)。
*/
List<UserPermissionUserVo> searchUsers(String keyword, java.util.Set<Integer> scopeGroupIds);
/**
* 权限一览:根据用户ID聚合该用户的全部已授权应用模块树。<br>
* 只返回<b>有授权模块</b>的应用,并按应用类型分组;无授权的应用类型和应用不显示。<br>
@@ -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<SysGroupVo> getTree();
/**
* 获取组织架构树(按访问范围裁剪):SUPER 原样全量;BUREAU 以区域组为根的子树;
* SCHOOL 各学校子树(多校 = 多根数组)。scope 为 null 或 SUPER 级时行为与 {@link #getTree()} 完全一致。
*/
List<SysGroupVo> getTree(AccessScope scope);
/**
* 获取「学校组」剪枝树:末端叶子一定是学校组。<br>
* 规则:遇到学校组即截断(学校组作为叶子,其下所有组不显示);<br>
@@ -92,6 +99,12 @@ public interface ISysGroupService {
*/
List<SysGroupVo> getSchoolTree();
/**
* 获取「学校组」剪枝树(按访问范围裁剪):BUREAU 树根 = 区域组;SCHOOL = 各学校组多根。
* scope 为 null 或 SUPER 级时行为与 {@link #getSchoolTree()} 完全一致。
*/
List<SysGroupVo> getSchoolTree(AccessScope scope);
/** 在指定父组下按规则批量生成子组。 */
List<BoaGroup> batchAdd(SysGroupBatchRequest request);
@@ -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。<br>
* 不存在或不存在的 id 返回 null 不入缓存(unless 拦截)——不能用 sync=true:SD Redis 2.7 的
* sync 路径对 null 返回值会无条件 put 并在禁 null 缓存下抛 IllegalArgumentException(查不存在=500)。<br>
* 失效走 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
@@ -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})。<br><br>
*
* 三个查询维度各自成 key({@code login:}/{@code phone:}/{@code id:}),查无此人返回 null 不入缓存
* ({@code unless} 拦截;穿透防护由 UserCenterService 的 30s 空值哨兵缓存承担)。<br>
* <b>注解不使用 sync=true 的原因</b>:spring-data-redis 2.7 的 sync 路径对 loader 返回 null 会无条件
* put(key, null),在 {@code disableCachingNullValues} 下直接抛 IllegalArgumentException(查无此人=500),
* 无法与空值保护共存;单 JVM 排队回源(防击穿)由 UserCenterService 门面的条带锁承担,语义等价。<br>
* BoaUser 组装逻辑(组织链/身份推导)自 UserCenterService 平移至此;
* UserCenterService 作为对外门面保留原方法签名,内部委托本类(调用必须经代理,禁止同类自调用)。<br>
* <b>失效责任</b>: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<Integer> roleIds = sysUserRoleRelRepository
.findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL);
boa.setRoleIds(roleIds);
List<BoaGroup> groups = buildGroups(user.getId());
boa.setGroups(groups);
// 部门/教育局:仍从组织链推导(与主组/区域组语义绑定)
deriveOrg(groups, boa);
// 学校/当前激活身份/可选学校:从身份关联表推导(school 语义=当前激活学校)
deriveIdentity(user.getId(), boa);
return boa;
}
/**
* 组装用户组织链:取主归属组(isMain=1 取 id 最小,无主兜底 id 最小),沿 parentId 向上回溯到根,
* 顺序为「主组 -> ... -> 根方向」(近 -> 远)。
*/
private List<BoaGroup> buildGroups(Integer userId) {
List<SysUserGroupRel> 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<BoaGroup> chain = new ArrayList<>();
Integer cursor = groupId;
Set<Integer> 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<BoaGroup> 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 字段语义=当前激活学校:<br>
* ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);<br>
* ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行,
* 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);<br>
* ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);<br>
* ④ schools:用户在全部学校下的可选身份去重 schoolId(&gt;0)集合组装 BoaGroup 列表(本期补码)。<br>
* 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<Integer> schoolIds = sysUserRoleRelRepository
.findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL);
if (schoolIds != null && !schoolIds.isEmpty()) {
List<BoaGroup> 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;
}
}
@@ -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 事件驱动)。<br><br>
*
* 订阅三个失效事件,与注册表({@link CacheNames})的「失效责任」声明一一对应:<br>
* <ul>
* <li>{@link ApplicationInfoCacheChangedEvent} → 清 appInfo + 搭车清 appKeyToId;</li>
* <li>{@link GroupChangedEvent} → 驱逐 groupSnapshot 单条快照(下次读取排队重建)+
* 清 boaUser/boaUserNull(BoaUser 组织链内嵌组名,组改名后不能吃 5min 旧值);</li>
* <li>{@link UserChangedEvent} → 按 id:{userId} 精确驱逐,并回查当前 account/phone 驱逐
* login:/phone: 维度;改名等无法回查的旧 key 由事件 staleKeyHints 补充;用户被物理删除时
* 整清兜底(无法反查 account/phone,删除是罕见操作)。</li>
* </ul>
*
* <b>护栏</b>(方案 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<Integer> 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);
}
}
}
@@ -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<BoaModule> 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<UserPermissionUserVo> searchUsers(String keyword) {
return searchUsers(keyword, null);
}
/**
* 全局搜索用户(带管理端范围过滤):scopeGroupIds 非空时仅返回「归属组与范围有交集」的行,
* 无组关系的用户行一并滤除;scopeGroupIds 为 null(超管)行为与 {@link #searchUsers(String)} 一致。
*/
@Override
public List<UserPermissionUserVo> searchUsers(String keyword, Set<Integer> scopeGroupIds) {
if (keyword == null) {
keyword = "";
}
@@ -793,7 +811,11 @@ public class GroupAuthzService implements IGroupAuthzService {
for (SysUser user : users) {
List<SysUserGroupRel> 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<Integer, SysGroup> buildAllGroupMap() {
Map<Integer, SysGroup> 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<Integer, SysGroup> allGroupMap) {
@@ -1449,11 +1476,8 @@ public class GroupAuthzService implements IGroupAuthzService {
if (userRels.isEmpty()) {
return false;
}
// 2. 全部组载入内存(树深度有限),供祖先链回溯
Map<Integer, SysGroup> allGroupMap = new HashMap<>();
for (SysGroup g : sysGroupRepository.findAll()) {
allGroupMap.put(g.getId(), g);
}
// 2. 全部组载入内存(树深度有限,取自快照),供祖先链回溯
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
// 3. 前置校验:所属组必须已开通模块所属应用(最终权限 = 就近来源模块权限 ∩ 已开通应用);
// 通过校验的组构建祖先链(含自身),并收集祖先链并集用于一次批量 IN 查询
Map<Integer, List<Integer>> chainMap = new HashMap<>();
@@ -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} 缓存的唯一入口)。<br><br>
*
* 消除权限链路中按用户归属组循环重复执行的全表 {@code findAll()}:
* 一次 findAll 建索引(几十毫秒)后,同一快照内所有祖先链判定都是内存 Map 操作。<br>
* TTL 30min + GroupChangedEvent 事务提交后驱逐;{@code sync=true} 保证驱逐后并发读只重建一次。<br>
* <b>仅限只读路径使用</b>:组域写操作的事务内校验(环检测/边界校验)必须直查库,不能吃旧快照。
*/
@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());
}
}
@@ -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());
}
@@ -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):学校管理员对「本校已开通应用」的
* 名称/描述/图标/排序 + 应用类型改名/排序做该校私有覆盖,主数据零侵入。<br>
* 读写口径:应用范围恒为「该(些)学校在 sys_group_app_rel 已开通的应用并集」;
* override 行字段为 null 表示沿用主表值(语义即「别名」)。<br>
* 仅管理端调用(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<Integer> resolveSchools(AccessScope scope, Integer schoolId) {
List<Integer> 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<Integer> openedAppIds(List<Integer> schoolIds) {
Set<Integer> 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<Integer, SysSchoolAppOverride> appOverridesFor(List<Integer> schoolIds) {
Map<Integer, SysSchoolAppOverride> 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<Integer, SysSchoolCategoryOverride> categoryOverridesFor(List<Integer> schoolIds) {
Map<Integer, SysSchoolCategoryOverride> 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<Integer, SysSchoolAppOverride> 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<ApplicationInfoVo> effectiveAppOrder(Map<Integer, SysSchoolAppOverride> 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<Integer, SysSchoolAppOverride> 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 合并 名称/描述/图标/排序。<br>
* 已开通应用为小集合(校内几十个),全量载入内存过滤合并后按生效排序内存分页,
* 保证 override 排序在翻页间一致。
*/
public Page<ApplicationInfoVo> listSchoolApps(ApplicationInfoQuery query, Pageable pageable,
AccessScope scope, Integer schoolId) {
List<Integer> schools = resolveSchools(scope, schoolId);
Set<Integer> opened = openedAppIds(schools);
if (opened.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
List<ApplicationInfo> 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<ApplicationInfo> 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<Integer, SysSchoolAppOverride> overrides = appOverridesFor(schools);
List<ApplicationInfoVo> 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<ApplicationInfoSortVo> schoolSortList(Integer categoryId, AccessScope scope, Integer schoolId) {
List<Integer> schools = resolveSchools(scope, schoolId);
Set<Integer> opened = openedAppIds(schools);
if (opened.isEmpty()) {
return Collections.emptyList();
}
Map<Integer, SysSchoolAppOverride> 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 <T> Page<T> pageInMemory(List<T> 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<AppCategoryVo> mergeCategoryList(List<AppCategoryVo> baseList, AccessScope scope, Integer schoolId) {
List<Integer> schools = resolveSchools(scope, schoolId);
Map<Integer, SysSchoolCategoryOverride> overrides = categoryOverridesFor(schools);
List<AppCategoryVo> 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<Integer> appIds, AccessScope scope, Integer schoolId) {
Integer school = resolveSingleSchool(scope, schoolId);
if (appIds == null || appIds.isEmpty()) {
throw new DException("排序应用ID列表不能为空");
}
Set<Integer> 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<Integer> 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<Map<String, Object>> manageableSchools(AccessScope scope) {
List<Map<String, Object>> result = new ArrayList<>();
for (SysGroup school : sysGroupRepository.findAllById(scope.schoolGroupIds())) {
if (school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
continue;
}
Map<String, Object> 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;
}
}
@@ -64,9 +64,11 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex
/**
* 事务提交后的安全订阅:只有 ApplicationInfoService 对应事务真正 commit 后,
* 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。
* 才会去写共享 Redis / 清本地缓存,避免事务回滚导致的 DB-Redis 数据不一致。<br>
* 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());
@@ -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<SysGroupVo> getTree() {
// 根节点由 SysGroupRootInitializer 在启动时幂等创建,此处直接读取组装
List<SysGroup> all = sysGroupRepository.findAll();
if (all.isEmpty()) {
GroupSnapshot snapshot = groupSnapshotService.snapshot();
if (snapshot.isEmpty()) {
return new ArrayList<>();
}
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
// 从根(pid=0)开始组装树
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList());
}
@Override
public List<SysGroupVo> getSchoolTree() {
List<SysGroup> all = sysGroupRepository.findAll();
if (all.isEmpty()) {
public List<SysGroupVo> getTree(AccessScope scope) {
GroupSnapshot snapshot = groupSnapshotService.snapshot();
if (snapshot.isEmpty()) {
return new ArrayList<>();
}
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
if (scope == null || scope.isSuper()) {
return getTree();
}
return buildTrimmedTree(scope, snapshot.getAll(), false);
}
@Override
public List<SysGroupVo> getSchoolTree() {
GroupSnapshot snapshot = groupSnapshotService.snapshot();
if (snapshot.isEmpty()) {
return new ArrayList<>();
}
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
List<SysGroupVo> result = new ArrayList<>();
for (SysGroup root : roots) {
@@ -322,6 +341,94 @@ public class SysGroupService implements ISysGroupService {
return result;
}
@Override
public List<SysGroupVo> 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 版本共用)。<br>
* 范围内节点重建 childrenMap;「父节点不在范围内」的节点提升为树根——
* BUREAU 得到以区域组为根的子树,SCHOOL 得到各学校子树(多校 = 多根数组)。<br>
* schoolPruned=true 时按「学校组叶子」剪枝(GetSchoolTree 语义)。<br>
* 注意不能用 childrenMap.get(ROOT_PARENT_ID) 取根:范围内不存在 pid=0 的节点
* (平台根不属于任何区域/学校子树),提升孤儿节点才是正确的根集合。
*/
private List<SysGroupVo> buildTrimmedTree(AccessScope scope, List<SysGroup> all, boolean schoolPruned) {
Set<Integer> keep = scopedKeepIds(scope, all);
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(
all.stream().filter(g -> keep.contains(g.getId())).collect(Collectors.toList()));
List<SysGroup> 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<SysGroupVo> 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 = 各学校子树并集。<br>
* SUPER/NONE 不应走到此处(调用方已分流);NONE 返回空集合(整树裁空)。
*/
private Set<Integer> scopedKeepIds(AccessScope scope, List<SysGroup> all) {
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
Set<Integer> keep = new HashSet<>();
List<Integer> 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<Integer> next = new ArrayList<>();
for (Integer id : anchors) {
if (keep.add(id)) {
List<SysGroup> children = childrenMap.get(id);
if (children != null) {
for (SysGroup child : children) {
next.add(child.getId());
}
}
}
}
anchors = next;
}
return keep;
}
/**
* 递归组装「学校组」剪枝节点。<br>
* 学校组:直接作为叶子返回(不再向下递归,其下所有组不显示);<br>
@@ -387,11 +494,11 @@ public class SysGroupService implements ISysGroupService {
*/
@Override
public BoaGroup getGroups() {
List<SysGroup> all = sysGroupRepository.findAll();
if (all.isEmpty()) {
GroupSnapshot snapshot = groupSnapshotService.snapshot();
if (snapshot.isEmpty()) {
return null;
}
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
List<SysGroup> 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<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, List<SysGroup>> 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<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, SysGroup> allMap = all.stream()
.collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a));
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
GroupSnapshot snapshot = groupSnapshotService.snapshot();
Map<Integer, SysGroup> allMap = snapshot.getById();
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
List<BoaGroup> 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<Integer> 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()));
}
}
@@ -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<String> 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<Integer, SysUser> userById = new HashMap<>();
for (SysUser u : users) {
userById.put(u.getId(), u);
}
// 被物理删除用户的 account/phone 事后无处回查,缓存旧 key 在删除时现场捕获随事件驱逐
Set<String> 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<String> 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<Integer> 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<Integer> userIds, Integer groupId) {
publishUserEvent(type, userIds, groupId, null);
}
/** 组维度 + 缓存旧 key 提示的完整版本(物理删除用户后 account/phone 无处回查,须显式给出旧 key)。 */
private void publishUserEvent(PushInterfaceType type, List<Integer> userIds, Integer groupId,
java.util.Set<String> 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));
}
}
@@ -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}(含部门/学校/教育局多级组织)。<br><br>
* 这是「UserBase 由子应用升级为用户中心」的关键:平台侧 {@code UserDetailsProvider}
* ({@code UserBaseUserDetailsProvider})与开放接口({@code OpenBaseController})都复用本服务,
* 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。
* 保证 CAS 会话流与签名开放流返回的 BoaUser 结构完全一致。<br><br>
*
* <b>缓存编排(boaUser + 空值哨兵)</b>:真实值经 {@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 抖动时不影响主流程。<br>
* 失效: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<Integer> roleIds = sysUserRoleRelRepository
.findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL);
boa.setRoleIds(roleIds);
List<BoaGroup> 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<BoaGroup> buildGroups(Integer userId) {
List<SysUserGroupRel> 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<BoaGroup> chain = new ArrayList<>();
Integer cursor = groupId;
Set<Integer> 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<String, BoaUser> 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<BoaGroup> 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 字段语义=当前激活学校:<br>
* ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);<br>
* ② 无切换记录时兜底取主身份整条记录(动态解析:主组 → 主校 → 该校 sortNo/id 最小行,
* 保证是合法存在的身份,避免「主组+主角色」组合出 B 学校老师的非法身份);<br>
* ③ currentRoleId:优先当前激活记录 roleId,兜底主身份行 roleId(本期补码,此前从未填充);<br>
* ④ schools:用户在全部学校下的可选身份去重 schoolId(&gt;0)集合组装 BoaGroup 列表(本期补码)。<br>
* 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<Integer> schoolIds = sysUserRoleRelRepository
.findDistinctSchoolIdsByUserIdAndStatus(userId, StatusEnum.NORMAL);
if (schoolIds != null && !schoolIds.isEmpty()) {
List<BoaGroup> 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;
}
}
@@ -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;
/**
* 管理端访问范围(一次请求内的权威判定结果)。<br>
* 不可变值对象:由 {@link AccessScopeService#resolveCurrentScope()} 计算,
* level 与范围组字段(bureauGroupId / schoolGroupIds)互斥——BUREAU 级只填 bureauGroupId,
* SCHOOL 级只填 schoolGroupIds,SUPER/NONE 均为空。<br>
* 数据范围展开(scopeGroupIds = 范围组整棵子树)不在此对象内,由 AccessScopeService 惰性计算并缓存。
*
* @author chenx
* @date 2026/9/4
*/
@Data
public class AccessScope {
/** 访问级别(后端权威判定) */
private AccessLevelEnum level;
/** BUREAU 级:区域组 id(范围 = 该区域组整棵子树) */
private Integer bureauGroupId;
/** SCHOOL 级:可管理学校组 id 列表(范围 = 各学校子树并集,多校管理员多棵子树) */
private List<Integer> 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<Integer> schoolGroupIds) {
AccessScope scope = of(AccessLevelEnum.SCHOOL);
scope.setSchoolGroupIds(Collections.unmodifiableList(schoolGroupIds));
return scope;
}
public List<Integer> schoolGroupIds() {
return schoolGroupIds == null ? Collections.emptyList() : schoolGroupIds;
}
public boolean isSuper() {
return level == AccessLevelEnum.SUPER;
}
public boolean isNone() {
return level == AccessLevelEnum.NONE;
}
}
@@ -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;
/**
* 管理端访问范围统一解析器(四级访问控制的唯一权威判定入口)。<br>
* 级别判定规则(《管理端权限分级改造方案》§1,优先级 SUPER &gt; SCHOOL &gt; BUREAU &gt; NONE):
* <ol>
* <li>SUPER:sys_user.type=1(内置超管账号)或基石账号 id=100000,行为与现状完全一致;</li>
* <li>SCHOOL:sys_user_role_rel 存在正常态身份行 roleId∈{2 校领导, 7 管理员} 且其 schoolId
* 对应组类型为学校组(schoolId 恒为真实组 id,局管理员的 schoolId=区域组,必须校验组类型
* 防止误判)。与当前激活身份无关,不看 sys_user_current_role;</li>
* <li>BUREAU:主归属组链(主组 → 根)含区域组且不含学校组,不限角色;</li>
* <li>NONE:其余(登录用户但无任何管理面)。</li>
* </ol>
* 请求级缓存:一次请求多次调用只算一次(scope 与展开后的 scopeGroupIds 各自惰性缓存),
* {@code BoaUserContextInterceptor.afterCompletion} 统一 clear。<br>
* 不加接口(参照 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<Integer> SCHOOL_ADMIN_ROLE_IDS = new HashSet<>(java.util.Arrays.asList(
SysRoleEnum.SCHOOL_LEADER.getKey(), SysRoleEnum.ADMIN.getKey()));
/** 请求级 scope 缓存(含 NONE,避免重复判定) */
private static final ThreadLocal<AccessScope> SCOPE_CACHE = new ThreadLocal<>();
/** 请求级范围组集合缓存(scopeGroupIds 的展开结果;SUPER 为 null 表示不限制) */
private static final ThreadLocal<Set<Integer>> 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 惰性缓存)。<br>
* 未登录(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<Integer, SysGroup> groupMap = null;
// 2) SCHOOL:正常态身份行 roleId∈{2,7} 且 schoolId 为学校组
List<SysUserRoleRel> roleRels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
List<Integer> 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<Integer> 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 各自展开的整棵子树(含自身)并集。<br>
* SUPER 返回 null(约定「不限制」,调用方据此跳过过滤);NONE 返回空集合(任何组都不在范围内)。<br>
* 请求级缓存:与 scope 同生命周期,避免同请求内重复 findAll。
*/
public Set<Integer> scopeGroupIds() {
AccessScope scope = resolveCurrentScope();
if (scope.isSuper()) {
return null;
}
Set<Integer> cached = SCOPE_GROUP_CACHE.get();
if (cached != null) {
return cached;
}
Set<Integer> 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<Integer> listSelfAndDescendantIds(Integer groupId) {
List<Integer> result = new ArrayList<>();
if (groupId == null) {
return result;
}
Map<Integer, List<Integer>> childrenMap = new HashMap<>();
for (SysGroup g : sysGroupRepository.findAll()) {
Integer parent = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(g.getId());
}
result.add(groupId);
List<Integer> frontier = new ArrayList<>();
frontier.add(groupId);
while (!frontier.isEmpty()) {
List<Integer> next = new ArrayList<>();
for (Integer id : frontier) {
List<Integer> children = childrenMap.get(id);
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<Integer> 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 必填且必须落在数据范围内。<br>
* 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<Integer> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return;
}
for (Integer groupId : groupIds) {
requireGroupInScope(groupId);
}
}
/**
* 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。<br>
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
*/
public void requireUsersInScope(Collection<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
return;
}
Set<Integer> scope = scopeGroupIds();
if (scope == null) {
return;
}
Set<Integer> distinct = new LinkedHashSet<>(userIds);
distinct.remove(null);
if (distinct.isEmpty()) {
return;
}
// 一次 IN 查询收集「归属组命中 scope」的用户,全部命中才放行
Set<Integer> 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<Integer> 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<Integer> scope = scopeGroupIds();
if (scope == null) {
return;
}
if (!userIntersectsScope(userId)) {
throw new DException("暂无权限操作该用户");
}
}
// ===================== 私有工具 =====================
private Map<Integer, SysGroup> buildAllGroupMap() {
Map<Integer, SysGroup> map = new HashMap<>();
for (SysGroup g : sysGroupRepository.findAll()) {
map.put(g.getId(), g);
}
return map;
}
/** 祖先链(自身 → 根,含自身),带 visited 防环(与 GroupAuthzService.ancestorChain 同套路) */
private List<Integer> ancestorChain(Integer groupId, Map<Integer, SysGroup> allGroupMap) {
List<Integer> chain = new ArrayList<>();
Set<Integer> 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;
}
}
@@ -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() {}
@@ -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 {
+4 -1
View File
@@ -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
@@ -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;
@@ -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;
@@ -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 权限计算单元测试。
* 重点验证「就近原则 + 子组继承闸门」:沿组的祖先链向上,取<b>最近</b>的有显式模块配置的组作为该应用唯一权限来源,
* 只返回该来源组的模块集合(不向上合并更远的祖先);且<b>来源组若为祖先,必须勾选子组继承(inheritFlag=true)才下放</b>
* (修复「子组只勾1个却继承学校全部」「返回3个而非最近祖先的2个」「父级未勾子组继承仍被继承」三类 bug)。
* (修复「子组只勾1个却继承学校全部」「返回3个而非最近祖先的2个」「父级未勾子组继承仍被继承」三类 bug)。<br>
* 组全量数据现经 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<SysGroup> 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