拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Security过滤器链与JDBC数据库认证实战指南

Spring Security过滤器链与JDBC数据库认证实战指南

1. 先搞清楚Spring Security的过滤器链到底是怎么工作的

很多同学学Spring Security,上来就写配置类、重写方法,跑通了就以为会了。结果有一天项目里加了一个自定义过滤器,或者升级到Spring Boot 3发现原来的配置全废了,才意识到根本没搞懂里面的核心机制。

Spring Security的核心机制就是过滤器链(Filter Chain)。它不是一个过滤器,而是一串过滤器按顺序执行,像工厂流水线一样,请求依次经过每个工位,每个工位只干自己那一件事。认证、授权、CSRF防护、登录表单处理、异常处理,全都是通过这一条链上的不同过滤器完成的。

1.1 过滤器链的组成与加载逻辑

Spring Security在启动时会自动构建一条过滤器链。以Spring Boot 3 + Spring Security 6为例,默认链上至少包含以下核心过滤器,我按执行顺序列出来:

顺序过滤器职责
1DisableEncodeUrlFilter禁用URL编码,避免会话ID暴露
2WebAsyncManagerIntegrationFilter集成异步请求的安全上下文
3SecurityContextHolderFilter从SecurityContextRepository中加载安全上下文
4HeaderWriterFilter写入安全响应头(如X-Frame-Options)
5CorsFilter处理跨域请求
6CsrfFilter处理CSRF防护
7LogoutFilter处理登出请求
8UsernamePasswordAuthenticationFilter处理表单登录认证
9DefaultLoginPageGeneratingFilter生成默认登录页
10BasicAuthenticationFilter处理HTTP Basic认证
11AuthorizationFilter执行授权判断
12ExceptionTranslationFilter捕获异常并转发
13FilterSecurityInterceptor方法级/URL级安全拦截

我第一次看这个列表的时候就在想,为什么AuthorizationFilter放在ExceptionTranslationFilter前面?后来踩坑才明白,ExceptionTranslationFilter是负责把AccessDeniedException转换成403响应或者重定向到登录页的,它必须在授权过滤器之后才能捕获到授权异常。这个顺序一旦乱了,安全机制就会失灵。

关键点来了:这整条链是通过FilterChainProxy这个代理过滤器注册到容器的。Spring Boot会自动把FilterChainProxy注册为一个Servlet过滤器,所以你在web.xml或者配置类里看不到一个名为springSecurityFilterChain的注册项,但它真实存在。

1.2 认证过滤器在链中的位置与执行流程

用户提交用户名密码之后,真正干活的过滤器是UsernamePasswordAuthenticationFilter。它从HttpServletRequest中取出username和password参数,封装成UsernamePasswordAuthenticationToken对象,然后交给AuthenticationManager去完成认证。

AuthenticationManager本身不直接查数据库,它是个调度者。它根据Token类型找到对应的AuthenticationProvider——对于用户名密码登录,找到的是DaoAuthenticationProvider。DaoAuthenticationProvider再去调用UserDetailsService,从数据库或者别的地方加载用户信息,然后比对密码、检查账号状态,最后把认证结果写回SecurityContext。

这里有一条非常容易踩坑的链:DaoAuthenticationProvider在比对密码时,不是简单地拿输入的明文和数据库里的密文做equals,而是用PasswordEncoder去校验。如果数据库里存的是BCrypt密文,而你的配置里PasswordEncoder用了NoOpPasswordEncoder(明文比较器),那永远匹配失败。反过来也是同样的问题。

所以JDBC认证实现的关键,不光是把数据库连上、SQL写对,还要理解这一整条调用链上每个环节的职责和数据流。我画的链路是这样:

客户端请求 -> UsernamePasswordAuthenticationFilter -> AuthenticationManager (ProviderManager) -> DaoAuthenticationProvider -> UserDetailsService (加载用户) -> PasswordEncoder (密码校验) -> SecurityContextHolder (写入认证结果)

明白这条链,后面写代码就是顺着这条线一个个填充实现而已。

2. 基于JDBC的认证实现思路与方案选型

2.1 为什么不用内存用户而用JDBC

Spring Security入门教程里最常见的就是在配置类里这样写:

@Bean public UserDetailsService userDetailsService() { UserDetails user = User.withUsername("admin") .password("{noop}123456") .roles("ADMIN") .build(); return new InMemoryUserDetailsManager(user); }

这段代码能跑,但它有一个致命问题:用户数据写死在代码里。改一个密码要重新编译、重新打包、重新部署,这在任何真实项目里都是不可接受的。

真实项目的用户数据一定在数据库里。用JDBC做认证,本质上是把UserDetailsService的实现从内存换成了数据库查询。Spring Boot提供了一套完整的JDBC用户存储方案,你要么直接用它内置的JdbcUserDetailsManager,要么自定义一个UserDetailsService实现,用JdbcTemplate去数据库里捞用户。两种方式各有适用场景。

2.2 数据库表设计与密码存储方案

先说密码存储。这是安全底线。明文密码在数据库里裸奔的项目我见过不止一个,一旦数据库泄露,所有用户账号直接暴露。Spring Security内置的BCryptPasswordEncoder就是干这个的——它是BCrypt哈希算法,自带盐,同一个密码每次加密结果都不同,暴力破解成本极高。

然后是表结构。如果你使用Spring Security内置的JdbcUserDetailsManager,它默认期望以下两张表存在:

CREATE TABLE users ( username VARCHAR(50) NOT NULL PRIMARY KEY, password VARCHAR(100) NOT NULL, enabled TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE authorities ( username VARCHAR(50) NOT NULL, authority VARCHAR(50) NOT NULL, CONSTRAINT fk_authorities_users FOREIGN KEY (username) REFERENCES users(username) );

这是Spring Security的默认schema。很多人不知道,在classpath下加一个schema.sql,内容是这两条建表语句,应用启动时Spring Boot的脚本初始化机制会自动执行。

但现实项目中,很少有用户表刚好叫users、字段刚好叫username和password。你大概率有自己的用户表,比如sys_user。那就有两条路:

第一条,配置JdbcUserDetailsManager的查询SQL,让它的查询语句适配你的表结构。 第二条,实现自定义UserDetailsService,完全掌控查询逻辑。

我的建议是:表结构离Spring Security默认schema比较远的项目,直接走自定义UserDetailsService。因为JdbcUserDetailsManager的适配SQL改起来比较绕,而且只能处理单表查询,一旦涉及联表查询(比如用户-角色-权限三表关联),它就无能为力了。

自定义UserDetailsService的核心,就是实现这个接口方法:

public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }

你只需在这个方法里做三件事:

  1. 根据用户名从数据库查出用户记录
  2. 把数据库记录转换成UserDetails对象
  3. 设置用户拥有的权限

就这么简单。JdbcTemplate负责第一步,User对象负责第二步。

3. 实操:Spring Boot 3中完整落地JDBC认证

3.1 工程结构与依赖引入

先看依赖。基于Spring Boot 3.2.x + Spring Security 6.2.x,需要引入以下依赖:

<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-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

注意,如果你引入了spring-boot-starter-jdbc,Spring Boot会利用DataSourceAutoConfiguration自动配置数据源。如果你项目里用的是MyBatis或者JPA,它内部已经传递依赖了这个starter,数据源自动配置依然生效。

这里有一个最容易被新手忽略的点:Spring Boot 3整个安全配置的API都变了。如果你在网上搜索教程,看到有类继承WebSecurityConfigurerAdapter的,那都是Spring Boot 2时代的写法,在Spring Boot 3里这个类已经删除了。现在的标准写法是定义SecurityFilterChain的@Bean。

再提一个IDEA里的坑:有些同学新建Spring Boot项目时,IDEA自动下载Maven依赖会失败,尤其是mysql-connector-j这个依赖偶尔会报"download from maven failed"。遇到这种情况,先检查Maven私服配置,再看本地仓库是否损坏。最常见的解决方法是删掉本地仓库(通常位于~/.m2/repository)下对应的失败目录,然后重新刷新Maven项目让IDEA重新下载。

3.2 数据源配置与数据库准备

接下来配置application.yml:

spring: datasource: url: jdbc:mysql://localhost:3306/security_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver

这里单独说一下driver-class-name。可能有人发现,明明不写driver-class-name也能跑,因为Spring Boot能从URL里推断驱动类。但建议还是显式写出来,尤其是在多数据源场景下,不写容易串驱动。另外MySQL 8之后的驱动类名是com.mysql.cj.jdbc.Driver,老写法com.mysql.jdbc.Driver已经废弃了。

然后准备测试数据。我的做法是在resources目录下放一个schema.sql和data.sql,这样应用启动时数据库表结构和测试数据会自动初始化:

-- schema.sql CREATE TABLE IF NOT EXISTS sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, enabled TINYINT DEFAULT 1 );
-- data.sql INSERT INTO sys_user (username, password, enabled) VALUES ('admin', '{bcrypt}$2a$10$dXJ3SW6G7P5lGz6VqZcZ6eCwQxMOvZSlVN7OZx1XHhE4wNOUuO5lO', 1), ('user', '{bcrypt}$2a$10$dXJ3SW6G7P5lGz6VqZcZ6eCwQxMOvZSlVN7OZx1XHhE4wNOUuO5lO', 1);

注意看密码前面那一段{bcrypt}前缀。这是Spring Security 5之后引入的PasswordEncoder Delegation机制。Spring Security会根据这个前缀自动选择对应的PasswordEncoder去做校验。如果你不想在前缀上做文章,可以统一在配置里指定一个PasswordEncoder Bean。

那这段密文怎么生成的?最方便的办法是写一个临时测试类,用BCryptPasswordEncoder生成:

public class PasswordGenerator { public static void main(String[] args) { BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); String rawPassword = "123456"; String encodedPassword = encoder.encode(rawPassword); System.out.println(encodedPassword); } }

跑一次,把输出粘到SQL里就行了。我实际项目中也是这么干的,没有比这更快的办法。

3.3 自定义UserDetailsService与JdbcTemplate实现

现在一步步写代码。首先自定义一个UserDetailsService实现类:

@Service public class JdbcUserDetailsService implements UserDetailsService { private final JdbcTemplate jdbcTemplate; public JdbcUserDetailsService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { String sql = "SELECT username, password, enabled FROM sys_user WHERE username = ?"; List<Map<String, Object>> list = jdbcTemplate.queryForList(sql, username); if (list == null || list.isEmpty()) { throw new UsernameNotFoundException("用户不存在: " + username); } Map<String, Object> row = list.get(0); String dbUsername = (String) row.get("username"); String dbPassword = (String) row.get("password"); Integer enabled = (Integer) row.get("enabled"); return User.withUsername(dbUsername) .password(dbPassword) .disabled(enabled == 0) .authorities("ROLE_USER") .build(); } }

有人会问,查询一条记录为什么不用queryForObject?我在实际开发中踩过坑。queryForObject配合RowMapper在查询结果为空时会抛EmptyResultDataAccessException,这倒也能捕获。但是当数据库字段有NULL或者类型映射不对时,RowMapper里的getString很容易因为SQL中间结果的问题报错,不如queryForList拿到List之后自己再做判断灵活,代码也好调试。

权限那里我先硬编码ROLE_USER。真实项目里应该是联表查询,比如从sys_role表查出用户名对应的角色,再拼成ROLE_ADMIN这样的字符串。我演示项目里简化了,但是思路是一样的。

3.4 安全配置类的写法(重点讲Spring Boot 3的迁移变化)

接下来是Spring Security的配置类。这是Spring Boot 3改动最大的一块,我重点讲。

Spring Boot 2时代的标准写法是继承WebSecurityConfigurerAdapter,重写configure(HttpSecurity http)方法。Spring Security 6把这条路彻底堵死了,现在的写法是直接定义SecurityFilterChain Bean:

@Configuration @EnableWebSecurity public class SecurityConfig { private final UserDetailsService userDetailsService; public SecurityConfig(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/css/**", "/js/**", "/register").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .usernameParameter("username") .passwordParameter("password") .defaultSuccessUrl("/index", true) .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") ) .userDetailsService(userDetailsService); return http.build(); } }

关于这段配置,有几个点我必须单独拉出来讲清楚,因为这些就是新人最容易卡住的地方。

第一个注意点,关于密码加密器(PasswordEncoder)的注入方式。很多教程会在过滤链里加上.and()或者是把PasswordEncoder作为参数传给http,其实不需要。只要你声明了PasswordEncoder Bean,Spring Security会自动把它装配到DaoAuthenticationProvider里。前提是——你声明的UserDetailsService Bean也要被Spring Security感知到。

第二个注意点,DaoAuthenticationProvider会自动使用容器中的UserDetailsService。但在某些情况下,Spring Security可能没有自动装配你的Bean。我怎么解决的?我习惯显式在配置里调用.userDetailsService(userDetailsService),这样做的好处是明确的告诉Spring Security:用我这个UserDetailsService,不要自己去猜测。

第三个注意点,关于登录页。如果你的项目有自定义登录页面,loginPage("/login")配置好后,还需要一个Controller来处理/login这个GET请求,返回login.html页面。如果你不想写Controller,Spring Security也提供了默认登录页,只要不配置loginPage,系统自动生成一个。

第四个注意点,Spring Boot 3中csrf默认开启。这意味着POST请求(比如登录)如果没有携带CSRF Token,会被拒绝——返回403。开发阶段最简单的解法是暂时关闭CSRF:

http.csrf(csrf -> csrf.disable());

但这里我明确提醒你:上线前务必评估CSRF的风险。对于公开的、无状态的服务,关闭CSRF可能问题不大;但如果是基于Session的有状态应用,CSRF防护是必须开的。关闭只是开发调试方便。

再看登录表单的UsernameParameter和PasswordParameter。如果前端登录页的输入框name属性就是username和password,这两个配置可以省略。但如果你的前端input写的是name="user"和name="pwd",那这里就必须对应修改,否则Spring Security取不到参数,登录必然失败。

这段配置里还涉及一个静态资源放行问题。在实际项目中,CSS、JS、图片这些资源一般都要放行,不然页面样式加载不出来,登录页一团乱。我的经验是尽量用具体的路径匹配,如果是Spring Boot项目,/static目录下的资源默认映射到/css/、/js/、/images/等路径,所以上面配置里我把这些路径都放行了。

最后是关键的一点:Spring Security 6用requestMatchers代替了antMatchers。Spring Security 5.8开始antMatchers标记为废弃,Spring Security 6删掉了。网上老教程里写的.antMatchers("/admin/")在Spring Boot 3里直接编译报错,要统一改成.requestMatchers("/admin/")。这个坑我自己就踩过,搜索的时候发现很多代码还是旧的写法,照着敲了一遍发现根本编译不过。

3.5 认证流程串联验证

配置完成后,整个认证流程是这样的:

  1. 用户打开/login页面,输入用户名密码,点击提交
  2. 请求发到/login处理URL(loginProcessingUrl指定的)
  3. UsernamePasswordAuthenticationFilter拦截请求,取出参数
  4. 构造UsernamePasswordAuthenticationToken,交给AuthenticationManager
  5. ProviderManager找到DaoAuthenticationProvider
  6. DaoAuthenticationProvider调用我们的JdbcUserDetailsService
  7. JdbcTemplate执行SQL,从sys_user表查出用户,转换成UserDetails
  8. DaoAuthenticationProvider用BCryptPasswordEncoder比对密码
  9. 把认证结果写入SecurityContextHolder
  10. 跳转到defaultSuccessUrl指定的页面

我在本地用Spring Boot 3.2 + MySQL 8实测过,整个流程跑通非常顺。唯一让我折腾了一会的是密码前缀的问题——因为我在数据库里存的密文格式带{bcrypt}前缀,而我的PasswordEncoder Bean是BCryptPasswordEncoder,校验时Spring Security发现前缀是{bcrypt},就会委托给对应的DelegatingPasswordEncoder处理。如果你的密文不带任何前缀,那就会直接使用你配置的BCryptPasswordEncoder进行比对。

4. 常见问题排查与避坑实录

4.1 登录页刷新后无限重定向

这个是我见过最多的问题。现象是输入正确账号密码后,页面一直在登录页和某个地址之间来回跳转,最终浏览器报"重定向次数过多"。

原因通常是defaultSuccessUrl配置出错。比如defaultSuccessUrl("/index", true)中,/index这个地址要求用户必须登录才能访问,而登录成功后Spring Security要重定向到/index,结果又触发认证检查,发现没有认证信息,又跳回登录页,无限循环。

解决思路有两条。一是把defaultSuccessUrl指向一个permitAll的地址,比如首页或欢迎页。二是检查SecurityContext里到底有没有写入认证信息——我在调试时习惯加一个Controller端点,返回SecurityContextHolder.getContext().getAuthentication()的内容,一眼就能看出认证是否成功。

还有一个隐藏原因:登录成功但SecurityContext没有持久化到Session。如果通过Spring Session或者分布式Session方案,Session的序列化问题可能导致安全上下文丢失。遇到这种情况,检查是否有实现HttpSessionEventPublisher的Listener注册,Spring Security需要这个Listener来清理Session中的安全上下文。

4.2 CSRF导致登录/注册接口403

Spring Boot 3中CSRF默认是开启的。如果你用Postman测试登录接口,返回403,根本不用怀疑别的地方,大概率就是CSRF拦截。

排查方法很简单:看响应体里有没有CSRF相关的错误信息。再一个,打开浏览器开发者工具,提交登录表单,看请求参数里有没有名为_csrf的字段。没有的话,说明前端没有带上Token。

开发阶段我的做法是直接关闭CSRF。但是关闭之后,表单登录方式会受影响吗?其实不会,登录流程本身不强制要求CSRF,只是作为防护手段。如果没有关闭,前端需要从后端拿到CSRF Token并附在表单或请求头里。这个流程对接比较繁琐,所以开发阶段关闭是合理的。

4.3 密码一直报错不匹配

登录时提示Bad credentials,但用户名是存在的,密码也是正确的。这种情况90%是密码编码问题。

我整理了几种常见场景,你们可以对号入座:

场景原因解决方式
数据库密码是明文,配置了BCryptPasswordEncoder编码器类型不匹配改用NoOpPasswordEncoder或重新生成密文
数据库密码是BCrypt,但配置了NoOpPasswordEncoder编码器类型不匹配改用BCryptPasswordEncoder
密码前面有{noop}前缀,但没配置DelegatingPasswordEncoder不支持该前缀去掉前缀或用DelegatingPasswordEncoder
密码里不小心带了空格或换行数据问题清理数据
字段长度不够,插入时被截断数据库字段长度不足把password字段长度扩展到100+

我在第一次实现JDBC认证时,就是被第二个场景坑的。我数据库里存的是BCrypt密文,但那个时候不懂PasswordEncoder机制,用了DelegatingPasswordEncoder的默认行为,而密文里没有前缀,所以它直接走BCrypt,理论上应该能匹配。后来发现问题是JdbcTemplate返回的字段名不对——MySQL查询结果字段名是大写还是小写取决于数据库配置和SQL别名。如果SQL没写别名,某些情况下取出来的是USERNAME而不是username,Map按下标取值直接拿到null。这提醒大家:用Map接收JdbcTemplate查询结果时,字段名的坑一定要小心。

我的经验是SQL里显式写别名:

SELECT username AS username, password AS password, enabled AS enabled FROM sys_user WHERE username = ?

这样不管数据库怎么处理字段名,查询结果一定是小写。

4.4 自定义过滤器不生效

有时候项目需要在Spring Security过滤器链上插入自定义过滤器,比如处理上传PDF文件时的XSS攻击、或者打点日志。结果配置了OrderedFilterRegistrationBean后,发现过滤器根本不执行。

这里要用一个绕不过去的概念:FilterRegistrationBean的注册顺序和Spring Security过滤器链的关系。

如果你直接使用Servlet的@WebFilter注解注册了一个过滤器,它是作为独立的Servlet过滤器运行,跑在Spring Security的FilterChainProxy之前或之后,取决于@Order注解。如果你想让过滤器作为Spring Security过滤器链的一部分,就需要在HttpSecurity配置里加:

http.addFilterBefore(new MyCustomFilter(), UsernamePasswordAuthenticationFilter.class);

这个区别很多教程不强调,但实际工作里非常重要。比如你想在认证之前对请求做一些预处理(如参数清洗),那就用addFilterBefore放在UsernamePasswordAuthenticationFilter之前。想在认证之后(拿到用户信息后)做业务处理,就放在它之后。

我在处理XSS攻击时就遇到了这个问题。项目里需要对上传的PDF文件做内容检查,我最初用@WebFilter注册全局过滤器,结果请求先经过了Spring Security的认证检查,如果此时用户未认证,请求直接被重定向到登录页了,我的过滤器根本没机会执行。后来把这部分逻辑改成了在Spring Security链内添加过滤器,放在认证之前执行,问题就解决了。

4.5 JdbcTemplate注入失败或数据源初始化失败

最后一个常见问题,应用启动时数据源初始化报错。特别是MySQL版本升级到8之后,驱动类、时间戳时区问题都可能导致启动失败。

报错信息通常包含"Unable to obtain connection from database"或者"Access denied for user"。前者一般是URL或者防火墙问题,后者是账号密码问题。

时区问题在MySQL 8里尤其典型。URL里必须加上serverTimezone=Asia/Shanghai,否则启动时可能报"Bad connection"或者"Unable to load authentication plugin"。

还有一个很隐蔽的问题:有些版本的Spring Boot对MySQL 8的驱动兼容性有问题,报"this version of the jdbc driver is only compatible with elasticsearch version",其实这是依赖冲突导致驱动类加载错乱。遇到这种问题,先检查pom里有没有多余的JDBC驱动依赖,或者用mvn dependency:tree看看实际生效的驱动版本。

我通常的做法是:去掉所有多余的驱动依赖,只保留一个mysql-connector-j,然后清理Maven本地仓库中该依赖的缓存,重新构建。这一套在绝大多数情况下都能解决问题。

最后再分享一个小技巧

我在实际项目中调试Spring Security时,很少去断点跟源码,通常两个技巧就够用了。

第一个是开启日志。在application.yml加一行:

logging: level: org.springframework.security: DEBUG

这个能让你看到认证过程每一步的执行情况——哪个过滤器在跑、从哪个Provider走、密码校验成功还是失败、SecurityContext里最后写入了什么。排查认证问题比看一百遍源码都高效。

第二个是在项目里临时加一个Controller端点,可以随时查看当前登录用户信息:

@RestController public class UserController { @GetMapping("/me") public Object me(Authentication authentication) { return authentication; } }

登录之后请求这个接口,返回的JSON里包括用户名、权限、认证时间等全部信息。接口如果返回403,说明还没认证;返回详细数据,说明认证链路已经通了。

Spring Security的过滤器链和JDBC认证结合在一起,是绝大多数Java Web项目的安全基石。这个机制搞明白了,后面再做权限管理、OAuth2扩展、JWT整合,都是在同一个架构上打补丁,不会走弯路。

返回列表