MyBatis分页插件实例

在上一篇文章中我们学习了 MyBatis 插件实现原理,这次我们根据所学的知识自己动手来实现一个简单的分页插件。

1、确定需要拦截的签名

MyBatis 运行拦截四大对象中的任意一个对象,通过 Plugin 的源码,我们也看到了需要先注册签名才能使用插件,所以首先要确定需要拦截的对象,才能进一步确定需要配置什么样的签名,进而完成拦截的方法逻辑。

1.1、确定需要拦截的对象

先来看一下四大对象的功能

  • Executor 是执行 SQL 的全过程,包括组装参数、执行 SQL 过程和组装结果集返回,都可以拦截,较为广泛,一般用的不算太多。根据是否启动缓存参数,决定它是否使用 CachingExecutor 进行封装,这是拦截该对象时我们需要注意的地方。
  • StatementHandler 是执行 SQL 的过程,我们可以重写执行 SQL 的过程,它是最常用的拦截对象
  • ParameterHandler 主要拦截执行 SQL 的参数组装,我们可以重写组装参数的规则
  • ResultSetHandler 用于拦截执行结果的组装,我们可以重写组装结果的规则

我们的分页插件要拦截的是 StatementHandler 对象,在预编译 SQL 之前修改 SQL,使得结果返回数量被限制。

1.2、拦截方法和参数

确定需要拦截的对象后,接下需要确定拦截的方法以及方法的参数,这些都是在理解了 MyBatis 四大对象运作的基础上才能确定的。

查询的过程是通过 Executor 调度 StatementHandler 来完成的。调度 StatementHandlerprepare 方法预编译 SQL,所以我们要拦截的方法便是 prepare 方法,在此方法调用之前完成对 SQL 的改写。先看看 StatementHandler 接口的定义,代码如下:

public interface StatementHandler {

	//预编译SQL
  Statement prepare(Connection connection, Integer transactionTimeout)
      throws SQLException;
	
	//调用 ParameterHandler 组装参数
  void parameterize(Statement statement)
      throws SQLException;

  void batch(Statement statement)
      throws SQLException;
	
  int update(Statement statement)
      throws SQLException;
      
	//执行SQL查询,并使用 resultHandler 对结果集进行处理
  <E> List<E> query(Statement statement, ResultHandler resultHandler)
      throws SQLException;

  <E> Cursor<E> queryCursor(Statement statement)
      throws SQLException;

  BoundSql getBoundSql();

  ParameterHandler getParameterHandler();

}

以上的任何方法都可以被拦截,一些主要的方法我都加上了注释,通过上面的接口定义代码,可以确定 prepare 方法有一个参数 Connection 对象,所以按照以下的代码来设计我们的拦截器。


@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class MyPlugin implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        return null;
    }
}

其中,@Intercepters 说明该类是一个拦截器。@Signature 是注册拦截器签名的地方,只有签名满足条件才能拦截。type 可以是四大对象中的一个,我们这里拦截的是 StatementHandler 。method 代表要拦截该对象中的方法名,而 args 则表示该方法的参数,这里我们根据 StatementHandler 中的 Statement prepare(Connection connection, Integer transactionTimeout) 方法进行设置。

2、实现拦截方法

上面对原理进行了分析,下面给出一个最简单的插件实现方法,如下:

package com.vnbear.mybatis;

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;

import java.sql.Connection;
import java.util.Properties;

import lombok.extern.slf4j.Slf4j;

/**
 * @Auther: VNBear
 * @Date: 2020/12/17 14:29
 * @Description:
 */
@Slf4j
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class MyPlugin implements Interceptor {

    private Properties properties;

    /**
     * 插件的核心方法,它将替代 statementHandler 的 prepare 方法
     *
     * @param invocation
     * @return 返回预编译后的 PreparedStatement
     * @throws Throwable
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        //获取代理的对象
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();

        //使用 MetaObject 工具绑定 StatementHandler,便于后续修改StatementHandler 的属性值
        MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);

        //分离代理对象链(由于目标类可能被多个插件拦截,形成多次代理,通过循环分离出原始对象)
        Object object = null;
        //如果是代理对象java.lang.reflect.Proxy,其中存在一个属性名为 h 的InvocationHandler类型的属性
        while (metaStatementHandler.hasGetter("h")) {
            object = metaStatementHandler.getValue("h");
            metaStatementHandler = SystemMetaObject.forObject(object);
        }
        //获取原始sql
        String sql = (String) metaStatementHandler.getValue("delegate.boundSql.sql");
        //获取sql的参数
        Object parameterObject = metaStatementHandler.getValue("delegate.boundSql.parameterObject");
        log.info("执行的SQL为:{}", sql);
        log.info("参数为:{}", parameterObject);
        log.info("before =======>>");

        //如果当前代理的是一个非代理对象,那么回调的是真实对象的方法
        //如果不是,那么回调的是下一个插件代理对象的invoke方法
        Object proceed = invocation.proceed();

        log.info("after <<=======");

        return proceed;
    }

    /**
     * 生成代理对象
     * @param target
     * @return
     */
    @Override
    public Object plugin(Object target) {
        //采用Mybatis默认提供的 Plugin.wrap() 方法生成代理对象
        return Plugin.wrap(target, this);
    }

    /**
     * 设置参数,mybatis 初始化的时候就会生成插件实例,然后回调该方法
     * @param properties
     */
    @Override
    public void setProperties(Properties properties) {
        this.properties = properties;
    }
}

这个插件首选会分离代理对象,然后使用 MetaObject 获取了执行的 SQL 和参数,在反射调度被代理对象的真实方法前后打印了日志,这意味着我们可以在 Object proceed = invocation.proceed() 这一行代码前后加入我们自己的处理逻辑,从而满足需求。

3、配置和运行

如果是使用 SpringBoot 可以使用如下方式进行插件的配置:

package com.vnbear.mybatis;

import org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

/**
 * @Auther: VNBear
 * @Date: 2020/12/17 15:08
 * @Description:
 */

@Configuration
public class PluginConfig {

    /**
     * 方法1
     * @return
     */
    @Bean
    public MyPlugin myPlugin() {
        MyPlugin myPlugin = new MyPlugin();
        Properties properties = new Properties();
        properties.setProperty("pluginKey", "helloWorld");
        myPlugin.setProperties(properties);
        return myPlugin;
    }

    /**
     * 方法2
     * @return
     */
    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return new ConfigurationCustomizer() {
            @Override
            public void customize(org.apache.ibatis.session.Configuration configuration) {
                //插件拦截链采用了责任链模式,执行顺序和加入连接链的顺序有关
                MyPlugin myPlugin = new MyPlugin();
                Properties properties = new Properties();
                properties.setProperty("pluginKey", "helloWorld");
                myPlugin.setProperties(properties);
                configuration.addInterceptor(myPlugin);
            }
        };
    }
}

如果使用 xml 配置文件的方式,参考如下配置:


<plugins> 
	<plugin interceptor= ” com.vnbear.mybatis.MyPlugin ” >
		<property name= ”pluginKey” value= ”helloWorld” />
	</plugin> 
</plugins>

4、分页插件实例

在Mybatis 中存在一个 RowBounds 参数用于分页,但是它的分页原理是基于第一次查询结果的再分页,也就是先让 SQL 查出所有的记录,然后分页,这是典型的物理分页,性能不高。当然也可以通过两条 SQl,一条用于当前页查询,一条用于查询记录总数,但是如果每一个查询都这样,过于繁琐,增加了工作量。而查询 SQL 往往存在一定规律,查询的 SQL 通过加入分页参数,可以查询当前页,查询的 SQL 也可以通过改造变为统计总数的 SQL。

通过上面的学习,我们已经做好自己动手实现分页插件的准备,下面开始自己动手造一个轮子。

4.1、分页参数

定义一个POJO,用于设置分页的各种参数,代码如下:

package com.vnbear.mybatis;

import lombok.Data;

/**
 * @Auther: VNBear
 * @Date: 2020/12/17 15:41
 * @Description:
 */
@Data
public class PageParams {

    /**
     * 当前页码
     */
    private Integer page;

    /**
     * 每页限制条数
     */
    private Integer pageSize;

    /**
     * 是否启动插件
     */
    private Boolean useFlag;

    /**
     * 是否检测页码的有效性,如果true,当页码大于最大页数时,抛出异常
     */
    private Boolean checkFlag;

    /**
     * 是否清除 order by 后面的语句
     */
    private Boolean cleanOrderBy;

    /**
     * 总条数,插件回填该值
     */
    private Integer total;

    /**
     * 总页数,插件回填该值
     */
    private Integer totalPage;
}

POJO 中的各参数都已经加上了注释,值得注意的是 total 和 totalPage 这两个属性需要插件进行回填,这样使用者使用该分页 POJO 传递分页信息时,最后也可以通过该分页 POJO 获得记录的总条数和分页总数。

在 MyBatis 中传递参数可以是单个参数,也可以是多个,或者使用 map。有了这些规则,为了使用方便,定义只要满足下列条件之一,就可以启用分页参数(PageParams)。

  • 传递单个 PageParmas 或者其子对象。
  • map 中存在一个值为 PageParmas 或者其子对象的参数。
  • 在 MyBatis 中传递多个参数,但其中之一为 PageParams 或者其子对象。
  • 传递单个 POJO 参数,该 POJO 有一个属性为 PageParams 或者其子对象,且提供了 setter 和 getter方法。

4.2、确定拦截对象和方法

我们可以通过拦截 StatementHandlerprepare 方法,进而从 BoundSql 中获取 SQL 以及参数规则的描述,从而获取我们的分页插件参数。

@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})

4.3、实现拦截方法

  • Plugin方法:我们使用 Plugin 类的静态方法 wrap 生成代理对象。当 PageParmas 的 useFlag 属性为 false 时,也就是禁用此分页插件是,就无需生成代理对象了,因为使用代理会造成性能下降。
  • setProperties方法:用它给 PageParams 属性定义默认值。
  • intercept方法:该方法是我们的核心

根据以上思路,给出如下的实现代码:

package com.vnbear.mybatis.plugin;

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.scripting.defaults.DefaultParameterHandler;
import org.apache.ibatis.session.Configuration;

import java.beans.IntrospectionException;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import lombok.Data;

/**
 * @Auther: VNBear
 * @Date: 2020/12/17 16:08
 * @Description:
 */
@Data
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class PagePlugin implements Interceptor {

    /**
     * 默认页码
     */
    private Integer defaultPage;
    /**
     * 默认每页数量
     */
    private Integer defaultPageSize;
    /**
     * 默认是否启用插件
     */
    private Boolean defaultUseFlag;
    /**
     * 默认是否检测页码参数
     */
    private Boolean defaultCheckFlag;
    /**
     * 默认是否清除最后一个 order by后的语句
     */
    private Boolean defaultCleanOrderBy;

    private static final String KEY_SQL = "delegate.boundSql.sql";
    private static final String KEY_BOUND_SQL = "delegate.boundSql";

    private static final String KEY_MAPPED_STATEMENT = "delegate.mappedStatement";


    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        StatementHandler statementHandler = (StatementHandler) getUnProxyObject(invocation.getTarget());

        MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);

        String sql = (String) metaStatementHandler.getValue(KEY_SQL);
        MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue(KEY_MAPPED_STATEMENT);

        //如果不是 select 语句,直接执行被代理对象的方法
        if (!checkSql(sql)) {
            return invocation.proceed();
        }

        BoundSql boundSql = (BoundSql) metaStatementHandler.getValue(KEY_BOUND_SQL);
        Object parameterObject = boundSql.getParameterObject();
        //获取分页参数
        PageParams pageParams = getPageParamsFromParamObj(parameterObject);
        //无法获取分页参数,不进行分页
        if (pageParams == null) {
            return invocation.proceed();
        }

        //判断是否启用分页功能
        Boolean useFlag = pageParams.getUseFlag() == null ? this.defaultUseFlag : pageParams.getUseFlag();
        if (!useFlag) {
            //不进行分页
            return invocation.proceed();
        }

        //获取相关配置的参数
        Integer pageNum = pageParams.getPage() == null ? defaultPage : pageParams.getPage();
        Integer pageSize = pageParams.getPageSize() == null ? defaultPageSize : pageParams.getPageSize();
        Boolean checkFlag = pageParams.getCheckFlag() == null ? defaultCheckFlag : pageParams.getCheckFlag();
        Boolean cleanOrderBy = pageParams.getCleanOrderBy() == null ? defaultCleanOrderBy : pageParams.getCleanOrderBy();

        //计算总条数
        int total = getTotal(invocation, metaStatementHandler, boundSql, cleanOrderBy);

        //回填总条数到分页参数
        pageParams.setTotal(total);

        //计算总页数
        int totalPage = total % pageSize == 0 ? total / pageSize : total / pageSize + 1;

        //回填总页数到分页参数
        pageParams.setTotalPage(totalPage);

        //检查当前页码的有效性
        checkPage(checkFlag, pageNum, totalPage);

        //修改sql
        return preparedSQL(invocation, metaStatementHandler, boundSql, pageNum, pageSize);
    }

    @Override
    public Object plugin(Object target) {
        //生成代理对象
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties props) {
        //从配置中获取参数
        String strDefaultPage = props.getProperty("default.page", "1");
        String strDefaultPageSize = props.getProperty("default.pageSize", "20");
        String strDefaultUseFlag = props.getProperty("default.useFlag", "false");
        String strDefaultCheckFlag = props.getProperty("default.checkFlag", "false");
        String StringDefaultCleanOrderBy = props.getProperty("default.cleanOrderBy", "false");

        //设置默认参数.
        this.defaultPage = Integer.parseInt(strDefaultPage);
        this.defaultPageSize = Integer.parseInt(strDefaultPageSize);
        this.defaultUseFlag = Boolean.parseBoolean(strDefaultUseFlag);
        this.defaultCheckFlag = Boolean.parseBoolean(strDefaultCheckFlag);
        this.defaultCleanOrderBy = Boolean.parseBoolean(StringDefaultCleanOrderBy);
    }

    /**
     * 从代理对象中分离出真实对象
     *
     * @param target
     * @return 非代理的 StatementHandler对象
     */
    private Object getUnProxyObject(Object target) {

        MetaObject metaStatementHandler = SystemMetaObject.forObject(target);

        //分离代理对象链(由于目标类可能被多个插件拦截,形成多次代理,通过循环分离出原始对象)
        Object object = null;
        //如果是代理对象java.lang.reflect.Proxy,其中存在一个属性名为 h 的InvocationHandler类型的属性
        while (metaStatementHandler.hasGetter("h")) {
            object = metaStatementHandler.getValue("h");
            metaStatementHandler = SystemMetaObject.forObject(object);
        }
        if (object == null) {
            return target;
        }
        return object;
    }

    /**
     * 判断是否是 select 语句
     *
     * @param sql
     * @return
     */
    private boolean checkSql(String sql) {
        String trimSql = sql.trim();
        int index = trimSql.toLowerCase().indexOf("select");
        return index == 0;
    }

    /**
     * 从sql参数中获取分页参数
     *
     * @param parameterObject
     * @return
     */
    private PageParams getPageParamsFromParamObj(Object parameterObject) throws IntrospectionException, InvocationTargetException, IllegalAccessException {
        PageParams pageParams = null;
        if (parameterObject == null) {
            return null;
        }
        //处理map参数,多个匿名参数和@param 注解参数都是map
        if (parameterObject instanceof Map) {
            Map<String, Object> paramMap = (Map<String, Object>) parameterObject;
            Set<String> keySet = paramMap.keySet();
            Iterator<String> iterator = keySet.iterator();
            while (iterator.hasNext()) {
                String key = iterator.next();
                Object value = paramMap.get(key);
                if (value instanceof PageParams) {
                    return (PageParams) value;
                }
            }
        } else if (parameterObject instanceof PageParams) {
            //参数是PageParams 或者是继承了 PageParams
            return (PageParams) parameterObject;
        } else {
            //从POJO属性尝试读取分页参数
            Field[] fields = parameterObject.getClass().getDeclaredFields();
            for (Field field : fields) {
                if (field.getType() == PageParams.class) {
                    PropertyDescriptor pd = new PropertyDescriptor(field.getName(), parameterObject.getClass());
                    Method readMethod = pd.getReadMethod();
                    //反射调用
                    return (PageParams) readMethod.invoke(parameterObject);
                }
            }
        }
        return pageParams;
    }

    /**
     * 获取总条数
     *
     * @param ivt
     * @param metaStatementHandler
     * @param boundSql
     * @param cleanOrderBy
     * @return sql 查询总数
     * @throws Throwable
     */
    private int getTotal(Invocation ivt, MetaObject metaStatementHandler,
                         BoundSql boundSql, Boolean cleanOrderBy) throws Throwable {
        //获取当前的mappedStatement
        MappedStatement mappedStatement = (MappedStatement) metaStatementHandler.getValue(KEY_MAPPED_STATEMENT);

        //获取配置对象
        Configuration configuration = mappedStatement.getConfiguration();

        //当前需要执行的SQL
        String sql = (String) metaStatementHandler.getValue(KEY_SQL);

        //去除最后的order by语句
        if (cleanOrderBy) {
            sql = this.cleanOrderByForSql(sql);
        }

        //改为统计总数的SQL
        String countSql = "select count(*)  as total from (" + sql + ") vnbear_paging";

        //获取拦截方法参数,根据插件签名,知道是Connection对象
        Connection connection = (Connection) ivt.getArgs()[0];
        PreparedStatement ps = null;
        int total = 0;

        try {
            //预编译总计总数的SQL
            ps = connection.prepareStatement(countSql);
            //构建统计总数的BoundSQL
            BoundSql countBoundSql = new BoundSql(configuration, countSql, boundSql.getParameterMappings(), boundSql.getParameterObject());
            //构建MyBatis的 ParameterHandler 用来设置总数 SQL 的参数
            DefaultParameterHandler handler = new DefaultParameterHandler(mappedStatement, countBoundSql.getParameterObject(), countBoundSql);
            //设置总数SQL参数
            handler.setParameters(ps);
            //执行查询
            ResultSet resultSet = ps.executeQuery();
            while (resultSet.next()) {
                total = resultSet.getInt("total");
            }
        } finally {
            //这里不能关闭connection,否则后面的sql无法执行
            if (ps != null) {
                ps.close();
            }
        }
        return total;

    }

    private String cleanOrderByForSql(String sql) {
        StringBuilder stringBuilder = new StringBuilder(sql);
        String newSql = sql.toLowerCase();
        int index = newSql.lastIndexOf("order");

        //没有order 语句,直接返回
        if (index == -1) {
            return sql;
        }
        return stringBuilder.substring(0, index).toString();
    }

    /**
     * 检测当前页码的有效性
     *
     * @param checkFlag
     * @param pageNum
     * @param pageTotal
     */
    private void checkPage(Boolean checkFlag, Integer pageNum, Integer pageTotal) {
        if (checkFlag) {
            //检查页码是否合法
            if (pageNum > pageTotal) {
                throw new IllegalArgumentException("查询失败,查询页码 " + pageNum + " 大于总页数 " + pageTotal);
            }
        }
    }

    /**
     * 预编译改写后的SQL,并设置分页参数
     *
     * @param invocation
     * @param metaStatementHandler
     * @param boundSql
     * @param pageNum
     * @param pageSize
     * @return
     */
    private Object preparedSQL(Invocation invocation, MetaObject metaStatementHandler, BoundSql boundSql, int pageNum, int pageSize) throws Exception {
        //当前需要执行的SQL
        String sql = boundSql.getSql();
        //分页SQL
        String newSql = "select * from (" + sql + ") vnbear_paging_table limit ?,?";
        //修改当前需要执行的sql
        metaStatementHandler.setValue(KEY_SQL, newSql);
        //执行编译,调度被代理对象的真实方法,相当于 StatementHandler 执行了 prepare 方法
        PreparedStatement ps = (PreparedStatement) invocation.proceed();

        //prepared 方法编译 SQL后 ,由于 MyBatis 上下文没有分页参数的信息,所以这里需要设置这两个参数
        //获取需要设置的参数个数,由于参数是最后的两个,所以很容易得到其位
        int idx = ps.getParameterMetaData().getParameterCount();
        //最后两个是分页参数,注意这里的parameterIndex是从1开始算得
        ps.setInt(idx - 1, (pageNum - 1) * pageSize);   //开始行
        ps.setInt(idx, pageSize); //限制条数
        return ps;
    }
}

逻辑已经在代码里注释的很清楚了,小伙伴们可以自己慢慢看,有问题可以留言。

4.3、配置启用分页插件

package com.vnbear.mybatis.plugin;



import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Properties;

/**
 * @Auther: VNBear
 * @Date: 2020/12/17 17:31
 * @Description:
 */
@Configuration
public class PagePluginConfig {

    @Bean
    public PagePlugin pagePlugin() {
        PagePlugin pagePlugin = new PagePlugin();
        Properties properties = new Properties();
        //默认页码
        properties.setProperty("default.page", "1");
        //默认每页条数
        properties.setProperty("default.pageSize", "20");
        //是否启动插件功能
        properties.setProperty("default.useFlag", "false");
        //是否检查页码有效性
        properties.setProperty("default.checkFlag", "false");
        //是否去掉最后一个 order by 以后的语句,以便提高查询效率
        properties.setProperty("default.cleanOrderBy", "false");
        pagePlugin.setProperties(properties);
        return pagePlugin;
    }

}

总结

通过以上的内容,我们分析了分页插件的实现原理以及实现插件时所需要处理的步骤,并进行了分页插件的实际代码编写。但是插件是 MyBatis 中最强大的组件,同时也是最危险的组件,必须清楚 MyBatis 内部的运行原理,才能避免编写插件时出错,否则可能摧毁整个 MyBatis 的功能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值