随着业务复杂度上升,单体应用逐渐暴露出部署慢、扩展难、耦合高等问题。微服务架构成为主流选择,但随之而来的是服务治理、流量管控、安全认证、可观测性等一系列新挑战。本文结合 Spring Cloud Netflix 生态(Eureka服务注册与发现)、Spring Cloud Gateway 路由网关、Sentinel 流量控制、Spring Boot Admin 监控以及 Spring Security6 基于RBAC的权限控制,并采用 Vue3 构建前端,提供一套前后端分离的完整微服务解决方案。架构中各组件各司其职,既保证系统弹性,又简化开发运维。

Admin 监控以及 Spring Security6 基于RBAC的权限控制,并采用 Vue3 构建前端,提供一套前后端分离的完整微服务解决方案。架构中各组件各司其职,既保证系统弹性,又简化开发运维。

2. 整体架构设计

下图展示了本方案的总体架构,包含用户端、API网关、服务注册中心、业务微服务、监控与流控组件,以及前端Vue应用。

核心流程

  1. Vue3前端发起请求 → Gateway网关

  2. Gateway中Sentinel进行流量控制,JWT过滤器验证Token合法性

  3. Gateway根据请求路径动态路由到后端微服务(基于Eureka服务发现)

  4. 微服务内部利用Spring Security6 + RBAC进行细粒度权限校验

  5. 所有微服务注册到Eureka Server,Spring Boot Admin通过Eureka自动发现并监控各实例健康状态

下面我们先看看Eureka ,spring admin,后台:

代码结构如图:

涉及到4个模块:

<modules>
        <module>eureka</module>
        <module>gateway</module>
        <module>back</module>
        <module>monitor</module>
    </modules>

下面我来讲一下怎么实现和原理。

首先建个maven父工程项目:

pom文件:

 <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2024.0.1</spring-cloud.version>
        <spring-boot-admin.version>3.4.5</spring-boot-admin.version>
        <spring-cloud-alibaba.version>2022.0.0.0</spring-cloud-alibaba.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>de.codecentric</groupId>
                <artifactId>spring-boot-admin-dependencies</artifactId>
                <version>${spring-boot-admin.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

然后依次创建每个子项目工程。

3. 核心组件详解与集成步骤

3.1 Eureka 服务注册中心

作用:提供服务注册与发现能力,所有微服务启动时向Eureka Server注册自身地址,Gateway可通过服务名调用具体实例。

配置示例(Eureka Server)

yaml

server:
  address: 127.0.0.1
  port: 20000
  servlet:
    context-path: /
    session:
      timeout: 86400
  compression:
    enabled: true
    mime-types: application/json,application/xml,text/html,text/xml,text/plain
    min-response-size: 2048

spring:
  application:
    name: eureka-server
  main:
    web-application-type: servlet
eureka:
  client:
    #由于该应用为注册中心,所以设置为false,代表不向注册中心注册自己
    register-with-eureka: true
    #由于注册中心的职责就是维护服务实例,它并不需要去检索服务,所以也设置为false
    fetch-registry: false
    service-url:
      defaultZone: http://127.0.0.1:20000/eureka/
  server:
    #注册中心清理间隔,单位为毫秒
    eviction-interval-timer-in-ms: 10000
  instance:
    prefer-ip-address: true
    ip-address: 127.0.0.1
    instance-id: ${spring.application.name}:${server.port}
    lease-renewal-interval-in-seconds: 10
    lease-expiration-duration-in-seconds: 15

客户端(微服务)依赖

pom

<dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

添加@EnableEurekaServer 注解即可。

配置服务名称和Eureka地址后,启动时自动注册。

3.2 Spring Boot Admin 监控

作用:集中展示微服务的健康状态、日志、JVM指标等信息。

Admin Server 需要添加依赖并配置:

pom

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jetty</artifactId>
        </dependency>
        <dependency>
            <groupId>de.codecentric</groupId>
            <artifactId>spring-boot-admin-starter-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

默认是没有登录密码的,添加一个登录密码即可。

配置SecurityConfig

@Bean
    protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        SavedRequestAwareAuthenticationSuccessHandler successHandler =
                new SavedRequestAwareAuthenticationSuccessHandler();
        successHandler.setTargetUrlParameter("redirectTo");
        successHandler.setDefaultTargetUrl(adminServer.path("/"));

        http.authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                adminServer.path("/assets/**"),
                                adminServer.path("/login"),
                                "/actuator/**",
                                "/instances",
                                "/instances/*"
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage(adminServer.path("/login"))
                        .successHandler(successHandler)
                )
                .logout(logout -> logout
                        .logoutUrl(adminServer.path("/logout"))
                        .logoutSuccessUrl(adminServer.path("/login"))
                )
                .httpBasic(Customizer.withDefaults())
                .csrf(csrf -> csrf
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                        .ignoringRequestMatchers(
                                new AntPathRequestMatcher("/instances"),
                                new AntPathRequestMatcher("/instances/*"),
                                new AntPathRequestMatcher("/actuator/**")
                        )
                );

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        UserDetails admin = User.builder()
                .username("serviceUaa")
                .password(encoder.encode("123456"))
                .roles("SERVICE_NODE_ROLE")
                .build();
        return new InMemoryUserDetailsManager(admin);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

yaml文件:

server:
  port: 8999
  address: 127.0.0.1
spring:
  application:
    name: admin-server
  profiles:
    active: dev # 环境 dev test prod
  security:
    user:
      name: serviceUaa
      password: 123456
      roles: SERVICE_NODE_ROLE
eureka:
  instance:
    health-check-url-path: /actuator/health
    instance-id: ${spring.application.name}:${server.port}:@project.version@
    # 优先使用IP地址方式进行注册服务
    prefer-ip-address: true
    # 配置使用指定IP
    ip-address: ${server.address}
    # 配置使用主机名注册服务
    hostname: ${server.address}  #保持和服务器ip一致
    lease-renewal-interval-in-seconds: 5    # 续约更新时间间隔(默认30秒)
    lease-expiration-duration-in-seconds: 15 # 续约到期时间(默认90秒)
    metadata-map:
      user.name: ${spring.security.user.name}
      user.password: ${spring.security.user.password}
  client:
    registryFetchIntervalSeconds: 30
    registerWithEureka: true # 是否从其他的服务中心同步服务列表,单机false,集群true
    fetchRegistry: true # 是否把自己作为服务注册到其他服务注册中心,单机false,集群true
    healthcheck:
      enabled: true
    service-url:
      defaultZone: http://${spring.security.user.name}:${spring.security.user.password}@127.0.0.1:20000/eureka/

management:
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: ALWAYS
      enabled: true
    shutdown:
      enabled: true

添加

@EnableAdminServer
@EnableDiscoveryClient ,启动可以看到注册中心能发现监控服务

在主类标注 @EnableAdminServer,并配置Eureka客户端,从Eureka抓取服务列表。

 

配置 spring.boot.admin.client.url 指向Admin Server地址,并开放端点 management.endpoints.web.exposure.include=*。最终Admin UI展示所有注册的服务实例。

3.3 Spring Cloud Gateway + Sentinel 流控

依赖(使用Spring Cloud Alibaba Sentinel):

xml

server:
  address: 127.0.0.1
  port: 8090
spring:
  application:
    name: gateway-service
  main:
    web-application-type: reactive
#  # 安全参数配置
  security:
    user:
      name: serviceUaa
      password: 123456
      roles: SERVICE_NODE_ROLE
  cloud:
    inetutils:
      # 忽略指定网卡,支持正则表达式(这里使用正则表达式忽略所有虚拟机网卡)
      ignoredInterfaces: [ 'VMware.*' ]
    gateway:
      httpclient:
        connect-timeout: 2000
        response-timeout: 60000
        pool:
          max-idle-time: 60000
          max-connections: 200
          max-life-time: 1800000
          acquire-timeout: 2000
          eviction-interval: 5000
      discovery:
        locator:
          # 是否和服务注册与发现组件结合,设置为 true 后可以直接使用应用名称调用服务
          enabled: true
          ##是否将请求路径的服务名配置改成小写  因为服务注册的时候,向注册中心注册时将服务名转成大写的了
#          lower-case-service-id: true
      globalcors: #网关跨域设置
        cors-configurations:
          '[/**]':
            allowed-origins-patterns:
              - "*"
            allowed-headers:
              - "*"
            allow-credentials: true
            allowed-methods:
              - GET
              - POST
              - DELETE
              - PUT
              - OPTIONS
              - HEAD
      routes:
        - id: api #API文档服务熔断
          uri: lb://admin-server
          predicates:
            - Path=/monitor/**
          filters:
            - StripPrefix=1
        - id: back-server
          uri: lb://back-server
          predicates:
              - Path=/iot-api/**
          filters:
            - StripPrefix=1
security:
  ignore-urls:
    - /auth/login
    - /auth/register
    - /auth/captcha
    - /public/**
    - /actuator/**
    - /health
    - /swagger-ui/**
    - /v3/api-docs/**
    - /doc.html
    - /webjars/**
  jwt:
    key: uaaa.cn
    blacklist-enabled: true
ribbon:
  eureka:
    enabled: true
  eager-load:
    enabled: true
eureka:
  client:
    serviceUrl:
      defaultZone: http://127.0.0.1:20000/eureka/
      register-with-eureka: true
      fetch-registry: true
    #Eureka客户端开启健康检查
    healthcheck:
      enabled: true
  #采用IP注册
  instance:
    prefer-ip-address: true
    ip-address: 127.0.0.1
    #定义实例ID格式
    instance-id:  ${spring.application.name}:${server.port}
    #设置Eureka客户端租期更新时间,默认30秒
    lease-renewal-interval-in-seconds: 10
    #设置租期到期时间,默认90秒
    lease-expiration-duration-in-seconds: 15

## 暴露所有端点(开发测试用)
management:
  endpoints:
    web:
      exposure:
        include: '*'  # 暴露所有端点。生产环境建议按需暴露,如 health, info, gateway
  endpoint:
    health:
      show-details: always

添加GatewayConfiguration配置:


    private final List<ViewResolver> viewResolvers;
    private final ServerCodecConfigurer serverCodecConfigurer;

    public GatewayConfiguration(ObjectProvider<List<ViewResolver>> viewResolversProvider,
                                ServerCodecConfigurer serverCodecConfigurer) {
        this.viewResolvers = viewResolversProvider.getIfAvailable(Collections::emptyList);
        this.serverCodecConfigurer = serverCodecConfigurer;
    }

    @Bean
    @Order(-1)
    public SentinelGatewayBlockExceptionHandler sentinelGatewayBlockExceptionHandler() {
        // 注册自定义的限流异常处理器
        return new SentinelGatewayBlockExceptionHandler(viewResolvers, serverCodecConfigurer);
    }

    @PostConstruct
    public void initBlockHandler() {
        BlockRequestHandler blockRequestHandler = (exchange, t) -> {
            Map<String, Object> result = new HashMap<>();
            result.put("code", 429);
            result.put("message", "系统繁忙,请稍后再试");
            return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
                    .contentType(MediaType.APPLICATION_JSON)
                    .bodyValue(result);
        };
        GatewayCallbackManager.setBlockHandler(blockRequestHandler);
    }

流控配置:

 @PostConstruct
    public void initGatewayRules() {
        Set<GatewayFlowRule> rules = new HashSet<>();
        // 为 user-service-route 路由设置 QPS 限流规则
        GatewayFlowRule routeRule = new GatewayFlowRule("back-server")
                .setResourceMode(SentinelGatewayConstants.RESOURCE_MODE_ROUTE_ID)
                .setCount(10)   // QPS 阈值
                .setIntervalSec(1);
        rules.add(routeRule);
        GatewayRuleManager.loadRules(rules);
    }

token拦截配置


    private List<String> ignoreUrls = new ArrayList<>();

    private Jwt jwt = new Jwt();

    @Data
    public static class Jwt {
        private String key = "uaaa.cn";
        private Boolean blacklistEnabled = true;
    }

添加全局过滤器,拦截token

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    String path = request.getURI().getPath();
    log.info("========== 网关收到请求 ==========");
    log.info("请求路径: {}", path);
    log.info("请求方法: {}", request.getMethod());

    AntPathMatcher pathMatcher = new AntPathMatcher();
    List<String> ignoreUrls = securityProperties.getIgnoreUrls();

    // 智能匹配:支持多种路径格式
    boolean shouldIgnore = ignoreUrls.stream().anyMatch(url -> {
        // 1. 直接匹配原始路径
        if (pathMatcher.match(url, path)) {
            log.debug("路径 {} 直接匹配白名单: {}", path, url);
            return true;
        }

        // 2. 尝试去掉第一段路径后匹配(处理 /iot-api/auth/login -> /auth/login)
        String[] segments = path.split("/");
        if (segments.length > 2) {
            String strippedPath = "/" + String.join("/", Arrays.copyOfRange(segments, 2, segments.length));
            if (pathMatcher.match(url, strippedPath)) {
                log.debug("路径 {} 去掉前缀后 {} 匹配白名单: {}", path, strippedPath, url);
                return true;
            }
        }

        return false;
    });

    if (shouldIgnore) {
        log.info("✅ 路径 {} 在白名单中,跳过JWT验证", path);
        return chain.filter(exchange);
    }

    log.info("❌ 路径 {} 不在白名单中,需要JWT验证", path);

    String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);

    if (token == null || !token.startsWith(TOKEN_PREFIX)) {
        log.warn("请求路径: {}, 缺少有效的 Authorization Header", path);
        return onError(exchange, HttpStatus.UNAUTHORIZED, "未提供有效的认证令牌");
    }

    token = token.substring(TOKEN_PREFIX.length());

    String secretKey = securityProperties.getJwt().getKey();
    boolean isValid = JwtUtil.validateToken(token, secretKey);

    if (!isValid) {
        log.warn("JWT Token 验证失败,路径: {}", path);
        return onError(exchange, HttpStatus.UNAUTHORIZED, "Token 无效或已过期");
    }

    String username = JwtUtil.getUsername(token);
    if (username != null) {
        ServerHttpRequest mutatedRequest = request.mutate()
                .header("X-User-Name", username)
                .build();
        ServerWebExchange mutatedExchange = exchange.mutate()
                .request(mutatedRequest)
                .build();

        log.info("用户 {} 认证成功,路径: {}", username, path);
        return chain.filter(mutatedExchange);
    }

    return chain.filter(exchange);,

启动配置类加上 

@EnableDiscoveryClient 注解即可

Sentinel限流规则:通过Sentinel Dashboard为Gateway路由配置流控规则(如QPS限制),也可通过代码动态加载。例如对 /api/user/login 接口设置单机阈值为10 QPS。

3.4 Spring Security6 RBAC 权限控制(核心)

本方案在微服务内部(以User Service为例)实现基于RBAC的权限校验,数据模型包含用户、角色、权限三张表,采用JWT作为认证凭证。

pom

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency> 
         
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- MyBatis-Plus https://baomidou.com-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-jsqlparser</artifactId>
        </dependency>

        <!--Knife4j https://doc.xiaominfo.com/-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
            <version>${knife4j.version}</version>
        </dependency>

        <!-- Java工具类库 https://doc.hutool.cn -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>

        <!-- 允许使用Lombok的Java Bean类中使用MapStruct注解 (Lombok 1.18.20+) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok-mapstruct-binding</artifactId>
            <version>${lombok-mapstruct-binding.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${mapstruct.version}</version>
        </dependency>

yaml

server:
  address: 127.0.0.1
  port: 9999
spring:
  application:
    name: back-server
  profiles:
    active: dev # 环境 dev test prod

#mybatis
mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  #实体扫描,多个package用逗号或者分号分隔
  typeAliasesPackage: com.iot.*.domain
  global-config:
    #数据库相关配置
    db-config:
      #主键类型  AUTO:"数据库ID自增", INPUT:"用户输入ID", ID_WORKER:"全局唯一ID (数字类型唯一ID)", UUID:"全局唯一ID UUID";
      id-type: none
      # 逻辑删除全局属性名(驼峰和下划线都支持)
      logic-delete-field: valid
      logic-delete-value: 0 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 1 # 逻辑未删除值(默认为 0)
    banner: false
  #原生配置
  configuration:
    # 驼峰下划线转换
    map-underscore-to-camel-case: true
    cache-enabled: false
    call-setters-on-nulls: true
    jdbc-type-for-null: 'null'
    # 这个配置会将执行的sql打印出来,在开发或测试的时候可以用
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

# 安全配置
security:
  jwt:
    # JWT 秘钥
    key: aaaau.cn
    # JWT 有效期(单位:秒)
    ttl: 7200
    # JWT 的黑名单功能是否启用
    blacklistEnabled: true
  # 白名单列表
  ignore-urls:
    - /v3/api-docs/**
    - /doc.html
    - /swagger-resources/**
    - /webjars/**
    - /swagger-ui/**
    - /swagger-ui.html
    - /auth/login
    - /auth/captcha
    - /ws/**

# springdoc配置: https://springdoc.org/properties.html
springdoc:
  swagger-ui:
    path: /swagger-ui.html
    operationsSorter: alpha
    tags-sorter: alpha
  api-docs:
    path: /v3/api-docs
  group-configs:
    - group: '认证中心'
      paths-to-match: "/**"
      packages-to-scan:
        - com.iot.business.auth.controller
    - group: '系统管理'
      paths-to-match: "/**"
      packages-to-scan:
        - com.iot.business.system.controller 

 

# 验证码配置
captcha:
  # 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码
  type: circle
  # 验证码宽度
  width: 130
  # 验证码高度
  height: 48
  # 验证码干扰元素个数
  interfere-count: 2
  # 文本透明度(0.0-1.0)
  text-alpha: 0.8
  # 验证码字符配置
  code:
    # 验证码字符类型 math-算术|random-随机字符
    type: math
    # 验证码字符长度,type=算术时,表示运算位数(1:个位数运算 2:十位数运算);type=随机字符时,表示字符个数
    length: 1
  # 验证码字体
  font:
    # 字体名称 Dialog|DialogInput|Monospaced|Serif|SansSerif
    name: SansSerif
    # 字体样式 0-普通|1-粗体|2-斜体
    weight: 1
    # 字体大小
    size: 20
  # 验证码有效期(秒)
  expire-seconds: 120

# 日志配置
logging:
  config: classpath:logback-spring.xml
  file:
    path: ../logs/iot
  level:
    root: info
    com.iot: debug

 
eureka:
  client:
    serviceUrl:
      defaultZone: http://127.0.0.1:20000/eureka/
    healthcheck:
      enabled: true
  instance:
    prefer-ip-address: true
    ip-address: 127.0.0.1
    instance-id: ${spring.application.name}:${server.port}
    lease-renewal-interval-in-seconds: 10
    lease-expiration-duration-in-seconds: 15

步骤

3.4.1 JWT 生成与解析

登录成功后生成Token,返回给前端。后续请求放入Header Authorization: Bearer <token>

3.4.2 自定义JwtValidationFilter

继承 OncePerRequestFilter,从请求头提取Token,解析用户信息并创建 UsernamePasswordAuthenticationToken 存入 SecurityContextHolder

java

@Slf4j
public class JwtValidationFilter extends OncePerRequestFilter {

    private final UserDetailsService userDetailsService;

    // 密钥
    private final byte[] secretKey;
    // 忽略的URL列表
    private final List<String> ignoreUrls;

    private final Boolean blacklistEnabled;

    private final StringRedisTemplate redisTemplate;

    public JwtValidationFilter(UserDetailsService userDetailsService, SecurityProperties properties,StringRedisTemplate redisTemplate) {
        this.userDetailsService = userDetailsService;
        this.secretKey = properties.getJwt().getKey().getBytes();
        this.ignoreUrls = properties.getIgnoreUrls();
        this.blacklistEnabled = properties.getJwt().getBlacklistEnabled();
        this.redisTemplate = redisTemplate;
    }
    @Override
    protected void doFilterInternal(@NotNull HttpServletRequest request, @Nonnull HttpServletResponse response, @Nonnull FilterChain chain) throws ServletException, IOException {
        String method = request.getMethod();
        String requestUri = request.getRequestURI();

        // ✅ 最关键:直接放行所有OPTIONS预检请求
        if ("OPTIONS".equalsIgnoreCase(method)) {
            log.debug("OPTIONS预检请求,直接放行: {}", requestUri);
            chain.doFilter(request, response);
            return;
        }

        System.out.println(request.getServletPath());
        // 如果黑名单功能启用,且请求头中有Authorization信息,则检查Token是否在黑名单中
        if(Boolean.TRUE.equals(blacklistEnabled)){
            log.info("JwtValidationFilter: 检查黑名单功能启用");
            String token = request.getHeader(HttpHeaders.AUTHORIZATION);
            if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
                token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
                JWT jwt = JWTUtil.parseToken(token);
                String jti = jwt.getPayloads().getStr(JWTPayload.JWT_ID);
                if (StrUtil.isNotBlank(jti) && Boolean.TRUE.equals(redisTemplate.hasKey("jwt:blacklist:" + jti))) {
                    log.error("用户尝试使用已失效的Token访问系统,Token已被加入黑名单,Payload: {}", jwt.getPayloads());
                    SecurityContextHolder.clearContext();
                    throw new ApiException(ResultCode.UNAUTHORIZED);
                }
            }
        }else {
            log.info("JwtValidationFilter: 黑名单功能未启用");
        }

        // 忽略的URL列表
        AntPathMatcher pathMatcher = new AntPathMatcher();
        if (ignoreUrls.stream().anyMatch(url -> pathMatcher.match(url, requestUri))) {
            log.debug("路径 {} 在白名单中,直接放行", requestUri);
            chain.doFilter(request, response);
            return;
        }

        // 获取请求token
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        try {
            // 如果请求头中没有Authorization信息,或者Authorization以Bearer开头,则认为是匿名用户
            if (StrUtil.isBlank(token) || !token.startsWith(SecurityConstants.JWT_TOKEN_PREFIX)) {
                chain.doFilter(request, response);
                return;
            }

            // 去除 Bearer 前缀
            token = token.substring(SecurityConstants.JWT_TOKEN_PREFIX.length());
            // 解析 Token
            JWT jwt = JWTUtil.parseToken(token);

            // 检查 Token 是否有效(验签 + 是否过期)
            boolean isValidate = jwt.setKey(secretKey).validate(0);
            if (!isValidate) {
                log.error("JwtValidationFilter error: token is invalid");
                throw new ApiException(ResultCode.UNAUTHORIZED);
            }
            JSONObject payloads = jwt.getPayloads();
            String username = payloads.getStr(JWTPayload.SUBJECT);
            SysUserDetails userDetails = (SysUserDetails) this.userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (Exception e) {
            log.error("JwtValidationFilter error: {}", e.getMessage());
            SecurityContextHolder.clearContext();
            throw new ApiException(ResultCode.UNAUTHORIZED);
        }
        // Token有效或无Token时继续执行过滤链
        chain.doFilter(request, response);
    }

}
3.4.3 Spring Security6 配置(基于方法注解)

在 SecurityConfig 中关闭默认表单登录,启用 @EnableMethodSecurity,并配置无状态会话、放行登录/注册路径。

java

private final RestfulAccessDeniedHandler restfulAccessDeniedHandler;
    private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    private final SecurityProperties securityProperties;
    private final UserDetailsService userDetailsService;
    private final StringRedisTemplate redisTemplate;

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(Collections.singletonList("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"));
        configuration.setAllowedHeaders(Collections.singletonList("*"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    protected SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

        http.authorizeHttpRequests(requestMatcherRegistry -> requestMatcherRegistry
                .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll()
                .requestMatchers(securityProperties.getIgnoreUrls().toArray(new String[0])).permitAll()
                .anyRequest().authenticated()
        );

        http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(configurer ->
                        configurer
                                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http.exceptionHandling(customizer ->
                customizer
                        .accessDeniedHandler(restfulAccessDeniedHandler)
                        .authenticationEntryPoint(restAuthenticationEntryPoint));

        http.addFilterBefore(new JwtValidationFilter(userDetailsService, securityProperties,redisTemplate), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }


    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
3.4.4 RBAC 实现

自定义权限,当注解上有 @ss.hasPermission(“xxx”)的时候,会执行这个xxx权限判断:

@Service("ss")
public class PermissionService {

    public Boolean hasPermission(String... permissions) {
        if (StringUtils.isEmpty(permissions)) {
            return false;
        }
        // 获取当前用户的所有权限
        List<String> perms = SecurityUtils.getUserDetails().getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
        // 判断当前用户的所有权限是否包含接口上定义的权限
        return perms.contains(CommonConstant.ALL_PERMISSION) || Arrays.stream(permissions).anyMatch(perms::contains);
    }

}
  • 数据库表sys_user(用户), sys_role(角色), sys_user_role(用户角色), sys_role_menu(角菜单色),sys_menu(菜单)。

    CREATE TABLE `sys_user` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
      `username` varchar(64) DEFAULT NULL COMMENT '用户名',
      `password` varchar(64) DEFAULT NULL COMMENT '密码',
      `dept_id` bigint(20) DEFAULT NULL COMMENT '所属部门',
      `dept_name` varchar(255) DEFAULT NULL COMMENT '所属部门名称',
      `post_ids` json DEFAULT NULL COMMENT '岗位组',
      `icon` varchar(500) DEFAULT NULL COMMENT '头像',
      `email` varchar(100) DEFAULT NULL COMMENT '邮箱',
      `phone` varchar(11) DEFAULT NULL COMMENT '手机号',
      `nick_name` varchar(200) DEFAULT NULL COMMENT '昵称',
      `sex` varchar(1) DEFAULT NULL COMMENT '性别,0:男 1:女 2:未知',
      `note` varchar(500) DEFAULT NULL COMMENT '备注信息',
      `status` char(1) DEFAULT '1' COMMENT '启用状态,0:禁用 1:启用',
      `login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
      `login_ip` varchar(255) DEFAULT NULL COMMENT '最后登陆IP',
      `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
      `modify_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
      `modify_by` varchar(64) DEFAULT NULL COMMENT '修改人',
      `valid` int(11) DEFAULT '1' COMMENT '有效状态,0:无效 1:有效',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=103 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='用户表';
    CREATE TABLE `sys_role` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
      `name` varchar(100) DEFAULT NULL COMMENT '名称',
      `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符',
      `data_scope` char(1) DEFAULT '1' COMMENT '数据范围(1:全部数据权限 2:自定数据权限 3:本部门数据权限 )',
      `description` varchar(500) DEFAULT NULL COMMENT '描述',
      `user_count` int(11) DEFAULT NULL COMMENT '后台用户数量',
      `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `status` char(1) DEFAULT '1' COMMENT '启用状态,0:禁用 1:启用',
      `sort` int(11) DEFAULT '0' COMMENT '排序',
      `valid` int(11) DEFAULT '1' COMMENT '有效状态,0:无效 1:有效',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='角色表';
    CREATE TABLE `sys_role_menu` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
      `role_id` bigint(20) DEFAULT NULL COMMENT '角色ID',
      `menu_id` bigint(20) DEFAULT NULL COMMENT '菜单ID',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=213 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='角色和菜单关系表';
    CREATE TABLE `sys_menu` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '菜单ID',
      `pid` bigint(20) DEFAULT NULL COMMENT '父级菜单id',
      `name` varchar(100) DEFAULT NULL COMMENT '名称',
      `permission` varchar(200) DEFAULT NULL COMMENT '权限标识',
      `icon` varchar(500) DEFAULT NULL COMMENT '图标',
      `type` int(11) DEFAULT NULL COMMENT '权限类型,0:目录 1:菜单 2:按钮(接口绑定权限)3:外链',
      `uri` varchar(200) DEFAULT NULL COMMENT '组件路径',
      `sort` int(11) DEFAULT NULL COMMENT '排序',
      `keep_alive` tinyint(4) DEFAULT NULL COMMENT '【菜单】是否开启页面缓存(1-是 0-否)',
      `always_show` tinyint(4) DEFAULT NULL COMMENT '【目录】只有一个子路由是否始终显示(1-是 0-否)',
      `path` varchar(255) DEFAULT NULL COMMENT '路由地址',
      `params` json DEFAULT NULL COMMENT '【菜单】路由参数',
      `status` char(1) DEFAULT '1' COMMENT '显示状态,0:隐藏 1:显示',
      `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
      `valid` int(11) DEFAULT '1' COMMENT '有效状态,0:无效 1:有效',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=1060 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='用户权限表';
  • 自定义UserDetailsServices实现loadUserByUsername方法:根据用户名加载用户及其角色、权限。

  •    @Override 
       public SysUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
            // 获取登录用户信息
            SysUser user = sysUserMapper.selectByUsername(username);
    
            // 用户不存在
            if (BeanUtil.isEmpty(user)) {
                throw new ApiException(SysExceptionEnum.USER_NOT_EXIST);
            }
            Long userId = user.getId();
    
            // 用户停用
            if (StatusEnums.DISABLE.getKey().equals(user.getStatus())) {
                throw new ApiException(SysExceptionEnum.USER_DISABLED);
            }
    
            // 获取角色
            Set<String> roles = sysUserRoleMapper.listRoleKeyByUserId(userId);
    
            // 获取数据范围标识
            Integer dataScope = sysUserRoleMapper.getMaximumDataScope(roles);
    
            Set<String> permissions = new HashSet<>();
            // 如果 roles 包含 root 则拥有所有权限
            if (roles.contains(CommonConstant.SUPER_ADMIN_ROOT)) {
                permissions.add(CommonConstant.ALL_PERMISSION);
            } else {
                // 获取菜单权限标识
                permissions = sysMenuMapper.getMenuPermission(userId);
                // 过滤空字符串
                permissions.remove("");
            }
    
            return new SysUserDetails(user, permissions, roles, username, dataScope);
        }
    

    方法级权限控制:在Controller方法上使用@PreAuthorize("@ss.hasPermission('sys_log_page')") ,Spring Security6自动根据上下文中的权限列表进行判断。

  • 如果不需要权限,就不加,表示放行,比如获取菜单列表:

        private final SysMenuService sysMenuService;
    
        @Operation(summary = "菜单下拉列表")
        @GetMapping("/options")
        public R<?> listMenuOptions(@Parameter(description = "是否只查询父级菜单")
                                    @RequestParam(required = false, defaultValue = "false") boolean onlyParent) {
            List<Option> menus = sysMenuService.listMenuOptions(onlyParent);
            return R.success(menus);
        }

    获取用户列表:

  •     @Operation(summary = "分页查询")
        @PreAuthorize("@ss.hasPermission('sys_user_page')")
        @GetMapping(value = "/page")
        public R<IPage<SysUser>> page(@ParameterObject Page<SysUser> page, @ParameterObject SysUser sysUser) {
            return R.success(sysUserService.getPage(page, sysUser));
        }

    登录的时候,动态获取用户权限菜单列表:

    @Operation(summary = "获取菜单路由信息")
        @GetMapping("/getRouters")
        public R<List<RouterVo>> getRouters() {
            List<SysMenu> menus = sysMenuService.getRouters(SecurityUtils.getUserId());
            return R.success(sysMenuService.buildMenus(menus));
        }
    
    
    @Override
        public List<SysMenu> getRouters(Long userId) {
            List<SysMenu> menus;
            if (SecurityUtils.isRoot()) {
                menus = list(new LambdaQueryWrapper<SysMenu>().in(SysMenu::getType,
                        Arrays.asList(
                                MenuTypeEnums.CATALOG.getValue(),
                                MenuTypeEnums.MENU.getValue(),
                                MenuTypeEnums.EX_LINK.getValue()
                        )));
            } else {
                menus = this.baseMapper.getRoutersByUserId(userId);
            }
            return getChildPerms(menus, 0);
        }
    
      public List<SysMenu> getChildPerms(List<SysMenu> list, int parentId) {
            List<SysMenu> returnList = new ArrayList<>();
            for (SysMenu t : list) {
                // 一、根据传入的某个父节点ID,遍历该父节点的所有子节点
                if (t.getPid() == parentId) {
                    recursionFn(list, t);
                    returnList.add(t);
                }
            }
            return returnList;
        }
    
    
     <select id="getRoutersByUserId" resultType="com.iot.business.system.domain.SysMenu">
            SELECT DISTINCT
                m.id,
                m.pid,
                m.NAME,
                uri,
                m.type,
                m.icon,
                m.sort,
                m.path,
                m.always_show,
                m.keep_alive,
                m.params
            FROM
                sys_menu m
                    LEFT JOIN sys_role_menu rm ON m.id = rm.menu_id
                    LEFT JOIN sys_user_role ur ON rm.role_id = ur.role_id
            WHERE
                ur.user_id = #{userId}
              AND m.type IN ( '0', '1' ,'3')
              AND m.valid = 1
            ORDER BY
                m.pid,
                m.sort
        </select>

3.5 Vue3 前端分离

前端技术栈:Vue3 + Vue Router + Axios + Element Plus。

关键实现

  1. 登录页调用 /auth/login 接口,成功后将JWT存入localStorage。

  2. Axios请求拦截器统一添加 Authorization: Bearer ${token}

  3. 路由守卫(permission.js)根据用户角色动态生成可访问的路由表(前端路由权限),实现菜单级控制。

  4. 响应拦截器处理401 Token过期,自动跳转登录。

javascript

# 应用端口
VITE_APP_PORT = 3000

# 代理前缀
VITE_APP_BASE_API = '/iot-api'

# 网关地址 通过网关地址转发到http://127.0.0.1:9999 接口真实地址
VITE_APP_API_URL = http://127.0.0.1:8090

# 是否启用 Mock 服务
VITE_MOCK_DEV_SERVER = false

前端vue页面就不做详细j介绍了,Node ≥18 可以直接去官网看看 

登录获取权限菜单:

4. 完整请求流程示例

  1. 用户在前端输入用户名密码,点击登录 → 请求经过Gateway,Sentinel限流检查通过 → 路由到User Service的 /auth/login

  2. User Service校验密码,生成JWT并返回。

  3. 前端保存JWT,之后每次请求携带该Token。

  4. 用户访问 /iot-api/sys/user

    接口 → Gateway限流 + 全局Token有效性验证(可选) → 路由到back Service。

  5. back Service的JWT过滤器解析Token,构建包含用户角色/权限的Authentication。

  6. Controller方法上标注 @PreAuthorize("@ss.hasPermission('sys_user_page')"),Spring Security6自动拦截校验权限,通过则执行业务逻辑,否则返回403。

5. 架构优势与总结

  • 高可用与弹性:Eureka集群支持多实例注册,Gateway与Sentinel结合实现流量洪峰防护。

  • 安全可靠:JWT + RBAC实现无状态认证与细粒度授权,符合前后端分离场景。

  • 可观测性:Spring Boot Admin实时监控服务健康、内存、线程,便于运维排错。

  • 解耦高效:前端只与Gateway通信,后端服务通过服务名调用,扩展性强。

本文介绍的架构已在多个生产项目中验证,能够支撑中等规模的微服务体系。开发者可根据实际业务需求调整限流策略、权限模型及监控告警规则,进一步集成链路追踪(如SkyWalking)、日志中心等组件,构建更为完善的云原生应用平台。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐