车金融|金融产品规则引擎的前世今生(中篇)

在车金融业务场景中,金融方案以资方为大维度。资方准入规则,进一步对进件的客户信息、车辆信息、贷款意向进行了场景条件控制,因此,资金方准入规则对于金融产品来讲,也是必不可少了,它构成了金融产品规则引擎的一部分。

秋夜无霜

车金融前世今生专辑连载系列,关注微信公众号【秋夜无霜】

车金融|金融产品中心的前世今生

车金融|GPS审核系统的前世今生

车金融|基础数据平台的前世今生

车金融|合同中心系统的前世今生

车金融|金融产品规则引擎的前世今生(上篇)

车金融|金融产品规则引擎的前世今生(中篇)

车金融|金融产品规则引擎的前世今生(下篇)

车金融|我在M公司的那两年

温馨提示:全文共计2500余字,乃上班地铁途中码字编写,素材代码则是周末抽出时间整理,实属不易,感谢关注。起草于2020年11月26日,终稿于2020年11月29日。

一、导读

上一篇,我们从金融产品规则引擎讲述了规则引擎的前后置规则发展史,这一篇我们将重点讲述资方准入规则的发展历程以及技术方案设计。

二、“黑暗”时期

最初金融产品内部并没有资方以及准入规则的,恰当是,车金融各个业务系统为了实现对于资方的业务逻辑控制,所采取的的方案是通过判断金融方案名称中是否包含某资方字样。常规来讲,倘若金融方案名称命名严格按照某种规范,还能说过去。但是一旦出现命名不按照常规出牌,那业务系统肯定出bug,事实上讲,最初也曾经出现因为名称导致的故障,这也反应最初的设计不合理性而最终导致悲剧发生。

此刻的你,是否曾经遇到这种设计挣扎,为了一时的简单设计或者考虑欠佳,从此留下了诟病,这种诟病进而在需求迭代或者人员更迭中,不经意间catch到一个bug。这也告诫我们,作为程序员,需要敬畏手中的代码编写权利,多思考,少写bug,相信有一天你会感慨,多亏曾经的自己志明抉择。

三、发展时期

2018年下半年,车金融业务线APP进行了第一次重构,这次对于金融产品系统来讲,产品提出了资金方以及资方准入规则的需求设计。相信它来的为时不晚,为拯救车金融“黎民百姓”带来了曙光。

此时,金融产品中心已完成了一次重构,拆分出金融产品平台(car-product)和金融产品服务(car-heil),这也为APP重构奠定基础,促进了业务流程对于审核效率以及改善交互体验的提升。

需求设计上,对于金融产品系统来讲,增加了资金方管理资方准入规则配置,然而,对于主导金融产品技术设计的我,为设计良好的技术方案下了一番心思。

四、需求分析

资方准入规则,是从资方纬度进行配置的,可以配置若干条规则。每条规则又包含若干项,譬如对于主贷人,包含年龄、户籍、薪资等;对于车辆来讲,车类,如果是二手车,包括车龄、里程;对于贷款意向,包括首付比,贷款金额,贷款总额,实际销售价等。

资方准入规则

对于APP上来讲,资方准入规则需要把每一项命中规则的文案列举出来,比如,主贷人信息:年龄不符合(20~60)岁。当我分析需求时,在技术方案设计时,我分析梳理考虑的有以下几点:

  • 每一项属于不同的大类,譬如主贷人,车辆,贷款信息,肯定返回的项必须携带具体哪个分类,以更友好精确地提示APP用户,这是比较重要的。
  • 每一项通过进一步分类,可以分类两种类型区间类和范围类,比如区间类,首付比(30%~100%);比如范围类,车类(LCV、乘用车)。
  • 这两种类型进一步提取,可以分类数值类和字符类。
  • 这些规则条件倘若按照属性-属性值的关系维护到一张子表中,那么对于主贷人信息中的户籍可不就是这么简单,它的存储跟这些并不是同类,这意味着需要再划分一个子表。
  • 这些规则分类项,我维护是通过Java枚举维护,但是数据库存储的是枚举索引,对于APP接口返回肯定是枚举值。
  • 对于准入规则输入项,输入参数传值有的并非枚举索引,而是索引值。

准入规则配置

五、核心设计

通过梳理分析,在代码实现上,我觉得可以引入设计模式,更好的完成代码设计,同时又能提高代码扩展性,支撑后续的需求场景变更。

我把主贷人信息(ProposerAccessHandler),车辆信息(CarInfoAccessHandler),贷款意向信息(LoanAccessHandler)划分为三个子类处理各自规则配置项,这三个子类完成各自规则子项的检验,子类的关系通过一个责任链串起来。同时,把规则条件进一步进行了抽象(AbstractStrategy),并通过一个枚举类实现字段的映射配置MapperConfig,具体详见如下核心代码。

总体核心交互时序图

1.准入规则处理器(AbstractAccessHandler)

准入规则配置

如上三个处理器。

AbstractAccessHandler

/**
 * @description: 准入处理器
 * @Date : 2018/7/7 下午7:45
 * @Author : 石冬冬-Seig Heil
 */
public abstract class AbstractAccessHandler {

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());
    /**
     * 处理器名称
     */
    protected String handlerName;
    /**
     * 上下文
     */
    protected FundAccessContext context;
    /**
     * 下一个节点
     */
    protected AbstractAccessHandler next;

    public AbstractAccessHandler(String handlerName, AbstractAccessHandler next) {
        this.handlerName = handlerName;
        this.next = next;
    }

    /**
     * 调用方法
     * @param context
     */
    public final void execute(FundAccessContext context){
        this.prepare(context);
        this.call();
        this.after();
    }

    /**
     * 处理请求
     */
    public abstract void call();

    /**
     * 初始化
     * @param context
     */
    public final void prepare(FundAccessContext context){
        this.context = context;
    }

    /**
     * 后置处理
     */
    public final void after(){
        if(null != next){
            next.execute(this.context);
        }
    }

    /**
     * 是否有下一个节点
     * @return
     */
    public boolean hasNext() {
        return null != next;
    }

    /**
     * 准入处理
     */
    protected final void access(MapperConfig.MapperEnumInterface[] values){
        try {
            FundAccessDTO accessDTO = context.getAccessDTO();
            Map<String,Object> beanMap = BeanMapper.objectToMap(accessDTO);
            //主贷人其他字段准入校验
            for(MapperConfig.MapperEnumInterface accessEnum : values){
                final boolean hasSkipCondition = null != accessEnum.skipCondition() && !"".equals(accessEnum.skipCondition());
                if(hasSkipCondition) {
                    AviatorContext ctx = AviatorContext.builder().expression(accessEnum.skipCondition()).env(beanMap).build();
                    if (AviatorExecutor.executeBoolean(ctx)){
                        continue;
                    }
                }
                String fileName = accessEnum.filed();
                boolean emptyValue = null == beanMap.get(fileName) || "".equals(beanMap.get(fileName));
                if(accessEnum.filter() || emptyValue){
                    continue;
                }
                StrategyContext strategyContext = new StrategyContext(context);
                AbstractStrategy strategy = SimpleStrategyFactory.create(strategyContext,accessEnum);
                strategy.access();
                if(!strategyContext.isAccess()){
                    context.getMessages().add(strategyContext.getTips());
                }
            }
            context.setAccess(context.getMessages().size()==0);
        } catch (Exception e) {
            logger.error("{}处理异常",handlerName,e);
        }
    }
    protected final String formatMessage2Collection(String message, Collection<String> collection){
        return MessageFormat.format(message,collection.stream().map(Object::toString).collect(Collectors.joining(",")));
    }
}

CarInfoAccessHandler

/**
 * @description: 车辆信息准入Handler
 * @Date : 2018/7/8 上午10:39
 * @Author : 石冬冬-Seig Heil
 */
public class CarInfoAccessHandler extends AbstractAccessHandler {

    public CarInfoAccessHandler(String handlerName, AbstractAccessHandler next) {
        super(handlerName, next);
    }

    @Override
    public void call() {
        try {
            super.access(MapperConfig.CarAccessEnum.values());
        } catch (Exception e) {
            logger.error("{}处理异常",handlerName,e);
        }
    }
}

2.准入规则责任链(AbstractAccessExecutor)

AbstractAccessExecutor

/**
 * @description:
 * @Date : 2018/7/7 下午7:16
 * @Author : 石冬冬-Seig Heil(dondongshi5@creditease.cn)
 */
public abstract class AbstractAccessExecutor implements ChainExecutor{
    /**
     * 上下文
     */
    protected FundAccessContext context;
    /**
     * 处理器集合
     */
    protected List<AbstractAccessHandler> handlerList;

    /**
     * 构造器
     * @param context
     */
    public AbstractAccessExecutor(FundAccessContext context) {
        this.context = context;
    }

    /**
     * 初始化
     */
    protected void prepare(){
        buildChain();
    }

    @Override
    public void execute() {
        this.prepare();
        handlerList.forEach(current -> {
            if(current.hasNext()){
                current.execute(context);
            }
        });
    }
}

FundAccessExecutor

/**
 * @description: 资金准入执行器
 * @Date : 2018/7/8 上午11:36
 * @Author : 石冬冬-Seig Heil
 */
public class FundAccessExecutor extends AbstractAccessExecutor {
    /**
     * 构造器
     *
     * @param context
     */
    public FundAccessExecutor(FundAccessContext context) {
        super(context);
    }

    @Override
    public void buildChain() {
        this.handlerList = new ArrayList<>(3);
        this.handlerList.add(
                new ProposerAccessHandler("主贷人准入",new CarInfoAccessHandler("车辆准入",new LoanAccessHandler("车贷准入",null))));
    }
}

3.规则条件策略(AbstractStrategy)

规则条件策略UML类图

AbstractStrategy

/**
 * @description: 抽象策略类
 * @Date : 2018/7/11 下午12:01
 * @Author : 石冬冬-Seig Heil
 */
public abstract class AbstractStrategy {
    protected final String EMPTY = "";
    protected final String ZERO_STR = "0";
    /**
     * 规则枚举
     */
    protected MapperConfig.MapperEnumInterface accessEnum;
    /**
     * 策略上线文对象
     */
    protected StrategyContext strategyContext;
    /**
     * 要校验的数据对象Map容器
     */
    protected Map<String,Object> beanMap;
    /**
     * 数据规则对象Map容器
     */
    protected Map<String,Object> propMap;
    /**
     * 存储数据字典Map容器
     */
    protected Map<String,Map<String, String>> dictMap;
    /**
     * 规则数据业务实体对象
     */
    protected FundRuleDataBo dataBo;
    /**
     * 字段名称
     */
    protected String filedName;
    /**
     * 校验消息
     */
    protected String tips;
    /**
     * 是否准入通过
     */
    protected boolean access;

    public AbstractStrategy(MapperConfig.MapperEnumInterface accessEnum, StrategyContext strategyContext) {
        this.accessEnum = accessEnum;
        this.strategyContext = strategyContext;
    }

    /**
     * 抽象方法,需要子类实现
     */
    protected abstract void accept();

    /**
     * 初始化
     */
    protected final void init(){
        FundAccessContext accessContext = strategyContext.getAccessContext();
        this.beanMap = BeanMapper.objectToMap(accessContext.getAccessDTO());
        this.dataBo = accessContext.getFundRuleDataBo();
        this.propMap = dataBo.getPropMap();
        this.dictMap = dataBo.getDictMap();
        filedName = accessEnum.filed();
    }

    /**
     * 外部调用方法
     */
    public final void access(){
        init();
        accept();
        after();
    }

    /**
     * 后置处理
     */
    public final void after(){
        this.strategyContext.setAccess(access);
        this.strategyContext.setTips(tips);
    }

    protected final String formatMessage(String message,Object...values){
        return MessageFormat.format(message,values);
    }
    protected final String formatMessage2Collection(String message, Collection<Object> collection){
        return MessageFormat.format(message,collection.stream().map(Object::toString).collect(Collectors.joining(",")));
    }
}

AbstractRangeStrategy

/**
 * @description: 抽象 区间类 策略类
 * @Date : 2018/7/11 下午12:14
 * @Author : 石冬冬-Seig Heil
 */
public abstract class AbstractRangeStrategy extends AbstractStrategy implements RangeValue<Map<String,Object>,Number> {

    protected final String MIN = "Min";
    protected final String MAX = "Max";

    public AbstractRangeStrategy(MapperConfig.MapperEnumInterface accessEnum, StrategyContext strategyContext) {
        super( accessEnum, strategyContext);
    }

    @Override
    protected void accept() {
        String message = accessEnum.message();
        BigDecimal srcValue = new BigDecimal(Objects.toString(beanMap.get(filedName),ZERO_STR));
        BigDecimal min = (BigDecimal)min(propMap);
        BigDecimal max = (BigDecimal)max(propMap);
        srcValue = new BigDecimal(Objects.toString(beanMap.get(filedName),ZERO_STR));
        access = srcValue.compareTo(min) >= 0 && max.compareTo(srcValue) >= 0;
        if(!access){
            tips = formatMessage(message,min,max);
        }
    }
}

AbstractScopeStrategy

/**
 * @description: 抽象 范围类 策略类
 * @Date : 2018/7/11 下午12:14
 * @Author : 石冬冬-Seig Heil
 */
public abstract class AbstractScopeStrategy extends AbstractStrategy implements ScopeValue<Map<String,Object>,Object> {

    public AbstractScopeStrategy(MapperConfig.MapperEnumInterface accessEnum, StrategyContext strategyContext) {
        super( accessEnum, strategyContext);
    }

    @Override
    protected void accept() {
        MapperConfig.EnumType enumType = accessEnum.enumType();
        String message = accessEnum.message();
        EnumValue[] enumValues = accessEnum.enums();
        String srcValue = Objects.toString(beanMap.get(filedName),EMPTY);
        List<Object> desValues = scope(propMap);
        access = desValues.contains(srcValue);
        if(access){
            return;
        }
        switch (enumType){
            case INDEX:
                message = formatMessage(message, EnumConvert.convertIndex2String(enumValues,desValues));
                break;
            case NAME:
                message = formatMessage(message, EnumConvert.convertIndex2String(enumValues,desValues));
                break;
            case DESC:
                message = formatMessage(message, EnumConvert.convertIndex2String((EnumDesc[]) enumValues,desValues, EnumDesc::getDesc));
                break;
            case DB:
                message = formatMessage(message, desValues.stream().map(d -> dictMap.get(filedName).get(d.toString())).collect(Collectors.joining(",")));
                break;
            default:
                message = formatMessage2Collection(message,desValues);
                break;
        }
        tips = message;
    }
}

DecimalRangeStrategy

/**
 * @description: 数字 区间类 策略类
 * @Date : 2018/7/11 下午12:14
 * @Author : 石冬冬-Seig Heil
 */
public class DecimalRangeStrategy extends AbstractRangeStrategy {

    public DecimalRangeStrategy(MapperConfig.MapperEnumInterface accessEnum, StrategyContext strategyContext) {
        super( accessEnum, strategyContext);
    }

    @Override
    public Number max(Map<String, Object> propMap) {
        return new BigDecimal(Objects.toString(propMap.get(filedName + MAX),ZERO_STR));
    }

    @Override
    public Number min(Map<String, Object> propMap) {
        return new BigDecimal(Objects.toString(propMap.get(filedName + MIN),ZERO_STR));
    }
}

StringScopeStrategy

/**
 * @description: 字符串 范围类 策略类
 * @Date : 2018/7/11 下午12:14
 * @Author : 石冬冬-Seig Heil
 */
public class StringScopeStrategy extends AbstractScopeStrategy {

    public StringScopeStrategy(MapperConfig.MapperEnumInterface accessEnum, StrategyContext strategyContext) {
        super( accessEnum, strategyContext);
    }

    @Override
    public List<Object> scope(Map<String, Object> propMap) {
        String desValue = Objects.toString(propMap.get(filedName),EMPTY);
        return StringTools.toList(desValue,Object.class);
    }
}

SimpleStrategyFactory

/**
 * @description: 策略简单工厂类
 * @Date : 2018/7/12 下午8:33
 * @Author : 石冬冬-Seig Heil(dondongshi5@creditease.cn)
 */
public final class SimpleStrategyFactory {
    /**
     * 创建方法
     * @param accessEnum
     * @return
     */
    public static AbstractStrategy create(StrategyContext strategyContext,MapperConfig.MapperEnumInterface accessEnum){
        AbstractStrategy strategy = null;
        MapperConfig.FiledType filedType = accessEnum.filedType();
        MapperConfig.ValueType valueType = accessEnum.valueType();
        if(MapperConfig.ValueType.STRING == valueType && MapperConfig.FiledType.SCOPE == filedType){
            strategy = new StringScopeStrategy(accessEnum,strategyContext);
        }
        if(MapperConfig.ValueType.DECIMAL == valueType && MapperConfig.FiledType.RANGE == filedType){
            strategy = new DecimalRangeStrategy(accessEnum,strategyContext);
        }
        return strategy;
    }

    private SimpleStrategyFactory(){}
}

4.规则字段配置(MapperConfig)

/**
 * @description: 规则字段映射枚举类
 * @Date : 2018/7/8 上午11:04
 * @Author : 石冬冬-Seig Heil
 */
public final class MapperConfig {
    /**
     * 主贷人准入
     * 年龄、从事行业、税后月收入、本人是否有驾照、户籍所在省份、户籍所在城市
     */
    public enum ProposerAccessEnum implements MapperEnumInterface{
        age(false,"age","主贷人准入:[年龄]不符合范围({0}~{1})",FiledType.RANGE,ValueType.DECIMAL,EnumType.NONE,null,null),
        nowIndustry(false,"nowIndustry","主贷人准入:[从事行业]不符合范围({0})",FiledType.SCOPE,ValueType.STRING,EnumType.NONE,null,null),
        provinceName(true,"provinceName","主贷人准入:[户籍所在省份]不符合({0})",FiledType.SCOPE,ValueType.STRING,EnumType.NONE,null,null),
        cityName(true,"cityName","主贷人准入:[户籍所在城市]不符合({0})",FiledType.SCOPE,ValueType.STRING,EnumType.NONE,null,null),
        //省略其他...
        ;
        private boolean filter;
        private String field;
        private String message;
        private FiledType filedType;
        private ValueType valueType;
        private EnumType enumType;
        private EnumValue[] enums;
        private String skipCondition;

        ProposerAccessEnum(boolean filter,String field, String message,FiledType filedType,ValueType valueType,EnumType enumType,EnumValue[] enums,String skipCondition) {
            this.filter = filter;
            this.field = field;
            this.message = message;
            this.filedType = filedType;
            this.valueType = valueType;
            this.enumType = enumType;
            this.enums = enums;
            this.skipCondition = skipCondition;
        }
        //省略 getter/setter
    }
    /**
     * 车辆信息
     * 是否二手车、车牌类型、车型、车龄(月)、里程
     */
    public enum CarAccessEnum implements MapperEnumInterface{
        carAge(false,"carAge","车辆准入:[二手车车龄]不符合({0}~{1})",FiledType.RANGE,ValueType.DECIMAL,EnumType.NONE,null,"isOld==0"),
        carAgeAddLoanPeriods(false,"carAgeAddLoanPeriods","车辆准入:[二手车车龄+贷款期限]不符合({0}~{1})",FiledType.RANGE,ValueType.DECIMAL,EnumType.NONE,null,"isOld==0"),
        carMiles(false,"carMiles","车辆准入:[二手车里程数]不符合({0}~{1})",FiledType.RANGE,ValueType.DECIMAL,EnumType.NONE,null,"isOld==0"),
        carType(false,"carType","车辆准入:[车型]不符合范围({0})",FiledType.SCOPE,ValueType.STRING,EnumType.NAME, ConstEnum.CarTypeEnum.values(),null),
        carLicenseType(false,"carLicenseType","车辆准入:[车牌类型]不符合范围({0})",FiledType.SCOPE,ValueType.STRING,EnumType.DB, null, null),
        //省略其他...
        ;
        //省略 fileds getter/setter
    }
    /**
     * 车贷信息
     * 还款期限、车贷金额、首付比
     */
    public enum LoanAccessEnum implements MapperEnumInterface{
        applyLoanPeriods(false,"applyLoanPeriods","车贷准入:[借款期限]不符合范围({0})",FiledType.SCOPE,ValueType.STRING,EnumType.INDEX, ConstEnum.LoanPeriodsEnum.values(),null),
        applyCarLoanAmount(false,"applyCarLoanAmount","车贷准入:[车辆借款金额]不符合({0}~{1})",FiledType.RANGE,ValueType.DECIMAL,EnumType.NONE,null,null),
         //省略其他...
        ;
        //省略 fileds getter/setter
    }

    /**
     * 枚举要实现的接口
     */
    public interface MapperEnumInterface{
        /**
         * 是否需要过滤,该字段不从pd_fund_rule_prop获取
         * @return
         */
        boolean filter();
        /**
         * 字段属性名称
         * @return
         */
        String filed();
        /**
         * 文案
         * @return
         */
        String message();
        /**
         * 字段类型
         * @return
         */
        FiledType filedType();
        /**
         * 字段值类型
         * @return
         */
        ValueType valueType();
        /**
         * 枚举取值类型
         * @return
         */
        EnumType enumType();
        /**
         * 对应枚举
         * @return
         */
        EnumValue[] enums();

        /**
         * 跳跃条件
         */
        String skipCondition();
    }

    /**
     * 枚举取值类型
     */
    public enum EnumType{
        INDEX, // 意味通过枚举维护,获取对应index属性
        NAME, // 意味通过枚举维护,获取对应name属性
        DESC, // 意味通过枚举维护,获取对应desc属性
        DB, // 意味着通过sy_arg_control 数据字典表维护,比如审批流程
        NONE // 意味着 非枚举字段,比如 贷款金额、年龄 这样的字段
    }

    /**
     * 字段类型枚举
     */
    public enum FiledType{
        SCOPE, //范围类,比如 车类、还款期限等
        RANGE //期间类,比如 年龄、贷款金额、税后月收入
    }

    /**
     * 字段值类型
     */
    public enum ValueType{
        STRING, // 字符串类型
        DECIMAL // 数字类型,包括 Integer,Long,BigDecimal等
    }
}

5.规则准入业务对象(FundRuleDataBo)

/**
 * @description: 资金方准入规则数据业务实体对象
 * @Date : 2018/7/7 下午6:34
 * @Author : 石冬冬-Seig Heil
 */
@NoArgsConstructor
@AllArgsConstructor
@Data
public class FundRuleDataBo {
    /**
     * 规则属性实体对象
     */
    private Map<String,Object> propMap;
    /**
     * 户籍省份城市Map规则结构
     * <数据字典类型key,数据字典集合></>
     */
    private Map<String,Map<String, String>> dictMap;
    /**
     * 户籍省份城市Map规则结构
     * <省份,城市集合></>
     */
    private Map<String,List<String>> censusMap;
}

6.规则准入上下文对象(FundAccessContext)

/**
 * @description: 资金方规则准入上下文
 * @Date : 2018/7/5 下午3:48
 * @Author : 石冬冬-Seig Heil
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class FundAccessContext {
    /**
     * 准入是否通过
     */
    private boolean access;
    /**
     * 准入规则DTO对象
     */
    private FundAccessDTO accessDTO;
    /**
     * 资金方规则数据实体业务对象
     */
    private FundRuleDataBo fundRuleDataBo;
    /**
     * 校验信息
     */
    private List<String> messages = Lists.newArrayList();

    public FundAccessContext(boolean access, FundAccessDTO accessDTO, FundRuleDataBo fundRuleDataBo) {
        this.access = access;
        this.accessDTO = accessDTO;
        this.fundRuleDataBo = fundRuleDataBo;
    }
}

7.总结

  • MapperConfig:为新增或者调整规则条件字段提供了扩展性,同时对于修改返回app文案提供了统一管理配置。
  • AbstractAccessHandler:对分类处理规则提供了扩展性。

时序图

如上,是对外提供的api接口,到整个相关类的时序图。

六、尾语

坦白讲,这个资方准入规则是属于我代码设计值得称赞的一个模块,运用了(责任链、模板方法、策略、工厂)设计模式,保持灵活的扩展性和伸缩性,为后续需求迭代和开发维护奠定基础。

秋夜无霜

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值