当前位置: 首页 > article >正文

跨境电商支付平台-PingPong Pay(实现收银台模式沙箱支付)

介绍

PingPongCheckout 跨境支付的 API 接口文档,商户服务器和 PingPongCheckout 服务器进行交互。 供商户/平台服务方的技术开发及测试相关人员使用。 本文档分别从交互流程、通讯方式、签名方 案、交易接口、注意事项等⻆度详细介绍了 PingPongCheckout 跨境支付 API 接口的工作方式和开发过 程,可以帮助开发人员快速接入支付系统,同时也可以作为后续接口参数以及参数类型的速查手册。

Pingpong商户接入指南

收银台模式系统交互流程介绍

通俗一点收银台的付款方式就像现实生活中我们去饭店吃饭时一样,我们只需要去饭店的收银台给他钱然后至于如何扣款怎么扣款我们都不需要过多关心,而收银台付款也是这样,我们只需要把付款的参数交给对方至于具体扣款第三方支付平台会给我们返回一个他们的支付界面,然后我们引导用户过去进行支付操作,支付支付之后的结果我们需要提供一个回调函数,用于第三方支付平台告诉我们支付结果

image-20230505163716276

对于整一个收银台的支付流程借鉴一下官方的图,说的很详细

JS-SDK交互流程

步骤

  1. 客户端提交订单给商户服务端处理
  2. 商户服务端返回PingPongCheckout JS-SDK需要的参数(包含订单信息,签名和JS-SDK初始化需要的参数)
  3. 客户端初始化PingPongCheckout SDK,并且调用createPayment,传入订单信息。
  4. SDK自动开始和PingPongCheckout服务端交互,成功之后将会渲染PingPongCheckout收银台
  5. 买家填写卡号和cvv等支付信息
  6. 提交支付信息
  7. 如果是3D交易,还需要3D验证,否则直接展示交易结果
  • 图例:

  • 7.1 3D流程

  • 7.2 非3D流程

  1. 异步通知详见如何获取交易状态
  2. 收到异步通知需要响应OK给PingPongCheckout

此次对接的是“统一下单-本地支付&信用卡”方式支付,他与“获取跳转收银台”方式两者都是在请求完pingpong支付之后生成收银台的跳转链接,但是后者是由我们开发者控制界面跳转只返回支付的界面URL,而后者是由pingpong直接跳转过去,我们采用的是前者也就是统一下单-本地支付&信用卡方式

image-20230506102830885

Coding

沙箱环境获取收银台VO类

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;
}

沙箱环境获取收银台DTO类

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;
}

参数加密PingPongSignUtil

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访问

image-20230506103217619

官方服务器ip
image-20230509095547434

沙箱的测试效果

image-20230506103311579

apifox测试界面

image-20230506105348137

可以看一下返回参数paymentUrl,它就是我们需要的支付界面,在浏览器中访问查看,与apifox中的paymentUrl地址一致

image-20230508182805614

支付结果页

image-20230508182815753


http://www.kler.cn/news/18296.html

相关文章:

  • 关于WPA3-H2E的技术讲解
  • Verilog概述一:Verilog HDL和 VHDL详细对比
  • JavaWeb综合案例1-2
  • OpenCV实战——根据立体图像计算深度信息
  • JavaScript:二叉树(前序遍历,中序遍历,后序遍历,递归法,统一迭代法)
  • Databend 开源周报第 92 期
  • C关键字解读——volatile, extern, struct, union, enum
  • Unity 向量
  • Linux上安装Elasticsearch
  • 4。计算机组成原理(2)存储系统
  • 理解FPGA的基础知识——逻辑电路
  • 一次业务系统无法使用对应的oracle数据库性能分析过程
  • React条件渲染、列表渲染和组件传值
  • 增强型语言模型——走向通用智能的道路?!?
  • 11 KVM虚拟机配置-配置虚拟设备(存储)
  • PostgreSQL数据库自带的命令行工具--psql
  • Lumerical------S 参数文件格式
  • Qt 制作小程序登录系统(超详细)
  • 浅谈 LRU
  • Web2与Web3开发的不同之处
  • python正则表达式
  • 第二章 Vim编辑器与Shell命令脚本
  • 双线性插值(Bilinear interpolation)原理推导
  • PyQGIS中一次性加载多个shp文件
  • 探索语音识别技术:从自动翻译到智能客服
  • electron打包运行白屏、Can not find modules ‘xxx‘,Dynamic Linking Error
  • JavaWeb ( 六 ) JSP
  • C# 判断文件/目录是否存在
  • AWS VPC 配置指南:快速创建和设置你的虚拟私有云
  • 【C++】map和set的介绍+使用