收费数据源规则执行设计的演进

背景介绍

风控系统每种场景 如现金贷 都需要跑很多规则

  • 规则1 申请人姓名身份证号实名验证
  • 规则2 申请人手机号码实名认证
  • 规则3 银行卡预留手机号码实名认证
  • 规则4 申请人银行卡预留手机号码在网状态检验
  • 规则5 申请人银行借记卡有效性核验
  • 规则6 户籍地址与身份证号归属地比对
  • ...

而这些规则的验证都需要调用外部收费接口 鉴于外部接口调用逻辑很多可以复用 于是使用模板模式进行封装

调用外部接口模板

  • 组装入参 (不同的接口有不同的入参 因为接口有数十个 省去创建数十个对象 入参统一使用Map)
  • 发送请求 (可以统一)
  • 返回内容解析 (不同的接口有不同的返回 返回对象统一继承FeeData
  • 返回对象

AbstractFeeDataManagerTemplate

  • getFeeData(params) // 得到接口返回数据
  • abstract buildParams(Map params) // 组装该接口特有恒定的入参
  • private sendRequest(param) // 发送请求
  • abstract FeeData resolveResponse(String response) // 解析不同返回内容 统一返回FeeData
设计类图

C端风控系统 产品架构_接口调用

伪代码
public abstract class AbstractFeeDataManagerTemplate {
    protected abstract void buildParams(Map params);

    public FeeData getFeeData(Map params){
        buildParams(params);
        String response = sendRequest(params);
        return resolveResponse(response);
    }

    protected abstract FeeData resolveResponse(String response);

    private String sendRequest(Map params) {
        //使用HttpClient调用外部接口
    }
}

public class NameIdCardVerificationManager extends AbstractFeeDataManagerTemplate {


    protected void buildParams(Map params) {
        // 组装此接口特有且恒定入参 如
        params.put("code", "NAME_IDCARD_VERIFICATION");

    }

    protected FeeData resolveResponse(String response) {
        // 解析接口返回 并组装成FeeData返回 
    }
}

外部接口门面

每种场景包含很多规则 每个规则逐个执行 怎么知道哪个规则要调用哪个接口呢?于是创建了一个门面 保存规则与具体的接口实现类的关联关系 但是考虑到很多规则会调用同一接口 如申请人手机号码实名验证、银行预留手机号码实名验证、第一联系人手机号码实名验证均是调用手机实名验证接口 于是实际保存的是接口编码与接口的映射关系

FeeDataManagerFacade

  • Map : code <--> Manager [考虑到多个规则会调用同一外部接口 定义了接口编码]
  • FeeData getFeeData(code,params)
伪代码
public class FeeDataManagerFacade {
    private static final Map<String, AbstractFeeDataManagerTemplate> code2FeeDataManagerMap = new HashMap();
    static{
        code2FeeDataManagerMap.put("NAME_IDCARD_VERIFICATION", new NameIdCardVerificationManager());
        //...
    }
    public FeeData getFeeData(String code, Map<String, Object> params){
        return code2FeeDataManagerMap.get(code).getFeeData(params);
    }
}

于是当执行规则1 -- 申请人姓名身份证号实名验证 时 这样调用

FeeDataManagerFacade feeDataManagerFacade = new FeeDataManagerFacade();

RuleContext ruleContext = ...;
String code = ruleContext.getRule().getFeeDataCode(); // 每个规则配置了其对应的收费数据源接口的Code

Map params = new HashMap<>();
params.put("name", "张三");
params.put("idcard", "123456199001011233");

try {
    FeeData feeData = feeDataManagerFacade.getFeeData(code, params);
    if(!feeData.isPass()){
        // 校验未通过处理
        ruleContext.setResult(ruleContext.getRule().getResult()); // 设置决策结果 来自规则配置 如拒绝 人工复核
        ruleContext.setMessage(String.format("申请人姓名: %s 身份证号: %s 实名验证失败",params.get("name"),params.get("idcard")));
    }
} catch (Exception e) {
    // 接口调用异常 默认为人工复核
    ruleContext.setResult("REVIEW"); // 设置决策结果:人工复核
    ruleContext.setMessage(String.format("接口调用失败: %s",e.getMessage()));
}

规则处理模板

由于每个需要调用外部数据源的规则的处理逻辑类似

  • 组装参数
  • 调用该规则对应的外部接口
  • 接口调用成功: 规则校验未通过处理
  • 接口调用异常: 接口异常处理

同样可以采用模板模式进行封装 如下伪代码所示

public abstract class AbstractFeeRuleProcessServiceTemplate {
    private static FeeDataManagerFacade facade = new FeeDataManagerFacade();
    public void process(Map params, RuleContext ruleContext){
        try {
            FeeData feeData = facade.getFeeData(ruleContext.getRule().getFeeDataCode(), params);
            if(!feeData.isPass()){
                // 校验未通过处理
                ruleContext.setResult(ruleContext.getRule().getResult());                
                ruleContext.setMessage(buildMessage());
            }
        } catch (Exception e) {
            // 接口调用异常 默认为人工复核
            ruleContext.setResult("REVIEW");
            ruleContext.setMessage(String.format("接口调用失败: %s",e.getMessage()));
        }
        
    }
    // 因为每个规则 返回提示信息不同 所以将提示信息提取出来作为抽象方法
    protected abstract String buildMessage(); 

}

对应类图

C端风控系统 产品架构_C端风控系统 产品架构_02

此时规则1--申请人姓名身份证号实名验证的处理方式为

new AbstractFeeRuleProcessServiceTemplate(){
            @Override
            protected String buildMessage() {
                return String.format("申请人姓名: %s 身份证号: %s 实名验证失败",params.get("name"),params.get("idcard"));
            }
        }.process(params,ruleContext);

即只需自定义规则未通过时的提示信息即可

总体设计类图

C端风控系统 产品架构_接口调用_03

演进一

有些外部接口 并不是返回一个boolean类型的结果--校验通过或没通过 而是返回一个具体的信息 如身份证归属地、手机号码归属地 然后用用户提交的信息 如用户提交的户籍地址与身份证归属地进行比较
此时下面的代码就不合适了

if(!feeData.isPass()){
    // 校验未通过处理
}

于是抽象了一个checkFeeData方法 供规则覆盖

public abstract class AbstractFeeRuleProcessServiceTemplate {
    public void process(Map params, RuleContext ruleContext){
        try {
            FeeData feeData = ...
            if(!checkFeeData(feeData)){
                // 校验未通过处理
                // ...
            }
        } catch (Exception e) {
            // 接口调用异常 默认为人工复核
            // ...
        }
        
    }
    protected abstract boolean checkFeeData(FeeData feeData);
    protected abstract String buildMessage(FeeData feeData); 
}

对应类图为

C端风控系统 产品架构_C端风控系统 产品架构_04

此时规则1--申请人姓名身份证号实名验证的处理方式为

new AbstractFeeRuleProcessServiceTemplate(){

            @Override
            protected boolean checkFeeData(FeeData feeData) {
                return feeData.isPass();
            }

            @Override
            protected String buildMessage(FeeData feeData) {
                return String.format("申请人姓名: %s 身份证号: %s 实名验证失败",params.get("name"),params.get("idcard"));
            }
        }.process(params,ruleContext);

执行规则6--户籍地址与身份证号归属地比对 是这样校验

new AbstractFeeRuleProcessServiceTemplate(){
            @Override
            protected boolean checkFeeData(FeeData feeData) {
                // 为了避免创建很多对象 使用Map保存接口返回信息
                // 身份证归属地 如 河北省 邯郸市 临漳县 、重庆市綦江县 
                String location = (String) feeData.getExtra().get("location");
                // applyInfo 用户申请信息
                if (location.contains(applyInfo.getResidenceAddressProvinceName()) || location.contains(applyInfo.getResidenceAddressCountyName())) {
                    return true;
                }
                return false;
            }

            @Override
            protected String buildMessage(FeeData feeData) {
                String message = String.format("自述户籍地址:%s %s %s 与身份证归属地:%s 不一致",
                        applyInfo.getResidenceAddressProvinceName(),applyInfo.getResidenceAddressCityName()
                        ,applyInfo.getResidenceAddressCountyName(),feeData.getExtra().get("location"));
                return message;
            }
        }.process(params,ruleContext);

演进二 -- 批量查询

有些规则需要调用多次接口 如

  • 注册手机号码归属地与申请人身份证号归属地、现居住地、单位地址、家庭地址、户籍地址的交叉验证

查询注册手机号码归属城市、申请人身份证号码归属地城市。将注册手机号码归属地城市与申请人的身份证归属地城市、现居地址城市、单位地址城市 、家庭地址城市、户籍地址城市进行比对,如果任意一项一致,则通过。否则拒绝或人工复核

上面的规则 需要调用手机归属地身份证号码归属地 接口 此时已有的设计 -- 基于一个规则一个接口

  • 组装参数
  • 调用接口
  • 规则校验
  • 校验后处理

就不满足要求了 于是保持AbstractFeeRuleProcessServiceTemplate接口不变的情况下 对Facade做了如下修改:

  • 增加了一个虚拟接口编码--BATCH_QUERY_FEEDATA 表示批量查询接口
  • 每个接口的入参(params)中 添加实际的接口编码 如MOBILE_LOCATION_QUERY,IDCARD_LOCATION_QUERY
  • Facade入参变成 paramList : [param1,param2,...]
  • Facade返回结果 feeDataList : [feeData1, FeeData2, ...]

FeeDataManagerFacade对应的代码为

public FeeData getFeeData(String code, Map<String, Object> params){
        if("BATCH_QUERY_FEEDATA".equals(code)){ // 批量查询 
            List<Map<String,Object>> paramList = (List<Map<String, Object>>) params.get("paramList");
            List<FeeData> feeDataList = new ArrayList<>();
            for (Map<String, Object> param : paramList) {
                String realCode = (String) param.get("code"); // 实际接口编码
                Objects.requireNonNull(realCode,"接口编码不可为空");
                FeeData feeData = code2FeeDataManagerMap.get(realCode).getFeeData(params);
                feeDataList.add(feeData);
            }
            FeeData result = new FeeData();
            result.setExtra(newHashMap("feeDataList",feeDataList));
            return result; 
        }
        // 单个查询
        return code2FeeDataManagerMap.get(code).getFeeData(params);
    }

执行规则--注册手机号码归属地与申请人身份证号归属地、现居住地、单位地址、家庭地址、户籍地址的交叉验证

Map param1 = newHashMap("code", "MOBILE_LOCATION_QUERY", "mobile", "13800138000");
        Map param2 = newHashMap("code", "IDCARD_LOCATION_QUERY", "idcard", "123456199001011233");
        Map params = newHashMap("paramList", newArrayList(param1, param2));
        new AbstractFeeRuleProcessServiceTemplate2(){
            @Override
            protected boolean checkFeeData(FeeData feeData) {
                List<FeeData> feeDataList = (List<FeeData>) feeData.getExtra().get("feeDataList");
                String mobileLocation = (String) feeDataList.get(0).getExtra().get("location");
                String idcardLocation = (String) feeDataList.get(1).getExtra().get("location");
                // ... 规则校验
            }

            @Override
            protected String buildMessage(FeeData feeData) {
                //... 组装提示信息 
                // String message = "注册手机号码归属城市:%s ,注册手机号码归属地城市与申请人一系列地址城市都不一致";
            }
        }.process(params,ruleContext);

演进三--链式查询

如 规则 -- IP地址与三级商户地址的交叉验证

将IP地址和三级商户地址转为经纬度落在地图,如果两者相距半径小于 (2含)公里 则通过,否则拒绝或人工复核。

涉及到接口:

  • ip --> 地址
  • 两个地址之间的距离

后一个接口 需要依赖前一个接口的返回信息--ip地址 此时第二个接口的参数以动态变量的形式定义 如

startAddress : 三级商户地址
endAddress : #{extra['address']} // 动态解析接口一返回的地址 使用了spel

于是对批量查询做了修改 以便支持链式查询

public FeeData getFeeData(String code, Map<String, Object> params){
        if("BATCH_QUERY_FEEDATA".equals(code)){ // 批量查询
            List<Map<String,Object>> paramList = (List<Map<String, Object>>) params.get("paramList");
            List<FeeData> feeDataList = new ArrayList();
            FeeData previous = null; // 保存前一接口的返回
            for (Map<String, Object> param : paramList) {
                String realCode = (String) param.get("code"); // 实际接口编码
                Objects.requireNonNull(realCode,"接口编码不可为空");
                // 若输入参数依赖前一查询结果
                for (Map.Entry<String, Object> entry : param.entrySet()) {
                    String value = entry.getValue().toString();
                    if(value.startsWith("#{")){
                        // 表示动态变量 需要解析
                        String spel = value.replaceFirst("#\\{(.+)}", "$1");
                        Expression expression = expressionParser.parseExpression(spel);
                        Object resolvedValue = expression.getValue(previous);
                        entry.setValue(resolvedValue); // 实际值替换动态变量
                    }
                }
                FeeData feeData = code2FeeDataManagerMap.get(realCode).getFeeData(params);
                feeDataList.add(feeData);
                previous = feeData;
            }
            FeeData result = new FeeData();
            result.setExtra(newHashMap("feeDataList",feeDataList));
            return result;
        }
        // 单个查询
        return code2FeeDataManagerMap.get(code).getFeeData(params);
    }

执行规则 -- IP地址与三级商户地址的交叉验证

// IP地址与三级商户地址的交叉验证
        Map param1 = newHashMap("code", "IP_ADDRESS_QUERY", "ip", "222.128.42.13");
        Map param2 = newHashMap("code", "ADDRESSES_DISTANCE_QUERY", "startAddress", applyInfo.getThirdBusinessAddress(),"endAddress","#{extra['address']}");
        Map params = newHashMap("paramList", newArrayList(param1, param2));

        new AbstractFeeRuleProcessServiceTemplate2(){
            @Override
            protected boolean checkFeeData(FeeData feeData) {
                List<FeeData> feeDataList = (List<FeeData>) feeData.getExtra().get("feeDataList");
                double distance = Double.parseDouble(feeDataList.get(1).getExtra().get("distance").toString());
                // 规则校验 ...
            }

            @Override
            protected String buildMessage(FeeData feeData) {
                // 组装提示信息
                // String message=String.format("ip地址: %s与三级商户地址: %s 相距范围不符合要求。"...)                
            }
        }.process(params,ruleContext);