Security 原理与总结

认证与授权

认证:系统提供的用于识别用户身份的功能,通常提供用户名和密码进行登录其实就是在进行认证,认证的目的是让系统知道你是谁。(可用于动态菜单显示)

授权:用户认证成功后,需要为用户授权,其实就是指定当前用户可以操作哪些功能。(用户接口调用授权)

一、RBAC权限模式

业界通常基于RBAC实现授权

RBAC(Role-Based Access Control):基于角色的权限访问控制。它的核心在于用户只和角色关联,而角色代表了权限,是一系列权限的集合。RBAC的核心元素包括:用户、角色、权限。

二、Spring Security

一、介绍

Spring Security 的前身是 Acegi Security ,是 Spring 项目组中用来提供安全认证服务的框架。
Spring Security是一个功能强大且高度可定制的安全框架,专为基于Spring的企业应用系统提供声明式的安全访问控制解决方案。使用SrpingSecurity可以帮助我们简化认证和授权的过程。

二、原理

Spring Security的原理主要基于过滤器链。当一个请求到达Spring应用时,它首先会经过一些列的过滤器,这些过滤器负责身份验证、授权以及其他安全相关的任务。

  • CsrfFilter:用于处理跨站请求,防止跨站请求伪造共计,是导致POST请求失败的原因。
  • AuthorizaionFilter:负责授权模块。
  • UsernamePasswordAuthenticationFilter用于处理基于表单的请求登录。

这些Filter构成了Spring Security的核心功能,通过它们,可以实现身份验证、授权。防护等。

三、使用

1.依赖配置


    <!-- Spring Security -->  
    <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>  
  

2.配置类

可以设置密码加密密码加密方式,自定义异常处理器(401 未认证;403 无权限),如使用BCrypt加密方式替换底层加密方式,设置自定义的的安全认证,关闭CSRF防止请求被拦截等。

Security自带BCrypt加密工具类BCryptPasswordEncoder,每次加密使用的salt盐都不同,因此得到的加密后密码哈希值都不通过,但是可以使用BCryptPasswordEncoder中的matches()进行密码比较。

package com.example.boot.security;

import com.example.boot.security.sms.SmsCodeAuthenticationProvider;
import com.example.interceptor.JwtAuthenticationTokenFilter;
import com.example.service.Impl.SpringSecurityDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * 权限认证配置类
 *
 * @author ding
 * @since 2024/7/23
 */
@Configuration
@EnableWebSecurity
//开启注解权限认证功能    jsr250Enabled = true   securedEnabled = true
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //放行资源  通过split进行分割
    public static final String STATIC_PATH = "";

    //账号密码登录认证过滤器
    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private SpringSecurityDetailsService securityDetailsService;

    // 认证失败处理器
    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    //授权失败处理器
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

    //密码处理器
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }


    /**
     * 认证配置
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            //关闭CSRF(跨服务器的请求访问),默认是开启的,设置为禁用;如果使用自定义登录页面需要关闭此项,否则登录操作会被禁用403
                .csrf().disable()
                // 禁用session (前后端分离项目,不通过Session获取SecurityContext)
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                //请求认证配置
                .authorizeRequests()
                .antMatchers("/login", "/findUserName", "/socket/**",
                        "/getPermCode", "/swagger-ui.html",  "/webjars/**", "/swagger-resources/**", "/v2/**",
                        "/swagger-ui.html", "/doc.html", "/files/**",
                        "/register", "/sendSms/**", "/validSms").permitAll()
                .anyRequest().authenticated();

        //添加token过滤器
//        http.addFilterBefore( smsCodeCheckFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore( jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        // 配置异常处理器
        http.exceptionHandling()
                //认证失败
                .authenticationEntryPoint(authenticationEntryPoint)
                //授权失败
                .accessDeniedHandler(accessDeniedHandler);

        // 退出登录处理器 清除redis 中token GET请求
//        http.logout().logoutUrl("/admin/logout");

        //Spring Security 允许跨域
        http.cors();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 加入自定义的安全认证  如果自定义了多套身份验证系统  需要在这添加重写的 AuthenticationProvider
        auth.userDetailsService(this.securityDetailsService)
                .passwordEncoder(this.passwordEncoder())
                .and()
                //添加自定义的认证管理类
                .authenticationProvider(smsAuthenticationProvider())
                .authenticationProvider(authenticationProvider());
    }

    //注入AuthenticationManager 进行用户认证
    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    //账号密码
    @Bean
    public AuthenticationProvider authenticationProvider(){
        AuthenticationProvider authenticationProvider =  new UsernameAuthenticationProvider();
        return authenticationProvider;
    }
    
    //手机验证码
    @Bean
    public AuthenticationProvider smsAuthenticationProvider(){
        AuthenticationProvider authenticationProvider =  new SmsCodeAuthenticationProvider();
        return authenticationProvider;
    }
}

3.用户登录接口

账号密码登录方式

    public Admin login(Admin admin) {

        UsernameAuthenticationToken authenticationToken = new UsernameAuthenticationToken(admin.getUsername(), admin.getPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if (ObjectUtil.isNull(authenticate)) {
            throw new CustomException(ResultCodeEnum.USER_ACCOUNT_ERROR);
        }

        //认证通过 生成token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        Integer userId = loginUser.getAdmin().getId();
        Map<String, Object> claims = new HashMap<>();
        claims.put(JwtClaimsConstant.ADMIN_ID, userId);
        claims.put(JwtClaimsConstant.USERNAME, loginUser.getUsername());
        String token = JwtUtil.createJWT(
                jwtProperties.getAdminSecretKey(),
                jwtProperties.getAdminTtl(),
                claims
        );

        Admin dbAdmin = loginUser.getAdmin();
        dbAdmin.setToken(token);

        //认证通过 存入redis
        redisTemplate.opsForValue().set(KeyEnum.TOKEN + "_" + userId, JSONUtil.toJsonStr(loginUser.getAdmin()));
        redisTemplate.expire(KeyEnum.TOKEN + "_" + userId, 4, TimeUnit.HOURS);
        return dbAdmin;
    }

 4.自定义Dao层方法loadUserByUsername

可以从数据库中爬取对应数据

package com.example.service.Impl;

import cn.hutool.core.util.ObjectUtil;
import com.example.mapper.AdminMapper;
import com.example.pojo.entity.*;
import com.example.pojo.utils.LoginUser;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

/**
 * TODO
 *
 * @author ding
 * @since 2024/7/23
 */
@Service
public class SpringSecurityDetailsService implements UserDetailsService {
    @Autowired
    private AdminMapper adminMapper;

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

        MPJLambdaWrapper<Admin> mpjLambdaWrapper = new MPJLambdaWrapper<>();
        mpjLambdaWrapper
                .selectAll(Admin.class)
                .selectCollection(Role.class, Admin::getRoles,set -> set.collection(Permission.class, Role::getPermissions))
                .innerJoin(UserAndRole.class, UserAndRole::getUserId, Admin::getId)
                .innerJoin(Role.class, Role::getId, UserAndRole::getRoleId)
                .innerJoin(RoleAndPermission.class, RoleAndPermission::getRoleId, Role::getId)
                .innerJoin(Permission.class, Permission::getId, RoleAndPermission::getPermissionId)
                .eq(StringUtils.hasLength(username), Admin::getUsername, username);
        Admin admin = adminMapper.selectJoinOne(Admin.class, mpjLambdaWrapper);
        if (ObjectUtil.isNull(admin)) {
            throw new RuntimeException("当前用户不存在");
        }

        //封装LoginUser对象 包含账户名 密码 是否可用
        LoginUser loginUser = new LoginUser(admin);

        //将Authentication对象(用户信息,已认证转态、权限信息) 存入 SecurityContextConfig
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        return loginUser;
    }
}

定义后会替换到底层中的方法,在DaoAuthenticationProvider中,如果我们自定义该方法,就会跳转到该方法。

如果使用自定义的认证方法,我们需要再自定义的 短信登录身份认证组件 中写入该方法

package com.example.boot.security.sms;

/**
 * TODO
 *
 * @author ding
 * @since 2024/7/24
 */

import com.example.mapper.AdminMapper;
import com.example.pojo.entity.*;
import com.example.pojo.utils.LoginUser;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;


/**
 * @description: 短信登录身份认证组件
 **/
@Slf4j
@Component
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private AdminMapper adminMapper;


    private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String mobile = (String) authentication.getPrincipal();
        //根据手机号加载用户
        UserDetails user = loadUserByPhone(mobile);

        Object principalToReturn = user;
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

    @Override
    public boolean supports(Class<?> aClass) {
        //如果是SmsCodeAuthenticationToken该类型,则在该处理器做登录校验
        return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);
    }


    /**
     * 跟账号登录保持一致
     * @param principal
     * @param authentication
     * @param user
     * @return
     */
    protected Authentication createSuccessAuthentication(Object principal,
                                                         Authentication authentication, UserDetails user) {
        // Ensure we return the original credentials the user supplied,
        // so subsequent attempts are successful even with encoded passwords.
        // Also ensure we return the original getDetails(), so that future
        // authentication events after cache expiry contain the details
        UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(
                principal, authentication.getCredentials(),
                authoritiesMapper.mapAuthorities(user.getAuthorities()));
        result.setDetails(authentication.getDetails());

        return result;
    }

    /**
     * 获取用户信息
     * @param phone
     * @return
     * @throws UsernameNotFoundException
     */
    public UserDetails loadUserByPhone(String phone) throws UsernameNotFoundException {
        MPJLambdaWrapper<Admin> mpjLambdaWrapper = new MPJLambdaWrapper<>();
        mpjLambdaWrapper
                .selectAll(Admin.class)
                .selectCollection(Role.class, Admin::getRoles, set -> set.collection(Permission.class, Role::getPermissions))
                .innerJoin(UserAndRole.class, UserAndRole::getUserId, Admin::getId)
                .innerJoin(Role.class, Role::getId, UserAndRole::getRoleId)
                .innerJoin(RoleAndPermission.class, RoleAndPermission::getRoleId, Role::getId)
                .innerJoin(Permission.class, Permission::getId, RoleAndPermission::getPermissionId)
                .eq(StringUtils.hasLength(phone), Admin::getPhone, phone);
        Admin admin = adminMapper.selectJoinOne(Admin.class, mpjLambdaWrapper);
        if (admin == null) {
            throw new UsernameNotFoundException("该手机号不存在");
        }

        //封装LoginUser对象 包含账户名 密码 是否可用
        LoginUser loginUser = new LoginUser(admin);

        //将Authentication对象(用户信息,已认证转态、权限信息) 存入 SecurityContextConfig
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        return loginUser;
    }
}

5.LoginUser实体类

用户封装UserDetail类,UserDetailsService 加载好用户认证信息后会封装认证信息到一个 UserDetails 的实现类。跟方便将相关数据写入redis中,也更容易从redis中取出数据。

package com.example.pojo.utils;

import cn.hutool.core.util.ObjectUtil;
import com.example.pojo.entity.Admin;
import com.example.pojo.entity.Permission;
import com.example.pojo.entity.Role;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.*;

/**
 * TODO
 *
 * @author ding
 * @since 2024/7/23
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    //用户信息
    private Admin admin;

    public LoginUser(Admin admin) {
        this.admin = admin;
    }

    private List<SimpleGrantedAuthority> authorities;

    /**
     * 获取权限信息
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (!ObjectUtil.isEmpty(authorities)){
            return authorities;
        }

        //将权限信息封装成SimpleGrantedAuthority对象
        authorities = new ArrayList<>();

        Set<Role> roles = admin.getRoles();
        roles.forEach(role -> {
            //授权角色信息
            authorities.add(new SimpleGrantedAuthority(role.getRoleValue()));
            Set<Permission> permissions = role.getPermissions();
            permissions.forEach(permission -> {
                //授权权限信息
                authorities.add(new SimpleGrantedAuthority(permission.getKeyword()));
            });
        });

        return authorities;
    }

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

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

    /**
     * 判断是否过期
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 是否锁定
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 是否没有超时
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return admin.getStatus() == 1;
    }
}

6.SecurityHandler类

编写授权失败和认证失败的处理器。如果该类没有发挥功能建议看一下自己定义的全局异常处理器是否将异常取出并拦截。

package com.example.boot.security;

import cn.hutool.json.JSONUtil;
import com.example.common.Result;
import com.example.utils.WebUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

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

/**
 * 授权失败和认证失败的处理器
 *
 * @author ding
 * @since 2024/7/23
 */
@Component
public class SecurityHandler {

    //授权失败
    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        return (request, response, accessDeniedException) -> {
            Result result = Result.error("403","您的权限不足");
            String json = JSONUtil.toJsonStr(result);
            //将字符串渲染到客户端
            WebUtils.renderString403(response, json);
        };
    }

    //认证失败
    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return (request, response, authException) -> {
            Result result = Result.error("401", "认证失败,请重新登录");
            String json = JSONUtil.toJsonStr(result);
            WebUtils.renderString401(response, json);
        };
    }

}

7.创建JWT认证过滤器

该方法会检查HTTP中的URL路径,验证JWT令牌,并在验证成功后将用户信息放入Spring Security的上下文中。若为登录方法,则直接放心。

package com.example.interceptor;

import cn.hutool.json.JSONUtil;
import com.example.common.enums.KeyEnum;
import com.example.common.enums.ResultCodeEnum;
import com.example.common.exception.CustomException;
import com.example.constant.JwtClaimsConstant;
import com.example.context.BaseContext;
import com.example.pojo.entity.Admin;
import com.example.pojo.utils.LoginUser;
import com.example.properties.JwtProperties;
import com.example.utils.IpUtils;
import com.example.utils.JwtUtil;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;

/**
 * jwt令牌校验的拦截器
 *
 * @author ding
 * @since 2024/7/23
 */
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtProperties jwtProperties;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request.getRequestURI().equals("/login") || request.getRequestURI().equals("/validSms")){
            //调用登录方法
            String ip = IpUtils.getIp();
            //UV中新增相关数据
            redisTemplate.opsForHyperLogLog().add(KeyEnum.UV, ip);
            //当前在线人数中新增数据
            redisTemplate.opsForSet().add(KeyEnum.ONLINE, ip);

            filterChain.doFilter(request, response);
            return;
        }

        if (request.getRequestURI().equals("/logOut")){
            //如果调用退出方法,说明当前用户退出了系统
            String ip = IpUtils.getIp();
            //将该ip数据从redis中删除
            redisTemplate.opsForSet().remove(KeyEnum.ONLINE, ip);
        }

        //从请求头中获取token
        String token = request.getHeader("token");
        //没有token
        if (!StringUtils.hasLength(token)){
            //放行,因为后面的会抛出响应的异常 401 403
            filterChain.doFilter(request, response);
            return;
        }

        //有token
        Integer userId;
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
            Long adminId = Long.valueOf(claims.get(JwtClaimsConstant.ADMIN_ID).toString());
            //在线程中设置当前登录的ID
            BaseContext.setCurrentId(adminId);

            userId = Math.toIntExact(adminId);
        }catch (Exception e){
            e.printStackTrace();
            throw new CustomException(ResultCodeEnum.TOKEN_INVALID_ERROR);
        }

        //从redis中获取用户信息
        String redisKey = KeyEnum.TOKEN + "_" + userId;
        Object obj = redisTemplate.opsForValue().get(redisKey);
        if (Objects.isNull(obj)){
            throw new CustomException(ResultCodeEnum.TOKEN_CHECK_ERROR);
        }

        Admin admin = JSONUtil.toBean(obj.toString(), Admin.class);
        LoginUser loginUser = new LoginUser(admin);
        //将Authentication对象(用户信息、已认证状态、权限信息)存入SecurityContextHolder
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        //放行
        filterChain.doFilter(request, response);
    }
}

8.使用

在配置类上加注解@EnableGlobalMethodSecurity(prePostEnabled = true),即可开启注解验证是否授权的功能。

/**
 * 权限认证配置类
 *
 * @author ding
 * @since 2024/7/23
 */
@Configuration
@EnableWebSecurity
//开启注解权限认证功能    jsr250Enabled = true   securedEnabled = true
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
       ...
}

根据当前登录用户绑定的角色是否拥有 PROJECT_QUERY 权限,判断当前登录用户是否具有权限。 


    @ApiOperation("通过项目Id获取项目信息")
    @GetMapping("/selectById/{id}")
    @PreAuthorize("hasAuthority('PROJECT_QUERY')")
    public Result selectById(@ApiParam("项目id") @PathVariable Integer id){
        Project project = projectService.selectById(id);
        return Result.success(project);
    }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值