Shiro实现用户认证和授权

1. Shiro认证

1. Shiro认证流程源码分析

主体(subject)需要携带身份信息和凭证信息,shiro在认证时会将这些信息打包成一个令牌,进入到安全管理器中进行认证。
在这里插入图片描述

public class TestAuthenticator {
    public static void main(String[] args) {

        //1.创建安全管理器对象
        DefaultSecurityManager securityManager = new DefaultSecurityManager();

        //2.给安全管理器设置realm
        securityManager.setRealm(new IniRealm("classpath:shiro.ini"));

        //3.SecurityUtils 给全局安全工具类设置安全管理器
        SecurityUtils.setSecurityManager(securityManager);

        //4.关键对象 subject 主体
        Subject subject = SecurityUtils.getSubject();

        //5.创建令牌
        UsernamePasswordToken token = new UsernamePasswordToken("xiaochen","123");

        try{
            System.out.println("认证状态: "+ subject.isAuthenticated());
            subject.login(token);//用户认证
            System.out.println("认证状态: "+ subject.isAuthenticated());
        }catch (UnknownAccountException e){
            e.printStackTrace();
            System.out.println("认证失败: 用户名不存在~");
        }catch (IncorrectCredentialsException e){
            e.printStackTrace();
            System.out.println("认证失败: 密码错误~");
        }
    }
}

源码分析:

在这里插入图片描述

① subject.login(token)实际上底层是由securityManager进行认证的:

public void login(AuthenticationToken token) throws AuthenticationException {
        this.clearRunAsIdentitiesInternal();
        //底层是由securityManager进行认证的,this传入的是安全管理器的实现类,token是用户信息和认证信息
        Subject subject = this.securityManager.login(this, token);
 		//......
    }
}

② DefaultSecurityManager类中在login()方法内部,会调用authticate(token)方法进行认证,认证后返回认证信息:

public Subject login(Subject subject, AuthenticationToken token)throws AuthenticationException{
    	//封装了认证信息
        AuthenticationInfo info;
        try {
            //进项认证,返回认证信息
            info = authenticate(token);
        } catch (AuthenticationException ae) {
            //.....
        }
        return loggedIn;
}

③ AuthenticatingSecurityManager类,即调用的父类的中的authenticate(token)方法:

  public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
      	//调用当前这个类中的anthenticator这个方法中的authenticate(token)方法
        return this.authenticator.authenticate(token);
    }

④ AbstractAuthenticator类,在这个类中调用方法authenticate(token)方法:

		public final AuthenticationInfo authenticate(AuthenticationToken token) throws 	AuthenticationException {
		//认证信息
        AuthenticationInfo info;
        try {
            //执行认证
            info = doAuthenticate(token);
        }
            //.....
            throw ae;
        }

⑤ 判断是否配置realm,然后继续认证:

    protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        //断言,是否配置realm
        assertRealmsConfigured();
        Collection<Realm> realms = getRealms();
        if (realms.size() == 1) {
            //如果只配置了一个realm,进行认证
            return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
        } else {
            return doMultiRealmAuthentication(realms, authenticationToken);
        }
    }

⑥ 调用realm.getAuthenticationInfo(token)

    protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
        AuthenticationInfo info = realm.getAuthenticationInfo(token);
        return info;
    }

⑦ getAuthenticationInfo( token)方法:先从缓存中获取认证信息,先进行用户名的认证

    public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        AuthenticationInfo info = getCachedAuthenticationInfo(token);
        if (info == null) {
            //otherwise not cached, perform the lookup:
            info = doGetAuthenticationInfo(token);
        }
		//.....
        return info;
    }

⑧ 在SimpleAccountRealm类中的doGetAuthenticationInfo()方法:这个方法只完成了用户名的认证

    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //将AuthenticationToken强转为UsernamePasswordToken 
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        //通过用户名获取用户信息
        SimpleAccount account = getUser(upToken.getUsername());
        return account;
    }

⑨ getAuthenticationInfo( token)方法:继续进行用户密码的认证

  public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        AuthenticationInfo info = getCachedAuthenticationInfo(token);
        if (info == null) {
            //如果用户信息为null,就去认证用户名是否正确
            info = doGetAuthenticationInfo(token);
            log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
            if (token != null && info != null) {
                cacheAuthenticationInfoIfPossible(token, info);
            }
        }
        if (info != null) {
            //如果用户信息不为null,就去认证密码是否正确
            assertCredentialsMatch(token, info);
        } 
        return info;
    }

总结:分析到这儿就结束了,就是说如果我们想自定义Realm,就需要重写doGetAuthenticationInfo()方法换成自己去读数据库用户信息即可,密码不需要我们去校验,密码是在用户信息校验之后自己去校验的:

    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //将AuthenticationToken强转为UsernamePasswordToken 
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        //通过用户名获取用户信息
        SimpleAccount account = getUser(upToken.getUsername());
        return account;
    }

真正实现认证的类就是SimpleAccountRealm,看下这个类的源码,可以得知,这个类继承自AuthorizingRealm,因此如果我们想自定义realm也需要继承这个类AuthorizingRealm

public class SimpleAccountRealm extends AuthorizingRealm {
    // .....
    
	//实现认证的方法
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        SimpleAccount account = getUser(upToken.getUsername());

        if (account != null) {
            if (account.isLocked()) {
                throw new LockedAccountException("Account [" + account + "] is locked.");
            }
            if (account.isCredentialsExpired()) {
                String msg = "The credentials for account [" + account + "] are expired";
                throw new ExpiredCredentialsException(msg);
            }
        }
        return account;
    }

    // 实现授权的方法
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = getUsername(principals);
        USERS_LOCK.readLock().lock();
        try {
            return this.users.get(username);
        } finally {
            USERS_LOCK.readLock().unlock();
        }
    }
}

2. 自定义Realm

自定义Realm的目的是将认证/授权的数据源转为数据库:

public class CustomerRealm extends AuthorizingRealm {
    //授权方法
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    //认证方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //从token中获取用户名
        String principal = (String) token.getPrincipal();
        if("xiaochen".equals(principal)){
            //数据库中的身份信息、凭证信息、realm的名字
            return new SimpleAuthenticationInfo(principal,"123",this.getName());
        }
        return null;
    }
}

使用自定义realm进行认证:

public class TestAuthenticatorCusttomerRealm {
    public static void main(String[] args) {
        //创建securityManager
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();
        //IniRealm realm = new IniRealm("classpath:shiro.ini");
        //设置为自定义realm获取认证数据
        defaultSecurityManager.setRealm(new CustomerRealm());
        //将安装工具类中设置默认安全管理器
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        //获取主体对象
        Subject subject = SecurityUtils.getSubject();
        //创建token令牌,交给安全管理器去进行认证(查询数据库信息并比较是否相同)
        UsernamePasswordToken token = new UsernamePasswordToken("xiaochen", "123");
        try {
            subject.login(token);//用户登录
            System.out.println("登录成功~~");
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            System.out.println("用户名错误!!");
        }catch (IncorrectCredentialsException e){
            e.printStackTrace();
            System.out.println("密码错误!!!");
        }
    }
}

3. md5+salt密码加盐认证

实际应用是将盐和散列后的值存在数据库中,自动realm从数据库取出盐和加密后的值由shiro完成密码校验。

public class TestShiroMD5 {
    public static void main(String[] args) {
        //使用md5
        Md5Hash md5Hash = new Md5Hash("123");

        System.out.println(md5Hash.toHex());

        //使用MD5 + salt处理
        Md5Hash md5Hash1 = new Md5Hash("123", "X0*7ps");

        System.out.println(md5Hash1.toHex());

        //使用md5 + salt + hash散列
        Md5Hash md5Hash2 = new Md5Hash("123", "X0*7ps", 1024);
        System.out.println(md5Hash2.toHex());
    }
}

使用密码加盐自定义Realm:

public class CustomerRealm extends AuthorizingRealm {
    //授权方法
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    //认证方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String principal = (String) token.getPrincipal();
        if("xiaochen".equals(principal)){
            //数据库中加密加盐后使用md5算法后的密码
            String password = "3c88b338102c1a343bcb88cd3878758e";
            //盐
            String salt = "Q4F%";
            return new SimpleAuthenticationInfo(principal,password, 
                                                ByteSource.Util.bytes(salt),this.getName());
        }
        return null;
    }
}

使用密码加盐自定义Realm进行认证:

  1. 对用户输入的密码加盐后使用MD5算法加密;
  2. 将数据库中的密码和用户输入的密码进行比较:这两个密码使用的算法必须一致;
  3. 如果数据库中的密码使用的MD5算法,那么shiro就需要对用户输入的密码使用MD5算法加密;
  4. 如果数据库中的密码+盐后使用的MD5算法,那么shiro就需要对用户输入的密码+盐后使用MD5算法加密;
  5. 如果数据库中的密码使用的MD5算法进行了2次散列,shiro就需要对用户输入的密码使用MD5算法进行2次;
public class TestAuthenticatorCusttomerRealm {
    public static void main(String[] args) {
        //创建securityManager
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();

        //设置为自定义realm获取认证数据
        CustomerRealm customerRealm = new CustomerRealm();
        
        //设置md5加密
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        credentialsMatcher.setHashAlgorithmName("MD5");
        //如果注册时用户数据库中的密码散列了1024次,就需要告诉shiro也做相同的处理
        credentialsMatcher.setHashIterations(1024);//设置散列次数
        
        customerRealm.setCredentialsMatcher(credentialsMatcher);
        
        defaultSecurityManager.setRealm(customerRealm);
        
        //将安装工具类中设置默认安全管理器
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        //获取主体对象
        Subject subject = SecurityUtils.getSubject();
        //创建token令牌,realm使用了MD5加密算法,因此会对用户输入的这个密码使用md5算法加密,然后去认证
        UsernamePasswordToken token = new UsernamePasswordToken("xiaochen", "123");
        try {
            subject.login(token);//用户登录
            System.out.println("登录成功~~");
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            System.out.println("用户名错误!!");
        }catch (IncorrectCredentialsException e){
            e.printStackTrace();
            System.out.println("密码错误!!!");
        }
    }
}

2. Shiro授权

授权可简单理解为who对what(which)进行How操作:

Who,即主体(Subject)What,即资源(Resource)How,权限/许可(Permission)

在这里插入图片描述

授权:认证通过后就会进行授权,通过用户名到数据库中查询用户的角色和权限信息然后返回判断

public class CustomerRealm extends AuthorizingRealm {
    //授权方法
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //拿到用户名
        String primaryPrincipal = (String) principals.getPrimaryPrincipal();
        System.out.println("primaryPrincipal = " + primaryPrincipal);
        
        //根据用户名到数据库中获取该用户具有的角色信息和权限信息,获取后返回

        SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
        //到数据库中获取该用户对应的角色信息
        simpleAuthorizationInfo.addRole("admin");

        //到数据库中获取该用户对应的权限信息
        simpleAuthorizationInfo.addStringPermission("user:update:*");
        simpleAuthorizationInfo.addStringPermission("product:*:*");

        //返回权限和角色信息
        return simpleAuthorizationInfo;
    }

    //认证方法
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String principal = (String) token.getPrincipal();
        if("xiaochen".equals(principal)){
            String password = "3c88b338102c1a343bcb88cd3878758e";
            String salt = "Q4F%";
            return new SimpleAuthenticationInfo(principal,password, 
                                                ByteSource.Util.bytes(salt),this.getName());
        }
        return null;
    }
}

权限和角色判断:

public class TestAuthenticatorCusttomerRealm {
    public static void main(String[] args) {
        //创建securityManager
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();

        //设置为自定义realm获取认证数据
        CustomerRealm customerRealm = new CustomerRealm();
        
        //设置md5加密
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        credentialsMatcher.setHashAlgorithmName("MD5");
        credentialsMatcher.setHashIterations(1024);//设置散列次数
        customerRealm.setCredentialsMatcher(credentialsMatcher);
        defaultSecurityManager.setRealm(customerRealm);
        
        //将安装工具类中设置默认安全管理器
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        //获取主体对象
        Subject subject = SecurityUtils.getSubject();
        //创建token令牌
        UsernamePasswordToken token = new UsernamePasswordToken("xiaochen", "123");
        try {
            subject.login(token);//用户登录
            System.out.println("登录成功~~");
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            System.out.println("用户名错误!!");
        }catch (IncorrectCredentialsException e){
            e.printStackTrace();
            System.out.println("密码错误!!!");
        }
        //认证通过
        if(subject.isAuthenticated()){
            //基于角色权限管理
            boolean admin = subject.hasRole("admin");
            System.out.println(admin);

            //是否有某个权限
            boolean permitted = subject.isPermitted("product:create:001");
            System.out.println(permitted);
            

            //基于多角色权限控制
            System.out.println(subject.hasAllRoles(Arrays.asList("admin", "super")));

            //是否具有其中一个角色
            boolean[] booleans = subject.hasRoles(Arrays.asList("admin", "super", "user"));
            for (boolean aBoolean : booleans) {
                System.out.println(aBoolean);
            }
            System.out.println("==============================================");

            //基于权限字符串的访问控制  资源标识符:操作:资源类型
            System.out.println("权限:"+subject.isPermitted("user:update:01"));
            System.out.println("权限:"+subject.isPermitted("product:create:02"));

            //分别具有那些权限
            boolean[] permitted = subject.isPermitted("user:*:01", "order:*:10");
            for (boolean b : permitted) {
                System.out.println(b);
            }

            //同时具有哪些权限
            boolean permittedAll = subject.isPermittedAll("user:*:01", "product:create:01");
            System.out.println(permittedAll);
        }
    }
}

3. SpringBoot整合shiro

3.1 UserService

@Service("userService")
@Transactional
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDAO userDAO;

    @Override
    public List<Perms> findPermsByRoleId(String id) {
        return userDAO.findPermsByRoleId(id);
    }

    @Override
    public User findRolesByUserName(String username) {
        return userDAO.findRolesByUserName(username);
    }

    @Override
    public User findByUserName(String username) {
        return userDAO.findByUserName(username);
    }

    //用户注册时使用的MD5算法对密码使用密文保存
    @Override
    public void register(User user) {
        //处理业务调用dao
        //1.生成随机盐
        String salt = SaltUtils.getSalt(8);
        //2.将随机盐保存到数据
        user.setSalt(salt);
        //3.明文密码进行md5 + salt + hash散列
        Md5Hash md5Hash = new Md5Hash(user.getPassword(),salt,1024);
        user.setPassword(md5Hash.toHex());
        userDAO.save(user);
    }
}

3.2 UserController

@Controller
@RequestMapping("user")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 用户注册
     */
    @RequestMapping("register")
    public String register(User user) {
        try {
            userService.register(user);
            return "redirect:/login.jsp";
        }catch (Exception e){
            e.printStackTrace();
            return "redirect:/register.jsp";
        }
    }

    /**
     * 用来处理身份认证:
     * 将用户输入的用户名和密码封装成UsernamePasswordToken后交给shiro的安全管理器进行认证
     */
    @RequestMapping("login")
    public String login(String username, String password,String code,HttpSession session) {
        //比较验证码
        String codes = (String) session.getAttribute("code");
        try {
            if (codes.equalsIgnoreCase(code)){
                //获取主体对象
                Subject subject = SecurityUtils.getSubject();
                //交给shiro的安全管理器进行认证
                subject.login(new UsernamePasswordToken(username, password));
                return "redirect:/index.jsp";
            }else{
                throw new RuntimeException("验证码错误!");
            }
        } catch (UnknownAccountException e) {
            e.printStackTrace();
            System.out.println("用户名错误!");
        } catch (IncorrectCredentialsException e) {
            e.printStackTrace();
            System.out.println("密码错误!");
        }catch (Exception e){
            e.printStackTrace();
            System.out.println(e.getMessage());
        }
        return "redirect:/login.jsp";
    }

    /**
     * 退出登录
     */
    @RequestMapping("logout")
    public String logout() {
        Subject subject = SecurityUtils.getSubject();
        subject.logout();//退出用户
        return "redirect:/login.jsp";
    }
    
    /**
     * 验证码方法
     */
    @RequestMapping("getImage")
   public void getImage(HttpSession session, HttpServletResponse response) throws IOException {
        //生成验证码
        String code = VerifyCodeUtils.generateVerifyCode(4);
        //验证码放入session
        session.setAttribute("code",code);
        //验证码存入图片
        ServletOutputStream os = response.getOutputStream();
        response.setContentType("image/png");
        VerifyCodeUtils.outputImage(220,60,os,code);
    }
}

3.3 ShiroConfig

/**
 * 用来整合shiro框架相关的配置类
 */
@Configuration
public class ShiroConfig {
    //1.创建shiroFilter:负责拦截所有请求
    @Bean
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager defaultWebSecurityManager){
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();

        //给filter设置安全管理器
        shiroFilterFactoryBean.setSecurityManager(defaultWebSecurityManager);

        Map<String,String> map = new HashMap<String,String>();
        //anon:配置的请求路径放行
        map.put("/user/login","anon");
        map.put("/user/register","anon");
        map.put("/register.jsp","anon");
        map.put("/user/getImage","anon");

        //authc:请求这个资源需要认证和授权
        map.put("/**","authc");

        //默认认证界面路径
        shiroFilterFactoryBean.setLoginUrl("/login.jsp");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(map);

        return shiroFilterFactoryBean;
    }

    //2.创建安全管理器
    @Bean
    public DefaultWebSecurityManager getDefaultWebSecurityManager(Realm realm){
        DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
        //给安全管理器设置realm
        defaultWebSecurityManager.setRealm(realm);
        return defaultWebSecurityManager;
    }

    //3.创建自定义realm
    @Bean
    public Realm getRealm(){
        //需要配置密码的认证方式,不然那就还是用户用户输入的明文,用户输入明文后对这个明文做处理
        //处理的方式需要和用户注册时使用的方式相同
        CustomerRealm customerRealm = new CustomerRealm();

        //修改凭证校验匹配器
        HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher();
        //设置加密算法为md5
        credentialsMatcher.setHashAlgorithmName("MD5");
        //设置散列次数
        credentialsMatcher.setHashIterations(1024);
        customerRealm.setCredentialsMatcher(credentialsMatcher);

        return customerRealm;
    }
}

3.4 CustomerRealm中实现认证和授权

注意:这里先不考虑授权

因为我们注册的时候用户密码使用的MD5+salt的方式,因此在认证的时候我们就需要指定用户密码的认证方式,

//自定义realm
public class CustomerRealm extends AuthorizingRealm {
    /**
     *	用户认证
    */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        //从token解析出用户输入的身份信息
        String principal = (String) token.getPrincipal();
        //在工厂中获取service对象
        UserService userService = (UserService) ApplicationContextUtils.getBean("userService");
        //到数据库中查询用户user
        User user = userService.findByUserName(principal);
        //如果user不为null,就返回从数据库中查询出的用户名和密码以及盐等信息
        if(!ObjectUtils.isEmpty(user)){
            return new SimpleAuthenticationInfo(user.getUsername(),user.getPassword(),
                    new MyByteSource(user.getSalt()),
                    this.getName());
        }
        return null;
    }
    
    /**
     *	用户授权
    */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //获取身份信息
        String primaryPrincipal = (String) principals.getPrimaryPrincipal();
        UserService userService = (UserService) ApplicationContextUtils.getBean("userService");
        User user = userService.findRolesByUserName(primaryPrincipal);
        //授权角色信息
        if(!CollectionUtils.isEmpty(user.getRoles())){
            SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
            //根据user到数据库中查询用户的角色信息
            //遍历每个角色,获取每个角色对应的权限信息
            user.getRoles().forEach(role->{
                //将角色信息添加到simpleAuthorizationInfo中
                simpleAuthorizationInfo.addRole(role.getName());
                //根据roleId获取用户的每个角色对应的权限信息
                List<Perms> perms = userService.findPermsByRoleId(role.getId());
                //遍历权限列表
                if(!CollectionUtils.isEmpty(perms)){
                    perms.forEach(perm->{
                        //将权每个权限添加到simpleAuthorizationInfo中
                        simpleAuthorizationInfo.addStringPermission(perm.getName());
                    });
                }
            });
            //返回用户的角色和权限信息
            return simpleAuthorizationInfo;
        }
        return null;
    }
}

3.5 OrderController

@Controller
@RequestMapping("order")
public class OrderController {
    @RequiresRoles(value={"admin","user"})//用来判断角色  同时具有 admin user
    @RequiresPermissions("user:update:01") //用来判断权限字符串
    @RequestMapping("save")
    public String save(){
        System.out.println("进入方法");
        return "redirect:/index.jsp";
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

我一直在流浪

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值