Spring Boot 整合 Shiro 实现登录认证和权限控制

在 Spring Boot 中做权限管理,一般来说,主流的方案是 Spring Security ,但是,仅仅从技术角度来说,也可以使用 Shiro。

一般来说,Spring SecurityShiro 的比较如下:

  1. Spring Security 是一个重量级的安全管理框架;Shiro 则是一个轻量级的安全管理框架
  2. Spring Security 概念复杂,配置繁琐;Shiro 概念简单、配置简单
  3. Spring Security 功能强大;Shiro 功能简单

虽然 Shiro 功能简单,但是也能满足大部分的业务场景。所以在传统的 SSM 项目中,一般来说,可以整合 Shiro

在 Spring Boot 中,由于 Spring Boot 官方提供了大量的非常方便的开箱即用的 Starter ,当然也提供了 Spring Security 的 Starter ,使得在 Spring Boot 中使用 Spring Security 变得更加容易,甚至只需要添加一个依赖就可以保护所有的接口,所以,如果是 Spring Boot 项目,一般选择 Spring Security 。这只是一个建议的组合,单纯从技术上来说,无论怎么组合,都是没有问题的。

在 Spring Boot 中整合 Shiro,有两种不同的方案:

  1. 第一种就是原封不动的,将 SSM 整合 Shiro 的配置用 Java 重写一遍。
  2. 第二种就是使用 Shiro 官方提供的一个 Starter 来配置,但是,这个 Starter 并没有简化多少配置。

准备工作

创建数据库

所需表如下:

  • user:用户表
  • role:角色表
  • perm:权限菜单表
  • user_role:用户与角色关联的中间表
  • role_prem:角色与权限菜单关联的中间表
执行数据库脚本
/*
 Navicat Premium Data Transfer

 Source Server         : 127.0.0.1
 Source Server Type    : MySQL
 Source Server Version : 50718
 Source Host           : 127.0.0.1:3306
 Source Schema         : shiro

 Target Server Type    : MySQL
 Target Server Version : 50718
 File Encoding         : 65001
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for perm
-- ----------------------------
DROP TABLE IF EXISTS `perm`;
CREATE TABLE `perm`  (
  `perm_id` int(32) NOT NULL COMMENT '权限主键',
  `perm_url` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '权限url',
  `perm_description` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '权限描述',
  PRIMARY KEY (`perm_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of perm
-- ----------------------------
INSERT INTO `perm` VALUES (1, '/user/*', '拥有对用户的所有操作权限');

-- ----------------------------
-- Table structure for role
-- ----------------------------
DROP TABLE IF EXISTS `role`;
CREATE TABLE `role`  (
  `role_id` int(32) NOT NULL COMMENT '角色主键',
  `role_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '角色名',
  `role_description` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '角色描述',
  PRIMARY KEY (`role_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of role
-- ----------------------------
INSERT INTO `role` VALUES (1, '超级管理员', '超级管理员');

-- ----------------------------
-- Table structure for role_perm
-- ----------------------------
DROP TABLE IF EXISTS `role_perm`;
CREATE TABLE `role_perm`  (
  `role_id` int(32) NOT NULL COMMENT '角色主键',
  `perm_id` int(32) DEFAULT NULL COMMENT '权限主键'
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of role_perm
-- ----------------------------
INSERT INTO `role_perm` VALUES (1, 1);

-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `user_id` int(32) NOT NULL COMMENT '用户主键',
  `username` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '用户名',
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL COMMENT '密码(存储加密后的密码)',
  PRIMARY KEY (`user_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES (1, 'root', 'e10adc3949ba59abbe56e057f20f883e');

-- ----------------------------
-- Table structure for user_role
-- ----------------------------
DROP TABLE IF EXISTS `user_role`;
CREATE TABLE `user_role`  (
  `user_id` int(32) NOT NULL COMMENT '用户主键',
  `role_id` int(32) NOT NULL COMMENT '角色主键'
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_bin ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user_role
-- ----------------------------
INSERT INTO `user_role` VALUES (1, 1);

SET FOREIGN_KEY_CHECKS = 1;

数据库创建完成以后,用逆向工程生成对应的实体类和 mapper.xml 文件并加入到项目当中。

业务代码

这里我们需要定义一个业务接口查询用户的相关信息(包括用户关联的角色与权限)

UserService

public interface UserService {

   /**
     * 根据用户名查询用户信息(包含角色及权限信息)
     * @param username 用户名
     * @return User
     */
    User selectByUsername(String username);
}

UserServiceImpl

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public User selectByUsername(String username) {

        return userMapper.selectByUsername(username);
    }
}
Web 页面

创建 login.html

引入 jquery.js

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-spring4-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <div>
        用户名:<input id="username" name="username" type="text" /><br/>
        密码:<input id="password" name="password" type="password"><br/>
        <span id="tip" class="tip"></span><br/>
        <button onclick="login()">点击登录</button>
    </div>
</body>

<script type="text/javascript" src="/js/jquery-3.4.1.min.js"></script>
<script type="text/javascript">
    function login() {
        var username = $('#username').val()
        var password = $('#password').val()
        $.ajax({
            url: '/login.do'
            , data: {
                username: username
                , password: password
            }
            , type: 'post'
            , dataType: 'json'
            , success: function(res) {
                if (res.code == 200) {
                    // 登录成功,跳转到 index.html
                    window.location.href = '/index.html'
                } else {
                    // 登录失败,提示登录错误信息
                    $("#tip").text(res.msg)
                }
            }
            , error: function() {
                $("#tip").text('服务器响应失败')
            }
        })
    }
</script>
</html>

创建 index.html

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-spring4-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
</head>
<body>
    Hello Shiro
    <a href="/logout.do">退出</a>
</body>
</html>

创建 unauthorized.html

<!DOCTYPE html SYSTEM "http://www.thymeleaf.org/dtd/xhtml1-strict-thymeleaf-spring4-4.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>无权访问</title>
</head>
<body>
    权限不足
</body>
</html>

原生整合

引入依赖

加入 Shiro 相关的依赖,完整的 pom.xml 文件中的依赖如下:

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

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-web</artifactId>
    <version>1.4.0</version>
</dependency>
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.4.0</version>
</dependency>
创建 Realm

接下来我们来自定义核心组件 Realm

public class MyRealm extends AuthorizingRealm {
    
    @Autowired
    private UserService userService;
    
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        Subject subject = SecurityUtils.getSubject();
        User user = (User) subject.getPrincipal();
        //  如果当前用户已完成认证则授权,注意:user 对象中包含了用户拥有的权限信息
        if (user != null) {
            SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
            List<String> roles = new LinkedList<>();
            List<String> perms = new LinkedList<>();
            //  添加角色
            for (Role role : user.getRoleList()) {
                roles.add(role.getRoleName());
            }
            // 添加菜单
            for (Perm perm : user.getPermList()) {
                perms.add(perm.getPermUrl());
            }
            simpleAuthorizationInfo.addRoles(roles);
            simpleAuthorizationInfo.addStringPermissions(perms);
            return simpleAuthorizationInfo;
        }
        // 否则不授权
        return null;
    }
    
       @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {

        UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
        User user = userService.selectByUsername(token.getUsername());
        if (user == null) {
            throw new UnknownAccountException();
        }
        return new SimpleAuthenticationInfo(user, user.getPassword(), getName());
    }
}
配置 Shiro

接下来进行 Shiro 的配置:

import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;

@Configuration
public class ShiroConfig {

    /**
     * 配置密码加密
     */
    @Bean("hashedCredentialsMatcher")
    public HashedCredentialsMatcher hashedCredentialsMatcher() {

        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        // 散列算法(加密)
        credentialsMatcher.setHashAlgorithmName("MD5");
        // 散列次数(加密次数)
        credentialsMatcher.setHashIterations(1);
        // storedCredentialsHexEncoded 默认是true,此时用的是密码加密用的是Hex编码;false时用Base64编码
        credentialsMatcher.setStoredCredentialsHexEncoded(true);
        return credentialsMatcher;
    }

    /**
     * 注入自定义的 Realm
     */
    @Bean("MyRealm")
    public MyRealm MyRealm(@Qualifier("hashedCredentialsMatcher") HashedCredentialsMatcher matcher) {

        MyRealm MyRealm = new MyRealm();
        MyRealm.setCredentialsMatcher(matcher);
        return MyRealm;
    }

    /**
     * 配置自定义权限过滤规则
     */
    @Bean
    public ShiroFilterFactoryBean shirFilter(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {

        ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean();
        bean.setSecurityManager(securityManager);
        bean.setSuccessUrl("/index.html");
        bean.setLoginUrl("/login.html");
        bean.setUnauthorizedUrl("/unauthorized.html");

        /**
         * anon:匿名用户可访问
         * authc:认证用户可访问
         * user:使用rememberMe可访问
         * perms:对应权限可访问
         * role:对应角色权限可访问
         **/
        Map<String, String> filterMap = new LinkedHashMap<>();
        /**
         * 允许匿名访问静态资源
         */
        filterMap.put("/image/**", "anon");
        filterMap.put("/css/**", "anon");
        filterMap.put("/js/**", "anon");
        filterMap.put("/plugin/**", "anon");
        /**
         * 允许匿名访问登录页面和登录操作
         */
        filterMap.put("/login.html", "anon");
        filterMap.put("/login.do", "anon");
        /**
         * 其它所有请求需要登录认证后才能访问
         */
        filterMap.put("/**", "authc");
        bean.setFilterChainDefinitionMap(filterMap);
        return bean;
    }

    /**
     * 注入 securityManager
     */
    @Bean(name = "securityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(HashedCredentialsMatcher hashedCredentialsMatcher, @Qualifier("sessionManager") DefaultWebSessionManager defaultWebSessionManager) {

        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        securityManager.setRealm(MyRealm(hashedCredentialsMatcher));
        securityManager.setSessionManager(defaultWebSessionManager);
        return securityManager;
    }

    /**
     * 开启权限注解
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(@Qualifier("securityManager") DefaultWebSecurityManager securityManager) {

        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
    @Bean
    @ConditionalOnMissingBean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {

        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    /**
     * 配置异常跳转页面
     */
    @Bean
    public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {

        SimpleMappingExceptionResolver resolver = new SimpleMappingExceptionResolver();
        Properties properties = new Properties();
        // 未认证跳转页面(跳转路径为项目里的页面相对路径,并非 URL)
        properties.setProperty("org.apache.shiro.authz.UnauthenticatedException", "login");
        // 权限不足跳转页面
        properties.setProperty("org.apache.shiro.authz.UnauthorizedException", "unauthorized");
        resolver.setExceptionMappings(properties);
        return resolver;
    }

    /**
     * 会话管理器
     */
    @Bean("sessionManager")
    public DefaultWebSessionManager defaultWebSessionManager() {

        DefaultWebSessionManager defaultWebSessionManager = new DefaultWebSessionManager();
        // 设置用户登录信息失效时间为一天(单位:ms)
        defaultWebSessionManager.setGlobalSessionTimeout(1000L * 60L * 60L * 24L);
        return defaultWebSessionManager;
    }

    /**
     * 重置 ShiroDialect,省略此步将不能在 Thymeleaf 页面使用 Shiro 标签
     */
    @Bean(name = "shiroDialect")
    public ShiroDialect shiroDialect(){
        return new ShiroDialect();
    }
}

在这里进行 Shiro 的配置主要配置 3 个 Bean :

  1. 首先需要提供一个 Realm 的实例。
  2. 需要配置一个 SecurityManager,在 SecurityManager 中配置 Realm。
  3. 配置一个 ShiroFilterFactoryBean,在 ShiroFilterFactoryBean 中指定路径拦截规则等。
  4. 配置登录和测试接口。

其中,ShiroFilterFactoryBean 的配置稍微多一些,配置含义如下:

  • setSecurityManager 表示指定 SecurityManager
  • setLoginUrl 表示指定登录页面。
  • setSuccessUrl 表示指定登录成功页面。
  • 接下来的 Map 中配置了路径拦截规则,注意,要有序。

这些东西都配置完成后,接下来配置登录 Controller

创建 Controller
@Controller
public class IndexController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "login.html")
    public String loginView() {

        // 判断当前用户是否通过认证
        if (SecurityUtils.getSubject().isAuthenticated()) {
            // 认证通过,重定向到首页
            return "redirect:index.html";
        } else {
            // 未认证或认证失败,转发到登录页
            return "login";
        }
    }

    @RequestMapping(value = "login.do")
    @ResponseBody
    public AppReturn loginDo(@RequestParam String username, @RequestParam String password) {
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, password);
        try {
            // 执行认证
            subject.login(usernamePasswordToken);
        } catch (UnknownAccountException e) {
            return AppReturn.defeated("账号不存在");
        } catch (IncorrectCredentialsException e) {
            return AppReturn.defeated("密码错误");
        }
        return AppReturn.succeed("登录成功");
    }

    @RequestMapping(value = "index.html")
    public String indexView() {
        return "index";
    }

    @RequestMapping(value = "logout.do")
    public String logoutDo() {

        if (SecurityUtils.getSubject().isAuthenticated()) {
            // 退出
            SecurityUtils.getSubject().logout();
        }
        return "redirect:login.html";
    }

    @RequestMapping(value = "unauthorized.html")
    public String unauthorizedView() {

        return "unauthorized";
    }
}
@Controller
public class IndexController {

    @Autowired
    private UserService userService;

    @RequestMapping(value = "login.html")
    public String loginView() {

        // 判断当前用户是否通过认证
        if (SecurityUtils.getSubject().isAuthenticated()) {
            // 认证通过,重定向到首页
            return "redirect:index.html";
        } else {
            // 未认证或认证失败,转发到登录页
            return "login";
        }
    }

    @RequestMapping(value = "login.do")
    @ResponseBody
    public AppReturn loginDo(@RequestParam String username, @RequestParam String password) {
        return userService.loginDo(username, password);
    }

    @RequestMapping(value = "index.html")
    public String indexView() {
        return "index";
    }

    @RequestMapping(value = "logout.do")
    public String logoutDo() {

        if (SecurityUtils.getSubject().isAuthenticated()) {
            // 退出
            SecurityUtils.getSubject().logout();
        }
        return "redirect:login.html";
    }

    @RequestMapping(value = "unauthorized.html")
    public String unauthorizedView() {

        return "unauthorized";
    }
}

最后访问 http://localhost:8080/login.html 进行登录即可。账号:root,密码:123456

整合 Shiro Starter

上面这种配置方式实际上相当于把 SSM 中的 XML 配置拿到 Spring Boot 中用 Java 代码重新写了一遍,除了这种方式之外,我们也可以直接使用 Shiro 官方提供的 Starter 。

引入依赖

添加 shiro-spring-boot-web-starter 依赖,这个依赖可以代替之前的 shiro-webshiro-spring 两个依赖,如下:

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

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring-boot-web-starter</artifactId>
    <version>1.4.0</version>
</dependency>
创建 Realm

这里的 Realm 和前面的一样,我就不再赘述。

配置 Shiro 基本信息

接下来在 application.properties 中配置 Shiro 的基本信息:

shiro.sessionManager.sessionIdCookieEnabled=true
shiro.sessionManager.sessionIdUrlRewritingEnabled=true
shiro.unauthorizedUrl=/unauthorizedurl
shiro.web.enabled=true
shiro.successUrl=/index
shiro.loginUrl=/login

配置解释:

  1. 第一行表示是否允许将sessionId 放到 cookie 中
  2. 第二行表示是否允许将 sessionId 放到 Url 地址拦中
  3. 第三行表示访问未获授权的页面时,默认的跳转路径
  4. 第四行表示开启 shiro
  5. 第五行表示登录成功的跳转页面
  6. 第六行表示登录页面
配置 ShiroConfig
@Configuration
public class ShiroConfig {
    
    @Bean
    MyRealm myRealm() {
        return new MyRealm();
    }
    
    @Bean
    DefaultWebSecurityManager securityManager() {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        manager.setRealm(myRealm());
        return manager;
    }
    
    @Bean
    ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition definition = new DefaultShiroFilterChainDefinition();
        definition.addPathDefinition("/doLogin", "anon");
        definition.addPathDefinition("/**", "authc");
        return definition;
    }
}

这里的配置和前面的比较像,但是不再需要 ShiroFilterFactoryBean 实例了,替代它的是 ShiroFilterChainDefinition,在这里定义 Shiro 的路径匹配规则即可。这里定义完之后,接下来的登录接口定义以及测试方法都和前面的一致,我就不再赘述了,大家可以参考上文。

Java 中使用 Shiro 权限注解

除了在 ShiroConfig 配置类中自定义权限过滤规则,还可以使用 Shiro 提供的注解实现权限过滤,在 Controller 中的每个请求方法上可以添加以下注解实现权限控制:

  • @RequiresAuthentication: 只有认证通过的用户才能访问

  • @RequiresRoles(value = {“root”}, logical = Logical.OR):

    • value:指定拥有 root 角色才能访问,角色可以是多个,以逗号隔开
    • logical:该属性有两个值,Logical.OR(只要拥有其中一个角色就能访问),Logical.AND(需要拥有指定的全部角色才能访问,否则会抛出权限不足异常)
  • @RequiresPermissions(value = {“/user/delete”}, logical = Logical.OR)

    • value:指定拥有 /user/delete 权限才能访问,权限可以是多个,以逗号隔开
    • logical:有两个值,Logical.OR(只要拥有其中一个权限就访问),Logical.AND(需要拥有指定的全部权限才能访问,否则会抛出权限不足异常)

Thymeleaf 模板中使用 Shiro 权限标签

修改 thymeleaf 模板的 html 标签,加入 xmlns:shiro=”http://www.pollix.at/thymeleaf/shiro 命名空间:

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:shiro="http://www.pollix.at/thymeleaf/shiro">

常用的 Shiro 标签有以下:

  • <shiro:hasRole=”root”>:需要拥有 root 角色
  • <shiro:hasAnyRoles=”root,guest”>:需要拥有 root 和 guest 中的任意一个角色
  • <shiro:hasAllRoles=”root,guest”>:需要同时拥有 root 和 guest 角色
  • <shiro:hasPerm="userAdd>":需要拥有 userAdd 权限
  • <shiro:hasAnyPerms="userAdd,userDelete>":需要拥有 userAdd 和 userDelete 中的任意一个权限
  • <shiro:hasAllPerms="userAdd,userDelete>":需要同时拥有 userAdd 和 userDelete 权限

更多干货请移步:https://antoniopeng.com

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值