需求
- 灵活配置账单,10行代码批量生成商户账单.
场景概述
一个代理商,下面有n个收单商户,要生成下面每个收单商户的每天的交易流水账单文件
实现能力
- 能通过模版文件配置修改账单内容
- 修改账单内容和结构只需修改配置文件sql
- 数据读取通过分页实现
- 对不同数据源的支持
- 支持多库数据组合生成账单的场景
- 支持自定义特殊字段的转换
- 支持文件的后置处理,可自定义存放位置
源码地址:
https://gitee.com/kaiyang_taichi/bill-Plugins.git
使用方法:
- 导入pom,因为未deploy到公有仓库,需要使用,可以自行下载源码编译
<dependency>
<groupId>cn.bese.bill.template.plugins</groupId>
<artifactId>bill-plugins</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
- 编写配置文件:
例:
sql1: SELECT * FROM HUSKY2.MERCHANT where merchant_type in (${init.0})
sql2: select r,${sql3.Merchant_no} t,m.MERCHANT_NO,m.MERCHANT_NAME,m.POS_CATI,m.POS_SERIAL_NUMBER,m.TRX_TYPE,
m.TRADE_SERIAL_NO,m.CREATE_TIME,m.CARD_NO,m.TRADE_AMOUNT,m.STATUS,m.CARD_TYPE,m.MERCHANT_FEE,'' shuangmian,m.AGENT_NO,'' AGENT_NAME,'' so from (
SELECT row_number() over(ORDER BY mr.id DESC) as r,mr.*
FROM OFFLINE.TBL_OFFLINE_ORDER mr
where mr.MERCHANT_NO=${sql3.Merchant_no} and mr.status='SUCCESS'
) m where m.r>${sys.pageIndex} fetch first ${sys.pageSize} rows only
sql3: select ym.* from (
SELECT row_number() over(ORDER BY mr.id DESC) as r,m.* FROM HUSKY2.MERCHANT_RELA_NEW mr
inner join HUSKY2.MERCHANT m on mr.SUb_NO = m.merchant_no and m.merchant_type='MERCHANT'
where mr.PARENT_NO=${file.2}) ym where ym.r>${sys.pageIndex} fetch first ${sys.pageSize} rows only
file-name: /Users/kai.yang/Desktop/yeepay/bill-plugins/bills/orders/${sys.yyyy}/${sys.MM}/${sql1.MERCHANT_NO}/${sql1.MERCHANT_NAME}/交易_${sys.yyyy}${sys.MM}${sys.dd}.csv
transfers:
- AGENT_NAME->class:com.example.plugns.demoweb.config.bill.AgentNameHandler
- STATUS->map:SUCCESS|成功
file-templates:
- 标题:商户交易数据
- 商户名称:${file.3}
- 商户编号|商户名称|终端编号|SN号|产品类型|交易号|交易日期|交易时间|交易对方银行卡号|交易金额|交易状态|卡类型|手续费|小额双免|代理商编号|代理商名称|S0出款状态
- ${sql2.t}|${sql2.MERCHANT_NO}|${sql2.MERCHANT_NAME}|${sql2.POS_CATI}|${sql2.POS_SERIAL_NUMBER}|${sql2.PRODUCT_CODE}|${sql2.TRADE_SERIAL_NO}|${sql2.CREATE_TIME}|${sql2.CARD_NO}|${sql2.TRADE_AMOUNT}|${sql2.STATUS}|${sql2.CARD_TYPE}|${sql2.MERCHANT_FEE}|${sql2.INPUT_TYPE}|${sql2.AGENT_NO}|${sql2.AGENT_NAME}|${sql2.so}
null-file-templates: sql2 -> no data today!
file-content-format-class: com.example.plugns.demoweb.config.bill.FileContentTransferHandler
save-after-class: com.example.plugns.demoweb.config.bill.SaveBillConfig
参数:
-
模版key配置方法:
sql*: 模版主要内容,就是我们平时的sql语句,你可以根据所用数据库语言自己规范sql方言.多个sql可以组合使用,key为sql+(自定义数码,只用来区分sql没有特殊先后顺序)例子中:
sql1--> 查询出指定类型的所有商户,本例中为了查出所有代理商
sql3(先跳过sql2,因为sql2以sql3的结果作为了查询条件)-->遍历sql1的每个代理商,分页查出每个代理商对应的所有子商户
sql2-->在每个文件中,分页查询sql3中每个子商户的交易数据,汇总生成文件内容
sql1、sql2、sql3 其实就是我们平时写账单的三个步骤的sql语句,此处通过模版key的方式灵活替换file-name : 最后生成的文件名称,如例子;
file-name: /Users/kai.yang/Desktop/yeepay/bill-plugins/bills/orders/${sys.yyyy}/${sys.MM}/${sql1.MERCHANT_NO}/${sql1.MERCHANT_NAME}/交易_${sys.yyyy}${sys.MM}${sys.dd}.csv
其中所有${*}
定义的参数,都可以在模版中通过${file.*}
获取到,这里的index 从0开始.transfers:定义的转换器,可以对一些特殊字段进行后置处理.默认有两种转换器:
1. map型:map:SUCCESS|成功
,定义你SUCCESS到成功的映射,自动替换,场景如数据库枚举值,文件中转换为中文.
2. class型:class:com.example.plugns.demoweb.config.bill.AgentNameHandler
自定义转换类,只要出现你指定的字段,就会根据你定义的转换类进行替换.此类要继承TransferValueHandler接口-
file-templates: 文件模版,最终的csv文件模版定义.用yml文件的
-
表示换行,注意点,最终的文件内容暂时只能通过一个sql主体出数据,否则系统无法组合分页.如本例中,最终数据从sql2中产出,本行模版不能有其他sql替换符,但可以有其他系统内置参数.
null-file-templates: 空文件模版配置,指获取的主sql数据为空时,文件展示的内容,不配的话只展示表头,否则根据你配置写文件.如例子中,当sql2数据为空时,文件内容为:
no data today!
6.file-content-format-class ,整行内容处理类,使用较少.作用是你可以对每一行数据都可以做整体的特殊处理,不过场景不多.
- save-after-class :文件后置处理类,如果你需要对最后的文件做相应的处理,如发送邮件,或保存到其他服务器的,可以通过此配置实现,继承SaveAfterProcessConfig接口:
public class SaveBillConfig implements SaveAfterProcessConfig {
@Override
public boolean afterProcess(File file, String fileName, Object[] fileParams) {
System.out.println("文件存储后置处理");
return true;
}
}
- 系统内置参数说明:
${init.*}
:以init开头的参数为,executer启动时传入的初始化参数,单个 executer上下文全局唯一,不会更改.可用于一些固定的外部参数,如时间范围、业务类型等等.${sys.*}
: 为系统内定参数模式,不需要外不指定,有自己的实现逻辑,可直接使用,其中:
${sys.pageIndex}
: 分页页码参数,在sql中使用,系统会自动从0开始自增
${sys.pageSize}
: 分页每页数据条数默认配置,默认200,也可自定义
${sys.yyyy}
: 系统年份获取参数,取系统年份,格式如:2019
${sys.MM}
:系统年份获取月份,取系统年份,格式如:09
${sys.dd}
: 系统天:格式:23
处理代码在cn.base.bill.template.plugins.config.SysParamConfig中,有需要可自行调整:${file.*}
:获取最终文件名中的指定参数,在单个文件不变的参数上下文传递时可以使用(但缺陷是文件目录会多出此参数,后续有机会可以优化,加入文件级别的上下文).例如:
file-name: /Users/kai.yang/Desktop/yeepay/bill-plugins/bills/orders/${sys.yyyy}/${sys.MM}/${sql1.MERCHANT_NO}/${sql1.MERCHANT_NAME}/交易_${sys.yyyy}${sys.MM}${sys.dd}.csv
但这里的fiile参数只取file-name配置中的${}
中的参数,所以此例汇总${file.2}
就是对应的${MERCHANT_NO}
获取当前文件中的月份字段值(小标从0开始).${sql*.*}
: 重点的sql参数,在文件模版key中,已经说过sqln就是对应指定的sql,如${sql2.MERCHANT_NO}
就是对应的sql2中的MERCHNAT_NO字段.
- 代码启动:
配置文件配好后,10来行代码就可以生产你需要的账单了.
public class DemoController implements InitializingBean {
/**
* 配置的一个数据源
*/
@Resource(name = "posDataSource")
DataSource posDataSource;
/**
* 配置的第二个数据源
*/
@Resource(name = "huskyDataSource")
DataSource huskyDataSource;
/**
* 对应的执行器构造者,通过afterPropertiesSet方法初始化
*/
BillPluginsExecuteBuilder orderBillPluginsExecuteBuilder;
@Override
public void afterPropertiesSet() {
//初始化构造者,
//1。setBillConfigFilePath 指定配置文件路径
//2。setDataSource指定数据源配置,参数(DataSource dataSource, String... keys),指定哪些sql的key对应哪个数据源
// 本例子中配置了两个数据源,sql1、sql3对应huskyDataSource,sql2对应posDataSource数据源
//3。最后调用init()方法启动builder
orderBillPluginsExecuteBuilder = new BillPluginsExecuteBuilder()
.setBillConfigFilePath("/biil-template/order-templates-demo-db2.yml")
.setDataSource(huskyDataSource, "sql1", "sql3").setDataSource(posDataSource, "sql2").init();
}
@GetMapping("/test2")
public String test2() throws SQLException {
//params为配置执行器上下文的初始化参数,可通过${init.n}获得
Object[] params = new Object[]{"MIDDLE_AGENT", "10040041322"};
//最后执行generate生产所有文件
orderBillPluginsExecuteBuilder.build(params).generate();
return "ok";
}
}
生产的账单例子,生成这个代理商下每个子商户的数据:
源码简介
此处先简单介绍下代码结构,有需要以后再细说.
看下源码机构图:
- config是对应上面说的
系统内置参数
的处理逻辑 - context 为组件上下文定义,里边有全局的一些缓存
- dao为数据库交互层,封装了sql的执行过程、分页实现都在这里
- format为对应参数格式化实现,默认有时间、和空值的处理
- model里定义的是实体模型
- parse是对yml配置文件的解析过程
- transfer为对应个别字段的特殊转换处理
- BillPluginsExecuteBuilder是对文件解析的入口,是Executor的构造者
9 BillPluginsExecutor 是最终的执行类,所有核心逻辑的入口 从generate方法开始.
generate主要执行时序图:
其中主要流程分为两步:
第一步: 对文件名的解析;
第二步:针对每个文件,对file-templates文件模版的解析
原则就是,解析过程中如果有sql依赖,就先执行sql依赖(文件名目前执行1层sql依赖,内容支持两层,基本满足大多数场景).
对于sql的执行通过DefaultSqlCallerImpl进行封装,然后类似于jdbc的流式读取,在ResultRows结果集中处理分页逻辑
/**
* 遍历行,获取数据
* 1。 对数据进行参数格式化,可用户自定义格式
* 2。对于特殊参数进行转换处理,用户可自定义
*/
public Map<String, String> next() throws SQLException {
if (index >= rowMaps.size()) {
if (isHasNext() && pageNoIndex != -1) {
//存在下一页情况,先进行页码替换
Object[] newParams = Arrays.copyOf(parsms, parsms.length);
newParams[pageNoIndex] = Integer.valueOf(newParams[pageNoIndex].toString()) + pageSize;
//更换下页码参数换成
this.parsms=newParams;
//当前页数据,索引清零
index = 0;
//下页查询
ResultRows call = ((DefaultSqlCallerImpl) sqlCaller).call(newParams);
this.rowMaps = call.getRowMaps();
call.close(); //帮助gc
}
//此时只能返回null,说明没有值了
if (index >= rowMaps.size()) {
return null;
}
}
return formartResult(rowMaps.get(index++));
}
并通过formartResult方法进行参数的自定义格式化
/**
* 映射格式化
*/
private Map<String, String> formartResult(Map<String, Object> resultMap) {
Map<String, String> result = new HashMap<>();
if (MapUtils.isNotEmpty(resultMap)) {
resultMap.forEach((k, v) -> {
//1。固定类型格式化
String formatValue = FormaterRegistry.getFormater(typeMaps.get(k)).format(v);
//2。对于特殊参数的转换处理
TransfersConfig transferConfig = BillPluginsContext.getTransferConfig(k);
if (transferConfig != null) {
switch (transferConfig.getTransferTypeEnums()) {
case MAP:
String transferValue = transferConfig.getTransferMap().get(formatValue.toUpperCase());
result.put(k, StringUtils.isEmpty(transferValue) ? formatValue : transferValue);
break;
case Class_TRANSFER:
result.put(k, transferConfig.getTransferType().transfer(formatValue,resultMap));
break;
default:
result.put(k, formatValue);
break;
}
} else {
result.put(k, formatValue);
}
});
}
return result;
}
总结
写的有点急,细节处理有很多没处理到位,但已基本实现了大多数生成账单的场景.