diff --git a/pom.xml b/pom.xml
index 4907139..4935f10 100644
--- a/pom.xml
+++ b/pom.xml
@@ -82,6 +82,14 @@
spring-boot-starter-actuator
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
+
com.alibaba.cloud
diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java
index c06b8e0..2355501 100644
--- a/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java
+++ b/src/main/java/com/yunhai/userbase/common/interceptor/BoaUserContextInterceptor.java
@@ -3,6 +3,7 @@ package com.yunhai.userbase.common.interceptor;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.service.impl.UserCenterService;
+import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -44,5 +45,7 @@ public class BoaUserContextInterceptor implements HandlerInterceptor {
HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear();
+ // 访问范围请求级缓存与用户上下文同生命周期,一并清理(防线程复用串档)
+ AccessScopeService.clearCache();
}
}
diff --git a/src/main/java/com/yunhai/userbase/common/interceptor/ManageAccessInterceptor.java b/src/main/java/com/yunhai/userbase/common/interceptor/ManageAccessInterceptor.java
new file mode 100644
index 0000000..794d5ec
--- /dev/null
+++ b/src/main/java/com/yunhai/userbase/common/interceptor/ManageAccessInterceptor.java
@@ -0,0 +1,70 @@
+package com.yunhai.userbase.common.interceptor;
+
+import com.yunhai.userbase.enums.AccessLevelEnum;
+import com.yunhai.userbase.service.resolve.AccessScope;
+import com.yunhai.userbase.service.resolve.AccessScopeService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 管理端统一防线:NONE 级(普通用户/未登录)一律拦截,HTTP 200 + {code:403, msg:"暂无权限"}。
+ * 与前端 alova 约定一致(status!=200 会被当作网络错误,业务码走 errorHandle default 分支弹 msg)。
+ * 白名单(仅登录即可访问):GetUserInfo / GetUserScope / GetSysDict / RedirectTo。
+ * 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。
+ * SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
+ *
+ * @author chenx
+ * @date 2026/9/4
+ */
+@Component
+public class ManageAccessInterceptor implements HandlerInterceptor {
+
+ /**
+ * 白名单(Ant 风格,* 匹配 platform 前缀):
+ * 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理。
+ */
+ private static final List WHITELIST = Arrays.asList(
+ "/*/base/GetUserInfo",
+ "/*/base/GetUserScope",
+ "/*/base/GetSysDict",
+ "/*/base/RedirectTo/*");
+
+ private static final AntPathMatcher MATCHER = new AntPathMatcher();
+
+ @Autowired
+ private AccessScopeService accessScopeService;
+
+ @Override
+ public boolean preHandle(HttpServletRequest request,
+ HttpServletResponse response,
+ Object handler) throws Exception {
+ String path = request.getRequestURI().substring(request.getContextPath().length());
+ if (isWhitelisted(path)) {
+ return true;
+ }
+ AccessScope scope = accessScopeService.resolveCurrentScope();
+ if (scope.getLevel() != AccessLevelEnum.NONE) {
+ return true;
+ }
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType("application/json;charset=UTF-8");
+ response.getWriter().write("{\"code\":403,\"msg\":\"暂无权限\",\"data\":null}");
+ return false;
+ }
+
+ private boolean isWhitelisted(String path) {
+ for (String pattern : WHITELIST) {
+ if (MATCHER.match(pattern, path)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/yunhai/userbase/config/CacheConfig.java b/src/main/java/com/yunhai/userbase/config/CacheConfig.java
index 444784a..641584e 100644
--- a/src/main/java/com/yunhai/userbase/config/CacheConfig.java
+++ b/src/main/java/com/yunhai/userbase/config/CacheConfig.java
@@ -8,9 +8,16 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+import com.github.benmanes.caffeine.cache.Weigher;
+import com.yunhai.userbase.config.CacheNames.Meta;
import com.yunhai.userbase.utils.ConstantUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
+import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
@@ -18,56 +25,123 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
-import org.springframework.data.redis.serializer.RedisSerializationContext;
+import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
+/**
+ * 缓存统一基建(Spring Cache 抽象)。
+ *
+ * 组成(缓存名单一定义、一注册、一路由,全部由 {@link CacheNames#REGISTRY} 驱动):
+ *
+ * - redisCacheManager:注册表中 REDIS 存放的缓存按名定制 TTL(30s~2h 分级定价),
+ * 写入经抖动装饰器打散过期点(±10%,防同时过期型雪崩);未注册名兜底 5min;
+ * - caffeineCacheManager:Redis 降级兜底,按字节限重(maximumWeight + 启发式估算,
+ * 防巨型条目绕过条数限制撑爆内存);
+ * - localCacheManager:LOCAL 存放专用(groupSnapshot / appKeyToId),Caffeine 独立实例,
+ * 永不进 Redis;
+ * - FallbackCacheManager(@Primary):本地专用名恒路由 localCacheManager,
+ * 其余 Redis 健康走 Redis、故障降级 Caffeine,后台 10s 探活自动切回;
+ * - CacheErrorHandler:堵探活间隙——Redis 读写异常降级为 log+miss 而非上冒 500,
+ * 驱逐失败打 error(脏数据存活至 TTL 后自愈)。
+ *
+ */
@EnableCaching
@Configuration
-public class CacheConfig {
+public class CacheConfig implements CachingConfigurer {
+ private static final Logger log = LoggerFactory.getLogger(CacheConfig.class);
+
+ /** 降级 Caffeine 的字节限重上限(按估算权重,防巨型条目绕过条数统计)。 */
+ private static final long CAFFEINE_MAX_WEIGHT_BYTES = 64L * 1024 * 1024;
+
+ /** groupSnapshot 本地实例的字节限重上限(快照约 4~8MB,留出翻倍余量)。 */
+ private static final long SNAPSHOT_MAX_WEIGHT_BYTES = 64L * 1024 * 1024;
+
+ /** appKeyToId 本地实例的条目上限(application_info 仅十几条,宽松限量)。 */
+ private static final long APP_KEY_TO_ID_MAX_ENTRIES = 512;
/**
- * 一级缓存:Redis 分布式缓存管理器。
+ * 一级缓存:Redis 分布式缓存管理器。
+ * 各缓存 TTL 由 {@link CacheNames#REGISTRY} 定价(配置型 1h~2h / 用户身份型 5min / 空值哨兵 30s),
+ * 未注册名兜底全局 5min;每次写入 TTL 加 ±10% 随机抖动。
*/
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) {
Jackson2JsonRedisSerializer