1. 为什么内存用户的Demo一上生产就翻车1.1 上一篇文章留下的历史遗留问题上一篇我们搭建Spring Security的时候用的是最经典的InMemoryUserDetailsManager直接把用户名密码写死在代码里。当时跑demo确实爽启动一个Spring Boot项目打开登录页输入账号密码就进去了前后不过十分钟。但如果你跟我一样学着学着就忍不住想往真实业务上靠很快就会碰到同一个尴尬用户的账号密码存在数据库里你的登录入口还躺在配置文件里怎么把这两件事接起来我第一次尝试改造的时候脑子里冒出来的第一个念头是我自己写一个拦截器在Controller前面判断一下登录状态。这个想法不能说全错但Spring Security之所以是Spring Security就是因为它把认证和授权这一整套链路已经做成了标准流水线你要做的是把数据库里的数据接到流水线的某个工位上而不是另起炉灶再焊一条水管。后面我会带你完整走一遍这条流水线你就明白为什么大家都在说不要重复造轮子。这一篇的内容我默认你已经掌握了上一篇的基本玩法依赖怎么加、SecurityConfig怎么写、表单登录怎么生效。如果这些还不熟建议先回去把基础Demo跑通因为接下来我的每一步操作都是建立在项目里已经有Spring Security而且能用内存用户登录这个前提之上的。1.2 从内存用户到数据库用户的三个关键变更很多人第一次接触数据库认证最大的困惑是我不就是换了个数据源吗怎么配置看起来动了个大手术实话说代码层面确实不是改一两行配置就完事但逻辑层面其实就三个点第一认证数据的来源变了。原来用户的身份信息来自InMemoryUserDetailsManager这个写死在内存里的Map现在要改成从数据库按用户名查出来。这一步对应的是UserDetailsService接口你需要写一个实现类让Spring Security在拿到表单提交的用户名之后调用你的loadUserByUsername方法去查库。第二密码校验的方式变了。Spring Security默认不知道你数据库里的密码是什么格式它只管用PasswordEncoder去比对用户输入的原密码和数据库里取到的密码密文。从这一刻起密码绝对不能是明文哪怕你数据库里存的是明文能登录成功我也强烈建议你立刻停手先把密码加密方案定下来。原因我后面用一整章来讲。第三权限和角色的加载时机变了。内存用户时代角色就是写死在UserDetails对象里的一行字符串。数据库时代角色权限要通过关联表从库里查出来然后塞进UserDetails.getAuthorities()。权限数据的读取效率、实时性、缓存策略都会从这里开始牵一发动全身。这三点理解到位后面写代码就不会晕。我见过很多新人把UserDetailsService和AuthenticationProvider混为一谈其实它们只是认证流水线上不同工位的工人职责是完全分开的。1.3 认清认证链路里三个角色Manager、Provider、UserDetailsService为了后面不踩坑我先把这条流水线讲清楚用大白话打个比方。你可以把AuthenticationManager想象成一家餐厅的大堂经理。有顾客进来说我要认证一下。经理不自己动手做菜他只负责把请求也就是Authentication对象转交给后厨里合适的厨师AuthenticationProvider。如果多个认证方式并存比如账号密码登录 短信验证码登录经理就负责判断这张单子该给哪个后厨。后厨的DaoAuthenticationProvider是专门处理账号密码认证的厨师。这道菜的做法是先让UserDetailsService去食材仓库数据库里按用户名把食材找出来再把用户输入的原密码放到PasswordEncoder这个调料机里加工一下和仓库里取回来的密码密文比对。对上了这道菜就算做成了。所以关键结论是你真正要写代码的基本只有UserDetailsService这一个实现类把查库和组装权限的逻辑写对它就够。AuthenticationProvider和AuthenticationManager在Spring Boot自动配置里已经帮你接好了除非你要搞用户名验证码双因子认证这种花活否则不需要碰它们。理解了这个链路再回去看网上那些配置文件你就不会对着userDetailsService()和passwordEncoder()两个方法发愣了。一个是接食材的一个是调料的分工完全不同。2. 数据库表设计少画一张关系表后面全是坑2.1 按RBAC思路拆表还是按用户-角色两张表凑合改代码之前先把表设计好。这个顺序不能反否则后面代码写得再漂亮数据模型一瘸一拐也很难受。最常见的做法是经典的RBAC基于角色的访问控制模型。抽象一点说就是把用户角色权限这三个概念分开再通过关联表把它们的关系记录下来。很多教程上来就给五张表用户表、角色表、用户角色关联表、权限表、角色权限关联表。我见过一些简化方案只建用户表和角色表把权限硬编码在代码里。如果你的需求就是管理员和普通用户看到的菜单不一样那确实够用。但只要你的业务里出现给某个角色临时加一个导出权限不同角色的同一个按钮要显示或隐藏这类需求硬编码权限的方案就会让你开始写大量的if/else越往后越没法收拾。我的建议是哪怕是学习项目也直接把五张表建全。五张表的维护成本其实很低但收益是数据模型非常干净权限的扩展空间全留出来了。后面你想加权限点、加角色、做权限变更完全不用改表结构只改数据。2.2 表结构DDL与初始化数据下面是我实际项目中一直在用的一套简化版RBAC表结构去掉了审计字段保留最核心的部分。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, enabled TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE, role_name VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, UNIQUE KEY uk_user_role (user_id, role_id) ); CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, permission_code VARCHAR(128) NOT NULL UNIQUE, permission_name VARCHAR(128) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, UNIQUE KEY uk_role_permission (role_id, permission_id) );初始化数据我给一套最基础的账号和密码一会儿要用来测试-- 密码均为Admin123 的BCrypt密文 INSERT INTO sys_user (username, password, enabled) VALUES (admin, $2a$10$CwTycUXWue0Thq9StjUM0uJ8zB4dF1yQn1U2MxG9eBkP8eQz7y8Si, 1), (user, $2a$10$CwTycUXWue0Thq9StjUM0uJ8zB4dF1yQn1U2MxG9eBkP8eQz7y8Si, 1); INSERT INTO sys_role (role_code, role_name) VALUES (ADMIN, 管理员), (USER, 普通用户); INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1), (2, 2); INSERT INTO sys_permission (permission_code, permission_name) VALUES (system:user:list, 查看用户列表), (system:user:create, 创建用户), (system:report:view, 查看报表); INSERT INTO sys_role_permission (role_id, permission_id) VALUES (1, 1), (1, 2), (1, 3), (2, 3);注意一个小细节role_code字段我特意用了ADMIN这种大写形式这是为了和后面Spring Security的hasRole(ADMIN)保持一致的习惯。实际开发里有团队喜欢存ROLE_ADMIN全量前缀我更喜欢只存业务代号把前缀留给Java代码去组装这样数据库里看着干净语义也清晰。2.3 为什么权限不直接挂在用户上这个问题经常有小伙伴问既然用户表可以直接关联权限表为什么中间非要夹一个角色说个真实发生过的场景。某次内部系统上线新功能需要开放给客服主管和运营主管两个角色这俩角色分属不同部门各自又有很多细分的权限差异。如果权限直接挂在用户上那每个用户都要配一长串权限点新增一个人要配十几条漏配一个功能就用不了。排查的时候还要挨个用户去对权限效率极低。用角色中转之后事情就变成了给两个角色各加一个相同的权限点所有人立刻生效。你只需要维护角色和权限的关系新用户进来只要分配一个角色权限自动到位。这就是RBAC模型的核心价值多对多的关系交给中间表去维护人的管理逻辑里永远只出现角色这一个概念。代码层面角色和权限查出来之后最终都会汇聚到UserDetails.getAuthorities()里所以并不存在挂角色就不能挂权限的问题。后面我们做细粒度接口控制时可以直接用权限码来控制而角色则是权限码的集合。3. 代码改造把认证从写死改成查库3.1 第一步让数据库实体能被Easy Code一样查出来表结构建好了接下来就是SSM/Spring Boot项目里的常规操作写实体类、Mapper、Service。这里我不展开具体ORM框架的配置细节我下面用MyBatis-Plus的写法来演示因为最近几年大家新起项目用MyBatis-Plus的确实多。如果你用的是原生MyBatis或者JPA思路完全一样重点是逻辑而不是某个框架的API。实体类的核心就两个SysUser和SysRole关联查询我用一个额外的方法搞定不去建太复杂的VO对象。Data TableName(sys_user) public class SysUser { private Long id; private String username; private String password; private Integer enabled; private LocalDateTime createdAt; }Data TableName(sys_role) public class SysRole { private Long id; private String roleCode; private String roleName; private LocalDateTime createTime; }然后我需要一个方法根据userId查出他拥有的所有角色再根据角色查出所有权限码。这一步用SQL来做最直接ORM里硬写一堆关联查反而绕。public interface SysUserMapper extends BaseMapperSysUser { Select( SELECT r.role_code FROM sys_user_role ur JOIN sys_role r ON ur.role_id r.id WHERE ur.user_id #{userId} ) ListString selectRoleCodesByUserId(Long userId); Select( SELECT p.permission_code FROM sys_role_permission rp JOIN sys_permission p ON rp.permission_id p.id JOIN sys_user_role ur ON rp.role_id ur.role_id WHERE ur.user_id #{userId} ) ListString selectPermissionCodesByUserId(Long userId); }提示SQL里千万不要写成从sys_role直接join sys_permission然后忘了拼sys_user_role。我第一次改造时图省事在用户详情接口里少关联了一层表结果查出来的权限是所有用户的权限总和这个bug很隐蔽排查了很久。3.2 第二步重构UserDetailsService这是整个改造的核心现在开始写真正和Spring Security打交道的部分。核心逻辑其实非常紧凑一个方法而已Service public class DatabaseUserDetailsService implements UserDetailsService { Autowired private SysUserMapper sysUserMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser sysUserMapper.selectOne( new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, username) ); if (sysUser null) { throw new UsernameNotFoundException(用户不存在); } // 查角色和权限 ListString roleCodes sysUserMapper.selectRoleCodesByUserId(sysUser.getId()); ListString permissionCodes sysUserMapper.selectPermissionCodesByUserId(sysUser.getId()); // 组装成GrantedAuthority集合 ListGrantedAuthority authorities new ArrayList(); // 角色转成 ROLE_ 前缀 for (String roleCode : roleCodes) { authorities.add(new SimpleGrantedAuthority(ROLE_ roleCode)); } // 权限码直接作为权限标识 for (String permissionCode : permissionCodes) { authorities.add(new SimpleGrantedAuthority(permissionCode)); } return new User( sysUser.getUsername(), sysUser.getPassword(), sysUser.getEnabled() 1, true, // accountNonExpired true, // credentialsNonExpired true, // accountNonLocked authorities ); } }我直接用了Spring Security自带的org.springframework.security.core.userdetails.User没有自定义UserDetails实现类。很多人喜欢自定义一个SecurityUser把 userId、部门ID也塞进去方便后续取用这个需求是合理的。我的经验是如果你只是往UserDetails里塞业务字段But 后续代码可以通过Authentication取得UserDetails后再强转成自己的类型所以你可以自定义。但前提是要在实现类里老老实实把构造函数的六个参数都传对尤其是是否启用这个布尔值因为它直接对应数据库里的enabled字段。这里有一个初学者最容易掉的坑User的构造方法里有一个enabled参数如果你查出来的用户被禁用比如离职账号这里传falseSpring Security会直接抛出DisabledException压根不让你进行密码比对。这个行为很多人意想不到但它其实是安全设计——禁用账号就不该有校验密码的机会。3.3 第三步SecurityConfig里的装配变化改造完UserDetailsService回到SecurityConfig这一步把内存用户时代的配置替换成数据库驱动的方式。Configuration EnableWebSecurity public class SecurityConfig { Autowired private DatabaseUserDetailsService userDetailsService; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(userDetailsService) .passwordEncoder(passwordEncoder()); } }看到AuthenticationManagerBuilder的写法很多用Spring Boot 3 / Spring Security 6版本的读者可能会愣一下。在Spring Security 5.7之后官方推荐用SecurityFilterChainBean的方式代替WebSecurityConfigurerAdapterAuthenticationManager也不再通过继承类的方式来构建。上面这段是经典写法如果你的项目是Spring Boot 3.x我建议你把configure(AuthenticationManagerBuilder)这段换成直接声明UserDetailsService和PasswordEncoder为Bean让自动配置自己把它们接上。Configuration EnableWebSecurity public class SecurityConfig { Bean public UserDetailsService userDetailsService() { return new DatabaseUserDetailsService(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/login, /css/**, /js/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(form - form .loginPage(/login) .defaultSuccessUrl(/index) .permitAll() ) .logout(logout - logout.logoutSuccessUrl(/login?logout)); return http.build(); } }注意在Spring Boot 3环境下如果项目里同时存在多个UserDetailsServiceBean或者你手动new了DatabaseUserDetailsService而没有加Service注解自动配置可能不会帮你把它接进认证链路。最稳的做法是给实现类加Service然后在SecurityFilterChain里什么都不做让自动配置链自动发现它。无论你用的是版本几脑子里时刻记住那条流水线AuthenticationManager-DaoAuthenticationProvider-UserDetailsService-PasswordEncoder。配置虽然变了链条没变。4. 密码加密BCrypt不是可选项是必选项4.1 先说说MD5为什么不够再提明文数据库认证改造完成后的第一件事就是审视密码字段。我见过不少老项目数据库里password字段存的是明文或者简单MD5。你要是问我为什么不能这么干我的回答很直接只要有一个人能拿到数据库的读取权限所有账号就全部沦陷了。明文的问题不用多说。MD5的坑很多人没意识到它太快了一台普通的机器每秒能算上亿次MD5配合彩虹表绝大多数弱密码一两秒就能逆向出来。就算你加了固定盐salt只要盐是写死在代码里的一样是掩耳盗铃。有人提出用SHA-256结论一样它们都属于快速哈希不适合存密码。密码哈希需要的是慢哈希函数故意把计算速度拖慢让攻击者批量尝试的成本暴涨。BCrypt就是这个思路的典型实现Spring Security默认支持它也是我强烈推荐你使用的方案。4.2 BCrypt的工作原理盐、工作因子和那个莫名其妙的密文第一次看到BCrypt密文的人通常会问为什么同一串明文密码每次调用encode生成的密文都不一样这里的关键就是随机盐的概念。BCrypt会把随机生成的盐通常22个字符嵌入到最终密文里。所以你每次加密同一个密码得到的密文都不同但验证的时候它能从密文里把盐提取出来重新计算因此不需要你在数据库里单独存一个盐字段。这一点非常重要因为很多人以前做加盐MD5需要额外维护一个salt列逻辑复杂度高且容易漏配。BCrypt把盐的管理收进了算法内部简单可靠。BCrypt密文一般长这样$2a$10$CwTycUXWue0Thq9StjUM0uJ8zB4dF1yQn1U2MxG9eBkP8eQz7y8Si拆开来看$2a$是算法版本标识10是工作因子cost factor后面的部分是盐和哈希值的组合。工作因子10意味着计算哈希时要做 (2^{10}1024) 次轮转迭代。每增加1计算量翻倍加密耗时也翻倍。合理范围通常设在10~12具体取多少要看你的服务器性能。我在本地开发一般用10生产环境压测后取12。Test void bcryptTest() { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(10); String encoded1 encoder.encode(Admin123); String encoded2 encoder.encode(Admin123); System.out.println(encoded1); System.out.println(encoded2); System.out.println(encoder.matches(Admin123, encoded1)); // true System.out.println(encoder.matches(Admin1234, encoded1)); // false }跑一次你就明白了两次输出完全不一样但matches都能正确校验通过。4.3 PasswordEncoder的Bean定义注意事项配置里的BCryptPasswordEncoder要当心几个细节。第一全局只能有一个PasswordEncoder的Bean。如果你的项目里某个配置类自己又声明了一个启动时就会报PasswordEncoder类型冲突。出了这个错别懵不是代码逻辑问题纯粹是Bean重复定义。我习惯在SecurityConfig里定义一个其他地方要用就Autowired注入绝不重复声明。第二BCryptPasswordEncoder构造函数的strength参数一旦设定在运行时不要随便改。如果数据库里已有密文是cost10加密的你把Bean改成cost12验证仍然能通过因为BCrypt能从密文里读出当时的工作因子但新注册的用户会用新因子加密老密文和新密文的强度就不一致了。统一的做法是先定好值上线前在测试环境压测一把上线后不要动。第三有些老系统历史遗留的密码可能是MD5或SHA-256加密的这时候别急着全部重置。Spring Security提供了DelegatingPasswordEncoder它可以让你同时支持多种加密方式。但对学习项目来说我的建议更简单粗暴把这些老账号的密码统一重置成BCrypt版本趁早摆脱兼容负担。后面我分享一个踩坑经历就是因为老库里混着好几种加密格式排查得头皮发麻。5. 接口权限控制从URL拦截到方法级注解5.1 基于HttpSecurity的URL权限配置数据库认证接好之后接下来就是谁可以访问哪个接口的授权问题。最基础的做法是在SecurityFilterChain里按URL路径配置就像前面代码里写的.authorizeHttpRequests(auth - auth .requestMatchers(/admin/**).hasRole(ADMIN) .requestMatchers(/report/**).hasAnyRole(ADMIN, USER) .anyRequest().authenticated() )这里有一个很重要的认知hasRole(ADMIN)判断的不是数据库里那个role_code字符串而是GrantedAuthority里有没有ROLE_ADMIN这个前缀标识。因为我们在UserDetailsService里组装的时候写了ROLE_ roleCode所以这里hasRole(ADMIN)才能匹配上。如果你组装的时候没加前缀而直接塞了ADMIN那么要匹配就该写hasAuthority(ADMIN)。这个前缀机制是Spring Security的一个隐性约定很多人一开始被绕晕其实记熟了就好hasRole自动加前缀hasAuthority不加。URL级配置适合粗粒度拦截比如/admin/**只能管理员进/user/**登录就能进。但真实业务里经常有更细的需求查看用户列表管理员可以、运营可以但创建用户只有管理员可以。如果全靠URL配置一个/user/**下的创建接口你根本没法在URL级区分。这时候就该上方法级安全。5.2 方法级安全PreAuthorize、Secured、RolesAllowed怎么选Spring Security提供了方法级安全注解开启方式很简单。Spring Boot 3 / Spring Security 6用的是Configuration EnableMethodSecurity public class SecurityConfig { // ... }Spring Security 5.6之前的旧项目用的是EnableGlobalMethodSecurity(prePostEnabled true)你要是维护老项目可能会遇到知道它是旧版写法即可。开启之后你就可以直接在Controller或者Service方法上写权限注解RestController RequestMapping(/user) public class UserController { GetMapping PreAuthorize(hasAuthority(system:user:list)) public ListSysUser list() { return userService.list(); } PostMapping PreAuthorize(hasAuthority(system:user:create)) public SysUser create(RequestBody SysUser user) { return userService.create(user); } }这里我用的是权限码system:user:list而不是角色ADMIN。为什么因为权限码更细。角色ADMIN可能拥有一堆权限但你现在只关心这一个操作。用权限码控制后续角色变更多个、权限组合变了代码不用改只要改角色-权限关联数据就行。三种注解的区别注解需要开启配置表达式能力适用场景PreAuthorizeEnableMethodSecurity最强支持SpEL表达式能写 、||、自定义Bean方法现代项目首选Secured旧版EnableGlobalMethodSecurity(securedEnabled true)只能写角色名列表老项目遗留代码RolesAllowed需要引入JSR-250注解并开启enableJsr250只能写角色名语义和Secured类似兼容JavaEE规范的项目我的结论很明确新代码一律用PreAuthorize。它能写表达式比如PreAuthorize(hasAnyAuthority(system:user:list, system:report:view))也支持自定义校验Bean比如PreAuthorize(authService.checkOwner(#id))这个扩展空间是另两个注解给不了的。5.3 hasRole与hasAuthority的使用场景前面提过一次前缀区别但实际使用中更麻烦的是什么时候用角色什么时候用权限码。我的建议是分两层来控制粗粒度用角色细粒度用权限码。比如菜单显示控制、首页跳转逻辑这种粗粒度场景判断hasRole(ADMIN)简单直接。具体某一个写操作、某一个按钮用hasAuthority(system:user:create)精细可控。一个小技巧角色本质上也是一条GrantedAuthority它的标识是ROLE_开头。所以你可以在数据库里把能查看报表定义成一个权限码然后把ADMIN角色拥有该权限码写在角色-权限关联表里。这样既保持了角色管理的简单性又获得了权限控制的精确性。这就是前面设计五张表的意义所在现在你应该能体会到为什么我用role_code而不是直接拿角色名做权限判断。6. 实测中容易踩的四个坑排查链路分享6.1 坑一数据库密码没加密或者PasswordEncoder不匹配症状很典型登录页面提交之后Spring Security直接返回错误页后台日志也没有异常堆栈。查看日志里有一行There is no PasswordEncoder mapped for the id null或者类似的警告时基本上就是密码格式或加密器不匹配。我第一次做数据库认证时直接从库里复制了一段别人生成的BCrypt密文当时没注意这是$2a$还是$2b$结果密码怎么验证都失败。后来用{bcrypt}前缀的方式才恍然大悟——Spring Security的DelegatingPasswordEncoder在解析密文时如果没有前缀会默认采用你指定的PasswordEncoder但一旦密文格式不标准匹配机制就会出问题。排查链路供参考先在数据库里执行一条查询把当前用户的password字段原样打印出来。写一个最简单的单元测试用passwordEncoder.matches(原始密码, 数据库密文)看返回的是不是true。这一步能快速定位问题是出在加密器还是出在数据本身。如果是false用passwordEncoder.encode(原始密码)重新生成一条密文UPDATE进数据库再测一次。如果还是false检查你的UserDetailsService里从数据库取密码字段时有没有被框架做过二次处理比如MyBatis的类型处理器把加密串截断了。这个坑的核心教训是不要把校验逻辑正确建立在数据库里的密文一定正确这个假设上。用单元测试把密码校验从整个认证链路里单独摘出来验证是最有效的定位手段。6.2 坑二角色加进数据库了但接口还是403症状数据库里用户角色关联表数据没问题登录也成功了但访问一个PreAuthorize(hasRole(ADMIN))的接口还是403。这种问题绝大多数出在UserDetailsService组装authorities的时候。有个细节Spring Security在hasRole判断时会拿ROLE_ADMIN去和GrantedAuthority.getAuthority()比较你在数据库里存的是ADMIN代码里忘了加ROLE_前缀结果自然匹配不上。排查链路供参考登录成功后在任意一个能进入的Controller方法里把当前登录用户的权限列表打印出来RequestMapping(/debug/auth) ResponseBody public Object debugAuth() { Authentication authentication SecurityContextHolder.getContext().getAuthentication(); return authentication.getAuthorities(); }看看返回的集合里到底是[ADMIN, system:user:list]还是[ROLE_ADMIN, system:user:list]。少了ROLE_前缀就是组装的问题。顺便检查sys_user_role和sys_role两张表关联是否成功。我遇到过一种情况selectRoleCodesByUserId的SQL里关联条件写错导致永远查不到角色这种情况接口403的原因就是权限列表压根为空。记住一个心法403不是你想当然的角色名对不对而是当前用户的authorities里到底有没有那个标识。所以第一步永远是拿到真实的authorities用数据说话而不是盯着配置文件猜。6.3 坑三权限修改后不实时生效会话里还是旧权限数据库里把某个用户加了一个角色理论上下次登录就生效但实际测试发现该用户退出再重新登录权限没变甚至换一台机器登录也是旧权限。如果遇到这种情况先别急着怀疑Spring Security检查你的会话管理。Spring Security默认的登录会话会缓存用户信息但缓存的是Authentication对象这个对象的getAuthorities()在认证通过那一刻就被快照了。你改了数据库除非强制用户重新走一次完整认证流程否则它不会自动刷新。更麻烦的是如果你用了Spring Session把会话数据存到Redis那这个快照可能存在于Redis里排查起来容易怀疑人生。常见解决方案就三招第一招修改权限后让该用户强制退出登录重新走一次认证。最简单适合权限变更不频繁的系统。第二招在UserDetailsService的查询逻辑里加一层缓存失效机制。比如权限变更时清掉该用户的缓存键下次读取重新查库。第三招用JWT这类无状态认证每次请求都重新解析令牌并重新查询权限。牺牲一点性能换取实时性很多微服务体系里确实这么干。别忘了另一个隐蔽点UsernamePasswordAuthenticationFilter成功认证后会把Authentication放进SecurityContextHolder后续请求如果走了过滤器链重新读取信息可能会从Session里拿旧数据。这就是为什么重启服务后才生效的现象让很多人困惑——因为重启把Session清了重新登录走了新数据。6.4 坑四懒加载把UserDetails查询搞崩了这个坑在项目用了JPA/Hibernate时特别常见。loadUserByUsername方法里查出了SysUser实体但角色权限是通过懒加载关联查询获取的。当Spring Security在事务外的某个地方触发懒加载时直接抛LazyInitializationException。排查链路供参考确认loadUserByUsername是否被事务管理。如果Service层方法没加Transactional而你又在方法返回之后才访问懒加载集合必炸。一个更稳妥的思路不要在实体关联上依赖懒加载而是像我前面的代码那样用显式SQL把角色和权限一次性查出来。这样loadUserByUsername返回时所有数据都已经装进ListString和实体生命周期完全解耦。如果确实想在实体上维护关联关系那就要保证认证流程中loadUserByUsername执行期间有活跃的事务。但我要泼一盆冷水Spring Security的过滤链可能在事务提交前或提交后访问UserDetails这个事务边界非常难控远不如能查到的数据都提前查好这个方案省心。这个坑暴露的其实是服务分层设计的弱点——认证是属于安全层的事不是业务层的事。安全层调用你的Service方法你不能要求安全层去理解你的JPA实体生命周期。所以让loadUserByUsername返回一个完全独立的POJO对象、不持有任何数据库实体引用是最不惹麻烦的做法。我自己后来写UserDetailsServiceImpl基本都是返回org.springframework.security.core.userdetails.User里面只有字符串和boolean干净利落地绕开所有实体缓存和懒加载问题。我个人的体会是Spring Security的学习曲线里最陡的一段不是理解过滤器链而是把认证数据从内存迁到数据库这一步。只要把UserDetailsService和PasswordEncoder这两个点吃透后面的权限注解、方法级安全都是顺水推舟的事。如果你也正在走这条路建议按我上面的顺序一步一步来先建表再改造认证加上加密最后配权限注解。每一步都跑通验证过再进入下一步。这样即便中间出了bug你也知道问题大概率出现在新动的那一块排查范围小心里不慌。