java(springboot) mybatis 数据权限详细实现(图文)


来吧,整起,又一新功能,通用数据权限,注意是通用,通用的东西,反正挺烦的。
我还是第一次搞这玩意儿,因为之前做细节的数据权限都是直接写在代码里面的

好,开整,这篇文章我会写得详细一点,并且提供开源源码,全靠我自己设计,编码,一步步的敲出来的,很少的地方借鉴到了别人的东西,切看切珍惜,动动你的小手点个赞,点个收藏吧。


一、啥子是数据权限?

嗯,数据权限?有些朋友可能会问了,“嗯,数据还有权限?”
没错,简单来讲:数据权限无非就是某人只能看到某些数据。
举个例子:张三登录了A系统,那么根据系统查询出来的张三所拥有的权限,比如张三有一个A部门的数据权限,
那么,在A系统中,张三只能看到A部门相关的数据。

二、做这个功能的思路是啷个一个样子的?

那好了,啷个才能实现这个功能呢?
别慌,我们先回忆一下我们在不做通用的情况下是怎么做数据权限的呢?

1.没有做通用数据权限

比如张三有A部门的权限:

String deptId = servie.getDeptByUser("张三");
//在xml里
select * from test_table
where dept_id = #{deptId}

如上面的代码所示,我们一般是通过直接写sql带条件的方式实现的,写起来非常的方便,但是代码多了就求了,万一有1W个mapper都需要这样做,那写到吐,好吧,给你搞个通用的。

2.实现的原理和思路

好,上面已经说到了痛点,那么我们只需要拦截住我们需要的sql,能根据获取到的用户数据权限,动态的拼接出我们想要的sql,再给他装回去,那这个问题就能解决了。
ok,按照这个步骤:用户登录→登录验证通过后获取到用户的所有权限信息→放入到redis中→做数据查询时拦截对应的sql→详细封装处理→执行新sql返回权限后数据


二、开整!

1.新建一个项目并添加依赖和工具类包

在这里插入图片描述
在这里插入图片描述

初始化

选择spring初始化,下一步下一步就可以了,名字随便取,先不用选依赖。
建完了在详细下面找到pom.xml,添加如下依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.4.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.kbplus.demo.data</groupId>
    <artifactId>permission</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>permission</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>
    
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

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

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

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.12</version>
        </dependency>

        <!-- 数据库-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.24</version>
        </dependency>

        <!-- mybatis-plus-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.0</version>
        </dependency>
        <!--mybaits-plus生成代码的依赖 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.4.0</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.9</version>
        </dependency>

        <!--      工具包  -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.16</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.8</version>
            <scope>provided</scope>
        </dependency>

        <!--   swagger     -->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>

        <!--fastjson-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.68</version>
        </dependency>

        <!--   redis     -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

        <!-- Token生成与解析-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

建库建表

添加数据库,并导入提供好的sql
在这里插入图片描述
名字随便取,字符集和排序规则选择我选择的就ok,比较通用的utf规则和排序规则,
简单介绍一下,utf8mb4是mysql的一种拓展字符集,可以存储一些特殊字符,utf8mb4_general_ci是兼容大多主流语言并同时比较高效的排序规则,如果你的项目有使用到少见的语言,比如俄语,可以使用utf8mb4_unicode_ci来提高精准性
在这里插入图片描述
导入完sql后,大概是这么一个结构。

添加公共配置,工具类

接着添加项目所需类包(源码里会提供),
大致结构如下:
在这里插入图片描述

2.书写简单的登录实现登录验证并保存用户信息

核心代码如下:

    /**
     * 登录验证
     *
     * @param username 用户名
     * @param password 密码
     * @return 结果
     */
    @Override
    public String login(String username, String password)
    {
        User user1 = baseMapper.selectOne(new QueryWrapper<User>().eq("username", username));
        if(user1==null){
            throw new CustomException("用户不存在");
        }
        if(!password.equals(user1.getPassword())){
            throw new CustomException("密码不正确");
        }
        //这里使用mybaitis特性collection之类的好像会更快,各位有兴趣可以尝试

        Set<Role> allRoleByUserId = roleMapper.getAllRoleByUserId(user1.getId());
        UserDept userDept = userDeptMapper.selectOne(new QueryWrapper<UserDept>().eq("user_id", user1.getId()));
        Set<String> allPositionByUserId = positionMapper.getAllPositionByUserId(user1.getId());
        List<String> deptChildren = departmentService.getDeptChildren(userDept.getDeptId());
        Set<String> allDataPermissionByUserId = dataPermissionMapper.getAllDataPermissionByUserId(user1.getId());

        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user1);
        loginUser.setTenantId(user1.getTenantId());
        loginUser.setRoles(allRoleByUserId);
        loginUser.setUserId(user1.getId());
        loginUser.setDeptId(userDept.getDeptId());
        loginUser.setPostIds(allPositionByUserId);
        loginUser.setDeptChildren(deptChildren);
        loginUser.setDataPermissions(allDataPermissionByUserId);
        String token = UUID.randomUUID().toString();
        loginUser.setToken(token);
        //存入redis
        tokenService.setLoginUser(loginUser);
        // 生成token
        return tokenService.createToken(loginUser);
    }

在创建token的时候有这一步,以便可以通过token直接拿到想要的信息,不用去redis再查。

    /**
     * 创建令牌
     * 
     * @param loginUser 用户信息
     * @return 令牌
     */
    public String createToken(LoginUser loginUser)
    {
        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_USER_KEY, loginUser.getToken());
        claims.put("tenantId",loginUser.getTenantId());
        claims.put("id",loginUser.getUserId());
        claims.put("name",loginUser.getUser().getName());
        claims.put("postIds",loginUser.getPostIds());
        claims.put("organizationId",loginUser.getDeptId());

        return createToken(claims);
    }

3.添加基本数据并测试效果

1.添加用户数据,角色数据,权限数据等并关联:
大家可以使用sql一键导入,详情大家可以参考我的权限认证文章:待完善

我们使用user1账号登录后创建2条测试数据,再用user2登录创建2条测试数据,步骤如下:
2.使用user1模拟登录:
在这里插入图片描述
3.拿到返回的token去生成测试数据:
在这里插入图片描述
在这里插入图片描述
使用user2登录做同样的操作,让后可以看到表里有6条数据,null值的是我之前添加的不用在意~~,主要可以看到有4条是部门1的,2条是部门2的 我设置的权限分别是user1是部门1,user2是部门2,且部门2是部门1的子部门
在这里插入图片描述
4.测试效果:
使用user1成功返回6条数据,因为我现在给他的数据权限是拥有部门及子部门,所有能查到所有
在这里插入图片描述

使用user2登录,user2和user1拥有一样的角色,即拥有一样的权限,请求接口,发现只返回了2条数据,且都是dept2的,因为部门2是部门1的子集
在这里插入图片描述
目前系统支持以下类型的数据权限

    ALL("1","拥有所有数据权限"),
    NONE("2","未拥有数据权限"),
    DEPT("3","拥有部门权限"),
    DEPT_CHILDREN("4","拥有部门权限及子权限"),
    POST("5","拥有职位权限"),
//    POST_CHILDREN("6","拥有职位权限及子权限"),
    OWN("7","拥有自身权限"),

大家可以根据自己的需要进行测试

4.核心类解析

新建拦截机继承 InnerInterceptor (mybatis-plus的一个拦截器)

public class DataPermissionInterceptor implements InnerInterceptor 

把拦截器注入进配置,这里注意添加拦截器的编写顺序,会影响到拦截器执行的先后顺序

package com.kbplus.demo.data.permission.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

    @Bean
    public DataPermissionInterceptor dataPermissionInterceptor() {
        return new DataPermissionInterceptor();
    }
    
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
        paginationInnerInterceptor.setDbType(DbType.MYSQL);
        interceptor.addInnerInterceptor(dataPermissionInterceptor());
        interceptor.addInnerInterceptor(paginationInnerInterceptor);
        return interceptor;
    }
}

核心拦截类:大致原理是先做有效性判断,包括是否属于管理员等,这里只拦截需要分页的查询,然后根据权限匹配,生成相对应的代码

package com.kbplus.demo.data.permission.config;

import cn.hutool.core.collection.CollectionUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.kbplus.demo.data.permission.entity.LoginUser;
import com.kbplus.demo.data.permission.entity.Role;
import com.kbplus.demo.data.permission.mapper.CommonMapper;
import com.kbplus.demo.data.permission.utils.SpringUtils;
import com.kbplus.demo.data.permission.utils.TokenService;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.select.*;
import net.sf.jsqlparser.util.TablesNamesFinder;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Autowired;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Set;

/**
 * @author kbplus
 * @version v1.0
 * @date 2022-05-13 17:49:18
 */
public class DataPermissionInterceptor implements InnerInterceptor {

    @Autowired
    private TokenService tokenService;

    @Autowired
    private HttpServletRequest httpServletRequest;

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql){
        String firstSql = boundSql.getSql();
        Field field = null;
        try {

            Select statement = (Select) CCJSqlParserUtil.parse(boundSql.getSql());
            if(!ifPage(parameter)){
                return;
            }

            LoginUser loginUser = tokenService.getLoginUser(httpServletRequest);
            if(loginUser==null){
                return;
            }

            Set<String> dataPermissions = loginUser.getDataPermissions();
            Set<Role> roles = loginUser.getRoles();
            for (Role role : roles) {
                if("admin".equals(role.getCode())){
                    return;
                }
            }


            List<String> mainTables = getMainTable(statement);
            field = boundSql.getClass().getDeclaredField("sql");
            field.setAccessible(true);

            if(dataPermissions==null || dataPermissions.contains(DataPermissionEnum.NONE.getCode())){
                String newSql = addWhereCondition(boundSql.getSql(), "1=2",DataPermissionEnum.NONE);
                //通过反射修改sql语句
                field.set(boundSql, newSql);
                System.out.println(newSql);
                return;
            }

            //获取公共mapper取字段是否存在
            CommonMapper commonMapper = SpringUtils.getBean(CommonMapper.class);

            String tenantTable = getFirstTableOnField("tenant_id", mainTables,commonMapper);
            if(tenantTable!=null) {
                //初始默认匹配租户id查询
                String tenantSql = addWhereCondition(boundSql.getSql(), tenantTable + ".tenant_id=" + loginUser.getTenantId(), DataPermissionEnum.NONE);
                //通过反射修改sql语句
                field.set(boundSql, tenantSql);
                System.out.println(tenantSql);
            }

            String organizationTable=null;
            if(dataPermissions.contains(DataPermissionEnum.DEPT.getCode())){

                organizationTable = getFirstTableOnField("create_user_organization_id", mainTables,commonMapper);
                if(organizationTable!=null) {
                    String newSql = addWhereCondition(boundSql.getSql(), organizationTable + ".create_user_organization_id=" + loginUser.getDeptId(), DataPermissionEnum.DEPT);
                    //通过反射修改sql语句
                    field.set(boundSql, newSql);
                    System.out.println(newSql);
                }
            }

            if(dataPermissions.contains(DataPermissionEnum.DEPT_CHILDREN.getCode())){

                if(organizationTable==null){
                    organizationTable = getFirstTableOnField("create_user_organization_id", mainTables,commonMapper);
                }
                if(organizationTable!=null) {

                    String newSql = addWhereCondition(boundSql.getSql(), getCondition(organizationTable, "create_user_organization_id", loginUser.getDeptChildren()), DataPermissionEnum.DEPT_CHILDREN);
                    //通过反射修改sql语句
                    field.set(boundSql, newSql);
                    System.out.println(newSql);
                }
            }

            if(dataPermissions.contains(DataPermissionEnum.POST.getCode())){

                String postTable = getFirstTableOnField("tenant_id", mainTables,commonMapper);
                if(postTable!=null) {

                    String sql = boundSql.getSql();
                    if (sql.contains("where") || sql.contains("WHERE")) {
                        sql = sql + " and ";
                    } else {
                        sql = sql + " where ";
                    }
                    String newSql = sql + getPositionCondition(postTable, "create_user_post_id", loginUser.getPostIds());
                    //通过反射修改sql语句
                    field.set(boundSql, newSql);
                    System.out.println(newSql);
                }
            }
            if(dataPermissions.contains(DataPermissionEnum.OWN.getCode())){

                String userTable = getFirstTableOnField("create_user", mainTables,commonMapper);
                if(userTable!=null) {
                    String newSql = addWhereCondition(boundSql.getSql(), userTable + ".create_user=" + loginUser.getUserId(), DataPermissionEnum.OWN);
                    //通过反射修改sql语句
                    field.set(boundSql, newSql);
                    System.out.println(newSql);
                }
            }
        } catch (JSQLParserException | NoSuchFieldException | IllegalAccessException e) {
            if(StringUtils.isNotEmpty(firstSql)&& ObjectUtils.isNotEmpty(field)){
                try {
                    field.set(boundSql, firstSql);
                } catch (IllegalAccessException illegalAccessException) {
                    illegalAccessException.printStackTrace();
                }
            }
            e.printStackTrace();
        }

    }

    /**
     * 获取拥有字段的指定第一张表
     *
     * @param field
     * @return
     */
    private String getFirstTableOnField(String field,List<String> tables,CommonMapper commonMapper) {
        if(CollectionUtil.isEmpty(tables))
            return null;
        for (String table : tables) {
            if(commonMapper.getFieldExists(table,field)>0)
                return table;
        }

        return null;
    }

    /**
     * 获取查询字段
     *
     * @param selectBody
     * @return
     */
    private List<SelectItem> getSelectItems(SelectBody selectBody) {
        if (selectBody instanceof PlainSelect) {
            return ((PlainSelect) selectBody).getSelectItems();
        }
        return null;
    }

    /**
     * 特殊处理 创建职业sql
     *
     * @param tableName 表名
     * @param fieldName 字段名
     * @param ids       值
     * @return
     */
    private String getPositionCondition(String tableName, String fieldName, Collection<String> ids) {

        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("(");
        for (String id : ids) {
            stringBuilder.append(tableName).append(".").append(fieldName).append(" like '%").append(id).append("%' or ");
        }
        stringBuilder.delete(stringBuilder.length()-3,stringBuilder.length()).append(")");

        return stringBuilder.toString();
    }

    /**
     * 生成where 条件字符串
     *
     * @param tableName 表名
     * @param fieldName 字段名
     * @param ids       值
     * @return
     */
    private String getCondition(String tableName, String fieldName, Collection<String> ids) {
        return tableName + "." + fieldName + " in (" + StringUtils.join(ids, ",") + ")";
    }

    /**
     * 获取tables的表名
     *
     * @param statement
     * @return
     */
    private List<String> getMainTable(Select statement) {
            TablesNamesFinder tablesNamesFinder = new TablesNamesFinder();
            return tablesNamesFinder.getTableList(statement);

    }

    /**
     * 判断是否分页
     *
     * @param selectBody
     * @return
     */
    private Limit ifPage(SelectBody selectBody) {
        if (selectBody instanceof PlainSelect) {
            return ((PlainSelect) selectBody).getLimit();
        }
        return null;
    }

    /**
     * 判断是否分页
     *
     * @param parameter
     * @return
     */
    private  boolean ifPage(Object parameter) {
        if(parameter instanceof String) return false;
        JSONObject jsonObject = JSON.parseObject(JSON.toJSONString(parameter));
        return jsonObject.containsKey("page") ||
                jsonObject.containsKey("size") ||
                jsonObject.containsKey("current");
    }



    /**
     * 在原有的sql中增加新的where条件
     *
     * @param sql       原sql
     * @param condition 新的and条件
     * @return 新的sql
     */
    private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum, List<String> detps,Set<String> posts) {
        try {
            Select select = (Select) CCJSqlParserUtil.parse(sql);
            PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
            final Expression expression = plainSelect.getWhere();
            final Expression envCondition = CCJSqlParserUtil.parseCondExpression(condition);
            if (Objects.isNull(expression)) {
                plainSelect.setWhere(envCondition);
            } else {
                if(DataPermissionEnum.NONE==dataPermissionEnum){
                    AndExpression andExpression = new AndExpression(expression, envCondition);
                    plainSelect.setWhere(andExpression);
                }else {
                    OrExpression orExpression = new OrExpression(expression, envCondition);
                    plainSelect.setWhere(orExpression);
                }

            }
            return plainSelect.toString();
        } catch (JSQLParserException e) {
            throw new RuntimeException(e);
        }
    }

    private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum) {
        return addWhereCondition(sql,condition,dataPermissionEnum,null,null);
    }

    private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum, List<String> detps) {
        return addWhereCondition(sql,condition,dataPermissionEnum,detps,null);
    }

    private String addWhereCondition(String sql, String condition, DataPermissionEnum dataPermissionEnum, Set<String> posts) {
        return addWhereCondition(sql,condition,dataPermissionEnum,null,posts);
    }

}

5.开源源码

这里给大家提供开源源码:java-springboot-mybatis-数据权限详细实现
编写不易,点个赞再走!

  • 47
    点赞
  • 91
    收藏
    觉得还不错? 一键收藏
  • 8
    评论
在Spring Boot中使用MyBatis实现数据权限控制的方法可以通过自定义拦截器来实现。首先,你可以创建一个自定义拦截器,比如命名为PowerInterceptor。这个拦截器需要实现Interceptor接口,并且使用@Intercepts注解来指定拦截的方法和参数。在这个拦截器中,你可以通过重写intercept方法来实现对SQL语句的处理。 在intercept方法中,你可以获取到方法的调用信息,并且可以对SQL进行修改或者添加额外的条件。比如,你可以在该方法中获取到StatementHandler对象,并通过调用getBoundSql方法来获取到SQL语句。然后,你可以根据自定义的数据权限规则来修改SQL语句,添加相应的条件。最后,通过调用invocation.proceed()方法来继续执行下一个拦截器或者目标方法。 为了将自定义拦截器应用到MyBatis中,你需要在拦截器中实现Plugin接口,并将其配置为一个Bean。在plugin方法中,你可以判断被代理对象是否是StatementHandler类型的,如果是的话,就使用Plugin.wrap方法对其进行包装,并将自定义拦截器传入。最后,你需要将该拦截器配置到MyBatis的配置件中或者使用@MapperScan注解进行扫描。 这样,当MyBatis执行SQL语句时,自定义拦截器将会被触发,并对SQL进行相应的处理,从而实现数据权限控制。通过这种方式,你不需要在每个SQL语句中添加权限判断条件,避免了代码冗余和维护困难的问题。 希望以上信息对你有帮助!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值