SpringSecurity 源码解析 | 加JWT 实战 之 自定义认证流程

        上篇我们已经源码分析了SpringSecurity登录认证流程。很多内容都是默认实现,实际的企业开发中,对接很多内容都会有自行的扩充和增加。

现在我们就实现自定以基于JWT登录认证,满足我们的登录需要。

为什么要基于JWT登录认证?

至于JWT相对于session的有点和好处我就不阐述了。主要说下我们用它来干嘛

企业研发中,任何api的调用都不会是轻而易举的,必须是有合法的权限和资格才行调用api,所以前端在调用api的时候,必须带上标识token,调用之前对其进行验证,验证通过,在继续自资格验证。大致流程如下:

  1.      用户登录 : 登录认证,认证通过将用户信息id或者其他,生成JWT返回给前端  
  2.     token验证:前端访问API时,首先对其头部token值JWT进行验证,是否与上下文中一致,当然可以存  在redis,是否还在有效期,没问题的话进行权限验证
  3.     授权验证 :  用户token能够满足访问条件,不代表用户有访问资格,比如我token是合法的,但是我比没有这个api的资格。

自定义登录认证的点: 

  1.   自定义登录页面(正常现在的企业研发的都是前后端分离的,所以我这里就不实现页面了,只要能保证api正确返回就ok)
  2.  自定义 AbstractAuthenticationProcessingFilter(JwtLoginFilter):可以自定义拦截的url和方式,也可以在认证之前处理一些需要的逻辑内容,比如加个验证码校验等等。
  3.  自定义 UsernamePasswordAuthenticationToken(LoginJwtToken):这里是用来封装用户信息为一个实体成为token,然后提供给后续认证,这里就自定义实现,暂时还是用方法内容。
  4.  自定义Provider(JwtAuthProvider):这是实现认证的真实执行者,处理认证逻辑。我们可以自定义认证逻辑,根据需求来定,比如:额外需求需要验证用户IP是否有效啊等等。
  5.   自定义 loadUserByUsername:可以实现想要的用户数据读取方式
  6.  自定义实现接口 UserDetails ( LoginUser) : 可以将更多的用户信息存储起来,方便后续工作的使用,比如获取当前登录用户的各种信息。默认只是提供了必要的信息存储:用户名、密码、权限等。当然UserDetails是springsecurity为我们提供的返回模板,不然一个人一个样,默认User。
  7.  认证成功处理器AuthenticationSuccessHandler(JwtAuthenticationSuccessHandler):可以自定义认证通过后返回数据的格式等,我们这里使用JWT返回。
  8.  认证失败处理器AuthenticationFailureHandler(JwtAuthenticationFailureHandler):可以自定义认证失败后处理逻辑,比如是要返回错误信息呢,还是调转登录页等。

自定义实现

       为了方便理解我们就根据上面的逻辑来一步步实现。因为流程也是按照此顺序执行的(我tm真贴心)

1. 自定义 AbstractAuthenticationProcessingFilter  -- >  JwtLoginFilter

   上篇已经说过了AbstractAuthenticationProcessingFilter  的作用,设置请求url和方法、封装token、调用认证接口。自定义实现基本和原代码差不多,多余的需求可以自行扩充,我这就简单操作。

来吧展示:

package com.cmmcinemaserver.security.filter;

import com.cmmcinemaserver.security.entity.LoginJwtToken;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 17:29
 * @Version 1.0
 *
 * 自定义Jwt 认证过滤器,主要用来实现认证前的用户信息处理
 **/
@Component
public class JwtLoginFilter extends AbstractAuthenticationProcessingFilter {

    protected JwtLoginFilter() {
        //该过滤器会拦截哪些路径和类型的请求
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Autowired
    @Override
    public void setAuthenticationManager(AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    /**
     *  认证预处理,主要用来处理将用户名和密码封装成 jwt token 以便后续认证
     *
     * */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        String username = request.getParameter( "username" );
        String password = request.getParameter( "password" );


        /??  这里可以做一些其他操作,比如获取验证码,对验证码进行验证等...../


        // 将用户信息封装成未认证的凭证JwtToken
        LoginJwtToken loginJwtToken = new LoginJwtToken( username,password );
        //将请求的会话id,IP地址保存在token中
        loginJwtToken.setDetails(new WebAuthenticationDetails(request));
        //调用认证管理器对token进行认证
        Authentication authentication=this.getAuthenticationManager().authenticate(loginJwtToken);
        return authentication;
    }
}

2. 自定义 UsernamePasswordAuthenticationToken(LoginJwtToken)

根据之前的源码分析,同样的JwtLoginFilter内部也是做了token的封装,所以我们这边也重新自定义个UsernamePasswordAuthenticationToken,代码就仿照源码就行了。

来吧展示:

package com.cmmcinemaserver.security.entity;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 17:35
 * @Version 1.0
 * 自定义JWTToken 主要用来封装用户名和密码认证时提供token;认证通过之后再构建一个认证过的token,此token会作为上线文数据
 **/
public class LoginJwtToken extends AbstractAuthenticationToken {
    // 核心是认证成功后存储用户信息
    private Object Principal;
    // 认证成功后存储令牌
    private Object Credential;

    /**
     * 构造等待认证的token
     * @param principal
     * @param credential
     */
    public LoginJwtToken(Object principal, Object credential) {
        super(null);
        Principal = principal;
        Credential = credential;
        //设置token状态为未认证
        setAuthenticated(false);
    }

    /**
     * 构造已认证的token
     * @param authorities
     * @param principal
     * @param credential
     */
    public LoginJwtToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credential) {
        // 权限集合设置
        super(authorities);
        // 用户信息
        Principal = principal;
        // 凭证
        Credential = credential;
        //设置token状态为已认证
        setAuthenticated(true);
    }


    @Override
    public Object getCredentials() {
        return this.Credential;
    }

    @Override
    public Object getPrincipal() {
        return this.Principal;
    }
}

 3. 自定义ProviderJwtAuthProvider)

     上面已经对token封装好了并传递给了 authenticate方法,之前源码分析过,authenticate方法是由 AuthenticationManager管理的,默认实现 ProviderManager管理器去选择真实处理的Provider,那好办我们也自定义个Provider,然后加入到ProviderManager管理呗。当然后面配置里面需要加入到Manage的,最后再说。

看下源码所有的Provider都实现与 AuthenticationProvider。那我们实现 AuthenticationProvider接口

 来吧展示:

package com.cmmcinemaserver.security.provider;

import com.cmmcinemaserver.business.system.entity.SysUser;
import com.cmmcinemaserver.security.entity.LoginJwtToken;
import com.cmmcinemaserver.security.entity.LoginUser;
import org.jboss.marshalling.TraceInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticatedPrincipal;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.stereotype.Component;
import springfox.documentation.spi.service.contexts.SecurityContext;

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 17:54
 * @Version 1.0
 *  自定义认证 Provider
 **/
@Component
public class JwtAuthProvider implements AuthenticationProvider {

    @Autowired
    UserDetailsService userDetailsService;

    @Autowired
    PasswordEncoder passwordEncoder;
    /**
     *  认证流程
     * */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 获取未认证的token
        LoginJwtToken jwtToken= (LoginJwtToken) authentication;

        // 调用用户认证功能,目的根据用户名 获取用户信息
        UserDetails details=userDetailsService.loadUserByUsername( (String) jwtToken.getPrincipal() );

        // 认证检测 查看从数据库获取的用户信息和传递过来的是否一致
        additionalAuthenticationChecks(details,jwtToken);

        // 认证检测,其他信息检测
        UserDetailsChecker preAuthenticationChecks = user -> {
            // 获取用户信息 sysUser
            LoginUser loginUser = (LoginUser) user;
            SysUser sysUser = loginUser.getUserInfo();
            // 是否被锁定
            if (!sysUser.getAccountNotLocked()) {
                throw new LockedException("账号被锁定");
            }
            // 是否被注销
            if (!sysUser.getEnabled()) {
                throw new DisabledException("账号已注销");
            }

            if (!sysUser.getNotExpired()) {
                throw new AccountExpiredException("账户已过期");
       }
        };
        preAuthenticationChecks.check( details );
         // 这里不用手动将用户信息存入上线文,问题在UsernameAuthenticationFitler父过滤器已经帮我们处理了AbstractAuthenticationProcessingFilter#successfulAuthentication
        // 认证通过之后,构建认证通过的token,此时用户信息details已经存入到了principal中(之前是username),
        LoginJwtToken token=new LoginJwtToken(details.getAuthorities(),details,jwtToken.getCredentials());
        return token;
    }

    /**
     *  认证检测 查看从数据库获取的用户信息和传递过来的是否一致
     * */
    protected void additionalAuthenticationChecks(UserDetails userDetails,LoginJwtToken authentication)
            throws AuthenticationException {
        if (authentication.getCredentials() == null) {
            System.out.println( "Authentication failed: no credentials provided" );
             throw new BadCredentialsException("密码不能为空");
        }

        String presentedPassword = authentication.getCredentials().toString();
        LoginUser loginUser = (LoginUser) userDetails;
        SysUser sysUser = loginUser.getUserInfo();
        if (!passwordEncoder.matches(presentedPassword,sysUser.getPassword())) {
            System.out.println( "Authentication failed: password does not match stored value" );
            throw new BadCredentialsException("密码不正确");
        }
    }

  // 改成刚才自定义的LoginJwtToken 封装token,所以LoginJwtToken 当成类型,后面进行筛选
  @Override
    public boolean supports(Class <?> authentication) {
        return (LoginJwtToken.class
                .isAssignableFrom(authentication));
    }
}

注意 supports记得换成 LoginJwtToken,因为我们用的自定义实现了AbstractAuthenticationToken接口,通过之前源码ProviderManager是根据supports做类型匹配筛选的。

上面重写的JwtAuthProvider就是具体实现认证的功能,其中发现是需要获取用户信息的。所以下面就是重写获取用户信息。

4. 自定义 loadUserByUsername

我这里使用的是Mybaits-Plus操作的,当然自行选择。

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 17:14
 * @Version 1.0
 **/
@Service
public class UserServiceImpl implements UserDetailsService {
    @Autowired
    SysUserMapper sysUserMapper;

    @Autowired
    SysRoleMapper sysRoleMapper;

    /**
     *  实现自定义用户认证逻辑
     *  我们这边主要是通过用户名称获取用户信息,以及简单的检查。因为我们自定义实现了jwt的认证provider,
     *  所以这里重点把最终用户信息封装返回UserDetails。密码检验交给自定义provider
     * */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        QueryWrapper<SysUser> queryWrapper = new QueryWrapper<>(  );
        queryWrapper.eq( "user_name",username );
        SysUser sysUser = sysUserMapper.selectOne( queryWrapper );
        if (StringUtils.isEmpty( sysUser )) {
            throw new UsernameNotFoundException( "当前用户不存在!" );
        }
        // 设置权限列表到UserDetails中,便于后面的授权
        List<SysRole> roleList = sysRoleMapper.selectRoleList(sysUser.getUserName());
        return new LoginUser(sysUser,getAuthorities(roleList));
    }

    // 将role列表封装成authorities
    private Collection<SimpleGrantedAuthority> getAuthorities(List<SysRole> roleList) {
         List<SimpleGrantedAuthority> authorities = new ArrayList<>();
         for (SysRole role : roleList) {
                SimpleGrantedAuthority auth = new SimpleGrantedAuthority("ROLE_" + role.getRoleName());
                authorities.add(auth);
            }
        return authorities;
    }
}

根据上面功能逻辑,发现获取到用户信息后返回,但返回结果类型为UserDetails,也能理解,SpringSecurity肯定要定一个模板来封装用户信息返回,不然一个人一个类型样,铁定不行。

所以下面自定义实现 UserDetails。

5.  自定义实现接口 UserDetails(LoginUser)

UserDetails是一个接口,SpringSecurity默认实现User ,我们自己搞一个。目的是将获取到的用户信息存起来。当然SpringSecurity给我提供了几个属性暂时不要改,使用就行,不然后面授权阶段得重写了。

package com.cmmcinemaserver.security.entity;

import com.cmmcinemaserver.business.system.entity.SysUser;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 17:15
 * @Version 1.0
 *
 **/
@Data
public class LoginUser implements UserDetails {

    // 用户信息(自定义塞入,格式自己定义,后面只要能解析出来就行)
    private SysUser userInfo;
    // 用户权限(权限集合,SpringSecurity提供的,用于授权阶段进行授权处理的,参数名建议不要改)
    private Collection<? extends GrantedAuthority> authorities;

    public LoginUser(SysUser userInfo,Collection<? extends GrantedAuthority> authorities){
        this.userInfo = userInfo;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    public void setAuthorities(Collection <? extends GrantedAuthority> authorities) {
        this.authorities = authorities;
    }

    @Override
    public String getPassword() {
        return userInfo.getPassword();
    }

    @Override
    public String getUsername() {
        return userInfo.getUserName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

okok,基本实现到这SpringSecurity自定义认证算是完成了,但是我们这次的目的是基JWT的token,所以我们必须要在最后生成JWT返回给用户。

 6. 认证成功处理器AuthenticationSuccessHandler(JwtAuthenticationSuccessHandler)

     可以自定义认证通过后返回数据的格式等,我们这里使用JWT返回,代码简单就不说了。

package com.cmmcinemaserver.handler;

import com.alibaba.fastjson.JSONObject;
import com.cmmcinemaserver.business.system.entity.SysUser;
import com.cmmcinemaserver.security.utils.JwtTokenUtils;
import com.cmmcinemaserver.security.entity.LoginUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 18:50
 * @Version 1.0
 * springsecurity 自定义认证成功handler
 **/
@Component
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
            try {
                Map<String,Object> map = new HashMap <>(  );
                httpServletResponse.setContentType("application/json;charset=utf-8");
                LoginUser user = (LoginUser) authentication.getPrincipal();
                SysUser sysUser = user.getUserInfo();
                System.out.println( sysUser );

                String token = JwtTokenUtils.createToken( sysUser.getUserName(), false );
                map.put( "token",token );
                map.put( "userinfo",sysUser );
                map.put( "code",200 );
                httpServletResponse.getWriter().write(JSONObject.toJSONString(map));

            } catch (Exception e) {
                e.printStackTrace();
            }
        }
}

 7.  认证失败处理器AuthenticationFailureHandler(JwtAuthenticationFailureHandler)

      可以自定义认证失败后处理逻辑,比如是要返回错误信息呢,还是调转登录页等。

package com.cmmcinemaserver.handler;

import com.alibaba.fastjson.JSONObject;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 18:50
 * @Version 1.0
 * springsecurity 自定义认证失败handler
 **/
@Component
public class JwtAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        Map<String,Object> map = new HashMap<>(  );
        httpServletResponse.setContentType("application/json;charset=utf-8");
        String msg = "登录失败";
         if(e instanceof UsernameNotFoundException || e instanceof BadCredentialsException){
             msg = "用户名或密码输入错误";
         }
         else if(e instanceof LockedException){
             msg = "当前用户已锁定";
         }
         else if(e instanceof DisabledException){
             msg="当前用户已被禁用";
         }
         else if(e instanceof AccountExpiredException){
             msg="当前用户已过期";
         }
        map.put( "msg",msg );
        map.put( "code",500 );
        httpServletResponse.getWriter().write( JSONObject.toJSONString(map));
    }
}

这下终于完成了

是吗? 

额.... 

最后一步才是最关键的一步:配置HttpSecurity,目的将我们自定义filter加入到链中,不然执行毛,将自定义的Provider加入管理中,将处理器加入到filter中。这样才能准确的进入到我们自定义流程嘛

展示:

/**
 * @Author: 一只会飞的猪
 * @Date: 2021/9/11 17:22
 * @Version 1.0
 **/
@Configuration
//@EnableWebSecurity
 开启支持注解形式授权
//@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    JwtLoginFilter jwtLoginFilter;

    @Autowired
    JwtAuthProvider jwtAuthProvider;

    @Autowired
    UserServiceImpl userService;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Autowired
    JwtAuthenticationSuccessHandler jwtAuthenticationSuccessHandler;

    @Autowired
    JwtAuthenticationFailureHandler jwtAuthenticationFailureHandler;

    @Autowired
    MyAuthenticationEntryPoint myAuthenticationEntryPoint;

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable();
        http.authorizeRequests().antMatchers( "/**/login" ).permitAll()
                 .antMatchers( "/system/user/list" ).authenticated();

        // 设置用于认证的管理器
        jwtLoginFilter.setAuthenticationManager( this.authenticationManagerBean() );
        // 自定义的认证过滤器加入filter链
        http.addFilterAt( jwtLoginFilter,UsernamePasswordAuthenticationFilter.class );
        // 自定义认证成功Handler加入过滤器
        jwtLoginFilter.setAuthenticationSuccessHandler( jwtAuthenticationSuccessHandler );
        // 自定义认证失败Handler加入过滤器
        jwtLoginFilter.setAuthenticationFailureHandler( jwtAuthenticationFailureHandler );
        // 自定义的Provider 加入认证管理器
        http.authenticationProvider( jwtAuthProvider );
        // 自定义验证失败点 (后续讲解,处理验证用的)
        http.exceptionHandling().authenticationEntryPoint( myAuthenticationEntryPoint );
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定义UserDetailsService
        auth.userDetailsService(userService).passwordEncoder(passwordEncoder);
    }
}

,以上算是全部完成了。

这里补充几个额外的点: 

SpringSecurity 在认证的时候是对密码进行加密的,加密方式提供了很多种,感觉最常用的还是BCryptPasswordEncoder,所以配置里面选用的是它。

发一下JWT工具类的代码:

/**
 *  自定义Jwt工具
 * **/
public class JwtTokenUtils {
    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";

    private static final String SECRET = "jwtsecretdemo";
    private static final String ISS = "echisan";

    // 过期时间是3600秒,既是1个小时
    private static final long EXPIRATION = 3600L;

    // 选择了记住我之后的过期时间为7天
    private static final long EXPIRATION_REMEMBER = 604800L;

    // 创建token
    public static String createToken(String username, boolean isRememberMe) {
        long expiration = isRememberMe ? EXPIRATION_REMEMBER : EXPIRATION;
        return Jwts.builder()
                .signWith( SignatureAlgorithm.HS512, SECRET)
                .setIssuer(ISS)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
                .compact();
    }

    // 从token中获取用户名
    public static String getUsername(String token){
        return getTokenBody(token).getSubject();
    }

    // 是否已过期
    public static boolean isExpiration(String token){
        return getTokenBody(token).getExpiration().before(new Date());
    }

    private static Claims getTokenBody(String token){
        return Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token)
                .getBody();
    }
}

下面附上演示截图:

登录失败:

 登录成功:(token已是JWT)

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Survivor001

你可以相信我,如果你愿意的话

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值