03.SpringBoot3+JDK17+Shiro+OAuth2授权方式+JWT

SpringBoot3+JDK17+Shiro+OAuth2授权方式+JWT

OAuth2认证方式概念

OAuth2 是一种用于授权的开放标准,它允许用户授权第三方应用访问他们存储在授权服务器上的资源,而无需将用户名和密码提供给第三方应用。OAuth2 的核心概念是授权,而不是认证。下面是 OAuth2 的一些重要概念:

  • 资源所有者(Resource Owner):资源所有者是指能够授权访问自己受保护资源的实体,通常是用户。

  • 客户端(Client):客户端是请求访问受保护资源的应用程序。它可以是 Web 应用、移动应用、桌面应用或者后端服务。

  • 授权服务器(Authorization Server):授权服务器负责验证资源所有者的身份,并颁发访问令牌给客户端。

  • 资源服务器(Resource Server):资源服务器存储了受保护的资源,它负责接收和响应受保护资源的请求,同时验证访问令牌。

  • 访问令牌(Access Token):访问令牌是用于访问受保护资源的令牌,它由授权服务器颁发给客户端,并用于向资源服务器请求受保护资源。

OAuth2 定义了多种授权方式,包括授权码授权、密码授权、客户端凭证授权、隐式授权和资源所有者密码凭证授权等。每种授权方式适用于不同的场景和安全需求。

再这篇文章中,博主使用的是授权服务器、与资源服务器是一台,其实就是使用Shiro来完成的,而访问令牌使用的是JWT(JSON WEB TOKEN)来完成的

依赖

注意由于JDK17使用的是Jakarta EE规范,而截止2024年1月24日Shiro2.0还处于(alpha)测试阶段,所以只能使用目前最新的版本shiro1.13,但是Shiro1.13版本目前默认使用的是Java EE规范,所以不能直接引入shiro-spring-boot-web-starter依赖

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

		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<version>8.0.33</version>
		</dependency>

		<dependency>
			<groupId>com.baomidou</groupId>
			<artifactId>mybatis-plus-boot-starter</artifactId>
			<version>3.5.3.2</version>
		</dependency>

		<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-spring</artifactId>
			<classifier>jakarta</classifier>
			<version>1.13.0</version>
			<exclusions>
				<exclusion>
					<groupId>org.apache.shiro</groupId>
					<artifactId>shiro-core</artifactId>
				</exclusion>
				<exclusion>
					<groupId>org.apache.shiro</groupId>
					<artifactId>shiro-web</artifactId>
				</exclusion>
			</exclusions>
		</dependency>

		<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-core</artifactId>
			<classifier>jakarta</classifier>
			<version>1.13.0</version>
		</dependency>

		<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-web</artifactId>
			<classifier>jakarta</classifier>
			<version>1.13.0</version>
			<exclusions>
				<exclusion>
					<groupId>org.apache.shiro</groupId>
					<artifactId>shiro-core</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		
		<dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>4.4.0</version>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

涉及表结构(核心字段)

  1. 用户表(sys_user)

    FieldTypeNullKeyDefaultExtraComment
    user_idbigintNOPRINULLauto_increment
    usernamevarchar(50)NOUNI登录用户名
    namevarchar(128)YESNULL姓名
    passwordvarchar(100)YESNULL密码
  2. 角色表(sys_role)

    FieldTypeNullKeyDefaultExtraComment
    role_idbigintNOPRINULLauto_increment
    role_namevarchar(100)YESNULL角色名称
    remarkvarchar(100)YESNULL备注
  3. 权限表/菜单表(sys_menu)

    FieldTypeNullKeyDefaultExtraComment
    perm_idbigint unsignedNOPRINULLauto_increment
    permsvarchar(500)YESNULL授权(多个用逗号分隔,如:user:list,user:create)
  4. 用户角色表(sys_user_role)

    FieldTypeNullKeyDefaultExtraComment
    idbigintNOPRINULLauto_increment
    user_idbigintYESNULL用户ID
    role_idbigintYESNULL角色ID
  5. 角色权限表(sys_role_menu)

    FieldTypeNullKeyDefaultExtraComment
    idbigintNOPRINULLauto_increment
    role_idbigintYESNULL角色ID
    perm_idbigintYESNULL权限ID

springboot配置

server:
  port: 端口号
spring:
  datasource:
    type: com.mysql.cj.jdbc.MysqlDataSource
    username: 数据库账号
    password: 数据库密码
    url: jdbc:mysql://localhost:3306/数据库名?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&allowMultiQueries=true
mybatis-plus:
  global-config:
    banner: off
logging:
  level:
    org.apache.shiro.authc.AbstractAuthenticator: debug

Shiro组件选择

  1. Filter选择:
    shiro有内置的13中过滤器,已经在01.Shiro基础概念以及快速入门中有解释说明。
    那么过滤器的选择有两种选择:(我这里是自定义过滤器,当然authcBearer过滤器也有实现,在博文最后会给出源码)

    • 内置的过滤器:authcBearer(BearerHttpAuthenticationFilter.class)

      • 原理:获取请求头中的Authorization头信息,并以空格进行切分,所以Authorization头中的值固定格式一般为:Bearer {token值}
    • 自定义过滤器:继承HttpAuthenticationFilter类重写createTokenisAccessAllowedonAccessDeniedonLoginFailure方法即可

      public class OAuth2Filter extends AuthenticatingFilter {
      
          @Override
          protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
              //获取请求token
              String token = getRequestToken((HttpServletRequest) request);
      
              if (token == null || token.isEmpty()) {
                  return null;
              }
      
              return new OAuth2Token(token);
          }
      
          //可以设置是请求是否允许访问(不用认证)
          @Override
          protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
              if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) {
                  return true;
              }
              return false;
          }
      
          //当拒绝访问时,执行的逻辑代码
          @Override
          protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
              //获取请求token,如果token不存在,直接返回401
              String token = getRequestToken((HttpServletRequest) request);
              if (token == null || token.isEmpty()) {
                  HttpServletResponse httpResponse = (HttpServletResponse) response;
                  HttpServletRequest httpRequest = (HttpServletRequest) request;
                  httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
                  httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader("Origin"));
                  httpResponse.setContentType("application/json");
                  httpResponse.getWriter().print("invalid token");
                  return false;
              }
      
              return executeLogin(request, response);
          }
      
          //当登录失败时,执行的逻辑代码
          @Override
          protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
              HttpServletResponse httpResponse = (HttpServletResponse) response;
              HttpServletRequest httpRequest = (HttpServletRequest) request;
              httpResponse.setContentType("application/json;charset=utf-8");
              httpResponse.setHeader("Access-Control-Allow-Credentials", "true");
              httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader("Origin"));
              try {
                  //处理登录失败的异常
                  Throwable throwable = e.getCause() == null ? e : e.getCause();
                  httpResponse.getWriter().print("登录失败 :" + throwable.getMessage());
              } catch (IOException e1) {
                  //其他逻辑
              }
              return false;
          }
      
          /**
           * 获取请求的token
           */
          private String getRequestToken(HttpServletRequest httpRequest) {
              return httpRequest.getHeader("Authorization");
          }
      }
      
  2. Realm选择:
    内置的Realm并没有适用于OAuth2认证方式的,所以只能自定义Realm
    继承AuthorizingRealm类重写doGetAuthorizationInfodoGetAuthenticationInfo方法即可

    @Component
    public class OAuth2Realm extends AuthorizingRealm {
        @Autowired
        private ISysUserService userService;
    
        /**
         * 默认使用的token为:{@link org.apache.shiro.authc.UsernamePasswordToken}
         * ,所以需要重写support方法,不然会报错token不匹配
         * <p>或者在realm中设置authenticationTokenClass属性为你想要使用的Token</p>
         */
        @Override
        public boolean supports(AuthenticationToken token) {
            return token instanceof OAuth2Token;
        }
    
        @Override
        protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
            SysUser user = (SysUser)principalCollection.getPrimaryPrincipal();
            Long userId = user.getUserId();
    
            //用户权限列表
            Set<String> permsSet = userService.getUserPermissions(userId);
    
            //用户角色列表
            Set<String> rolesSet = userService.getUserRoles(userId);
    
    
            SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
            info.setStringPermissions(permsSet);
            info.setRoles(rolesSet);
            return info;
        }
    
        @Override
        protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) {
            OAuth2Token auth2Token = (OAuth2Token) authenticationToken;
            SimpleAuthenticationInfo info = null;
            String token = auth2Token.getPrincipal();
            String userId;
            try {
                DecodedJWT parse = JwtUtil.parse(token);
                userId = parse.getClaim("userId").toString();
    
            } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
                throw new IncorrectCredentialsException("无效的token");
            } catch (JWTVerificationException e) {
                throw new JWTVerificationException("过期的token");
            }
            SysUser user = userService.getById(userId);
            if (user == null) {
                throw new BizException("没有该用户");
            }
            info = new SimpleAuthenticationInfo(user, token, getName());
            return info;
        }
    }
    
  3. Token选择:

    • 内置的token:BearerToken,与authcBearer过滤器是配套的,authcBearer过滤器生成的token类型就是BearerToken类型的。

    • 自定义token:继承AuthenticationToken类,重写getPrincipalgetCredentials方法即可

      public class OAuth2Token implements AuthenticationToken {
          private String token;
      
          public OAuth2Token(String token){
              this.token = token;
          }
      
          @Override
          public String getPrincipal() {
              return token;
          }
      
          @Override
          public Object getCredentials() {
              return token;
          }
      }
      
  4. SecurityManage选择:
    使用DefaultWebSecurityManager即可

访问令牌(token)

  1. 博主使用的JWT(JSON WEB TOKEN),官方网址https://jwt.io/,官网上有关于JWT的详细介绍,这里不做过多解释。
  2. java生成JWT,有两种开源的java依赖包可以生成(我用的是java-jwt)
    • https://github.com/jwtk/jjwt
    • https://github.com/auth0/java-jwt
      1. 生成密钥对(博主使用的是RAS256加密方式)

            public static Map<String, String> createKey() {
                Map<String, String> keyPairMap = new HashMap<>();
                KeyPairGenerator rsa = null;
                try {
                    rsa = KeyPairGenerator.getInstance("RSA");
                } catch (NoSuchAlgorithmException e) {
                    log.error(e.getMessage());
                }
                if (rsa != null) {
                    rsa.initialize(1024, new SecureRandom());
                    KeyPair keyPair = rsa.generateKeyPair();
                    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();   // 得到私钥
                    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();  // 得到公钥
        
                    //获取公、私钥值
                    String publicKeyValue = byte2Base64String(publicKey.getEncoded());
                    String privateKeyValue = byte2Base64String(privateKey.getEncoded());
        
                    keyPairMap.put("public", publicKeyValue);
                    keyPairMap.put("private", privateKeyValue);
                }
        
                return keyPairMap;
            }
        
      2. 根据获取公钥字符串与私钥字符串(保存到服务器)

            public static RSAPrivateKey getPrivateKey(String privateKeyBase64Str) throws NoSuchAlgorithmException, InvalidKeySpecException {
                PKCS8EncodedKeySpec pkcs8EncodedKeySpec = new PKCS8EncodedKeySpec(base64String2Byte(privateKeyBase64Str));
                KeyFactory keyFactory;
                keyFactory = KeyFactory.getInstance("RSA");
                return (RSAPrivateKey) keyFactory.generatePrivate(pkcs8EncodedKeySpec);
            }
        
            public static RSAPublicKey getPublicKey(String publicKeyBase64Str) throws NoSuchAlgorithmException, InvalidKeySpecException {
                X509EncodedKeySpec keySpec = new X509EncodedKeySpec(base64String2Byte(publicKeyBase64Str));
                KeyFactory keyFactory;
                keyFactory = KeyFactory.getInstance("RSA");
                return (RSAPublicKey) keyFactory.generatePublic(keySpec);
            }
            
            public static byte[] base64String2Byte(String base64Str) {
                return Base64.getDecoder().decode(base64Str);
            }
            
            public static String byte2Base64String(byte[] bytes) {
                return new String(Base64.getEncoder().encode(bytes));
            }
        
        
      3. 生成jwt(当执行登录逻辑时生成)

            private static final String PUBLIC_KEY_STR = "你的公钥字符串";
            private static final String PRIVATE_KEY_STR = "你的私钥字符串";
            public static String create(Integer userId, long expiresSeconds) throws NoSuchAlgorithmException, InvalidKeySpecException {
                Algorithm algorithm = getAlgorithm();
                return JWT.create()
                        .withClaim("userId", userId)
                        .withExpiresAt(Instant.now().plusSeconds(expiresSeconds))
                        .sign(algorithm);
            }
            
            private static Algorithm getAlgorithm() throws NoSuchAlgorithmException, InvalidKeySpecException {
                RSAPrivateKey privateKey = RsaUtil.getPrivateKey(PRIVATE_KEY_STR);
                RSAPublicKey publicKey = RsaUtil.getPublicKey(PUBLIC_KEY_STR);
                return Algorithm.RSA256(publicKey, privateKey);
            }
        
      4. 校验jwt(当要访问资源时校验)

            public static DecodedJWT parse(String jwtStr) throws NoSuchAlgorithmException, InvalidKeySpecException {
                Algorithm algorithm = getAlgorithm();
                return JWT.require(algorithm).build().verify(jwtStr);
            }
        
            private static Algorithm getAlgorithm() throws NoSuchAlgorithmException, InvalidKeySpecException {
                RSAPrivateKey privateKey = RsaUtil.getPrivateKey(PRIVATE_KEY_STR);
                RSAPublicKey publicKey = RsaUtil.getPublicKey(PUBLIC_KEY_STR);
                return Algorithm.RSA256(publicKey, privateKey);
            }
        

Shiro配置

  1. 开启shiro注解支持

    注意:如果不配置这两货,使用shiro的注解时会失效

    @Configuration
    public class ShiroConfig {
        @Bean
        public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
            DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator=new DefaultAdvisorAutoProxyCreator();
            defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
            return defaultAdvisorAutoProxyCreator;
        }
        
        @Bean
        public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
            AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
            advisor.setSecurityManager(securityManager);
            return advisor;
        }
    }
    
  2. 配置LifecycleBeanPostProcessor

    @Configuration
    public class ShiroConfig {
        /**
         * 用于管理shiro组件的生命周期
         * @return
         */
        @Bean("lifecycleBeanPostProcessor")
        public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
            return new LifecycleBeanPostProcessor();
        }
    }
    
  3. 配置SecurityManager、Reaml
    注意1:自定义的Realm,用于OAuth2的Reaml。
    注意2:自定义的Realm,交给IOC管理这里直接注入即可。

    @Configuration
    public class ShiroConfig {
        @Bean("securityManager")
        public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
            return new DefaultWebSecurityManager(oAuth2Realm);
        }
    }
    
  4. 配置过滤器链

    @Configuration
    public class ShiroConfig {
         @Bean("shiroFilter")
         public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
             ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
     
             Map<String, Filter> filters = new HashMap<>();
             filters.put("oauth2", new OAuth2Filter());
             shiroFilter.setFilters(filters);
     
             Map<String, String> filterMap = new LinkedHashMap<>();
             filterMap.put("/oauth/login", "anon");//设置不需要认证的url
             filterMap.put("/**", "oauth2");//设置需要Bearer方式认证的url,并设置允许跨域访问,跨域的url直接放行,
             shiroFilter.setFilterChainDefinitionMap(filterMap);
             shiroFilter.setSecurityManager(securityManager);
             shiroFilter.setGlobalFilters(Arrays.asList("noSessionCreation"));//设置无状态服务(禁用会话)
             return shiroFilter;
         }
    }
    
  5. 整体的配置如下:

     @Configuration
     public class ShiroConfig {
     
         /**
          * 用于管理shiro组件的生命周期
          *
          * @return
          */
         @Bean("lifecycleBeanPostProcessor")
         public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
             return new LifecycleBeanPostProcessor();
         }
     
         /**
          * 开启Shiro注解功能
          *
          * @return
          */
         @Bean
         public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
             DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
             defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
             return defaultAdvisorAutoProxyCreator;
         }
     
         /**
          * 开启Shro注解功能
          */
         @Bean
         public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
             AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
             advisor.setSecurityManager(securityManager);
             return advisor;
         }
     
     
         @Bean("securityManager")
         public SecurityManager securityManager(OAuth2Realm oAuth2Realm) {
             return new DefaultWebSecurityManager(oAuth2Realm);
         }
     
         @Bean("shiroFilter")
         public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
             ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
     
             Map<String, Filter> filters = new HashMap<>();
             filters.put("oauth2", new OAuth2Filter());
             shiroFilter.setFilters(filters);
     
             Map<String, String> filterMap = new LinkedHashMap<>();
             filterMap.put("/oauth/login", "anon");//设置不需要认证的url
             filterMap.put("/**", "oauth2");//设置需要oauth2方式认证的url,并设置允许跨域访问,跨域的url直接放行,
             shiroFilter.setFilterChainDefinitionMap(filterMap);
             shiroFilter.setSecurityManager(securityManager);
             shiroFilter.setGlobalFilters(Arrays.asList("noSessionCreation"));//设置无状态服务(禁用会话)
             return shiroFilter;
         }
     }
    

测试类(controller)

	@RestController
	@RequestMapping("/oauth")
	public class Oauth2Controller {
	
	    @GetMapping("/login")
	    public String login() throws NoSuchAlgorithmException, InvalidKeySpecException {
	        return JwtUtil.create(2, 60*60);
	    }
	
	    @GetMapping("/other")
	    public String other(){
	        return "other";
	    }
	
	    @RequiresPermissions("sys:perm:read")
	    @GetMapping("/permRead")
	    public String permRead() {
	        return "授权:读";
	    }
	
	    @RequiresPermissions("sys:perm:write")
	    @GetMapping("/permWrite")
	    public String permWrite() {
	        return "授权:写";
	    }
	
	    @RequiresRoles("系统管理员")
	    @GetMapping("/roleSys")
	    public String roleSys() {
	        return "授权:系统管理员";
	    }
	
	    @RequiresRoles("普通管理员")
	    @GetMapping("/roleCom")
	    public String roleCom() {
	        return "授权:普通管理员";
	    }
	}

测试

注意1:测试的用户名test002,该用户是普通管理员角色(角色id为2),只有读的权限没有写的权限
注意2:博主使用的是apifox测试接口,也可以用postman。

  • 模拟登录接口(获取token),并讲token设置到其他接口的请求头(Authorization)中
    登录接口获取toekn

  • 模拟需要有“读”权限才能访问的接口
    “读”权限接口

  • 模拟需要有“写”权限才能访问的接口
    “写”权限接口

  • 模拟普通管理员能访问的接口
    “普通管理员“权限接口

  • 模拟系统管理员能访问的接口
    “系统管理员”权限接口

源码

最后贴出源码,希望给个赞!谢谢

实现授权的另外一种思路

这里博主使用的是JWT的方式认证,这种方式是将用户信息存到token中存到客户端,使用SHA256非对称加密。也可以将用户信息存到服务端,使用将redis+uuid的方式,大致流程:

  1. 登录
  2. 使用唯一值(uuid的md5加密)作为redis的key也作为token,将用户信息(id)作为reids的值,并设置存活时间
  3. 将token返回给前端,作为访问资源的凭证
  • 28
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
在Linux系统下,我们可以使用Docker来部署Spring Boot 3和JDK 17。 首先,我们需要在Linux中安装Docker。可以根据具体的Linux发行版来选择不同的安装方式。安装完成后,我们可以通过运行以下命令来验证Docker安装是否成功: ``` docker --version ``` 接下来,我们需要创建一个Docker镜像来运行我们的Spring Boot 3应用程序。首先,我们需要创建一个Dockerfile,可以使用任何文本编辑器来创建一个名为“Dockerfile”的文件。在Dockerfile中,我们可以定义我们的镜像的构建过程。以下是一个简单的Dockerfile示例: ``` FROM openjdk:17-jdk-alpine WORKDIR /app COPY target/springboot3-app.jar /app CMD ["java", "-jar", "springboot3-app.jar"] ``` 在这个Dockerfile中,我们首先选择了一个基础镜像(openjdk:17-jdk-alpine),其中包含了JDK 17的安装。然后,我们定义了工作目录为“/app”,将Spring Boot 3应用程序的JAR文件复制到工作目录中,并使用CMD命令定义了镜像启动时要运行的命令。 在创建了Dockerfile后,我们可以使用以下命令来构建Docker镜像: ``` docker build -t springboot3-app . ``` 这将会在当前目录下构建一个名为“springboot3-app”的Docker镜像。 最后,我们可以使用以下命令来运行我们的应用程序容器: ``` docker run -p 8080:8080 springboot3-app ``` 这将会将容器的8080端口映射到主机的8080端口,并运行我们的Spring Boot 3应用程序。 通过以上步骤,我们可以在Linux系统中使用Docker快速部署和运行Spring Boot 3和JDK 17
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值