拦截器与切面
拦截器与切面
业务用途
后端在 WebMvcConfig 注册了一组 MVC 拦截器,在请求进入 Controller 前后做横切处理:限流、IP 白名单、Redis 接口防抖、请求日志;另外有一个 慢查询 MyBatis-Plus 内拦截器(当前未启用)。AOP 方面有两个切面:AuditLogAspect 把 Controller 的增删改操作写入审计日志表,ValidationAspect 把 BindingResult 校验错误统一转成 ValidationException。
涉及文件
| 文件 | 作用 |
|---|---|
backend/src/main/java/com/lowcode/config/RateLimitInterceptor.java | 按公网/认证接口分别限流 |
backend/src/main/java/com/lowcode/config/IpAllowlistInterceptor.java | 租户级 IP 白名单(CIDR) |
backend/src/main/java/com/lowcode/config/RedisApiDebounceInterceptor.java | Redis SETNX 防重复提交 |
backend/src/main/java/com/lowcode/config/RequestLoggingInterceptor.java | 写 logs/api-request.log |
backend/src/main/java/com/lowcode/config/SlowQueryInterceptor.java | 慢查询内拦截器(未注册) |
backend/src/main/java/com/lowcode/config/WebMvcConfig.java | 注册上述 MVC 拦截器 + CORS + 静态资源 + RestTemplate |
backend/src/main/java/com/lowcode/config/AuditLogAspect.java | Controller 操作审计切面 |
backend/src/main/java/com/lowcode/config/ValidationAspect.java | 参数校验切面 |
backend/src/main/java/com/lowcode/common/ValidationException.java | 校验异常 |
数据库表
| 表 | 关键字段 | 说明 |
|---|---|---|
sys_audit_log | user_id、username、action、table_name、record_id、old_value、new_value、ip、request_params、user_agent、tenant_id | 审计日志,由 AuditLogAspect 写入 |
sys_config | config_key、config_value、tenant_id | 存审计开关等配置(audit.*) |
lc_ip_allowlist | tenant_id、ip_address、enabled | 租户 IP 白名单 |
后端实现
拦截器注册 WebMvcConfig
// backend/src/main/java/com/lowcode/config/WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final RedisApiDebounceInterceptor redisApiDebounceInterceptor; // 构造器注入
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 1. 请求日志(new 实例)
registry.addInterceptor(new RequestLoggingInterceptor()).addPathPatterns("/api/**");
// 2. IP 白名单(new 实例,带排除路径)
registry.addInterceptor(new IpAllowlistInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login", "/api/auth/captcha", "/api/auth/register",
"/api/auth/sso/login", "/api/system/tenant/**", "/api/permission/**", "/api/open/**");
// 3. Redis 防抖(注入的 Bean)
registry.addInterceptor(redisApiDebounceInterceptor).addPathPatterns("/api/**");
// 4. 限流(new 实例)
registry.addInterceptor(new RateLimitInterceptor()).addPathPatterns("/api/**");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 上传文件访问映射,兼容 /upload 与 /uploads
registry.addResourceHandler("/uploads/**", "/upload/**")
.addResourceLocations("file:" + uploadPath); // ${file.upload.path:./uploads/}
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**").allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*").allowCredentials(true).maxAge(3600);
}
@Bean
public RestTemplate restTemplate() { /* connectTimeout 5000, readTimeout 10000 */ }
}注意 new 出来的拦截器
RequestLoggingInterceptor、IpAllowlistInterceptor、RateLimitInterceptor 在注册时都是 new 出来的新实例,并非 Spring 容器里那个 @Component Bean。这意味着它们内部的 @Autowired 字段不会被注入。RedisApiDebounceInterceptor 则是构造器注入的容器 Bean,依赖正常。
对 IpAllowlistInterceptor 影响最大:它 @Autowired 了 LcIpAllowlistMapper,但注册实例里该字段为 null(详见下文)。
CORS 配了两次
CORS 在 SecurityConfig.corsConfigurationSource() 与 WebMvcConfig.addCorsMappings() 两处都配了,策略一致。Spring Security 那份作用于过滤链,WebMvc 这份作用于 MVC 层,互为补充。
限流 RateLimitInterceptor
// backend/src/main/java/com/lowcode/config/RateLimitInterceptor.java
public class RateLimitInterceptor implements HandlerInterceptor {
private static final int PUBLIC_LIMIT = 100; // 公开接口:100 次/分钟/IP
private static final int AUTH_LIMIT = 1000; // 认证接口:1000 次/分钟/用户
private static final long WINDOW_MS = 60 * 1000;
private final ConcurrentHashMap<String, long[]> requestCounts = new ConcurrentHashMap<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String path = request.getRequestURI();
if (path.startsWith("/api/ai/form-designer-chat")) return true; // AI 聊天不限流
String key = isPublicPath(path) ? "ip:" + getClientIp(request) : "user:" + userId; // 无 userId 回退 IP
int limit = isPublicPath(path) ? PUBLIC_LIMIT : AUTH_LIMIT;
return checkRateLimit(key, limit, response);
}
}- 公开路径判定:
/api/auth/login、/api/auth/captcha、/api/auth/register、/api/mobile/。 - 计数存内存
ConcurrentHashMap(单机限流,非分布式),按 1 分钟滑动窗口。 - 超限返回
429+ JSON{"code":429,"message":"请求过于频繁,请稍后再试"},并设置X-RateLimit-Remaining、X-RateLimit-Reset响应头。
IP 白名单 IpAllowlistInterceptor
// backend/src/main/java/com/lowcode/config/IpAllowlistInterceptor.java
public class IpAllowlistInterceptor implements HandlerInterceptor {
private static final String[] WHITELIST_PATHS = {
"/api/auth/login", "/api/auth/captcha", "/api/auth/register",
"/api/auth/sso/login", "/api/system/tenant"
};
@Autowired
private LcIpAllowlistMapper ipAllowlistMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (isWhitelistedPath(path)) return true;
Long tenantId = TenantContext.getTenantId(); // ⚠ 读的是 security.TenantContext
if (tenantId == null) return true; // 无租户上下文,跳过
List<LcIpAllowlist> allowlists = ipAllowlistMapper.selectList(... eq(tenantId).eq(enabled, "1"));
if (allowlists == null || allowlists.isEmpty()) return true; // 未配置白名单,放行
// 支持单 IP 与 CIDR 格式匹配,不命中返回 403 {"code":403,"msg":"IP not allowed"}
}
}- 支持单 IP(
192.168.1.1)与 CIDR(10.0.0.0/8),用位运算判定是否在网段内。 - 排除路径(
WebMvcConfig注册时):登录、验证码、注册、SSO 登录、/api/system/tenant/**、/api/permission/**、/api/open/**。
当前 IP 白名单实际未生效
两个原因叠加导致该拦截器目前恒放行:
- 它读取的是
com.lowcode.security.TenantContext(与过滤器写入的config.TenantContext不是同一个 ThreadLocal),tenantId永远为null,于是在if (tenantId == null) return true;处提前放行; - 即便
tenantId非空,注册时用的是new IpAllowlistInterceptor(),@Autowired LcIpAllowlistMapper未注入(为null),继续执行会 NPE。
要启用 IP 白名单:①把引用改为 com.lowcode.config.TenantContext;②在 WebMvcConfig 中改为注入 IpAllowlistInterceptor Bean 而非 new,使 Mapper 被注入。
Redis 防抖 RedisApiDebounceInterceptor
// backend/src/main/java/com/lowcode/config/RedisApiDebounceInterceptor.java
@Component
public class RedisApiDebounceInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
@Value("${api.debounce.enabled:true}") private boolean enabled;
@Value("${api.debounce.ttl-ms:1000}") private long ttlMs; // 默认 1000ms
@Value("${api.debounce.key-prefix:lowcode:api:debounce}") private String keyPrefix;
@Value("${api.debounce.fail-open:true}") private boolean failOpen; // Redis 异常时放行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!enabled || isExcluded(request)) return true;
try {
String key = buildKey(request); // identity + method + sha256(uri) + sha256(query)
Boolean acquired = stringRedisTemplate.opsForValue()
.setIfAbsent(key, UUID.randomUUID().toString(), Duration.ofMillis(ttlMs));
if (Boolean.TRUE.equals(acquired)) return true; // 首次提交,放行
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(Math.max(1, (ttlMs + 999) / 1000)));
response.setHeader("X-Debounce-TTL", String.valueOf(ttlMs));
response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\"}");
return false; // TTL 内重复提交,拒绝
} catch (Exception e) {
return failOpen; // Redis 故障时按 failOpen 决定
}
}
}- 用 Redis
SETNX + TTL实现「N 毫秒内同一请求只处理一次」,默认1000ms。 - 排除:
OPTIONS、GET(只防写操作),以及/api/auth/captcha、/api/auth/sso/callback、/api/auth/sso/token、/api/ai/form-designer-chat。 - 防抖 key 维度:身份(移动端
mobileUserId>Authorizationtoken 的 sha256 > 客户端 IP)+ HTTP method + URI 的 sha256 + query 的 sha256。 - fail-open:Redis 不可用时默认放行,避免拖垮业务(可由
api.debounce.fail-open关闭)。
依赖 Redis
此拦截器依赖 StringRedisTemplate。若未配置 Redis,启动仍可成功(构造器注入),但运行时 setIfAbsent 会抛异常,按 fail-open 放行。生产环境建议保证 Redis 可用。
请求日志 RequestLoggingInterceptor
// backend/src/main/java/com/lowcode/config/RequestLoggingInterceptor.java
public class RequestLoggingInterceptor implements HandlerInterceptor {
private static final String LOG_FILE = "logs/api-request.log";
@Override
public boolean preHandle(...) { request.setAttribute("startTime", System.currentTimeMillis()); return true; }
@Override
public void afterCompletion(...) {
long duration = System.currentTimeMillis() - (Long) request.getAttribute("startTime");
// 拼装:[时间] METHOD path IP:xxx User:id/- Duration:xxms Status:xxx
Files.writeString(Paths.get(LOG_FILE), logLine + System.lineSeparator(), APPEND);
}
}- 写入工作目录下
logs/api-request.log(目录不存在自动创建)。 - 排除路径:
/api/auth/captcha、/api/system/health、/actuator/**。 - 日志含:时间戳、method、path、IP、userId/username(取自
mobileUserId/mobileUsername请求属性)、耗时、状态码、异常信息。
慢查询 SlowQueryInterceptor
// backend/src/main/java/com/lowcode/config/SlowQueryInterceptor.java
@Component
public class SlowQueryInterceptor implements InnerInterceptor {
private long slowQueryThreshold = 1000; // 阈值 1000ms
@Override
public void beforeQuery(...) {
long startTime = System.currentTimeMillis(); // 仅记录开始时间(局部变量)
}
}当前未注册、未完整实现
该类虽标 @Component 并实现 InnerInterceptor,但:
- 未在
MybatisPlusConfig.mybatisPlusInterceptor()中addInnerInterceptor,所以根本不会触发; beforeQuery只把startTime存进局部变量,没有实现afterQuery/afterPrepare来计算耗时并落日志,逻辑不完整。
慢查询目前主要依赖 Tomcat 层的 server.tomcat.slow-query-* 与 application-mysql.yml 的 slow-query.threshold-ms: 1000 配置。若要启用 MyBatis-Plus 层慢查询,需在 MybatisPlusConfig 注册它并补全耗时计算与日志输出。
审计切面 AuditLogAspect
// backend/src/main/java/com/lowcode/config/AuditLogAspect.java
@Aspect
@Component
public class AuditLogAspect {
@Pointcut("execution(public * com.lowcode.controller..*.*(..))")
public void controllerPointcut() {}
@AfterReturning(pointcut = "controllerPointcut()", returning = "result")
public void auditAfterReturning(JoinPoint joinPoint, Object result) {
// 1. 排除登录/登出/验证码/审计日志/配置/控制台等路径
// 2. 由方法名前缀或 HTTP method 推断 action:CREATE/UPDATE/DELETE/QUERY
// 3. 按配置开关决定是否记录(audit.operation.enabled 总开关默认 false)
// 4. 解析 userId/username(优先 mobileUserId 请求属性,回退 JWT)
// 5. 脱敏敏感字段(password/token/apiKey 等 -> ******)
// 6. auditLogService.log(userId, username, action, tableName, recordId, null, newValue, ip, params, userAgent)
}
}没有 @AuditLog 注解
本切面不依赖任何注解,而是用 @Pointcut("execution(public * com.lowcode.controller..*.*(..))") 切所有 Controller 公有方法,按方法名前缀(create/save/insert/add -> CREATE,update/edit/change -> UPDATE,delete/remove -> DELETE)或 HTTP method(POST->CREATE、PUT/PATCH->UPDATE、DELETE->DELETE、GET->QUERY)推断动作。工程内不存在 @AuditLog 注解类型。
- 总开关默认关:
sys_config中audit.operation.enabled默认false,即操作日志默认不记录;登录/登出日志(audit.login.enabled/audit.logout.enabled)默认true。 - 分级开关:
audit.operation.create/update/delete.enabled默认true,audit.operation.query.enabled默认false。 - 排除路径:
/api/auth/login、/api/auth/logout、/api/auth/captcha、/api/system/audit-log、/api/system/config、/api/system/console。 - 敏感字段脱敏:
sanitizeValue用正则把password/oldPassword/newPassword/token/accessToken/refreshToken/authorization/apiKey/apiSecret/secret/redisPassword/datasourcePassword/captcha的值替换为******(由audit.sensitive.mask.enabled控制,默认开)。 - 请求参数记录:由
audit.request.params.enabled控制(默认开),把方法参数名-值序列化。 - 审计失败不影响业务(整段 try-catch)。
校验切面 ValidationAspect
// backend/src/main/java/com/lowcode/config/ValidationAspect.java
@Aspect
@Component
public class ValidationAspect {
@Pointcut("execution(public * com.lowcode.controller..*.*(..))")
public void controllerPointcut() {}
@Pointcut("execution(public * com.lowcode.service..*(..))")
public void servicePointcut() {}
@Before("controllerPointcut()")
public void validateBindingResult(JoinPoint joinPoint) {
for (Object arg : joinPoint.getArgs()) {
if (arg instanceof BindingResult bindingResult && bindingResult.hasErrors()) {
// 拼接所有错误信息,抛 ValidationException
throw new ValidationException(message.toString());
}
}
}
@Before("servicePointcut() && args(.., params)")
public void validateServiceParams(JoinPoint joinPoint, Object params) {
if (params instanceof java.util.Map) { /* Map 参数校验占位,可扩展 */ }
}
}- Controller 方法若带
BindingResult参数(@Valid校验结果),有错误时统一抛ValidationException,避免在每个 Controller 里手写if (result.hasErrors())。 - 第二个切面
validateServiceParams针对 Service 层末位为Map的参数,目前是占位实现,供扩展。
操作步骤
- 启用操作审计:在「基础设置」或直接写
sys_config表,把audit.operation.enabled设为true,按需开启audit.operation.create/update/delete/query.enabled。重启或刷新配置后,AuditLogAspect即开始记录到sys_audit_log。 - 查看请求日志:
tail -f logs/api-request.log(后端工作目录下)。 - 调整防抖窗口:修改
api.debounce.ttl-ms(默认 1000ms),或用环境变量API_DEBOUNCE_TTL_MS覆盖;临时关闭设api.debounce.enabled=false。 - 配置 IP 白名单:在
lc_ip_allowlist表为某租户插入ip_address(支持 CIDR)+enabled='1'。注意:当前因上文两个问题白名单未真正生效,需先修复后再配数据。 - 启用慢查询插件:在
MybatisPlusConfig中interceptor.addInnerInterceptor(new SlowQueryInterceptor()),并补全耗时计算逻辑。
常见问题
防抖误伤正常提交?
防抖默认只对非 GET、非 OPTIONS 的写接口生效,且按「身份+method+URI+query」维度判定。若同一用户在 1 秒内对同一接口连续提交两次,第二次会被拒。调大 api.debounce.ttl-ms 或对特定接口在 isExcluded 中加白名单可解决。
审计日志为什么不记录?
检查 sys_config 的 audit.operation.enabled 是否为 true(默认 false)。另外查询操作默认不记(audit.operation.query.enabled 默认 false)。AuditLogAspect 还排除了 login/logout/captcha 等路径。
限流是分布式的吗?
不是。RateLimitInterceptor 用单机 ConcurrentHashMap 计数,多实例部署时各实例独立计数,实际限流上限会被放大。分布式限流需改用 Redis 实现。
IpAllowlistInterceptor 的 Mapper 报空指针?
因为 WebMvcConfig 里是 new IpAllowlistInterceptor(),@Autowired 的 LcIpAllowlistMapper 未注入。当前因 tenantId 恒为 null 提前 return,不会触达 Mapper;一旦修复 TenantContext 引用,需同步把注册方式改成注入 Bean,否则会 NPE。
