在车金融业务场景中,金融方案以资方为大维度。资方准入规则,进一步对进件的客户信息、车辆信息、贷款意向进行了场景条件控制,因此,资金方准入规则对于金融产品来讲,也是必不可少了,它构成了金融产品规则引擎的一部分。
车金融前世今生专辑连载系列,关注微信公众号【秋夜无霜】
温馨提示:全文共计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)
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接口,到整个相关类的时序图。
六、尾语
坦白讲,这个资方准入规则是属于我代码设计值得称赞的一个模块,运用了(责任链、模板方法、策略、工厂)设计模式,保持灵活的扩展性和伸缩性,为后续需求迭代和开发维护奠定基础。