背景与问题
前后端分离之后,登录态管理这件事就不再像早期 JSP/Session 那样“顺手”了。
传统服务端 Session 模式有两个典型问题:
- 前后端跨域时处理麻烦
- Cookie 传递、SameSite、跨域凭据都容易出问题。
- 服务扩容后会遇到状态共享
- 多实例部署时,要么 sticky session,要么 session 共享,架构复杂度上来了。
这时候,JWT(JSON Web Token)就很自然地进入视野:服务端签发一个带声明的令牌,客户端后续请求携带该令牌,服务端校验签名后直接完成身份认证。
但真实项目里,很多人把 JWT 只当成“登录成功后发个 token”这么简单。真正难点往往在后面:
- Spring Security 过滤链怎么接入?
- 登录、鉴权、授权这三件事怎么拆开?
- 角色、权限、菜单到底怎么设计?
- token 失效、续签、登出、黑名单怎么处理?
- 出现 401/403 时,怎么快速排查?
我当时第一次把 JWT 接到 Spring Security 里,最容易混淆的就是:认证(你是谁) 和 授权(你能做什么)。这篇文章就从架构视角,把这套方案完整走一遍,并给出一套能跑起来的 Spring Boot 示例。
方案概览与取舍分析
先给结论:对于典型的前后端分离系统,Spring Boot + Spring Security + JWT 是一套非常主流且工程上平衡不错的方案。
方案角色划分
- Spring Security:安全框架骨架,负责认证入口、授权判断、异常处理、过滤器链
- JWT:令牌格式,负责跨请求携带用户身份与必要声明
- 业务系统数据库:保存用户、角色、权限等长期数据
- 前端:保存 access token,并在每次请求头中携带
与 Session 模式对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Session + Cookie | 简单直观,服务端可控 | 分布式共享成本高,跨域复杂 | 单体应用、后台管理 |
| JWT + Security | 无状态、适合前后端分离、扩展方便 | 撤销难、设计不当会有安全风险 | 移动端、前后端分离、微服务网关前置 |
| OAuth2/OIDC | 标准化、生态成熟 | 接入复杂度更高 | 多应用统一认证、第三方登录 |
架构上的取舍
JWT 不是没有代价:
-
优点
- 无状态,服务横向扩容友好
- 前后端解耦
- 网关层校验方便
-
代价
- token 一旦签发,天然不容易立即失效
- token 放太多信息会变大,增加传输与泄漏风险
- 权限变更存在“旧 token 权限仍生效”的时间窗口
所以实践中建议:
- 短时 access token
- 敏感系统加 refresh token
- 需要强制下线时引入黑名单或 token version
核心原理
这一部分先把链路讲透,后面代码就容易看懂了。
1. 登录认证流程
用户输入用户名密码,后端校验通过后,签发 JWT。
JWT 通常包含三部分:
- Header:签名算法等元信息
- Payload:用户声明,比如用户名、用户 ID、角色
- Signature:服务端密钥签名
JWT 结构大致如下:
header.payload.signature
2. 请求鉴权流程
前端后续请求在 Header 中带上:
Authorization: Bearer <token>
服务端在 Spring Security 过滤器中完成:
- 解析 Authorization 请求头
- 校验 JWT 签名、过期时间
- 从 token 中提取用户身份
- 构造
Authentication - 放入
SecurityContextHolder - 后续授权器按角色/权限判断是否放行
3. 认证与授权的边界
这个边界一定要清晰:
-
认证 Authentication
- 证明请求是谁发起的
- 比如用户
admin
-
授权 Authorization
- 决定该用户是否可以访问
/admin/user/list - 比如是否拥有
ROLE_ADMIN或user:read
- 决定该用户是否可以访问
很多项目把角色直接塞进 token,然后所有权限都靠 token 里的角色判断。这样做小项目没问题,但中大型系统会遇到:
- 权限变更不能及时生效
- token 内容不断膨胀
- 角色与资源绑定越来越复杂
更稳妥的做法是:
- token 里保留最小必要身份信息
- 权限可以根据用户 ID 动态加载,或者放入缓存
4. Spring Security 在这里做了什么
你可以把 Spring Security 理解成一条可编排的过滤链。
flowchart LR
A[前端请求] --> B[JWT过滤器]
B --> C{Token有效?}
C -- 否 --> D[返回401]
C -- 是 --> E[构造Authentication]
E --> F[放入SecurityContext]
F --> G[授权判断]
G --> H{有权限?}
H -- 否 --> I[返回403]
H -- 是 --> J[进入Controller]
这里两个 HTTP 状态码非常重要:
- 401 Unauthorized
- 没登录、token 无效、token 过期
- 403 Forbidden
- 已登录,但权限不够
这两个状态别混了,排查时非常关键。
权限模型设计:角色不是全部
JWT 鉴权真正落地时,往往不是“能不能登录”,而是“权限模型怎么设计”。
常见模型
1. 只用角色
例如:
ROLE_ADMINROLE_USER
优点是简单,缺点是粒度粗。
如果出现“用户管理只读”和“用户管理可编辑”这样的需求,就容易失控。
2. 角色 + 权限点
推荐这种:
- 角色:一组权限的集合
- 权限点:系统最小授权单元
例如:
-
角色
ROLE_ADMINROLE_EDITOR
-
权限
user:readuser:createuser:updateuser:delete
这种模式更适合后台管理系统和中型业务系统。
一套常用表设计
classDiagram
class User {
+Long id
+String username
+String password
+Boolean enabled
}
class Role {
+Long id
+String code
+String name
}
class Permission {
+Long id
+String code
+String name
}
class UserRole {
+Long userId
+Long roleId
}
class RolePermission {
+Long roleId
+Long permissionId
}
User --> UserRole
Role --> UserRole
Role --> RolePermission
Permission --> RolePermission
实战建议
如果是中级复杂度的系统,我建议:
- token 中放:
- userId
- username
- tokenVersion(可选)
- 权限从缓存或数据库获取
- controller 或 service 层使用:
hasRole('ADMIN')hasAuthority('user:read')
这样既保持 JWT 轻量,也利于权限动态调整。
实战代码(可运行)
下面给一套简化但完整的 Spring Boot 3 示例。为了便于直接理解,我使用内存用户做演示;真正上数据库时,只需要把 UserDetailsService 换成数据库实现即可。
1. Maven 依赖
<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>jwt-security-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
2. 配置文件
# src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的 secret 只是演示。生产环境必须使用高强度随机密钥,并通过环境变量或配置中心管理。
3. 启动类
// src/main/java/com/example/demo/JwtSecurityDemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtSecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtSecurityDemoApplication.class, args);
}
}
4. JWT 工具类
// src/main/java/com/example/demo/security/JwtUtil.java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final long expiration;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
public String generateToken(String username) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public String getUsernameFromToken(String token) {
return getClaims(token).getSubject();
}
public boolean isTokenValid(String token) {
try {
Claims claims = getClaims(token);
return claims.getExpiration().after(new Date());
} catch (Exception e) {
return false;
}
}
private Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
}
5. 自定义 JWT 过滤器
// src/main/java/com/example/demo/security/JwtAuthenticationFilter.java
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtUtil jwtUtil,
UserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null;
String username = null;
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
if (jwtUtil.isTokenValid(token)) {
username = jwtUtil.getUsernameFromToken(token);
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
6. Security 配置
// src/main/java/com/example/demo/security/SecurityConfig.java
package com.example.demo.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(
User.withUsername("admin")
.password(passwordEncoder.encode("123456"))
.roles("ADMIN")
.authorities("user:read", "user:create", "user:update", "user:delete")
.build(),
User.withUsername("jack")
.password(passwordEncoder.encode("123456"))
.roles("USER")
.authorities("user:read")
.build()
);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration)
throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/read").hasAuthority("user:read")
.requestMatchers("/user/write").hasAuthority("user:create")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"未认证或Token无效\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"message\":\"权限不足\"}");
})
);
return http.build();
}
}
7. 登录接口
// src/main/java/com/example/demo/controller/AuthController.java
package com.example.demo.controller;
import com.example.demo.security.JwtUtil;
import lombok.Data;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
public AuthController(AuthenticationManager authenticationManager,
JwtUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@PostMapping("/login")
public Map<String, Object> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtUtil.generateToken(authentication.getName());
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("tokenType", "Bearer");
return result;
}
@Data
public static class LoginRequest {
private String username;
private String password;
}
}
8. 受保护接口
// src/main/java/com/example/demo/controller/TestController.java
package com.example.demo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/admin/hello")
public String adminHello() {
return "hello admin";
}
@GetMapping("/user/read")
public String userRead() {
return "user read success";
}
@PreAuthorize("hasAuthority('user:create')")
@GetMapping("/user/write")
public String userWrite() {
return "user write success";
}
}
9. 访问流程验证
登录获取 token
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9.xxx.xxx",
"tokenType": "Bearer"
}
用 token 访问受保护资源
curl http://localhost:8080/admin/hello \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx"
使用普通用户访问管理员接口
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"jack","password":"123456"}'
再访问:
curl http://localhost:8080/admin/hello \
-H "Authorization: Bearer <jack的token>"
这时应返回 403。
认证到授权的时序图
这张图可以帮助你把整个链路在脑子里串起来。
sequenceDiagram
participant Client as 前端
participant Auth as AuthController
participant Security as Spring Security
participant JWT as JwtUtil
participant API as 业务接口
Client->>Auth: POST /auth/login 用户名+密码
Auth->>Security: AuthenticationManager.authenticate
Security-->>Auth: 认证通过
Auth->>JWT: generateToken(username)
JWT-->>Auth: JWT
Auth-->>Client: 返回token
Client->>API: GET /user/read + Bearer Token
API->>Security: 进入过滤器链
Security->>JWT: 校验token
JWT-->>Security: token有效 + username
Security->>Security: 构造Authentication
Security-->>API: 放行
API-->>Client: 返回业务数据
常见坑与排查
这一部分非常重要。很多项目不是“不会写”,而是“写完以后总是 401/403,不知道卡哪儿”。
1. 登录成功了,但访问接口仍然 401
常见原因
- 没有带
Authorization头 Bearer前缀写错- token 已过期
- JWT 过滤器没有加入过滤链
- secret 不一致,导致签名校验失败
排查顺序
- 先看请求头是否真的带了 token
- 打印过滤器日志,看是否进入
JwtAuthenticationFilter - 检查
SecurityContextHolder是否被成功设置 - 检查 JWT 解析是否抛异常
- 检查接口路径是否被
permitAll或authenticated正确匹配
我自己最常踩的一个坑是:路径匹配顺序。
比如你前面写了一个过宽的规则,后面的细粒度规则就根本不生效了。
2. 明明登录了,却返回 403
这通常说明“认证成功了,但授权失败”。
常见原因
hasRole("ADMIN")和hasAuthority("ROLE_ADMIN")混用- 角色前缀理解错误
- 用户权限没有被正确加载
- 方法级注解
@PreAuthorize没开启
关键区别
.hasRole("ADMIN")
本质上会匹配权限:
ROLE_ADMIN
而:
.hasAuthority("ADMIN")
就是严格匹配 ADMIN。
所以如果你存的是 ROLE_ADMIN,那就:
- 要么用
hasRole("ADMIN") - 要么用
hasAuthority("ROLE_ADMIN")
不要混着来。
3. 跨域后前端总是请求失败
前后端分离时很常见。
核心点
- 服务端要开启 CORS
- 前端要确认是否真的把
Authorization头传出去了 - 预检请求
OPTIONS可能被拦截
如果你的系统是网关统一处理跨域,那应用层就不要重复写一堆冲突配置。
4. token 改了权限却不生效
这是 JWT 设计上的典型问题。
原因
如果权限直接写在 token 里,那么 token 在过期前一直有效。
即使数据库里把某角色权限收回了,老 token 仍可能继续访问。
应对方式
- access token 设短一点,比如 15~30 分钟
- 权限从服务端缓存加载,而不是全塞进 token
- 加入
tokenVersion字段,用户强制下线时递增版本
5. 过滤器执行了,但 Controller 里拿不到用户
排查:
- 是否成功
SecurityContextHolder.getContext().setAuthentication(authentication); - 过滤器是否执行在
UsernamePasswordAuthenticationFilter之前 - 是否被后续过滤器覆盖
- 是否异步线程中丢失了安全上下文
安全/性能最佳实践
JWT 好用,但别因为它“无状态”就放松安全设计。
安全最佳实践
1. 不要把敏感信息放入 token
不要放这些:
- 明文密码
- 手机号、身份证号等高敏信息
- 过多业务字段
JWT 的 payload 只是 Base64Url 编码,不是加密。
拿到 token 的人是可以解开的。
2. access token 短时有效
推荐:
- access token:15 分钟 ~ 2 小时
- refresh token:7 天 ~ 30 天
如果系统是内部后台,时效可以略长;如果是高敏场景,尽量短。
3. secret 必须安全管理
- 不要写死在代码仓库
- 使用环境变量、KMS 或配置中心
- 定期轮换密钥
4. 强制下线能力要提前设计
JWT 最大的工程难点不是登录,而是“撤销”。
可选方案:
- Redis 黑名单
- tokenVersion
- 维护用户最后一次登出时间,与 token 签发时间比对
5. 使用 HTTPS
这是底线。
如果没有 HTTPS,token 被中间人截获后,后果和 Cookie 被盗没本质区别。
性能最佳实践
1. token 不要过大
如果把角色、权限、菜单树全塞进 token:
- 请求头变大
- 每次传输成本增加
- 代理层可能遇到 header 长度限制
建议 token 只保留必要声明。
2. 权限加载加缓存
如果每个请求都查一次用户角色和权限,数据库压力会明显上升。
常见做法:
- 用户权限放 Redis
- 设置合理 TTL
- 用户权限变更时主动失效缓存
3. 过滤器里逻辑尽量轻
JWT 过滤器是每个请求都经过的,不要在里面做重查询和复杂业务逻辑。
它的职责应该尽量单一:解析 token,构建认证上下文。
容量与落地建议
从架构角度,再补几条实战建议。
单体后台系统
推荐:
- JWT + Spring Security
- 权限模型用角色 + 权限点
- 权限缓存放本地缓存或 Redis
适合大多数管理系统。
多服务系统
推荐:
- 网关层统一校验 token
- 下游服务只信任网关透传的用户上下文,或自行做二次校验
- 权限判断收敛在网关或统一鉴权服务
否则每个微服务都各写一套 Security 配置,后期维护会非常痛苦。
高安全场景
如果涉及金融、支付、强实名、核心运营权限,建议进一步增强:
- refresh token + 设备绑定
- IP/设备指纹风控
- 操作二次确认
- 审计日志
- 更细粒度的资源级权限控制
JWT 不是万能钥匙,它解决的是身份在分布式/前后端分离场景下的高效传递,不等于整个安全体系就完成了。
总结
如果把这套方案压缩成一句话,就是:
用 Spring Security 管“安全流程”,用 JWT 管“无状态身份传递”,用角色与权限模型管“谁能访问什么”。
落地时建议优先记住这几点:
-
先分清认证和授权
- 认证解决“你是谁”
- 授权解决“你能干什么”
-
JWT 保持轻量
- 放用户标识,不要塞太多业务数据
-
Spring Security 过滤链要接对
- JWT 过滤器放在合适位置
- 401 与 403 明确区分
-
权限模型不要只靠角色
- 中型系统建议角色 + 权限点
-
给失效与强制下线留后路
- 短 token、refresh token、黑名单、tokenVersion 至少要考虑一种
如果你现在要在项目里真正开始做,我会建议按这个顺序实施:
- 先跑通登录签发 token
- 再接入 JWT 过滤器
- 然后做接口级权限控制
- 最后补强刷新、登出、黑名单、缓存和审计
这样不会一下子把系统复杂度拉满,也更符合真实项目的演进路径。