Springboot3.x.x使用SpringSecurity6(一文包搞定)

3 篇文章 0 订阅
2 篇文章 0 订阅

SpringSecurity6

什么是SpringSecurity?

Spring Security 是一个强大的、高度可定制的身份验证(Authentication)和访问控制(Authorization)框架。它是 Spring 框架家族的一员,主要用于保护基于 Java 的应用程序,无论是Web应用还是非Web应用。Spring Security 提供了以下功能:

  • 认证:管理用户凭证的验证过程,确定用户是否可以登录到系统。

  • 授权:控制经过认证的用户能够访问哪些资源或执行哪些操作。

  • 会话管理:对于Web应用,Spring Security 还处理用户的会话。

  • 跨站请求伪造(CSRF)保护:防止恶意网站利用用户的登录状态执行不受信任的操作。

  • 点击劫持保护:通过HTTP头部设置来帮助防御点击劫持攻击。

  • 加密和编码支持:提供密码加密和其他安全相关的编码任务。

    Spring Security 可以与 Spring MVC 和 Spring WebFlux 紧密集成,同时也支持传统的 Servlet API。它允许开发者以声明式的方式定义安全约束,并且可以通过编程方式自定义安全策略。此外,Spring Security 还支持多种认证方式,如表单登录、HTTP基本认证、OAuth2、OpenID Connect 等。

    在过去,Spring Security 的配置相对复杂,但是随着 Spring Boot 的出现,它提供了自动配置方案,使得集成 Spring Security 变得更为简单,甚至可以做到“零配置”使用。这使得 Spring Security 在现代 Java 应用程序的安全性管理方面变得非常流行。

Spring Security实现权限

要对Web资源进行保护,最好的办法莫过于Filter 要想对方法调用进行保护,最好的办法莫过于AOP

Spring Security进行认证和鉴权的时候,就是利用的一系列的Filter来进行拦截的。

img

如图所示,一个请求想要访问到API就会从左到右经过蓝线框里的过滤器,其中绿色部分是负责认证的过滤器,蓝色部分是负责异常处理,橙色部分则是负责授权。进过一系列拦截最终访问到我们的API。

这里面我们只需要重点关注两个过滤器即可:UsernamePasswordAuthenticationFilter负责登录认证,FilterSecurityInterceptor负责权限授权。

说明:Spring Security的核心逻辑全在这一套过滤器中,过滤器里会调用各种组件完成功能,掌握了这些过滤器和组件你就掌握了Spring Security!这个框架的使用方式就是对这些过滤器和组件进行扩展。

用户认证流程

认证核心

我们系统中会有许多用户,确认当前是哪个用户正在使用我们系统就是登录认证的最终目的。这里我们就提取出了一个核心概念:当前登录用户/当前认证用户。整个系统安全都是围绕当前登录用户展开的,这个不难理解,要是当前登录用户都不能确认了,那A下了一个订单,下到了B的账户上这不就乱套了。这一概念在Spring Security中的体现就是 Authentication,它存储了认证信息,代表当前登录用户。

我们在程序中如何获取并使用它呢?我们需要通过 SecurityContext 来获取AuthenticationSecurityContext就是我们的上下文对象!这个上下文对象则是交由 SecurityContextHolder 进行管理,你可以在程序任何地方使用它:

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

SecurityContextHolder原理非常简单,就是使用ThreadLocal来保证一个线程中传递同一个对象!

现在我们已经知道了Spring Security中三个核心组件:

1、Authentication:存储了认证信息,代表当前登录用户

2、SeucirtyContext:上下文对象,用来获取Authentication

3、SecurityContextHolder:上下文管理对象,用来在程序任何地方获取SecurityContext

Authentication中是什么信息呢:

1、Principal:用户信息,没有认证时一般是用户名,认证后一般是用户对象

2、Credentials:用户凭证,一般是密码

3、Authorities:用户权限

认证接口

AuthenticationManager的校验逻辑非常简单:

根据用户名先查询出用户对象(没有查到则抛出异常)将用户对象的密码和传递过来的密码进行校验,密码不匹配则抛出异常。

这个逻辑没啥好说的,再简单不过了。重点是这里每一个步骤Spring Security都提供了组件:

1、是谁执行 根据用户名查询出用户对象 逻辑的呢?用户对象数据可以存在内存中、文件中、数据库中,你得确定好怎么查才行。这一部分就是交由UserDetialsService 处理,该接口只有一个方法loadUserByUsername(String username),通过用户名查询用户对象,默认实现是在内存中查询。

2、那查询出来的 用户对象 又是什么呢?每个系统中的用户对象数据都不尽相同,咱们需要确认我们的用户数据是啥样的才行。Spring Security中的用户数据则是由UserDetails 来体现,该接口中提供了账号、密码等通用属性。

3、对密码进行校验大家可能会觉得比较简单,if、else搞定,就没必要用什么组件了吧?但框架毕竟是框架考虑的比较周全,除了if、else外还解决了密码加密的问题,这个组件就是PasswordEncoder,负责密码加密与校验。

我们可以看下AuthenticationManager校验逻辑的大概源码:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...省略其他代码

    // 传递过来的用户名
    String username = authentication.getName();
    // 调用UserDetailService的方法,通过用户名查询出用户对象UserDetail(查询不出来UserDetailService则会抛出异常)
    UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(username);
    String presentedPassword = authentication.getCredentials().toString();

    // 传递过来的密码
    String password = authentication.getCredentials().toString();
    // 使用密码解析器PasswordEncoder传递过来的密码是否和真实的用户密码匹配
    if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        // 密码错误则抛出异常
        throw new BadCredentialsException("错误信息...");
    }

    // 注意哦,这里返回的已认证Authentication,是将整个UserDetails放进去充当Principal
    UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(userDetails,
            authentication.getCredentials(), userDetails.getAuthorities());
    return result;

...省略其他代码
}

UserDetialsServiceUserDetailsPasswordEncoder,这三个组件Spring Security都有默认实现,这一般是满足不了我们的实际需求的,所以这里我们自己来实现这些组件!

加密器PasswordEncoder

加密我们项目采取MD5加密

操作模块:spring-security模块

自定义加密处理组件:CustomMd5PasswordEncoder

package com.atguigu.system.custom;

import com.atguigu.common.util.MD5;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * <p>
 * 密码处理
 * </p>
 *
 */
@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {

    public String encode(CharSequence rawPassword) {
        return MD5.encrypt(rawPassword.toString());
    }

    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
    }
}
用户对象UserDetails

该接口就是我们所说的用户对象,它提供了用户的一些通用属性,源码如下:

 

public interface UserDetails extends Serializable {
	/**
     * 用户权限集合(这个权限对象现在不管它,到权限时我会讲解)
     */
    Collection<? extends GrantedAuthority> getAuthorities();
    /**
     * 用户密码
     */
    String getPassword();
    /**
     * 用户名
     */
    String getUsername();
    /**
     * 用户没过期返回true,反之则false
     */
    boolean isAccountNonExpired();
    /**
     * 用户没锁定返回true,反之则false
     */
    boolean isAccountNonLocked();
    /**
     * 用户凭据(通常为密码)没过期返回true,反之则false
     */
    boolean isCredentialsNonExpired();
    /**
     * 用户是启用状态返回true,反之则false
     */
    boolean isEnabled();
}

实际开发中我们的用户属性各种各样,这些默认属性可能是满足不了,所以我们一般会自己实现该接口,然后设置好我们实际的用户实体对象。实现此接口要重写很多方法比较麻烦,我们可以继承Spring Security提供的org.springframework.security.core.userdetails.User类,该类实现了UserDetails接口帮我们省去了重写方法的工作。

了解以上后我们即可进入使用了

 

表结构

 

#用户表
CREATE TABLE `sys_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户主键',
  `wx_openid` varchar(100) DEFAULT NULL COMMENT '微信的Openid',
  `session_key` varchar(100) DEFAULT NULL COMMENT '微信的sessionKey(选择存储)',
  `phone` varchar(20) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '性别',
  `username` varchar(35) DEFAULT NULL COMMENT '用户名称',
  `vx_avatar` varchar(255) DEFAULT NULL COMMENT '微信头像路径',
  `status` tinyint(1) DEFAULT NULL COMMENT '是否可用 0可用 1不可用,默认0',
  `pwd` varchar(255) DEFAULT NULL COMMENT '密码',
  `type` tinyint(1) NOT NULL COMMENT '0用户登录,1管理员',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `is_deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
  `gmt_founder` varchar(35) DEFAULT NULL COMMENT '创建人',
  PRIMARY KEY (`id`),
  UNIQUE KEY `wx_openid` (`wx_openid`),
  UNIQUE KEY `phone` (`phone`),
  UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=58 DEFAULT CHARSET=utf8mb3 COMMENT='用户表';

#用户角色表
CREATE TABLE `sys_user_role` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户角色主键',
  `role_id` bigint NOT NULL COMMENT '角色主键',
  `user_id` bigint NOT NULL COMMENT '用户主键',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
  `gmt_founder` varchar(35) DEFAULT NULL COMMENT '创建人',
  PRIMARY KEY (`id`),
  KEY `id_role_id` (`role_id`) USING BTREE,
  KEY `id_user_id` (`user_id`) USING BTREE,
  CONSTRAINT `sys_user_role_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`),
  CONSTRAINT `sys_user_role_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb3 COMMENT='用户角色表';

#角色表
CREATE TABLE `sys_role` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '角色主键',
  `role_name` varchar(20) NOT NULL COMMENT '角色名称',
  `role_code` varchar(20) DEFAULT NULL COMMENT '角色编码',
  `description` varchar(100) DEFAULT NULL COMMENT '角色描述',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
  `gmt_founder` varchar(35) DEFAULT NULL COMMENT '创建人',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb3 COMMENT='角色管理表';

#菜单表
CREATE TABLE `sys_menu` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '菜单主键',
  `parent_id` bigint NOT NULL COMMENT '所属上级菜单',
  `name` varchar(20) NOT NULL COMMENT '菜单名字',
  `type` tinyint NOT NULL COMMENT '菜单类型(0:目录,1:菜单,2:按钮)',
  `path` varchar(100) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(100) DEFAULT NULL COMMENT '组件路径',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT NULL COMMENT '菜单图标',
  `sort_value` int DEFAULT NULL COMMENT '菜单排序',
  `status` tinyint DEFAULT NULL COMMENT '状态(0:禁止,1:正常)',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标记(0:未删除,1:删除)',
  `always_show` tinyint unsigned DEFAULT NULL COMMENT '总是展示(0:不展示,1展示)',
  `hidden` tinyint(1) DEFAULT NULL COMMENT '是否展示(0:不展示,1展示)',
  `keep_alive` tinyint(1) DEFAULT NULL COMMENT '是否缓存(0:不缓存,1缓存)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb3 COMMENT='菜单表';

#角色菜单关联表
CREATE TABLE `sys_role_menu` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `role_id` bigint NOT NULL DEFAULT '0',
  `menu_id` bigint NOT NULL DEFAULT '0',
  `gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '删除标记(0:可用 1:已删除)',
  PRIMARY KEY (`id`),
  KEY `id_role_id` (`role_id`) USING BTREE,
  KEY `id_menu_id` (`menu_id`) USING BTREE,
  CONSTRAINT `sys_role_menu_ibfk_1` FOREIGN KEY (`menu_id`) REFERENCES `sys_menu` (`id`),
  CONSTRAINT `sys_role_menu_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `sys_role` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=426 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='角色菜单';

表关系

 

 

首先要明白表之间的关系,了解下上面表的字段!!!准备工作完成后即可进入代码环节了。

Spring Security6的用户认证

这里我Springboot的版本是3.x.x


        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <!-- 如果jdk大于1.8,则还需导入下面依赖-->
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>${jaxb.version}</version>
        </dependency>


        <!--    SpringSecurity依赖    -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!--    JWT依赖    -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <dependency>
            <groupId>javax.xml.bind</groupId>
            <artifactId>jaxb-api</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-impl</artifactId>
            <version>2.3.0</version>
        </dependency>
        <dependency>
            <groupId>com.sun.xml.bind</groupId>
            <artifactId>jaxb-core</artifactId>
            <version>2.3.0</version>
        </dependency>

 

导入依赖后无法启动时正常的,因为没有配置文件~~~

流程:

1.我们先将账号密码交于UsernamePasswordAuthenticationToken

2.随后配置security

3.关联数据库获取UserDetail

4.编写认证监听器、过滤器等等

编写登录接口

controller

@RestController
@Tag(name = "登录接口/认证")
@RequestMapping("/api/v1/auth")
public class LoginController {
    @Resource
    private UserService userService;
    @Resource
    private RedisTemplate<String, String> redisTemplate;
    /*
     * @param loginDto
     * @return
     */
    @Operation(summary = "账号密码登录接口")
    @PostMapping("/login")
    public Result login(@RequestBody LoginDto loginDto){
        return userService.login(loginDto);
    }
}

注意这里的路径我们是自定义登录接口所以路径为:/api/v1/auth/login  

IMPL  

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private SmsUtils smsUtils;
    @Autowired
    private MenuService menuService;
    private final AuthenticationManager authenticationManager;
    @Override
    public Result login(LoginDto loginDto) {
        if (StringUtils.isBlank(loginDto.getUsername()) && StringUtils.isBlank(loginDto.getPassword())) {
            return Result.fail(500, "用户名或密码不能为空~");
        }
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
        Authentication authentication = authenticationManager.authenticate(authenticationToken);
        String accessToken = JwtUtils.generateToken(authentication);
        LoginVo loginVO = new LoginVo().setAccessToken(accessToken).setTokenType("Bearer");
        return Result.success(loginVO);
    }
}

 解释:我们上面就把账号密码交于UsernamePasswordAuthenticationToken去处理了

 

 jwt工具类

 

/**
 * JWT 工具类
 *
 * @author debug
 */
@Component
public class JwtUtils {

    /**
     * JWT 加解密使用的密钥
     */
    private static byte[] key;


    /**
     * JWT Token 的有效时间(单位:秒)
     */
    private static int ttl;

    /**
     * 生成 JWT Token
     *
     * @param authentication 用户认证信息
     * @return Token 字符串
     */
    public static String generateToken(Authentication authentication) {
        SysUserDetails userDetails = (SysUserDetails) authentication.getPrincipal();
        Map<String, Object> payload = new HashMap<>();
        payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); // 用户ID
        // claims 中添加角色信息
        Set<String> roles = userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toSet());
        payload.put(JwtClaimConstants.AUTHORITIES, roles);
        Date now = new Date();
        Date expiration = DateUtil.offsetSecond(now, ttl);
        payload.put(JWTPayload.ISSUED_AT, now);
        payload.put(JWTPayload.EXPIRES_AT, expiration);
        payload.put(JWTPayload.SUBJECT, authentication.getName());
        payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID());
        return JWTUtil.createToken(payload, JwtUtils.key);
    }
    /**
     * 从 JWT Token 中解析 Authentication  用户认证信息
     *
     * @param payload JWT 载体
     * @return 用户认证信息
     */
    public static UsernamePasswordAuthenticationToken getAuthentication(Map<String, Object> payload) {
        SysUserDetails userDetails = new SysUserDetails();
        // 用户ID
        userDetails.setUserId(Convert.toLong(payload.get(JwtClaimConstants.USER_ID)));
        // 用户名
        userDetails.setUsername(Convert.toStr(payload.get(JWTPayload.SUBJECT)));
        // 角色集合
        Set<SimpleGrantedAuthority> authorities = ((JSONArray) payload.get(JwtClaimConstants.AUTHORITIES))
                .stream()
                .map(authority -> new SimpleGrantedAuthority(Convert.toStr(authority)))
                .collect(Collectors.toSet());
        return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
    }

    /**
     * 解析 JWT Token 获取载体信息
     *
     * @param token JWT Token
     * @return 载体信息
     */
    public static Map<String, Object> parseToken(String token) {
        try {
            if (StrUtil.isBlank(token)) {
                return null;
            }
            if (token.startsWith("Bearer ")) {
                token = token.substring(7);
            }
            JWT jwt = JWTUtil.parseToken(token);
            if (jwt.setKey(JwtUtils.key).validate(0)) {
                return jwt.getPayloads();
            }
        } catch (Exception ignored) {
        }
        return null;
    }
    @Value("${jwt.key}")
    public void setKey(String key) {
        JwtUtils.key = key.getBytes();
    }
    @Value("${jwt.ttl}")
    public void setTtl(Integer ttl) {
        JwtUtils.ttl = ttl;
    }
}
# 认证配置
jwt:
  # 密钥
  key: SecretKey012345678901234567890123456789012345678901234567890123456789
  # token 过期时间(单位:秒)
  ttl: 7200
//JwtClaimConstants

public interface JwtClaimConstants {
    /**
     * 用户ID
     */
    String USER_ID = "userId";
    /**
     * 权限(角色Code)集合
     */
    String AUTHORITIES = "authorities";
}
绑定管理数据库获取UserDetail

/**
 * Spring Security 用户对象
 *
 * @author debug
 */
@Data
@NoArgsConstructor
public class SysUserDetails implements UserDetails {

    private Long userId;

    private String username;

    private String phone;

    private String password;

    //    private Boolean enabled;
    private Integer status;
    private Collection<SimpleGrantedAuthority> authorities;

    //权限信息
    @TableField(exist = false)
    private Set<String> perms;

    @TableField(exist = false)
    private Set<String> roles;

    private Boolean enabled;

    //数据范围
    private Integer dataScope;

    public SysUserDetails(UserAuthInfo user) {
        this.userId = user.getUserId();
        this.roles=user.getRoles();
        Set<String> roles = user.getRoles();
        Set<SimpleGrantedAuthority> authorities;
        if (CollectionUtil.isNotEmpty(roles)) {
            authorities = roles.stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // 标识角色
                    .collect(Collectors.toSet());
        } else {
            authorities = Collections.EMPTY_SET;
        }
        this.authorities = authorities;
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.enabled = ObjectUtil.equal(user.getStatus(), 0);
        this.perms = user.getPerms();
    }
    public Long getUserId() {
        return this.userId;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }
    @Override
    public String getPassword() {
        return this.password;
    }
    @Override
    public String getUsername() {
        return this.username;
    }
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    @Override
    public boolean isEnabled() {
        return this.enabled;
    }
}

 通过实现UserDetailService的loadUserByUsername方法获取数据库里面的用户数据等。

/**
 * 系统用户认证
 * @author debug
 */
@Service
@RequiredArgsConstructor
public class SysUserDetailsService implements UserDetailsService {
    private final UserMapper userMapper;
    private final MenuService menuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        UserAuthInfo userAuthInfo = this.userMapper.getUserAuthInfo(username);
        if (userAuthInfo == null) {
            throw new UsernameNotFoundException(username);
        }
            Set<String> roles = userAuthInfo.getRoles();
            if (CollectionUtil.isNotEmpty(roles)) {
                Set<String> perms = menuService.listRolePerms(roles);
                userAuthInfo.setPerms(perms);
            }
        return new SysUserDetails(userAuthInfo);
    }
}

 

getUserAuthInfo()  

listRolePerms()  

 

 <select id="listRolePerms" resultType="java.lang.String">
        SELECT
        DISTINCT t1.perms
        FROM
        sys_menu t1
        INNER JOIN sys_role_menu t2 ON t1.id = t2.menu_id
        INNER JOIN sys_role t3 ON t3.id = t2.role_id
        AND t1.type = 2
        AND t1.perms IS NOT NULL
        <choose>
            <when test="roles!=null and roles.size()>0">
                AND t3.role_code IN
                <foreach collection="roles" item="role" separator="," open="(" close=")">
                    #{role}
                </foreach>
            </when>
            <otherwise>
                AND t1.id = -1
            </otherwise>
        </choose>
    </select>

 

 

Security配置类
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    // 自定义未认证处理类
    private final MyAuthenticationEntryPoint authenticationEntryPoint;
    // 自定义无权限访问处理类
    @Resource
    private final MyAccessDeniedHandler accessDeniedHandler;

    // Redis操作模板
    @Autowired
    private final RedisTemplate<String, Object> redisTemplate;

    /**
     * 配置Spring Security过滤器链。
     *
     * @param http HttpSecurity对象,用于构建安全配置
     * @return 构建好的SecurityFilterChain对象
     * @throws Exception 配置过程中可能抛出的异常
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(requestMatcherRegistry ->// 配置请求授权规则
                        //登录路径公开访问
                        requestMatcherRegistry.requestMatchers(SecurityConstants.LOGIN_PATH,
                                        SecurityConstants.LOGOUT_PATH,
                                        SecurityConstants.VERIFY_TREE_PATH,
                                        SecurityConstants.GET_PHONE_CODE_PATH,
                                        SecurityConstants.PHONE_LOGIN_PATH
                                ).permitAll()
                                // 其他所有请求都需要认证
                                .anyRequest().authenticated()
                )
                // 禁用Session创建
                .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                // 配置异常处理
                .exceptionHandling(httpSecurityExceptionHandlingConfigurer ->
                        httpSecurityExceptionHandlingConfigurer
                                // 设置未认证处理入口
                                .authenticationEntryPoint(authenticationEntryPoint)
                                // 设置无权限访问处理
                                .accessDeniedHandler(accessDeniedHandler)
                )
                // 禁用CSRF保护
                .csrf(AbstractHttpConfigurer::disable)
        ;
        // JWT 校验过滤器
        http.addFilterBefore(new JwtValidationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class);
        // 构建并返回过滤器链
        return http.build();
    }

    /**
     * 不走过滤器链的放行配置
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        // 忽略指定路径的安全检查
        return (web) -> web.ignoring()
                .requestMatchers(
                        "/api/v1/auth/captcha",
                        "/webjars/**",
                        "/doc.html",
                        "/swagger-resources/**",
                        "/v3/api-docs/**",
                        "/swagger-ui/**",
                        "/swagger-ui.html",
                        "/ws/**",
                        "/ws-app/**"
                );
    }
    /**
     * 密码编码器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 手动注入AuthenticationManager,用于处理认证和授权请求。
     *
     * @param authenticationConfiguration 认证配置对象
     * @return AuthenticationManager对象
     * @throws Exception 配置过程中可能抛出的异常
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        // 获取认证管理器实例
        return authenticationConfiguration.getAuthenticationManager();
    }
}

放开接口类SecurityConstants


public interface SecurityConstants {
    /**
     * 登录接口路径
     */
    String LOGIN_PATH = "/api/v1/auth/login";
    /**
     *  验证码接口路径
     */
    String VERIFY_TREE_PATH = "/api/v1/auth/getVerifyThree";
    /**
     * 退出登录接口
     */
    String  LOGOUT_PATH = "/api/v1/auth/logout";
    /**
     * 手机号登录接口
     */
    String PHONE_LOGIN_PATH = "/api/v1/auth/phoneLogin";
    /**
     * 获取手机验证码
     */
    String GET_PHONE_CODE_PATH = "/api/v1/auth/sendCode";
}
jwt校验过滤器

@Slf4j
public class JwtValidationFilter extends OncePerRequestFilter {

    private final RedisTemplate<String, Object> redisTemplate;
    /**
     * 构造函数
     * @param redisTemplate Redis模板,用于操作Redis
     */
    public JwtValidationFilter(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 从请求中获取 JWT Token,校验 JWT Token 是否合法
     * <p>
     * 如果合法则将 Authentication 设置到 Spring Security Context 上下文中
     * 如果不合法则清空 Spring Security Context 上下文,并直接返回响应
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //从请求头中提取Token
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        try {
            //如果Token非空,则进行解析
            if (StrUtil.isNotBlank(token)) {
                //解析Token的Payload部分
                Map<String, Object> payload = JwtUtils.parseToken(token);
                String jti = null;
                //如果Payload非空,提取JWT ID
                if (payload != null) {
                    jti = Convert.toStr(payload.get(JWTPayload.JWT_ID));
                }
                //从Payload中获取认证信息
                Authentication authentication = JwtUtils.getAuthentication(payload);
                //将认证信息设置到Spring Security上下文中
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (CustomException ex) {
            log.error("拦截出现错误,错误码为:{}", ex.getCode());
            ex.printStackTrace();
            //this is very important, since it guarantees the user is not authenticated at all
            //如果解析过程中出现业务异常,清除Security上下文并返回错误响应
            SecurityContextHolder.clearContext();
            ResponseUtils.writeErrMsg(response, ex.getCode());
            return;
        }
        //继续请求链
        filterChain.doFilter(request, response);
    }
}

 每个请求都会先进该过滤器

认证异常处理类

/**
 * 认证异常处理
 * 当未认证的用户尝试访问需要认证的资源时,该类负责处理相关的认证异常
 * 并向客户端返回具体的错误信息
 */
@Component
@Slf4j
public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

    /**
     * 开始处理认证异常
     *
     * @param request   当前的HTTP请求
     * @param response  当前的HTTP响应
     * @param authException  引发的认证异常
     * @throws IOException       如果在处理过程中发生输入输出异常
     * @throws ServletException 如果在处理过程中发生Servlet异常
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // 获取当前HTTP响应的状态码
        int status = response.getStatus();

        // 判断HTTP状态码是否为未找到资源(404)
        if (status == HttpServletResponse.SC_NOT_FOUND) {
            // 资源不存在,向客户端返回自定义的资源未找到错误信息
            ResponseUtils.writeErrMsg(response, ResultEnum.RESOURCE_NOT_FOUND);
        } else {
            // 判断引发的认证异常是否为凭证无效异常(例如用户名或密码错误)
            if(authException instanceof BadCredentialsException){
                // 用户名或密码错误,向客户端返回自定义的用户名或密码错误信息
                ResponseUtils.writeErrMsg(response, ResultEnum.ARGUMENT_VALID_ERROR);
            } else {
                // 处理其他类型的认证异常,如未认证或者令牌(token)过期
                // 向客户端返回自定义的令牌无效错误信息
                ResponseUtils.writeErrMsg(response, ResultEnum.TOKEN_INVALID);
            }
        }
    }
}
Security访问异常处理器  
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //访问没有授权
        ResponseUtils.writeErrMsg(response, ResultEnum.ACCESS_UNAUTHORIZED);
    }
}

 ResultEnum类

package com.brush.brushcommon.enums;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
 * @ClassName: ResultEnum
 * @Description:
 * @Author: cws
 * @Date: 2023/1/5 16:43
 */
@Getter
@AllArgsConstructor
@NoArgsConstructor
public enum ResultEnum {
    ENUM_USERNAME_NULL(6666,"账号不正确或者没有此用户哦~"),
    SUCCESS(200,"成功"),
    FAIL(201, "失败"),
    SERVICE_ERROR(2012, "服务异常"),
    DATA_ERROR(204, "数据异常"),
    ILLEGAL_REQUEST(205, "非法请求"),
    REPEAT_SUBMIT(206, "重复提交"),
    ARGUMENT_VALID_ERROR(210, "参数校验异常"),
    LOGIN_AUTH(208, "未登陆"),
    PERMISSION(209, "没有权限"),
    ACCOUNT_ERROR(214, "账号不正确"),
    PASSWORD_ERROR(215, "密码不正确"),
    LOGIN_MOBLE_ERROR( 216, "账号不正确"),
    ACCOUNT_STOP( 217, "账号已停用"),
    NODE_ERROR( 218, "该节点下有子节点,不可以删除"),
    TOKEN_INVALID(230, "token无效或已过期"),
    TOKEN_ACCESS_FORBIDDEN(231, "token已被禁止访问"),
    ACCESS_UNAUTHORIZED(301, "访问未授权"),
    RESOURCE_NOT_FOUND(401, "请求资源不存在"),
    PARAM_ERROR(400, "用户请求参数错误"),
            ;
    private int code;
    private String msg;
}

ResponseUtils工具  

 


public class ResponseUtils {

    /**
     * 异常消息返回方法,针对不同类型的错误设置适当的HTTP状态码
     * 并以JSON格式向客户端返回错误信息
     *
     * @param response  HttpServletResponse对象,用于获取响应输出流并设置响应头信息
     * @param resultEnum 结果枚举,表示不同的错误类型,用于确定响应的状态码和消息体内容
     * @throws IOException 如果在写入响应时发生I/O错误
     */
    public static void writeErrMsg(HttpServletResponse response, ResultEnum resultEnum) throws IOException {
        // 根据不同的结果枚举设置相应的HTTP状态码
        switch (resultEnum) {
            case ACCESS_UNAUTHORIZED:
            case TOKEN_INVALID:
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                break;
            case TOKEN_ACCESS_FORBIDDEN:
                response.setStatus(HttpStatus.FORBIDDEN.value());
                break;
            default:
                response.setStatus(HttpStatus.BAD_REQUEST.value());
                break;
        }
        // 设置响应内容类型为JSON
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        // 设置字符编码,确保响应内容的正确显示
        response.setCharacterEncoding("UTF-8");
        // 将错误信息结果转换为JSON字符串并写入响应
        //TODO:这里强转了,不知道会不会错。
        response.getWriter().print(JSONUtil.toJsonStr(Result.fail(resultEnum.toString())));
    }

    public static void writeErrMsg(HttpServletResponse response, Integer resultEnum) throws IOException {
        // 根据不同的结果枚举设置相应的HTTP状态码
        switch (resultEnum) {
            case 301:
            case 230:
                response.setStatus(HttpStatus.UNAUTHORIZED.value());
                break;
            case 231:
                response.setStatus(HttpStatus.FORBIDDEN.value());
                break;
            default:
                response.setStatus(HttpStatus.BAD_REQUEST.value());
                break;
        }
        // 设置响应内容类型为JSON
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        // 设置字符编码,确保响应内容的正确显示
        response.setCharacterEncoding("UTF-8");
        // 将错误信息结果转换为JSON字符串并写入响应
        //TODO:这里强转了,不知道会不会错。
        response.getWriter().print(JSONUtil.toJsonStr(Result.fail(resultEnum.toString())));
    }
}

 最后一步就是编写自定义异常处理了

自定义异常
@AllArgsConstructor
@NoArgsConstructor
@Data
public class CustomException  extends RuntimeException{
    private Integer code;
    private String msg;
}

 


@ControllerAdvice
//顾名思义,@ControllerAdvice就是@Controller 的增强版。@ControllerAdvice主要用来处理全局数据,一般搭配@ExceptionHandler、@ModelAttribute以及@InitBinder使用。
@Slf4j
public class AllExceptionHandler {


    //进行异常处理,处理Exception.class的异常
    @ExceptionHandler(Exception.class)
    @ResponseBody //返回json数据如果不加就返回页面了
    public Result doException(Exception ex) {
        //e.printStackTrace();是打印异常的堆栈信息,指明错误原因,
        // 其实当发生异常时,通常要处理异常,这是编程的好习惯,所以e.printStackTrace()可以方便你调试程序!
        ex.printStackTrace();
        System.out.println(ex.getClass());
        System.out.println(ex.getMessage());
        log.error("出现异常:{}",ex.getClass()+":"+ex.getMessage());
        return Result.fail(9999,ex.getMessage());
    }


    //自定义异常
    @ExceptionHandler(CustomException.class)
    @ResponseBody //返回json数据如果不加就返回页面了
    public Result CustomException(CustomException ex) {
        //e.printStackTrace();是打印异常的堆栈信息,指明错误原因,
        // 其实当发生异常时,通常要处理异常,这是编程的好习惯,所以e.printStackTrace()可以方便你调试程序!
        ex.printStackTrace();
        //自定义的code和msg
        log.error("出现异常:{}",ex.getClass()+":"+ex.getMessage());
        return Result.fail(ex.getCode(),ex.getMsg());
    }


    /**
     * 参数不能为空
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    @ResponseBody
    public Result bindException(MissingServletRequestParameterException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(400, String.format("参数%s不能为空!", exception.getParameterName()));
    }

    /**
     * boby参数为空异常
     * @param exception
     * @return
     */
    @ExceptionHandler(HttpMessageNotReadableException.class)
    @ResponseBody
    public Result bindException(HttpMessageNotReadableException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(400, "body参数不能为空!");
    }

    /**
     * AuthorizationDeniedException 没有权限访问
     */
    @ExceptionHandler(AuthorizationDeniedException.class)
    @ResponseBody
    public Result bindException(AuthorizationDeniedException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(403, "您没有权限访问该接口!");
    }



    /**
     * 缺少参数异常
     * @param exception
     * @return
     */
    @ExceptionHandler(MissingRequestHeaderException.class)
    @ResponseBody
    public Result bindException(MissingRequestHeaderException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(400, String.format("参数%s不能为空!",  exception.getHeaderName()));
    }

    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result processException(BindException e) {
        log.error("BindException:{}", e.getMessage());
        String msg = e.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";"));
        return Result.fail(ResultEnum.PARAM_ERROR.getCode(), msg);
    }

    /**
     * 请求方式异常
     * @param exception
     * @return
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseBody
    public Result bindException(HttpRequestMethodNotSupportedException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(400, String.format("请求方式异常",  exception.getMessage()));
    }


    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler({SQLException.class})
    @ResponseBody
    public Result handleSQLException(SQLException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(400, String.format("服务运行SQLException异常",  exception.getMessage()));
    }


    /**
     * 校验参数异常
     * @param exception
     * @return
     */
    @ExceptionHandler(ValidationException.class)
    @ResponseBody
    public Result bindException(ValidationException exception) {
        if(exception instanceof ConstraintViolationException) {
            return Result.fail(400, String.format("参数%s不能为空!", ((ConstraintViolationException) exception).getConstraintViolations()));
        }
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(400, String.format("参数%s不能为空!", exception.getCause()));
    }



    /**
     * 数据库异常
     * @param
     * @return
     */
    @ExceptionHandler(value = DataAccessException.class)
    @ResponseBody
    public Result repeatException(SQLIntegrityConstraintViolationException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(999, exception.getMessage());
    }

    /**
     * BuilderException  mybatis sql 构建异常
     */
    @ExceptionHandler(value = RuntimeException.class)
    @ResponseBody
    public Result repeatException(RuntimeException exception) {
        log.error("出现异常:{}",exception.getClass()+":"+exception.getMessage());
        return Result.fail(999, exception.getMessage());
    }
}
测试

 

带token即可返回成功!!!  

 

用户授权

在这之前我们需要了解一个注解@PreAuthorize():在 Spring Security 中,@PreAuthorize 是一个用于方法级别的安全注解,它允许你在方法执行之前基于表达式来进行访问控制。当一个带有 @PreAuthorize 注解的方法被调用时,Spring Security 会先评估 @PreAuthorize 注解中的表达式。如果表达式的结果为 true,则允许方法执行;如果结果为 false,则会抛出一个 AccessDeniedException 异常,阻止方法的执行。 @PreAuthorize 注解通常包含一个字符串表达式,这个表达式可以使用 Spring Expression Language (SpEL) 来编写。表达式可以访问当前认证对象 (authentication),以及方法的参数等。常用的表达式包括但不限于: hasRole('ROLE_ADMIN'):检查用户是否具有特定的角色。 hasAuthority('DELETE_PRIVILEGE'):检查用户是否具有特定的权限。 principal.username.equals('admin'):检查当前登录用户名是否等于 'admin'。 #id > 0:检查方法参数 id 是否大于0。

这里先说使用方法:

  /**
     * 获取菜单结点  menu:list
     */
    @Parameters({
            @Parameter(name = "Authorization", description = "请求token", required = true, in = ParameterIn.HEADER)
    })
    @Operation(summary = "获取菜单结点")
    @GetMapping("findNodes")
    @PreAuthorize("@ss.hasPerm('sys:user:select')")
    public Result findNodes() {
        List<MenuVo> menusVo = menuService.findNodes();
        return Result.success(menusVo);
    }

 这里的sys:user:select也就是在上面认证的perms

说白了就是你的角色是日志管理员,那么你的权限是系统日志这个模块,其他模块没有权限去请求。上图的perms字段是用户控制按钮的权限。  

 实现


@Component("ss")
@RequiredArgsConstructor
@Slf4j
public class PermissionService {

    private final RedisTemplate<String, Object> redisTemplate;

    private  final MenuService menuService;
    /**
     * 判断当前登录用户是否拥有操作权限
     *
     * @param requiredPerm 所需权限
     * @return 是否有权限
     */
    public boolean hasPerm(String requiredPerm) {

        if (StrUtil.isBlank(requiredPerm)) {
            return false;
        }
        // 超级管理员放行
        if (SecurityUtils.isRoot()) {
            return true;
        }

        // 获取当前登录用户的角色编码集合
        Set<String> roleCodes = SecurityUtils.getRoles();
        if (CollectionUtil.isEmpty(roleCodes)) {
            return false;
        }

        // 获取当前登录用户的所有角色的权限列表
        Set<String> rolePerms = this.getRolePermsFormCache(roleCodes);
        if (CollectionUtil.isEmpty(rolePerms)) {
            return false;
        }
        // 判断当前登录用户的所有角色的权限列表中是否包含所需权限
        boolean hasPermission = rolePerms.stream()
                .anyMatch(rolePerm ->
                        // 匹配权限,支持通配符(* 等)
                        PatternMatchUtils.simpleMatch(rolePerm, requiredPerm)
                );

        if (!hasPermission) {
            log.error("-------------------------用户无操作权限-----------------------------------");
        }
        return hasPermission;
    }


    /**
     * 从缓存中获取角色权限列表
     *
     * @param roleCodes 角色编码集合
     * @return 角色权限列表
     */
    public Set<String> getRolePermsFormCache(Set<String> roleCodes) {
        // 检查输入是否为空
        if (CollectionUtil.isEmpty(roleCodes)) {
            return Collections.emptySet();
        }
        
        Set<String> perms = menuService.listRolePerms(roleCodes);
        log.info("通过角色查询出来的权限列表为:{}",Arrays.toString(perms.toArray()));
        return perms;
    }
}

 建议:上面getRolePermsFormCache不应该总是去数据库查询,应该启动之前把menu加到redis中。

工具

public class SecurityUtils {

    /**
     * 获取当前登录人信息
     *
     * @return SysUserDetails
     */
    public static SysUserDetails getUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null) {
            Object principal = authentication.getPrincipal();
            if (principal instanceof SysUserDetails) {
                return (SysUserDetails) authentication.getPrincipal();
            }
        }
        return null;
    }


    /**
     * 获取用户ID
     *
     * @return Long
     */
    public static Long getUserId() {
        Long userId = Convert.toLong(getUser().getUserId());
        return userId;
    }


    /**
     * 获取用户角色集合
     *
     * @return 角色集合
     */
    public static Set<String> getRoles() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication != null) {
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            if (CollectionUtil.isNotEmpty(authorities)) {
                return authorities.stream().filter(item -> item.getAuthority().startsWith("ROLE_"))
                        .map(item -> StrUtil.removePrefix(item.getAuthority(), "ROLE_"))
                        .collect(Collectors.toSet());
            }
        }
        return Collections.EMPTY_SET;
    }

    /**
     * 是否超级管理员
     * <p>
     * 超级管理员忽视任何权限判断
     *
     * @return
     */
    public static boolean isRoot() {
        Set<String> roles = getRoles();
        return roles.contains("ROOT");
    }

}

 注意:这里isRoot方面的ROOT,应该在角色的role_code 设置。

 

测试 

  @PreAuthorize("@ss.hasPerm('sys:user:ll')")
    public Result findNodes() {
        List<MenuVo> menusVo = menuService.findNodes();
        return Result.success(menusVo);

这里我数据库并没有sys:user:ll权限,结果为:

  @PreAuthorize("@ss.hasPerm('sys:user:select')")
    public Result findNodes() {
        List<MenuVo> menusVo = menuService.findNodes();
        return Result.success(menusVo);
    }

 

 这里没有进行前端的对接,关注后续更新对接前端哦~~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值