前几天写了在兴业银行的银企直联中,如何查询手续费和退票流水,但没有完整的代码展示,所以这里再完整的提供下查询相关的代码。封装代码不涉及任何外部业务,如果你也正在接入兴业银行,且使用的开发语言是NET,那么你完全可以发挥拿来主义,完全不需要你修改一行代码!
2019-05-14补充 转账时手续费受多种因素影响,实际生产环境中,出现了仅仅因为判断时,因为系统中收款方银行开户地未填写城市名,导致判断时,错误的判断为跨行异地,即LOCAL=N,导致手续费由1元直接提升为5元
!
首先为了在转账时将企业内部系统业务Id
作为PURPOSE
,我在这里定义了一个ICIBTransactionPurposeBuilder
接口,该接口的用途是用于规范Id
与PURPOSE
互转约束,具体代码如下:
/// <summary>
/// 兴业银行交易流水用途构建约束接口
/// </summary>
public interface ICIBTransactionPurposeBuilder
{
/// <summary>
/// 根据内部系统业务Id构建Purpose
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
string GetPurpose(string id);
/// <summary>
/// 从交易流水Purpose中获取内部系统业务Id,注意此处Purpose应当为网上汇款交易流水中的PURPOSE
/// </summary>
/// <param name="purpose"></param>
/// <returns></returns>
string GetIdFromPurpose(string purpose);
/// <summary>
/// 当前PURPOSE是否符合标准
/// </summary>
/// <param name="purpose"></param>
/// <returns></returns>
bool IsCorrectPurpose(string purpose);
}
同时提供了该接口的默认实现,该实现内部其实什么都没做,且其所有方法实现都是virtual
的,所以你完全可以继承该实现,重写某些你需要自定义的方法,比如IsCorrectPurpose
。
/// <summary>
/// 兴业银行交易流水用途构建默认实现
/// </summary>
public class CIBTransactionPurposeBuilder : ICIBTransactionPurposeBuilder
{
/// <summary>
/// 从交易流水Purpose中获取内部系统业务Id,注意此处Purpose应当为网上汇款交易流水中的PURPOSE
/// </summary>
/// <param name="purpose"></param>
/// <returns></returns>
public virtual string GetIdFromPurpose(string purpose)
{
return purpose;
}
/// <summary>
/// 根据内部系统业务Id构建Purpose
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public virtual string GetPurpose(string id)
{
return id;
}
/// <summary>
/// 当前PURPOSE是否符合标准
/// </summary>
/// <param name="purpose"></param>
/// <returns></returns>
public virtual bool IsCorrectPurpose(string purpose)
{
return true;
}
}
接下来就是具体的CIBTransactionHelper
,在贴上其代码之前,我先罗列下开发中要注意的问题点,这样就算你不是NET开发,这篇内容也会对你有一定的帮助:
- 生成一个用于查询的
TRNUID
,该ID
需要唯一,但又因为不需要重复使用,所以此处简单的按年月日+标签+随机数
来组织 3.3.6 账户余额和交易流水分页查询
时,不允许当日与历史日期同查,所以此处要做判断处理,然而又因为可能业务所在服务与兴业银行银企直联服务器时间存在时间差,所以为一劳永逸的解决问题,在调用接口,直接将DTEND
和DTSTART
设置为同一天进行查询(注意有可能一天内的交易流水有多页记录,需要按返回的MORE
字段进行判断是否需要多次查询),然后外部进行循环以便执行指定日期范围的查询- 同一个银行账号,有可能在多个业务系统内存在交易记录,那么如何判断查询的交易流水是否是当前业务系统所属记录呢,这时候就需要
ICIBTransactionPurposeBuilder
了,后面会有具体的例子来展示如何处理该问题 - 因为转账存在手续费,所以在进行退票前,业务上完全可以先将交易流水相关的一些核心信息(比如
HXJYLSBH
)以及其对应的手续费先通过Job
等方式预先同步到本地数据库,那么在进行退票流水关联交易流水时,就不用再去通过3.3.6 账户余额和交易流水分页查询
查询前几天的交易流水,而是转而进行本地查询 - 如果你不关心手续费问题,那么你可能就不会去主动同步交易流水信息,这时候你就要按查询到的退票记录,自动推断其对应转账交易可能发生的日期,但因为实际交易时间可能发生在退票前的N天内,那么为了减少查询次数,需要将所有退票流水推断到的转账时间进行去重,然后再按结果时间范围进行
3.3.6 账户余额和交易流水分页查询
2019-05-05调整 实际生产发现汇款时用于判断的BUSINESSTYPE
居然为空,天坑!!!此处改为由SUMMNAME
进行判断。
using BEDA.CIB.Contracts;
using BEDA.CIB.Contracts.Requests;
using BEDA.CIB.Contracts.Responses;
/// <summary>
/// 兴业银行交易辅助类
/// </summary>
public class CIBTransactionHelper
{
private long _cid;
private string _userId;
private string _pwd;
private ICIBTransactionPurposeBuilder _buider;
/// <summary>
/// 转账对应的SUMMNAME
/// </summary>
public static string TransactionSummaryName = "汇款";
/// <summary>
/// 退票对应的SUMMNAME
/// </summary>
public static string RefundSummaryName = "汇出退回";
/// <summary>
/// 手续费对应的SUMMNAME
/// </summary>
public static string ChargesSummaryName = "收费";
/// <summary>
/// 构造函数
/// </summary>
/// <param name="cid">兴业银行银企直联客户号</param>
/// <param name="userId">兴业银行银企直联登录用户名</param>
/// <param name="pwd">兴业银行银企直联登录密码</param>
/// <param name="host">前置机域名,默认为127.0.0.1</param>
/// <param name="port">前置机端口,默认为8007</param>
/// <param name="builder">转账交易用途构建实现,如果不传则使用默认实现<see cref="CIBTransactionPurposeBuilder"/></param>
public CIBTransactionHelper(long cid, string userId, string pwd,
string host = "127.0.0.1", int port = 8007, ICIBTransactionPurposeBuilder builder = null)
{
if (cid <= 0 || string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(pwd)
|| string.IsNullOrWhiteSpace(host))
{
throw new ArgumentException();
}
this._cid = cid;
this._userId = userId;
this._pwd = pwd;
this._buider = builder;
if (builder == null)
{
this._buider = new CIBTransactionPurposeBuilder();
}
this.Client = new CIBClient(host, port);
}
/// <summary>
/// 兴业银行银企直联客户端
/// </summary>
public ICIBClient Client { get; set; }
/// <summary>
/// 当退票时查询几天内的交易流水,默认按兴业银行文档设置为2天
/// </summary>
public int RefundDayDiff { get; set; } = 2;
/// <summary>
/// 生成一个用于查询的TRNUID,注意转账之类的业务切记不要采用此方法获取TRNUID
/// </summary>
/// <param name="key"></param>
/// <returns></returns>
public static string GetQueryTRNUID(string key)
{
var tmp = (Math.Abs(Guid.NewGuid().GetHashCode()) % 10000).ToString("0000");
return string.Format("{0:yyMMddHHmmssfff}_{1}_{2}", DateTime.Now, key, tmp);
}
/// <summary>
/// 获取兴业银行3.6查询接口请求主体
/// </summary>
/// <param name="acctid"></param>
/// <param name="dtStart"></param>
/// <param name="dtEnd"></param>
/// <param name="page"></param>
/// <param name="selType"></param>
/// <returns></returns>
public FOXRQ<V1_SCUSTSTMTTRNRQ, V1_SCUSTSTMTTRNRS> GetCIBRequest_3_6(string acctid, DateTime dtStart, DateTime dtEnd, int page, int selType)
{
return new FOXRQ<V1_SCUSTSTMTTRNRQ, V1_SCUSTSTMTTRNRS>()
{
SIGNONMSGSRQV1 = new SIGNONMSGSRQV1
{
SONRQ = new SONRQ
{
CID = this._cid,
USERID = this._userId,
USERPASS = this._pwd,
}
},
SECURITIES_MSGSRQV1 = new V1_SCUSTSTMTTRNRQ
{
SCUSTSTMTTRNRQ = new SCUSTSTMTTRNRQ
{
TRNUID = GetQueryTRNUID("3.6" + "_" + selType),
SCUSTSTMTRQ = new SCUSTSTMTTRN_SCUSTSTMTRQ
{
VERSION = "2.0",
ACCTFROM = new ACCTFROM
{
ACCTID = acctid
},
INCTRAN = new INCTRAN
{
DTEND = dtEnd,
DTSTART = dtStart,
TRNTYPE = 2,
PAGE = page,
},
SELTYPE = selType
}
}
}
};
}
/// <summary>
/// 获取退票记录
/// </summary>
/// <param name="acctid"></param>
/// <param name="dtStart"></param>
/// <param name="dtEnd"></param>
/// <returns></returns>
public IList<STMTTRN> GetRefundRecords(string acctid, DateTime dtStart, DateTime dtEnd)
{
return this.GetRecords(acctid, dtStart, dtEnd, 3);
}
private List<STMTTRN> GetRecords(string acctid, DateTime dtStart, DateTime dtEnd, int selType)
{
var list = new List<STMTTRN>();
dtStart = dtStart.Date;
dtEnd = dtEnd.Date;
if (dtStart <= dtEnd)
{
//历史与当日不能同查,所以此处要加以判断,因为每日流水可能较大,所以此处简单拆分成按每天查询
var dt = dtStart;
for (; dt <= dtEnd;)
{
for (var i = 1; ; i++)
{
var rq = GetCIBRequest_3_6(acctid, dt, dt, i, selType);
var rs = this.Client.Execute(rq);
if (rs != null && rs.ResponseSuccess && rs.SIGNONMSGSRSV1?.SONRS?.STATUS?.IsCorrect == true
&& rs.SECURITIES_MSGSRSV1?.SCUSTSTMTTRNRS?.STATUS?.IsCorrect == true
&& rs.SECURITIES_MSGSRSV1.SCUSTSTMTTRNRS.SCUSTSTMTRS?.TRANLIST?.List != null)
{
list.AddRange(rs.SECURITIES_MSGSRSV1.SCUSTSTMTTRNRS.SCUSTSTMTRS.TRANLIST.List);
if (rs.SECURITIES_MSGSRSV1.SCUSTSTMTTRNRS.SCUSTSTMTRS.TRANLIST.MORE == "Y")
{
continue;
}
}
break;
}
dt = dt.AddDays(1);
}
}
return list;
}
/// <summary>
/// 获取交易记录(含手续费)
/// </summary>
/// <param name="acctid"></param>
/// <param name="dtStart"></param>
/// <param name="dtEnd"></param>
/// <returns></returns>
public IList<STMTTRN> GetTransactionRecords(string acctid, DateTime dtStart, DateTime dtEnd)
{
return this.GetRecords(acctid, dtStart, dtEnd, 1);
}
/// <summary>
/// 根据退票记录获取其对应的交易记录
/// </summary>
/// <param name="refundList">退票流水</param>
/// <param name="acctid">当前退票属于哪个账号</param>
/// <param name="transList">交易流水,默认为null,代表按退票流水自动查询,如果不为null则与退票流水进行对比</param>
/// <returns>Key为交易流水id,Tuple.Item1为交易流水,Tuple.Item2为退票流水</returns>
public IDictionary<string, Tuple<STMTTRN, STMTTRN>> GetRefundMapping(IList<STMTTRN> refundList, string acctid, IList<STMTTRN> transList = null)
{
var dic = new Dictionary<string, Tuple<STMTTRN, STMTTRN>>();
if (refundList != null && refundList.Count > 0)
{
refundList = refundList.Where(x => x.SUMMNAME == RefundSummaryName).OrderBy(x => x.DTACCT).ToList();
if (refundList.Count > 0)
{
if (transList == null || transList.Count == 0)
{//如果未传递交易流水,则自动按退票日期获取对应日期的所有交易流水
transList = this.GetTransactionRecords(acctid, refundList);
}
var query = from refund in refundList
join trans in transList
on refund.MEMO equals trans.HXJYLSBH
where trans.SUMMNAME == TransactionSummaryName
select Tuple.Create(trans, refund);
dic = query.ToDictionary(k => this._buider.GetIdFromPurpose(k.Item1.PURPOSE), v => v);
}
}
return dic;
}
private IList<STMTTRN> GetTransactionRecords(string acctid, IList<STMTTRN> refundList)
{
var transList = new List<STMTTRN>();
var timeList = refundList.Select(x => x.DTACCT.Date).Distinct().OrderBy(d => d).ToList();
//虽然底层查询时是拆分成按每日查询,但因为退票需要倒查两天的交易流水,所以将日期按连续性拆分成日期范围还是有必要的
var timeRange = this.GetTimeRange(timeList);
foreach (var t in timeRange)
{
var tmp = this.GetTransactionRecords(acctid, t.Item1.AddDays(-1), t.Item2);
transList.AddRange(tmp);
}
return transList;
}
private IList<Tuple<DateTime, DateTime>> GetTimeRange(IList<DateTime> timeList)
{
var timeRange = new List<Tuple<DateTime, DateTime>>();
var dtStart = timeList[0];
var dtEnd = timeList[0];
for (var i = 1; i <= timeList.Count; i++)
{
DateTime dt = DateTime.MaxValue;
if (i < timeList.Count)
{
dt = timeList[i];
}
if (dt >= dtEnd && dt <= dtEnd.AddDays(this.RefundDayDiff))
{
//退票需要查询交易流水日期范围为交易当天或交易前一天
//所以如果出现跳日,比如03-19和03-21,也应该算是连续日期
dtEnd = dt;
}
else
{
timeRange.Add(Tuple.Create(dtStart, dtEnd));
dtStart = dt;
dtEnd = dt;
}
}
return timeRange;
}
/// <summary>
/// 根据交易流水获取对应的交易记录及手续费
/// </summary>
/// <param name="list">交易流水</param>
/// <returns>Key为交易流水id,Tuple.Item1为交易流水,Tuple.Item2为手续费</returns>
public IDictionary<string, Tuple<STMTTRN, decimal>> GetServiceChargesMapping(IList<STMTTRN> list)
{
var dic = new Dictionary<string, Tuple<STMTTRN, decimal>>();
if (list != null && list.Count > 0)
{
//此处判断PURPOSE是否是当前业务组织的PURPOSE
list = list.Where(x => (x.SUMMNAME == TransactionSummaryName && this._buider.IsCorrectPurpose(x.PURPOSE))
|| x.SUMMNAME == ChargesSummaryName).ToList();
if (list.Count > 0)
{
var groups = list.GroupBy(x => x.HXJYLSBH); // new { x.SRVRTID, x.DTACCT }
#if DEBUG
var tmp = groups.ToList();
#endif
foreach (var g in groups)
{
var trans = g.FirstOrDefault(x => x.SUMMNAME == TransactionSummaryName);
if (trans == null)
{
continue;
}
var id = this._buider.GetIdFromPurpose(trans.PURPOSE);
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
//可能会无需手续费
var charge = g.FirstOrDefault(x => x.SUMMNAME == ChargesSummaryName)?.TRNAMT ?? 0;
dic.Add(id, Tuple.Create(trans, charge));
}
}
}
return dic;
}
}
要用到的BEDA.CIB
可见此处,该SDK支持NET452
及Standard2.0
版本,可以注意到CIBTransactionHelper
构造函数最后一个参数是ICIBTransactionPurposeBuilder
,因为在兴业银行的测试环境中,同时有其它对接用户也在进行直联测试,所以我们此时就需要自定义一个ICIBTransactionPurposeBuilder
,将查询流水获取到的数据进行过滤,因为只是测试,所以在发起3.4.1转账汇款指令提交
请求时,传入的PURPOSE
只是简单的按特定字符前缀+日期
的格式string.Format("fkb_{0:yyMMddHHmmssfff}", DateTime.Now)
进行组织,所以我们也只需要继承CIBTransactionPurposeBuilder
并简单的将IsCorrectPurpose
给重写下就行,当然实际业务场景中你肯定不能这么任性。
2020-04-26补充:貌似中国人民银行新规,如果PURPOSE
不是以汉字开头的,很可能会当日冲账
class CustCIBTransactionPurposeBuilder : CIBTransactionPurposeBuilder
{
public override bool IsCorrectPurpose(string purpose)
{
if (!string.IsNullOrWhiteSpace(purpose))
{
return Regex.IsMatch(purpose, @"^fkb_\d{15}$");
}
return false;
}
}
最后就是调用示例,具体如下
const long cid = 1100343164;
const string uid = "qw1";
const string pwd = "a1111111";//密码错误6次账号会被永久锁定无法解锁
const string ip = "127.0.0.1";
const int port = 8007;
const string mainAccountId = "117010100100000177";
public static void TransactionHelperSample()
{
var helper = new CIBTransactionHelper(cid, uid, pwd, ip, port, new CustCIBTransactionPurposeBuilder());
var transList = helper.GetTransactionRecords(mainAccountId, new DateTime(2019, 3, 19), new DateTime(2019, 3, 19));
var changeDic = helper.GetServiceChargesMapping(transList);
var refundList = helper.GetRefundRecords(mainAccountId, new DateTime(2019, 3, 19), new DateTime(2019, 3, 19));
#if DEBUG
if (refundList.Count == 0)
{
//为方便测试,手工增加一条退票记录
//fkb_190319140625534 H00100201903190004631399460000
refundList.Add(new STMTTRN
{
DTACCT = new DateTime(2019, 3, 19),
BUSINESSTYPE = CIBTransactionHelper.RefundBusinessType,
HXJYLSBH = "K00100201903190004631399460000",//假编号
MEMO = "H00100201903190004631399460000",
SUMMNAME = "汇出退回",
SUMMDESC = "汇出退回解付",
PURPOSE = "账号户名不符",
});
}
#endif
//如果你已经通过GetTransactionRecords获取并持久化了手续费及HXJYLSBH
//那么下面Mappding这步就可以忽略,转为直接查本地数据库
helper.RefundDayDiff = 2;
//人行退票实际允许的范围是3个工作日,极端情况下会出现7个工作日
//所以此处虽然兴业银行说是只要查2天范围,但此处还是允许自定义日期范围
var refundDic = helper.GetRefundMapping(refundList, mainAccountId, transList);
}
你可以在此处查看完整的示例代码。