引言:
- 上篇:业务功能、退款接口的协议规则、请求
- 下篇:返回结果处理、测试技巧、常见问题处理方案
背景:如果商家平台侧服务出现问题,商家需要一个备选方案进行正常的收退款;因此在POS机新增一个开关进行切换支付通道,智能机app直接与银联对接。并在本地维护产生的订单数据和维护订单状态。
需求:《备用无卡通道》备用收款模式下,扫码支付(微信/支付宝/银联二维码)向条码前置平台发起
1、支付成功的订单支持退款功能 2、退款中的订单支持查询退款状态 3、由于目前平台和银联的订单对账间隔是1天,因此为了解决商户平台交易流水的订单记录无法实时与银联同步的问题,对申请退款成功的订单进行本地数据的构造,以便商家实时看到最新的退款状态(
数据根据退款单号和用户ID为联合主键进行存储
) 4、银联前置支付当天退款成功的条件是,当天可退款金额<=当天的收款金额,否则会转为退款中状态
主要开发任务:
1、对接条码支付前置订单申请退款接口 2、构造条码前置的退款中的订单,并根据查询接口修改订单状态(本地数据保留七天) 3、对接条码前置退款查询API 4、封装银联接口协议(提交和返回数据都为XML格式) 5、我的>>设置,增加“备用收款模式”:校验到存在QRA商户号时显示;默认关闭;开启时,提示“备用收款模式启用1小时候将自动关闭!”
I、业务功能
商户针对某一个已经成功支付的订单发起退款,操作结果在同一会话中同步返回。
1.1 退款方式
- 退款方式:条码支付综合前置目前只支持原路返回退款
1、退到银行卡则是非实时的,每个银行的处理速度不同,一般发起退款后1-3个工作日内到账。2、同一笔单的部分退款需要设置相同的商户订单号out_trade_no和不同的商户退款单号out_refund_no 。一笔退款失败后重新提交,要采用原来 的out_refund_no。总退款金额不能超过用户实际支付金额(现金券金额不能退款)
1.2 退款条件判断
退款条件判断:
代码语言:javascript复制1、银联新一代不支持原路退款, 2、微商/银联前置支持原路退款 3、如果payChannelId = 18 就直接请求url:https://qra.95516.com/pay/gateway进行退款
case 6://银联:
{
if(self.onlinePayment.payChannelId.integerValue == 17){// ,银联新一代不支持原路退款,
return YES;//不支持原路
}else{// 微商/银联前置
return NO;//支持原路
// "payChannelId": 18,
// "payChannelName": "银联前置",支持退款,订单详情不展示结算状态信息
}
}
1.3 退款限制
- 退款限制
1、一笔交易单可以多次退款,商户退款单号唯一确定一 次退款(退款申请单号由商户生成,所以商户一定要保证退款申请单的唯一性。商家在退款过程中要特别 ) 2、请求频率限制:150qps(即每秒钟正常的申请退款请求次数不超过150次) 3、错误或无效请求频率限制:6qps,(即每秒钟异常或错误的退款申请请求不超过6次) 4、每个支付订单的部分退款次数不能超过50次。
在这里插入图片描述
针对
一笔退款失败后重新提交,请不要更换退款单号,请使用原商户退款单号。
的要求,我们可以再退款失败的订单详情新增一个重新发起退款入口
II 、退款接口的协议规则
- 采用UTF-8字符编码
2.1 数据格式
提交和返回数据都为XML格式,根节点名为xml
请求url:https://qra.95516.com/pay/gateway
代码语言:javascript复制POST XML 内容体进行请求 采用标准XML协议,所有参数只存在一级节点xml中,不采用多级节点嵌套,并且需要包含在CDATA内
<xml>
<body><![CDATA[测试支付]]></body>
<mch_create_ip><![CDATA[127.0.0.1]]></mch_create_ip>
<mch_id><![CDATA[7551000001]]></mch_id>
<nonce_str><![CDATA[1409196838]]></nonce_str>
<out_trade_no><![CDATA[141903606228]]></out_trade_no>
<service><![CDATA[unified.trade.micropay]]></service>
<sign><![CDATA[52836FAD27E0813DAA4072A4BDA9F654]]></sign>
<total_fee><![CDATA[1]]></total_fee>
</xml>
- java 代码的实现 Map to Xml(
所有参数只存在一级节点xml中,不采用多级节点嵌套,并且需要包含在CDATA内
)
//所有参数只存在一级节点xml中,不采用多级节点嵌套,并且需要包含在CDATA内
public static String toXml(Map<String, String> params){
StringBuilder buf = new StringBuilder();
List<String> keys = new ArrayList<String>(params.keySet());
Collections.sort(keys);
buf.append("<xml>");
for(String key : keys){
buf.append("<").append(key).append(">");
buf.append("<![CDATA[").append(params.get(key)).append("]]>");
buf.append("</").append(key).append(">n");
}
buf.append("</xml>");
return buf.toString();
}
2.2 签名算法
- 签名算法:MD5,后续会兼容SHA1、SHA256、HMAC等
- 请求和接收数据均需要校验签名安全规范-签名算法
签名时用机构对应的密钥key
iOS 安全规范指南之【对请求参数进行签名】请求参数按照ASCII码从小到大排序、拼接、加密(采用递归的方式进行实现)
1、文章:https://kunnan.blog.csdn.net/article/details/108195721
2、从CSDN下载demo地址:https://download.csdn.net/download/u011018979/15483107
2.3 封装银联接口协议
代码语言:javascript复制#import "ProjectMethod.h"
#import "NetworkHelper4XML.h"
@implementation NetworkHelper4XML
(void)postWithURL:(NSString *)url params:(NSData* )params success:(void (^)(id))success failure:(void (^)(NSError *))failure
{
// 1/封装NSMutableURLRequest
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
request.HTTPMethod = @"POST";
request.HTTPBody = params;
// 2/ AFHTTPSessionManager 创建NSURLSessionDataTask
AFHTTPSessionManager *mgr = [AFHTTPSessionManager manager];
mgr.responseSerializer = [AFHTTPResponseSerializer serializer];//返回数据以xml格式接收
//
mgr.requestSerializer = [AFHTTPRequestSerializer serializer];
[mgr.requestSerializer setValue:@"gzip, deflate" forHTTPHeaderField:@"Accept-Encoding"];
//text/xml
[mgr.requestSerializer setValue:@"application/xml;charset=UTF-8" forHTTPHeaderField:@"Content-Type"];
mgr.requestSerializer.timeoutInterval = 30.0;
// //3.NSURLSessionDataTask 进行请求
[[mgr dataTaskWithRequest:request uploadProgress:nil downloadProgress:nil completionHandler:^(NSURLResponse * _Nonnull response, id _Nullable responseObject, NSError * _Nullable error) {
//————————————————
//版权声明:本文为CSDN博主「#公众号:iOS逆向」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
//原文链接:https://blog.csdn.net/z929118967/article/details/74558561
//
// [[mgr dataTaskWithRequest:request completionHandler:^(NSURLResponse * _Nonnull response, id _Nullable responseObject, NSError * _Nullable error) {
NSString * strResponse = [[NSString alloc] initWithData:responseObject encoding:NSUTF8StringEncoding];
NSLog(@"requestFinished");
NSHTTPURLResponse *hTTPURLResponse =(NSHTTPURLResponse*)response;
NSLog(@"statusCode: %li", (long)[hTTPURLResponse statusCode]);
NSLog(@"response:n%@", strResponse);
if (error == nil) {//请求成功
if (hTTPURLResponse.statusCode == 500)
{
UIAlertView *av = [[UIAlertView alloc]initWithTitle:@"" message:@"系统内部错误" delegate:nil cancelButtonTitle:@"确定" otherButtonTitles:nil, nil];
[av show];
return;
}
if (success) {
success(responseObject);
}
}else{ //请求失败
if (failure) {
failure(error);
}
}
}] resume] ;
}
(void)postWithURL:(NSString *)url params:(NSData *)params successBlock:(void (^)(CXMLDocument *xml))success RspCDFailed:(void (^)(CXMLDocument *xml))RspCDFailed failure:(void (^)(NSError *error))failure isShowLoadingDataGif:(BOOL)isShowLoadingDataGif
{
if (isShowLoadingDataGif) {
[[ProjectMethod shareProjectMethod] showLoadingDataGif];
}
[self postWithURL:url params:params success:^(id response) {
if (isShowLoadingDataGif) {
[[ProjectMethod shareProjectMethod] hiddenLoadingDataGif];
}
NSString * strResponse = [[NSString alloc] initWithData:response encoding:NSUTF8StringEncoding];
CXMLDocument *xml= [[CXMLDocument alloc] initWithXMLString:strResponse options:0 error:nil];
if ([self generalExceptionHandling:strResponse]){
if (success) {
success(xml);
}
}else{
if (RspCDFailed) {//业务逻辑失败处理
RspCDFailed(xml);
}
else {
if (isShowLoadingDataGif) {
// <err_code_des><![CDATA[二维码已过期,请刷新再试]]></err_code_des>
// <message><![CDATA[Parse xml error,please use UTF-8 encoded]]></message>
// <err_msg>
// <![CDATA[已退款]]>
// </err_msg>
NSString *err_code_des = [[xml nodesForXPath:@"//xml/err_code_des" error:nil].lastObject stringValue];
NSString *message = [[xml nodesForXPath:@"//xml/message" error:nil].lastObject stringValue];
NSString *err_msg = [[xml nodesForXPath:@"//xml/err_msg" error:nil].lastObject stringValue];
if([NSStringQCTtoll isBlankString:err_msg]){
err_msg =message;
if([NSStringQCTtoll isBlankString:err_msg]){
err_msg =err_code_des;
}
}
if (![NSStringQCTtoll isBlankString:err_msg]) {
UIAlertView *al = [[UIAlertView alloc]initWithTitle:@"" message:err_msg delegate:nil cancelButtonTitle:nil otherButtonTitles:@"确定", nil];
[al show];
}
}
}
}
} failure:^(NSError *error) {//请求失败
if (failure) {
failure(error);
}else{
if (isShowLoadingDataGif) {
[[ProjectMethod shareProjectMethod] hiddenLoadingDataGif];
[QCTNetworkHelper showLoading_failed_please_try_again_laterBlock];
}else{// 异步请求,不展示错误信息
}
}
}];
}
/**
判断是否业务逻辑处理成功
@param strResponse XML数据的字符串
@return bool 是否业务逻辑处理成功
协议级错误返回:<status>500</status>
正确返回数据:<status>0</status> <result_code>0</result_code>
业务级错误返回:<status>0</status> <result_code>1</result_code>
<status><![CDATA[400]]></status>
<message><![CDATA[Parse xml error,please use UTF-8 encoded]]></message>
<code><![CDATA[04200_1219999]]></code>
*/
(BOOL)generalExceptionHandling:(NSString*)strResponse
{
CXMLDocument *xml= [[CXMLDocument alloc] initWithXMLString:strResponse options:0 error:nil];
//![CDATA[400]]
NSString *strRespondCode = [[xml nodesForXPath:@"//xml/status" error:nil].lastObject stringValue];
NSString *result_code = [[xml nodesForXPath:@"//xml/result_code" error:nil].lastObject stringValue];
//
NSLog(@"%@", FMSTR(@"strRespondCode%@ result_code:%@ ",strRespondCode,result_code));
if ([strRespondCode isEqualToString:@"0"] && [result_code isEqualToString:@"0"])
{
return true;
}
else{
return false;
}
}
@end
- 用法:
NSMutableDictionary *requestParameter = [NSMutableDictionary dictionary];
NSDictionary *requestInitDictionary =[CMPayInitXMLContent DictionaryWithservice:@"unified.trade.refund" requestParameter:requestParameter];
NSString *url = requestInitDictionary[CMPAYHttpParamsRequestURLkey];
NSData * params = requestInitDictionary[CMPAYHttpParamsPostBodykey];//"CXMLDocument.h"
[ERPNetworkHelper4XML postWithURL:url params:params successBlock:^(CXMLDocument * _Nonnull xml) {
NSLog(@"跳到成功页");
NSLog(@"退款中 跳转到退款申请中");
} RspCDFailed:nil failure:nil isShowLoadingDataGif:YES];
}
III、请求
字段名 | 变量名 | 必填 | 类型 | 说明 |
---|---|---|---|---|
接口类型 | service | 是 | String(32) | 接口类型:unified.trade.refund |
版本号 | version | 否 | String(8) | 版本号,version默认值是2.0。 |
字符集 | charset | 否 | String(8) | 可选值 UTF-8 ,默认为 UTF-8。 |
签名方式 | sign_type | 否 | String(8) | 签名类型,取值:MD5默认:MD5 |
商户号 | mch_id | 是 | String(15) | 商户号,由平台分配 |
商户订单号 | out_trade_no | 否 | String(32) | 商户系统内部的订单号, out_trade_no和transaction_id至少一个必填,同时存在时transaction_id优先 |
平台订单号 | transaction_id | 否 | String(32) | 平台单号, out_trade_no和transaction_id至少一个必填,同时存在时transaction_id优先 |
商户退款单号 | out_refund_no | 是 | String(32) | 商户退款单号,32个字符内、可包含字母,确保在商户系统唯一。同个退款单号多次请求,平台当一个单处理,只会退一次款。如果出现退款不成功,请采用原退款单号重新发起,避免出现重复退款。 |
总金额 | total_fee | 是 | Int | 订单总金额,单位为分 |
单品信息 | goods_detail | 否 | String(6000) | 单品营销活动该字段必传,且必须按照规范上送,JSON格式,详见单品优惠说明 |
退款金额 | refund_fee | 是 | Int | 退款总金额,单位为分,可以做部分退款 |
收银员 | op_user_id | 是 | String(32) | 操作员帐号,默认为商户号 |
退款渠道 | refund_channel | 否 | String(16) | ORIGINAL-原路退款,默认 |
随机字符串 | nonce_str | 是 | String(32) | 随机字符串,不长于 32 位 |
签名 | sign | 是 | String(32) | MD5签名结果,详见“安全规范” |
授权交易机构 | sign_agentno | 否 | String(12) | 授权交易的服务商机构代码,商户授权给服务商交易的情况下必填,签名使用服务商的密钥 |
连锁商户号 | groupno | 否 | String(15) | 连锁商户为其下门店发交易的情况必填,签名使用连锁商户的密钥 |
3.1 参数构造
- 必填 参数(商户订单号out_trade_no和平台单号transaction_id至少一个必填,同时存在时transaction_id优先)
接口类型(service) 商户号(out_trade_no)商户退款单号(out_refund_no):yyMMddHHmmss xxxxxx(sid) 递增序号(隔天重置)具体实现代码请看这篇文章:https://kunnan.blog.csdn.net/article/details/115135858 提交一次请求,生成一个新的退款单号 总金额(total_fee) 退款金额(refund_fee) 收银员(op_user_id) 随机字符串(nonce_str) 签名(sign): iOS 安全规范指南之【对请求参数进行签名】请求参数按照ASCII码从小到大排序、拼接、加密(采用递归的方式进行实现)https://kunnan.blog.csdn.net/article/details/108195721 MD5签名计算公式:
sign= Md5(原字符串&key=商户密钥).toUpperCase
- 固定参数
1、接口类型:unified.trade.refund 2、key:3、机构号:
- iOS网络请求指南之【请求数据格为XML格式】https://kunnan.blog.csdn.net/article/details/74938721
1、对第三方网络SDK API进一步封装,将业务逻辑代码与网络框架进行解耦 2 、 静态库中使用第三方框架与主app冲突的解决方案
- Map转Xml字符串 (Java)
public static String toXml(Map<String, String> params){
StringBuilder buf = new StringBuilder();
List<String> keys = new ArrayList<String>(params.keySet());
Collections.sort(keys);
buf.append("<xml>");
for(String key : keys){
buf.append("<").append(key).append(">");
buf.append("<![CDATA[").append(params.get(key)).append("]]>");
buf.append("</").append(key).append(">n");
}
buf.append("</xml>");
return buf.toString();
}
- iOS NSDictionary转Xml字符串
/**
提交和返回数据都为XML格式,根节点名为xml
采用标准XML协议,所有参数只存在一级节点中,不采用多级节点嵌套,并且需要包含在CDATA内
*/
(NSMutableString *)DicToXmlstr:(NSDictionary*)requestParameter{
NSMutableString *str = [[NSMutableString alloc ]initWithString:@"<xml>"];
[requestParameter enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
//#define FMSTR(x,...) [NSString stringWithFormat:(x), ##__VA_ARGS__]
[str appendString: FMSTR(@"<%@>",key) ];
[str appendString: FMSTR(@"<![CDATA[%@]]>",obj) ];
[str appendString: FMSTR(@"</%@>",key) ];
}];
[str appendString:@"</xml>"];
return str;
}
- 设置签名参数
(NSDictionary *)DictionaryWithservice:(NSString *)service requestParameter:(NSMutableDictionary *)requestParameter{
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
// 接口类型
[requestParameter setValue:service forKey:@"service"];//
//1、签名
[requestParameter setValue:[ERPSignatureGenerator getSortedDictionary4UnionpaySign:requestParameter] forKey:@"sign"];//
// 2、DicToXmlstr
NSMutableString *strXML = [self DicToXmlstr:requestParameter];
NSData *myPostData = [strXML dataUsingEncoding:NSUTF8StringEncoding];
NSMutableData *myMutablePostData = [NSMutableData dataWithData:myPostData];
dict[CMPAYHttpParamsPostBodykey] = myMutablePostData;
NSString *url = k_URL_qra_gateway_Refund;
dict[CMPAYHttpParamsRequestURLkey]= url ;
return dict;
}
- 随机字符串(nonce_str):
getrandomStringWithLength:
[requestParameter setValue:[ERPGeneralUtil getrandomStringWithLength:32] forKey:@"nonce_str"];
#import "ERPGeneralUtil.h"
@implementation ERPGeneralUtil
(NSString *)convertHexStrToString:(NSString *)str {
if (!str || [str length] == 0) {
return nil;
}
NSMutableData *hexData = [[NSMutableData alloc] initWithCapacity:8];
NSRange range;
if ([str length] % 2 == 0) {
range = NSMakeRange(0, 2);
} else {
range = NSMakeRange(0, 1);
}
for (NSInteger i = range.location; i < [str length]; i = 2) {
unsigned int anInt;
NSString *hexCharStr = [str substringWithRange:range];
NSScanner *scanner = [[NSScanner alloc] initWithString:hexCharStr] ;
[scanner scanHexInt:&anInt];
NSData *entity = [[NSData alloc] initWithBytes:&anInt length:1];
[hexData appendData:entity];
range.location = range.length;
range.length = 2;
}
NSString *string = [[NSString alloc]initWithData:hexData encoding:NSUTF8StringEncoding];
return string;
}
// 生成字符串长度
//#define kRandomLength 10
// 随机字符表
/**
生成0-x之间的随机正整数
int value =arc4random_uniform(x + 1);
生成随机正整数
int value = arc4random();
通过arc4random() 获取0到x-1之间的整数的代码如下:
int value = arc4random() % x;
获取1到x之间的整数的代码如下:
int value = (arc4random() % x) 1;
最后如果想生成一个浮点数,可以在项目中定义如下宏:
#define ARC4RANDOM_MAX 0x100000000
然后就可以使用arc4random() 来获取0到100之间浮点数了(精度是rand()的两倍),代码如下:
double val = floorf(((double)arc4random() / ARC4RANDOM_MAX) * 100.0f);
*/
static const NSString *kRandomAlphabet = @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
(NSMutableString*)getrandomStringWithLength:(NSInteger)kRandomLength{
NSMutableString *randomString = [NSMutableString stringWithCapacity:kRandomLength];
for (int i = 0; i < kRandomLength; i ) {
[randomString appendFormat: @"%C", [kRandomAlphabet characterAtIndex:arc4random_uniform((u_int32_t)[kRandomAlphabet length])]];
}
NSLog(@"randomString = %@", randomString);
return randomString;
}
@end
3.2 签名方式 sign_type
签名类型,取值:MD5
iOS 安全规范指南之【对请求参数进行签名】请求参数按照ASCII码从小到大排序、拼接、加密(采用递归的方式进行实现)
1、文章:https://kunnan.blog.csdn.net/article/details/108195721
2、从CSDN下载demo地址:https://download.csdn.net/download/u011018979/15483107
3.3 金额的格式转化处理(total_fee Int 类型)
金额的格式转化处理的原文地址:https://blog.csdn.net/z929118967/article/details/84658432
- 输入框显示金额字段
return FMSTR(@"%0.2f",self.refundableAmount);// 金融字段处理
//@property (nonatomic,assign) double refundableAmount;//
. 避免出现这样的错误
<total_fee><![CDATA[7.000000000000001]]></total_fee>
- lab控件显示格式化之后的金额(金额的格式转化)
(NSString*)getReservationConquestAmountStr:(NSString*)topNO{
NSString *tmp = [QCT_Common stringChangeMoneyWithStr:topNO numberStyle:kCFNumberFormatterDecimalStyle];
tmp = [tmp substringFromIndex:1-1];
return tmp;
}
(NSString*)getAmountStr:(NSString*)topNO{
if([topNO containsString:@","] || [topNO containsString:@"," ]){
return topNO;
}
NSString *tmp = [QCT_Common stringChangeMoneyWithStr:topNO numberStyle:kCFNumberFormatterDecimalStyle];
if ([topNO doubleValue]<0) {
NSString* tmp1 = [tmp substringFromIndex:2-1];
NSString* tmp2 = [tmp substringToIndex:1-1];
tmp = [tmp2 stringByAppendingString:tmp1];
}else{
tmp = [tmp substringFromIndex:1-1];
}
return tmp;
}
/**
* 金额的格式转化
* str : 金额的字符串
* numberStyle : 金额转换的格式
* return NSString : 转化后的金额格式字符串
/**
* 94863
* NSNumberFormatterNoStyle = kCFNumberFormatterNoStyle,
* 94,862.57
* NSNumberFormatterDecimalStyle = kCFNumberFormatterDecimalStyle,
* ¥94,862.57
* NSNumberFormatterCurrencyStyle = kCFNumberFormatterCurrencyStyle,
* 9,486,257%
* NSNumberFormatterPercentStyle = kCFNumberFormatterPercentStyle,
* 9.486257E4
* NSNumberFormatterScientificStyle = kCFNumberFormatterScientificStyle,
* 九万四千八百六十二点五七
* NSNumberFormatterSpellOutStyle = kCFNumberFormatterSpellOutStyle
*/
(NSString *)stringChangeMoneyWithStr:(NSString *)str numberStyle:(NSNumberFormatterStyle)numberStyle {
// 判断是否null 若是赋值为0 防止崩溃
if (([str isEqual:[NSNull null]] || str == nil)) {
str = 0;
}
NSNumberFormatter *formatter = [[NSNumberFormatter alloc]init];
formatter.locale = [NSLocale currentLocale];
formatter.numberStyle = numberStyle;
// 注意传入参数的数据长度,可用double
NSString *money = [formatter stringFromNumber:[NSNumber numberWithDouble:[str doubleValue]]];
return money;
}
- 元转分(total_fee
必须int类型
)
/**
元转分
%d:整数
%f:浮点数
%s:方法
%c:c字符
%@:OC对象
%p:指针
//保留两位小数
NSLog(@"%0.2f",1.0203);
输出结果:1.02
//使用0左补位
NSLog(@"d",1);
输出结果:0001
//使用空格左补位
NSLog(@"M",1);
输出结果: 1
//字符串补位
NSLog(@"%4s!","a");
输出结果: a!
//字符串右补位
NSLog(@"%-4s!","a");
输出结果:a !
<total_fee><![CDATA[7]]></total_fee>
total_fee Int 类型
*/
(NSString*)changeY2FWithStr:(NSString*)paymentAmount{
NSNumber *tm = [NSNumber numberWithDouble:paymentAmount.doubleValue*100];
return FMSTR(@"%ld",(long)tm.integerValue);//
// return FMSTR(@"%0.2f",tm.doubleValue);//浮点数会报参数错误
}
IV 返回结果处理、测试技巧、常见问题处理方案
https://kunnan.blog.csdn.net/article/details/122805883