赞
踩
PingPongCheckout 跨境支付的 API 接口文档,商户服务器和 PingPongCheckout 服务器进行交互。 供商户/平台服务方的技术开发及测试相关人员使用。 本文档分别从交互流程、通讯方式、签名方 案、交易接口、注意事项等⻆度详细介绍了 PingPongCheckout 跨境支付 API 接口的工作方式和开发过 程,可以帮助开发人员快速接入支付系统,同时也可以作为后续接口参数以及参数类型的速查手册。
通俗一点收银台的付款方式就像现实生活中我们去饭店吃饭时一样,我们只需要去饭店的收银台给他钱然后至于如何扣款怎么扣款我们都不需要过多关心,而收银台付款也是这样,我们只需要把付款的参数交给对方至于具体扣款第三方支付平台会给我们返回一个他们的支付界面,然后我们引导用户过去进行支付操作,支付支付之后的结果我们需要提供一个回调函数,用于第三方支付平台告诉我们支付结果
对于整一个收银台的支付流程借鉴一下官方的图,说的很详细
步骤
- 客户端提交订单给商户服务端处理
- 商户服务端返回PingPongCheckout JS-SDK需要的参数(包含订单信息,签名和JS-SDK初始化需要的参数)
- 客户端初始化PingPongCheckout SDK,并且调用
createPayment
,传入订单信息。- SDK自动开始和PingPongCheckout服务端交互,成功之后将会渲染PingPongCheckout收银台
- 买家填写卡号和cvv等支付信息
- 提交支付信息
- 如果是3D交易,还需要3D验证,否则直接展示交易结果
图例:
7.1 3D流程
7.2 非3D流程
- 异步通知详见如何获取交易状态
- 收到异步通知需要响应OK给PingPongCheckout
此次对接的是“统一下单-本地支付&信用卡”方式支付,他与“获取跳转收银台”方式两者都是在请求完pingpong支付之后生成收银台的跳转链接,但是后者是由我们开发者控制界面跳转只返回支付的界面URL,而后者是由pingpong直接跳转过去,我们采用的是前者也就是统一下单-本地支付&信用卡方式
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* PingPong支付充值获取支付收银台地址所需参数对象
* SDK address:<a href="https://acquirer-api-docs-v3.pingpongx.com/pages/a2c224/#%E5%85%AC%E5%85%B1%E8%AF%B7%E6%B1%82%E5%8F%82%E6%95%B0">...</a>
* 参数上没有写选传或者条件必传则代表字段为必传字段
*
* @author Czw
* @date 2023/05/05
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CoinRechargePingPongRequest implements Serializable {
/**
* 唯一请求号
*/
private String requestId;
/**
* PingPong 商户店铺编号
*/
private String accId;
/**
* MD5-使用MD5算法加签
* SHA256-使用SHA256算法加签
*/
private String signType;
/**
* 签名,签名秘钥(salt)放入签名串的位置为: 签名串的开头 , 即{salt}key1=val2&key2=val2&key3=val3
* <a href="https://acquirer-api-docs-v3.pingpongx.com/pages/77ae52/#%E8%AF%B7%E6%B1%82%E7%AD%BE%E5%90%8D%E8%8C%83%E5%9B%B4">...</a>
*/
private String sign;
/**
* 收单方式
* CHECKOUT-返回收银台地址
* PAY-直接支付
*/
private String acquirerType;
/**
* 交易金额,精确位数和币种有关,请查询附录交易币种
*/
private String amount;
/**
* 交易币种,ISO 4217 三位币种,具体支持币种⻅附录交易币种
*/
private String currency;
/**
* 商户网站交易流水号,每次请求的唯一标识,可用于后续订单查询和对账
*/
private String merchantTransactionId;
/**
* 商户自定义接收重定向的结果 URL;如 3DS验证,银行在线转账或虚拟钱包之类支付方式时,最后需要重定向到商户指定的⻚面地址
*/
private String shopperResultUrl;
/**
* 收银台页面取消支付操作时页面跳转地址,acquirerType=CHECKOUT,并且没有传送paymentBrand时候必传,条件必传
*/
private String shopperCancelUrl;
/**
* 异步通知地址,可选
*/
private String notificationUrl;
/**
* 商户扩展字段,可用于指定特定参数,会在响应体中原样返回(暂不可用),可选
*/
private String remark;
/**
* 建站平台标识,建站平台接入必传。作用是标识这笔交易的是从哪个建站平台发起的,条件必传
*/
private String merchantSource;
/**
* 用户ID,用户在HC的用户ID
*/
private String customerId;
/**
* 用户注册邮箱
*/
private String email;
/**
* 商品名称,例如钻石
*/
private String name;
/**
* 商品数量
*/
private String number;
}
import lombok.Data;
import java.io.Serializable;
/**
* PingPong支付获取收银台地址时返回值dto
*
* @author Czw
* @date 2023/05/06
*/
@Data
public class PingPongPayCheckoutDto implements Serializable {
private static final long serialVersionUID = 7575136778850419334L;
/**
* PingPong 商户店铺编号
*/
private String accId;
/**
* 交易金额
*/
private String amount;
/**
* PingPong 商户店商户号
*/
private String clientId;
/**
* 结果状态码
*/
private String code;
/**
* 交易币种,ISO 4217 三位币种,参考https://acquirer-api-docs-v3.pingpongx.com/pages/3c0bdf/
*/
private String currency;
/**
* 结果描述
*/
private String description;
/**
* 商户网站的的交易流水号
*/
private String merchantTransactionId;
/**
* 由商户自定义本次交易结果通知的地址,一旦填写该参数,PingPongCheckout 将通过 Post 方式异步推送交易结果到该地址
*/
private String notificationUrl;
/**
* 当前交易关联的 PingPong 交易流水号
*/
private String relateTransactionId;
/**
* 签名内容
*/
private String sign;
/**
* MD5、SHA256
*/
private String signType;
/**
* SUCCESS-成功
* FAILED-失败
* PROCESSING-进行中
* PENDING-处理中
* REVIEW-待审核
*/
private String status;
/**
* PingPong 交易流水号
*/
private String transactionId;
/**
* 交易发起时间,yyyyMMddHHmmss
*/
private String transactionTime;
/**
* 本次结账请求的唯一标示,用于初始化JS-SDK
*/
private String token;
/**
* 仅在收银台模式下返回,JS-SDK的加载地址
*/
private String innerJsUrl;
/**
* 仅在收银台模式下返回,PingPong 支付收银台地址
*/
private String paymentUrl;
}
package com.hc.app.user.server.util.pingpong;
import com.google.common.collect.Maps;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import com.hc.app.user.model.request.CoinRechargePingPongRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import java.security.MessageDigest;
import java.util.Map;
import java.util.TreeMap;
/**
* @description: PingPong支付签名工具类
* @author: Czw
* @create: 2023-05-05 12:02
**/
@Slf4j
public class PingPongSignUtil {
/**
* 签名方法-sha256
*/
public static final String SIGN_TYPE_SHA256 = "SHA256";
/**
* 签名方法-md5
*/
public static final String SIGN_TYPE_MD5 = "MD5";
/**
* 测试环境异步通知支付状态地址
*/
public static final String NOTIFICATION_URL_TEST = "";
/**
* 取消支付时跳转的界面
*/
public static final String SHOPPER_CANCEL_URL_TEST = "http://52.221.156.209/pay/pay_success.html?orderId=orderNum&status=4";
/**
* 支付的网站发起地址
*/
public static final String MERCHANT_SOURCE_URL = "";
//------------------沙箱参数信息开始------------------------
public static final String SANDBOX_ACCID = "2018092714313010016291";
/**
* 生成签名时的盐
*/
public static final String SANDBOX_SALT = "F78BC96A55548B2319EE68E0";
//------------------沙箱参数信息结束------------------------
/**
* 部分参数签名,参与签名的字段
*/
private static final String[] includeFields = {"accId", "amount", "clientId", "cardNum", "currency", "merchantTransactionId",
"requestId", "signType", "transactionId"};
/**
* 签名秘钥
*/
private String salt = null;
public PingPongSignUtil(String salt) {
this.salt = salt;
}
public static void main(String[] args) {
TreeMap<String, Object> signMap = new TreeMap<>();
signMap.put("clientId", "2018092714313010016");
signMap.put("accId", "2018092714313010016291");
signMap.put("order", "2018092J7Y1K4U313010016291");
String sha256 = signature("SHA256", SANDBOX_SALT, signMap);
System.out.println(sha256);
}
/**
* 执行签名
*
* @param signType 签名类型 SHA256/MD5
* @param salt 盐
* @param signMap 待签名串
* @return {@link String }
* @author Czw
* @date 2023/05/05
*/
public static String signature(String signType, String salt, TreeMap<String, Object> signMap) {
String signContent = getPartSignParams(signMap);
System.out.println("signContent=" + signContent);
if (StringUtils.equalsIgnoreCase("MD5", signType)) {
return md5Sign(salt, signContent);
} else if (StringUtils.equalsIgnoreCase("SHA256", signType)) {
return sha256(signContent, salt);
}
return null;
}
/**
* 转换为符号映射
*
* @param request 请求
* @param signMap 标志地图
* @author Czw
* @date 2023/05/08
*/
public static void convertToSignMap(CoinRechargePingPongRequest request, TreeMap<String, Object> signMap) {
// 获取对象的所有字段
Field[] fields = request.getClass().getDeclaredFields();
for (Field field : fields) {
// 设置字段可访问
field.setAccessible(true);
// 获取字段的值
Object value = null;
try {
value = field.get(request);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
if (value != null) {
// 将字段名和值作为键值对添加到TreeMap中
signMap.put(field.getName(), value);
}
}
}
/**
* 获取待签名串(部分字段签名)
*/
private static String getPartSignParams(TreeMap<String, Object> signMap) {
//添加需要签名的字段
TreeMap<String, Object> resultMap = Maps.newTreeMap();
for (String param : includeFields) {
String value = (String) signMap.get(param);
if (StringUtils.isNotBlank(value)) {
//应SDK要求对参数进行trim操作
resultMap.put(param, value.trim());
}
}
return getSignParams(resultMap);
}
/**
* 获取待签名串
*/
private static String getSignParams(TreeMap<String, Object> resultMap) {
StringBuilder stringBuilder = new StringBuilder();
int paramNum = 0;
for (Map.Entry<String, Object> signEntry : resultMap.entrySet()) {
paramNum++;
stringBuilder.append(signEntry.getKey());
stringBuilder.append("=");
stringBuilder.append(signEntry.getValue());
if (paramNum < resultMap.size()) {
stringBuilder.append("&");
}
}
log.debug("content:【{}】", stringBuilder);
return stringBuilder.toString();
}
private static String md5Sign(String salt, String content) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(salt.getBytes());
md.update(content.getBytes());
byte[] digest = md.digest();
return byteToHexString(digest);
} catch (Exception e) {
log.error("md5签名失败", e);
}
return null;
}
private static String sha256(String content, String salt) {
try {
if (StringUtils.isBlank(salt)) {
throw new RuntimeException("salt is null");
}
String contentStr = salt.concat(content);
return DigestUtils.sha256Hex(contentStr.getBytes(StandardCharsets.UTF_8)).toUpperCase();
} catch (Exception e) {
log.error("sha256", e);
}
return null;
}
public static String byteToHexString(byte[] b) {
StringBuilder hexString = new StringBuilder();
for (byte value : b) {
String hex = Integer.toHexString(value & 0xFF);
if (hex.length() == 1) {
hex = '0' + hex;
}
hexString.append(hex.toUpperCase());
}
return hexString.toString();
}
}
//------------------沙箱参数信息开始------------------------
public static final String SANDBOX_ACCID = "2018092714313010016291";
/**
* 生成签名时的盐
*/
public static final String SANDBOX_SALT = "F78BC96A55548B2319EE68E0";
/**
* 收单方式-直接支付
*/
public static final String ACQUIRER_TYPE_CHECKOUT = "CHECKOUT";
@GetMapping("/pingPongPay")
public String pingPongPay() {
CoinRechargePingPongRequest pingPongRequest = new CoinRechargePingPongRequest();
String uid = UUID.randomUUID().toString();
pingPongRequest.setRequestId(uid);
pingPongRequest.setAccId(SANDBOX_ACCID);
pingPongRequest.setSignType(SIGN_TYPE_SHA256);
pingPongRequest.setAcquirerType(ACQUIRER_TYPE_CHECKOUT);
pingPongRequest.setAmount("1.99");
pingPongRequest.setCurrency("USD");
String merchantTransactionId = UUID.randomUUID().toString();
pingPongRequest.setMerchantTransactionId(merchantTransactionId);
//URL是自定义的支付结果显示页,pingpongpay接收到后会在后面拼接订单ID"?orderId=123"
pingPongRequest.setShopperResultUrl("http://52.221.156.209/pay/pay_success.html");
//取消支付的URL
pingPongRequest.setShopperCancelUrl("http://52.221.156.209/pay/pay_success.html?orderId=orderNum&status=4".replace("orderNum", merchantTransactionId));
//这个URL是我们自定义的回调订单回调接口地址,参考文档:https://acquirer-api-docs-v3.pingpongx.com/pages/d0ddb3/#%E4%B8%9A%E5%8A%A1%E7%B3%BB%E7%BB%9F%E4%BA%A4%E4%BA%92%E6%B5%81%E7%A8%8B
pingPongRequest.setNotificationUrl("http://52.221.156.209/pay/callback");
pingPongRequest.setRemark("");
pingPongRequest.setMerchantSource("");
pingPongRequest.setCustomerId("10028");
pingPongRequest.setEmail("19**790****@163.com");
pingPongRequest.setName("Token充值");
pingPongRequest.setNumber("67499");
TreeMap<String, Object> signMap = new TreeMap<>();
// 将pingPongRequest对象字段放入map中
convertToSignMap(pingPongRequest, signMap);
pingPongRequest.setSign(PingPongSignUtil.signature("SHA256", SANDBOX_SALT, signMap));
String url;
if (envConfig.isProd()) {
//生产
url = "https://acquirer-payment.pingpongx.com/acquirer/payment";
} else {
//沙箱
url = "https://sandbox-acquirer-payment.pingpongx.com/acquirer/payment";
}
// 设置请求头
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// 构造请求实体
HttpEntity<String> requestEntity = new HttpEntity<>(JSONObject.toJSONString(pingPongRequest), headers);
//设置超时时间
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(5000);
//请求pingpong支付
ResponseEntity<JSONObject> result = restTemplate.postForEntity(url, requestEntity, JSONObject.class);
System.out.println(result);
JSONObject body = result.getBody();
SysLogger.info(this.getClass(), "payermax 创建订单结果" + body.toJSONString());
PingPongPayCheckoutDto payCheckoutDto = JsonFieldUtil.jsonToObj(body, PingPongPayCheckoutDto.class);
System.out.println(payCheckoutDto);
return responseEntity.toString();
}
需要说明的是,请求地址URL在sdk中是一个使用了占位符的地址:https://{host}/acquirer/payment,其中的{host}代表的是对接流程(必读)对应的资源,所以完整url是代码中的https://sandbox-acquirer-payment.pingpongx.com/acquirer/payment地址,包括salt以及accid都是该界面中的资源;我们在设置获取收银台URL时一定要设置NotificationUrl参数,它的作用时让pingpong支付支付的过程中以这样的频率 2s/5s/10s/30s/1m/10m/30m/1h/2h/1d/2d
访问这个NotificationUrl,我们可以在这个接口中更新支付订单的状态以及对应状态下的业务,另外回调函数NotificationUrl的接口中为了保证安全需要做IP访问限制,只允许pingpongpay的服务器IP访问
官方服务器ip
apifox测试界面
可以看一下返回参数paymentUrl,它就是我们需要的支付界面,在浏览器中访问查看,与apifox中的paymentUrl地址一致
支付结果页
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。