后端缓存管理
This commit is contained in:
@@ -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(组织架构型:变更以天/周计,脏数据影响祖先链→开通判定;组写频次极低,命中率 >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 > SCHOOL > BUREAU > 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&days=7&top=10
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/oapimetrics/")
|
||||
public class OapiMetricsController {
|
||||
|
||||
@Autowired
|
||||
private OapiMetricsRepository repository;
|
||||
|
||||
/**
|
||||
* TopN 热度报告:Top 接口 + appKey 分层分布。
|
||||
*/
|
||||
@Operation(summary = "[LOADTEST-METRICS] oapi热度TopN报告", operationId = "oapiMetricsTopN")
|
||||
@GetMapping("/TopN")
|
||||
public ReportVo topN(
|
||||
@RequestParam(value = "dimension", defaultValue = "both") String dimension,
|
||||
@RequestParam(value = "days", defaultValue = "1") Integer days,
|
||||
@RequestParam(value = "top", defaultValue = "10") Integer top) {
|
||||
|
||||
int d = clamp(days == null ? 1 : days, 1, 30);
|
||||
int n = clamp(top == null ? 10 : top, 1, 100);
|
||||
LocalDateTime since = LocalDateTime.now().minusDays(d);
|
||||
ReportVo vo = new ReportVo();
|
||||
vo.setGeneratedAt(LocalDateTime.now().toString());
|
||||
vo.setDays(d);
|
||||
vo.setTop(n);
|
||||
|
||||
Object[] total = repository.aggregateTotal(since, 0);
|
||||
vo.setTotalCnt(asLong(total, 0));
|
||||
vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1)));
|
||||
vo.setTotalErrCnt(asLong(total, 2));
|
||||
|
||||
if ("uri".equals(dimension) || "both".equals(dimension)) {
|
||||
List<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(>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(>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 > SCHOOL > BUREAU > 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 {
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user