Skip to content

OpenAPI v1 商户对接文档

目录

  1. 接入准备

  2. 通用约定

  3. 加解密与签名

  4. 接口列表

  5. 异步回调(Webhook)

  6. 错误码

  7. 枚举说明

  8. 最佳实践

  9. Java Demo


1. 接入准备

商户在管理后台完成开发设置后,将获得以下信息

配置项用途
clientId放入请求头 Authorization,标识商户身份
symmetricKey32 位 AES 对称密钥,加解密请求/响应 body
serverPublicKey商户 RSA 公钥(PEM),平台用于验签;商户侧保留对应私钥用于签名
depositCallback代收(充值)结果异步通知 URL
withdrawCallback代付(提现)结果异步通知 URL

环境地址(示例,以实际部署为准):

环境Base URL
SIThttps://{``sit_host``}/openapi/v1
生产https://{``prod_host``}/openapi/v1

2. 通用约定

2.1 公共请求头

所有 OpenAPI 接口均使用 POST,且必须携带以下 Header:

Header必填说明
Authorization商户 clientId(纯数字字符串)
X-Nonce-Str随机字符串,建议 UUID,每次请求唯一,防重放
X-Timestamp毫秒时间戳(字符串),建议与服务器时差 ±5 分钟内
X-Signature格式:sha256 {Base64签名}
Content-Typeapplication/json

2.2 公共 HTTP 请求体(外层)

JSON
{
  "data": "AES加密后的业务明文(HEX 小写字符串)"
}

2.3 公共 HTTP 响应体(外层)

成功:

JSON
{
  "code": 200,
  "encryptedData": "AES加密后的业务明文(HEX 小写字符串)",
  "message": "Success"
}

失败:

JSON
{
  "code": 400,
  "message": "Missing required parameters"
}

失败时通常无 encryptedData 字段,请根据 code + message 处理。

2.4 业务明文加解密流程

Plaintext
明文 JSON → UTF-8 字节 → AES-256-CBC 加密 → HEX 输出(请求 data / 响应 encryptedData)

解密为上述流程的逆操作。算法细节见 第 3 节。


3. 加解密与签名

3.1 AES-256-CBC

规则
算法AES/CBC/PKCS5Padding
KeysymmetricKey UTF-8 字节的前 32 字节
IVKey 的前 16 字节
密文编码二进制密文转 小写 HEX 字符串

3.2 RSA-SHA256 签名(商户请求签名)

待签名字符串(原文):

Plaintext
{clientId}\n{nonceStr}\n{timestamp}\n{encryptedData}

说明:encryptedData 为请求体 data 字段的 HEX 密文(签名时不解密)。

签名步骤:

  1. 使用商户 RSA 私钥(PKCS#8 PEM)对待签名字符串做 SHA256withRSA 签名

  2. 签名结果 Base64 编码

  3. 放入 Header:X-Signature: sha256 {Base64签名}

平台使用商户在后台配置的 serverPublicKey 验签。生产环境务必配置公钥;未配置时平台可能跳过验签(仅测试)。


4. 接口列表

4.1 商户下单(代收)

POST /openapi/v1/createPayment

业务明文(AES 解密后)

JSON
{
  "order": {
    "id": "MCH202603170001",
    "title": "商品购买",
    "amount": "88.50",
    "currencyType": "MYR",
    "additionalData": ""
  },
  "customer": {
    "name": "John Doe",
    "phone": "60123456789",
    "email": "john@example.com"
  },
  "method": "CIMB_MY"
}
字段类型必填说明
order.idString商户订单号,全局唯一,对应平台 mchtOrderNo
order.titleString订单标题/描述
order.amountString订单金额,如 "88.50",最小 0.01
order.currencyTypeString币种,默认 MYR
order.additionalDataString附加数据
customer.nameString客户姓名
customer.phoneString客户手机号
customer.emailString客户邮箱
methodString期望支付方式,见 7.1 代收支付方式

业务响应明文(AES 解密 encryptedData 后)

JSON
{
  "data": {
    "paymentUrl": "https://checkout.example.com/...",
    "transactionId": "12026031700000001"
  }
}
字段说明
paymentUrl收银台/支付跳转链接,引导用户完成支付
transactionId平台订单号,后续查询与对账使用,请持久化

典型失败 message

message原因
Missing order.id未传商户订单号
商户未配置代收渠道商户未绑定支付渠道
渠道创建订单失败: ...下游渠道拒绝或配置错误

4.2 查询代收状态

POST /openapi/v1/getTransactionStatusById

业务明文(AES 解密后)

JSON
{
  "transactionId": "12026031700000001",
  "type": 1
}
字段类型必填说明
transactionIdString平台订单号(下单返回的 transactionId
typeInteger固定传 1 表示代收查询(预留字段)

业务响应明文

JSON
{
  "data": {
    "status": "SUCCESS",
    "transaction": {
      "id": "12026031700000001",
      "amountPaid": 88.50,
      "confirmedAt": "2026-03-17 10:30:00",
      "paymentMethod": "CIMB_MY",
      "commissionFee": 1.20
    },
    "order": {
      "id": "MCH202603170001",
      "amount": 88.50,
      "currencyType": "MYR"
    },
    "customer": {
      "name": "John Doe",
      "phone": "60123456789",
      "email": "john@example.com"
    }
  }
}
字段说明
data.status订单状态,见 7.3 订单状态
transaction.id平台订单号
transaction.amountPaid实付金额
transaction.confirmedAt支付确认时间
transaction.paymentMethod实际支付方式
transaction.commissionFee手续费
order.id商户订单号
customer.*下单时传入的客户信息

4.3 发起提现(代付)

POST /openapi/v1/withdrawRequest

业务明文(AES 解密后)

JSON
{
  "order": {
    "id": "WD202603170001",
    "amount": "100.00",
    "currencyType": "MYR"
  },
  "recipient": {
    "name": "James Lee",
    "phone": "60123456789",
    "email": "james@example.com",
    "methodType": "CIBBMYKL",
    "methodValue": "8044591766",
    "methodRef": ""
  }
}
字段类型必填说明
order.idString商户提现单号,唯一
order.amountString提现金额(元),如 "100.00";平台内部转为分(×100)
order.currencyTypeString币种,默认 MYR
recipient.nameString收款人姓名
recipient.phoneString收款人手机
recipient.emailString收款人邮箱
recipient.methodTypeString收款银行标识,见 7.2 代付银行编码
recipient.methodValueString银行账号
recipient.methodRefString附加参考信息

业务响应明文

JSON
{
  "data": {
    "transactionId": "d34e29e57a4c466e9fe033a0430f8f1b"
  }
}
字段说明
transactionId平台提现流水号(渠道 transactionRefNum

4.4 查询提现状态

POST /openapi/v1/getPayoutStatusById

业务明文(AES 解密后)

JSON
{
  "transactionId": "d34e29e57a4c466e9fe033a0430f8f1b",
  "type": 2
}
字段类型必填说明
transactionIdString平台提现流水号
typeInteger固定传 2 表示代付查询

业务响应明文

JSON
{
  "message": "Success",
  "type": 2,
  "data": {
    "status": "PENDING",
    "transaction": {
      "id": "d34e29e57a4c466e9fe033a0430f8f1b",
      "amountWithdrawn": 0,
      "confirmedAt": 0,
      "commissionFee": 0
    },
    "order": {},
    "recipient": {}
  }
}

注意: 当前实现中提现状态查询为占位逻辑,默认返回 PENDING;完整字段回填依赖后续提现订单表。生产对接请以 提现回调 为准,并配合主动查询做补偿。


5. 异步回调(Webhook)

平台在支付/提现结果确定后,向商户配置的回调 URL 异步 POST 加密通知。商户需:

  1. AES 解密通知体

  2. 校验业务字段(订单号、金额、状态)

  3. 返回 HTTP 200(建议响应 {"response":true} 或纯文本 OK

  4. 做好 幂等 处理(同一订单可能重复通知)

5.1 代收回调 depositCallback

HTTP Body:

JSON
{
  "data": "AES加密的HEX密文"
}

解密后明文:

JSON
{
  "transactionId": "12026031700000001",
  "merchantOrderId": "MCH202603170001",
  "status": "01",
  "amount": "88.50",
  "currency": "MYR",
  "timestamp": "1710652800000"
}
字段说明
status内部订单状态码:00 待支付 / 01 成功 / 02 失败 / 03 关闭 / 04 退款
timestamp推送时间毫秒戳

5.2 代付回调 withdrawCallback

HTTP Body:

JSON
{
  "encryptedData": "AES加密的HEX密文"
}

解密后明文:

JSON
{
  "message": "Success",
  "data": {
    "status": "SUCCESS",
    "transaction": {
      "id": "d34e29e57a4c466e9fe033a0430f8f1b"
    }
  }
}

代收回调字段名为 data,代付回调字段名为 encryptedData,对接时请注意区分。


6. 错误码

OpenAPI 外层响应 code 为 HTTP 风格业务码(非 HTTP 状态码):

codemessage(示例)说明处理建议
200Success成功解密 encryptedData 获取业务数据
400Missing required parametersHeader 或 body 缺失检查四个 Header 及 data 字段
400Data decryption failedAES 解密失败检查 symmetricKey、HEX 格式、IV/Key 规则
400Invalid request body明文 JSON 无法解析检查 JSON 格式
400Missing order.id下单缺少商户订单号补全 order.id
400Missing transactionId查询缺少平台单号补全 transactionId
400Missing order or recipient提现缺少 order/recipient补全提现结构
401Invalid clientIdclientId 非数字检查 Authorization
401Unauthorized: clientId not found商户未开通联系运营配置
401Signature verification failedRSA 验签失败检查私钥、待签名字符串、密文是否一致
404Transaction not found订单不存在核对 transactionId
500Merchant application not found应用配置缺失联系运营
500Response encryption failed服务端加密异常重试或联系技术支持
500{业务错误信息}创单/出款失败根据 message 排查(渠道、金额、银行等)

下单/出款 500 阶段常见 message:

  • 商户未配置代收渠道

  • 渠道创建订单失败: ...

  • email不能为空 / bankAccNumber不能为空 等(出款参数校验)

  • SecurePays 渠道返回的原始错误 JSON


7. 枚举说明

7.1 代收支付方式 (Deposit)

编码说明大类
FPXFPX 网银(用户在收银台选择具体银行)FPX
CIMB_MYCIMB 银行FPX
MBB_MYMaybankFPX
PBB_MYPublic BankFPX
RHB_MYRHB BankFPX
HLB_MYHong Leong BankFPX
ABB_MYAffin BankFPX
AMB_MYAmBankFPX
BSN_MYBank Simpanan NasionalFPX
TNG_MYTouch 'n Go eWalleteWallet
GRB_MYGrabPayeWallet
BOOST_MYBoosteWallet
SHP_MYShopeePayeWallet

7.2 代付银行编码 (Payout)

recipient.methodType

编码说明大类
FPXFPX 网银(用户在收银台选择具体银行)FPX
CIMB_MYCIMB 银行FPX
MBB_MYMaybankFPX
PBB_MYPublic BankFPX
RHB_MYRHB BankFPX
HLB_MYHong Leong BankFPX
ABB_MYAffin BankFPX
AMB_MYAmBankFPX
BSN_MYBank Simpanan NasionalFPX
TNG_MYTouch 'n Go eWalleteWallet
GRB_MYGrabPayeWallet
BOOST_MYBoosteWallet
SHP_MYShopeePayeWallet

7.3 订单状态

查询接口 data.status

API status含义
PENDING待支付 / 处理中
SUCCESS支付成功
FAILED支付失败
CLOSED已关闭
REFUNDED已退款

代付 statusPENDING / SUCCESS / FAILED

7.4 币种

说明
MYR马来西亚林吉特(默认)
USDT文档预留,是否开通以商户配置为准

8. 最佳实践

8.1 安全

  • 生产环境 必须 配置 RSA 公钥并启用验签;私钥仅保存在商户服务端,禁止下发到客户端或前端。

  • symmetricKey 仅用于服务端对服务端通信,定期轮换需与平台同步。

  • 回调接口校验来源 IP(若平台提供 IP 白名单)并验证解密后金额、订单号。

  • X-Nonce-Str 每次唯一,平台侧建议做防重放(可自行记录已用 nonce)。

8.2 订单与幂等

  • 商户订单号 order.id / 提现单号必须 全局唯一;重复提交相同单号可能触发渠道幂等或业务错误。

  • transactionId(平台单号)作为对账主键,本地订单表同时保存商户单号。

  • 收到 SUCCESS 前不要发货;以 回调 + 主动查询 双保险确认终态。

8.3 超时与重试

  • 建议 HTTP 客户端连接超时 10s、读超时 30s。

  • 网络超时 不要 直接判定失败:使用相同商户单号查询状态,或等待回调。

  • 查询接口可指数退避重试(如 3s、10s、30s、60s),最多 24 小时。

8.4 金额

  • 金额统一使用 字符串 传递,保留两位小数,避免浮点精度问题。

  • 下单最小金额 0.01;提现金额平台内部 ×100 转分后送渠道。

8.5 联调检查清单

  • [ ] Authorization = 正确 clientId

  • [ ] AES 加解密 HEX 与平台 OpenApiUtil 一致

  • [ ] 签名原文四段用 \n 连接,且第四段为加密后 data(非明文 JSON)

  • [ ] X-Signature 带 sha256 前缀

  • [ ] 回调 URL 公网可达且支持 POST JSON

  • [ ] SIT 完成:下单 → 支付 → 回调 → 查询 全链路


9. Java Demo

仓库提供可独立运行的 Java 17 示例(无 Spring 依赖):

Plaintext
docs/demo/OpenApiClientDemo.java

9.1 运行前配置

编辑 OpenApiClientDemo.java 顶部常量:

Java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.HexFormat;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
 * Pay OpenAPI 商户对接 Demo(单文件,Java 11+,无第三方依赖)
 *
 * ============================================================
 * 协议说明
 * ============================================================
 * 1. Body 加密:AES-256-CBC
 *    - Key  = symmetricKey 前32字节(UTF-8)
 *    - IV   = Key 前16字节
 *    - 输出 = HEX 小写字符串
 *
 * 2. 签名算法:RSA-SHA256
 *    - 签名原文 = clientId + "\n" + nonceStr + "\n" + timestamp + "\n" + encryptedData
 *    - Header: X-Signature: sha256 {Base64签名}
 *
 * 3. 请求 Headers(所有接口统一):
 *    Authorization: {clientId}
 *    X-Nonce-Str:   随机16位字符串
 *    X-Timestamp:   当前毫秒时间戳
 *    X-Signature:   sha256 {Base64签名}
 *    Content-Type:  application/json
 *
 * 4. 请求 Body:  { "data": "{AES加密HEX}" }
 * 5. 响应 Body:  { "code": 200, "encryptedData": "{AES加密HEX}", "message": "Success" }
 *
 * ============================================================
 * 接口列表
 * ============================================================
 * POST /openapi/v1/createPayment            — 代收下单
 * POST /openapi/v1/getTransactionStatusById — 查询代收状态
 * POST /openapi/v1/withdrawRequest          — 代付/提现
 * POST /openapi/v1/getPayoutStatusById      — 查询提现状态
 *
 * ============================================================
 * 使用方式
 * ============================================================
 * 1. 填写下方配置区
 * 2. 在 Pay 后台将 depositCallback / withdrawCallback
 *    配置为 http://{测试服务器公网IP}:18080/callback
 * 3. javac PayDemo.java && java PayDemo
 */
public class PayDemo {

    // ============================================================
    // ★ 配置区
    // ============================================================

    /** Pay 平台地址(测试环境) */
    //static final String BASE_URL = "https://hpay.jyoou.com/Pay-gateway/Pay-order";
    static final String BASE_URL = "http://localhost:7104/";

    /** 商户 clientId(对应 c_app_security.client_id) */
    static final String CLIENT_ID = "43271";

    /** 商户 AES 对称密钥(32位字符串) */
    static final String SYMMETRIC_KEY = "";

    /**
     * 商户 RSA 私钥(PKCS#8 格式,纯 Base64,已去掉 BEGIN/END 行和换行符)
     * 对应公钥已录入 Pay 后台 c_app_security.server_public_key
     */
    static final String PRIVATE_KEY_PKCS8 =
            "";

    /**
     * 本地回调监听端口(平台推送到此端口)
     * 后台 depositCallback / withdrawCallback 配置为:
     *   http://{你的公网IP或ngrok地址}:18080/callback
     */
    static final int CALLBACK_PORT = 18080;

    /**
     * 等待异步回调的超时时间(秒)
     * 设为 0 则不等待回调,直接结束程序
     */
    static final int CALLBACK_WAIT_SECONDS = 120;

    // ============================================================

    /** 收到回调时的信号量(用于主线程等待) */
    private static final CountDownLatch callbackLatch = new CountDownLatch(1);

    public static void main(String[] args) throws Exception {
        System.out.println("===========================================");
        System.out.println("  Pay OpenAPI 商户对接 Demo");
        System.out.println("  BASE_URL  = " + BASE_URL);
        System.out.println("  CLIENT_ID = " + CLIENT_ID);
        System.out.println("===========================================\n");

        // ── 启动回调服务器 ─────────────────────────────────────────
        HttpServer callbackServer = startCallbackServer(CALLBACK_PORT);
        System.out.println("✓ 回调服务器已启动,监听 http://localhost:" + CALLBACK_PORT + "/callback");
        System.out.println("  请确认 Pay 后台 depositCallback 已配置为上述地址\n");

        // ── Demo 1: 代收下单 ──────────────────────────────────────
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        System.out.println("【Demo 1】代收下单 POST /openapi/v1/createPayment");
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        String merchantOrderId = "ORD-" + System.currentTimeMillis();
        String createPaymentBody = buildCreatePaymentBody(
                merchantOrderId, "20.00", "MYR", "Test Payment",
                "melody 商户", "fql_ww@163.com", "0123456789");
        System.out.println("  请求明文: " + createPaymentBody);

        ApiResponse createResp = callApi("/openapi/v1/createPayment", createPaymentBody);
        System.out.println("  响应 code=" + createResp.code + "  message=" + createResp.message);
        if (createResp.plainData != null) {
            System.out.println("  解密后: " + createResp.plainData);
        }

        String transactionId = createResp.plainData != null
                ? extractField(createResp.plainData, "transactionId") : null;
        String paymentUrl = createResp.plainData != null
                ? extractField(createResp.plainData, "paymentUrl") : null;
        System.out.println("  ✓ transactionId = " + transactionId);
        System.out.println("  ✓ paymentUrl    = " + paymentUrl);
        System.out.println();

        // ── Demo 2: 查询代收状态 ───────────────────────────────────
        if (transactionId != null && !transactionId.isEmpty()) {
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            System.out.println("【Demo 2】查询代收状态 POST /openapi/v1/getTransactionStatusById");
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            String queryBody = buildQueryStatusBody(transactionId, 1);
            System.out.println("  请求明文: " + queryBody);
            ApiResponse queryResp = callApi("/openapi/v1/getTransactionStatusById", queryBody);
            System.out.println("  响应 code=" + queryResp.code + "  message=" + queryResp.message);
            if (queryResp.plainData != null) {
                System.out.println("  解密后: " + queryResp.plainData);
                System.out.println("  ✓ status = " + extractField(queryResp.plainData, "status"));
            }
            System.out.println();
        }

        // ── Demo 3: 代付/提现 ──────────────────────────────────────
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        System.out.println("【Demo 3】代付/提现 POST /openapi/v1/withdrawRequest");
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        String payoutOrderId = "PAYOUT-" + System.currentTimeMillis();
        String payoutBody = buildPayoutBody(
                payoutOrderId, "11.00", "MYR",
                "Long Wan", "0123456789", "test@example.com",
                "Affin Bank", "1", "");
        System.out.println("  请求明文: " + payoutBody);



        ApiResponse payoutResp = callApi("/openapi/v1/withdrawRequest", payoutBody);
        System.out.println("  响应 code=" + payoutResp.code + "  message=" + payoutResp.message);
        if (payoutResp.plainData != null) {
            System.out.println("  解密后: " + payoutResp.plainData);
        }

        String payoutTxnId = payoutResp.plainData != null
                ? extractField(payoutResp.plainData, "transactionId") : null;
        System.out.println("  ✓ 提现 transactionId = " + payoutTxnId);
        System.out.println();

        // ── Demo 4: 查询提现状态 ───────────────────────────────────
        if (payoutTxnId != null && !payoutTxnId.isEmpty()) {
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            System.out.println("【Demo 4】查询提现状态 POST /openapi/v1/getPayoutStatusById");
            System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            String payoutQueryBody = buildQueryStatusBody(payoutTxnId, 2);
            System.out.println("  请求明文: " + payoutQueryBody);
            ApiResponse payoutQueryResp = callApi("/openapi/v1/getPayoutStatusById", payoutQueryBody);
            System.out.println("  响应 code=" + payoutQueryResp.code + "  message=" + payoutQueryResp.message);
            if (payoutQueryResp.plainData != null) {
                System.out.println("  解密后: " + payoutQueryResp.plainData);
                System.out.println("  ✓ status = " + extractField(payoutQueryResp.plainData, "status"));
            }
            System.out.println();
        }

        // ── Demo 5: 等待平台异步回调 ──────────────────────────────
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        System.out.println("【Demo 5】等待平台异步回调(depositCallback / withdrawCallback)");
        System.out.println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
        if (CALLBACK_WAIT_SECONDS > 0) {
            System.out.println("  等待最多 " + CALLBACK_WAIT_SECONDS + "s,回调地址:http://{测试服务器公网IP}:" + CALLBACK_PORT + "/callback");
            System.out.println("  请确认 Pay 后台 depositCallback / withdrawCallback 已配置为上述地址");
            System.out.println("  (确保防火墙/安全组已放行端口 " + CALLBACK_PORT + ")\n");
            boolean received = callbackLatch.await(CALLBACK_WAIT_SECONDS, TimeUnit.SECONDS);
            if (!received) {
                System.out.println("  ⚠ 超时未收到回调(可能需要实际完成支付,或检查后台回调地址配置)");
            }
        } else {
            System.out.println("  CALLBACK_WAIT_SECONDS=0,跳过等待");
        }

        callbackServer.stop(0);
        System.out.println("\n===========================================");
        System.out.println("  Demo 执行完毕");
        System.out.println("===========================================");
    }

    // ──────────────────────────────────────────────────────────────
    // 回调服务器(真实接收平台推送)
    // ──────────────────────────────────────────────────────────────

    /**
     * 启动内嵌 HTTP 服务器监听平台回调
     * 回调格式:POST { "encryptedData": "HEX" }
     * 商户处理完必须回复:HTTP 200 {"response":true}
     */
    static HttpServer startCallbackServer(int port) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/callback", exchange -> handleCallback(exchange));
        server.start();
        return server;
    }

    static void handleCallback(HttpExchange exchange) throws IOException {
        if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
            exchange.sendResponseHeaders(405, 0);
            exchange.close();
            return;
        }
        try {
            // 读取请求 body
            InputStream is = exchange.getRequestBody();
            String body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
            System.out.println("\n  ══════════════════════════════════════");
            System.out.println("  ★ 收到平台回调!");
            System.out.println("  原始 body: " + body);

            // 解密 encryptedData
            String encryptedData = extractField(body, "encryptedData");
            if (encryptedData != null && !encryptedData.isEmpty()) {
                String plainText = aesDecrypt(encryptedData, SYMMETRIC_KEY);
                System.out.println("  解密后明文: " + plainText);

                // 业务处理:根据 status 更新本地订单状态
                String status = extractField(plainText, "status");
                String txnId = extractField(plainText, "transactionId");
                System.out.println("  ✓ transactionId=" + txnId + "  status=" + status);
                System.out.println("  → 商户侧订单状态更新:" + (
                        "SUCCESS".equals(status) ? "支付成功,执行后续业务逻辑" : "状态=" + status));
            } else {
                System.out.println("  ⚠ 未找到 encryptedData 字段");
            }
            System.out.println("  ══════════════════════════════════════\n");

            // 必须回复 {"response":true}(代收) 或 HTTP 200
            byte[] response = "{\"response\":true}".getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add("Content-Type", "application/json");
            exchange.sendResponseHeaders(200, response.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(response);
            }

            // 通知主线程已收到回调
            callbackLatch.countDown();

        } catch (Exception e) {
            System.err.println("  ✗ 回调处理异常: " + e.getMessage());
            exchange.sendResponseHeaders(500, 0);
            exchange.close();
        }
    }

    // ──────────────────────────────────────────────────────────────
    // 请求体构建
    // ──────────────────────────────────────────────────────────────

    /** 代收下单明文(JSON key 按字母序) */
    static String buildCreatePaymentBody(String orderId, String amount, String currency,
                                          String title, String name, String email, String phone) {
        return "{" +
                "\"customer\":{" +
                    "\"email\":\"" + email + "\"," +
                    "\"name\":\"" + name + "\"," +
                    "\"phone\":\"" + phone + "\"" +
                "}," +
                "\"method\":\"FPX\"," +
                "\"order\":{" +
                    "\"amount\":\"" + amount + "\"," +
                    "\"currencyType\":\"" + currency + "\"," +
                    "\"id\":\"" + orderId + "\"," +
                    "\"title\":\"" + title + "\"" +
                "}" +
                "}";
    }

    /** 状态查询明文(type=1 代收 / type=2 代付) */
    static String buildQueryStatusBody(String transactionId, int type) {
        return "{\"transactionId\":\"" + transactionId + "\",\"type\":" + type + "}";
    }

    /** 代付/提现明文 */
    static String buildPayoutBody(String orderId, String amount, String currency,
                                   String name, String phone, String email,
                                   String methodType, String methodValue, String methodRef) {
        return "{" +
                "\"order\":{" +
                    "\"amount\":\"" + amount + "\"," +
                    "\"currencyType\":\"" + currency + "\"," +
                    "\"id\":\"" + orderId + "\"" +
                "}," +
                "\"recipient\":{" +
                    "\"email\":\"" + email + "\"," +
                    "\"methodRef\":\"" + methodRef + "\"," +
                    "\"methodType\":\"" + methodType + "\"," +
                    "\"methodValue\":\"" + methodValue + "\"," +
                    "\"name\":\"" + name + "\"," +
                    "\"phone\":\"" + phone + "\"" +
                "}" +
                "}";
    }


    // ──────────────────────────────────────────────────────────────
    // HTTP 调用核心(真实 POST 到 Pay 平台)
    // ──────────────────────────────────────────────────────────────

    static ApiResponse callApi(String path, String plainBody) throws Exception {
        // Step1: AES 加密
        String encryptedData = aesEncrypt(plainBody, SYMMETRIC_KEY);

        // Step2: 生成随机 nonceStr 和 timestamp
        String nonceStr = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
        String timestamp = String.valueOf(System.currentTimeMillis());

        // Step3: 签名原文 = clientId\nnonceStr\ntimestamp\nencryptedData
        String signContent = CLIENT_ID + "\n" + nonceStr + "\n" + timestamp + "\n" + encryptedData;
        String signature = rsaSign(signContent, PRIVATE_KEY_PKCS8);

        // Step4: 构建请求 body
        String requestBody = "{\"data\":\"" + encryptedData + "\"}";

        System.out.println("  → nonceStr=" + nonceStr + "  timestamp=" + timestamp);
        System.out.println("  → 签名原文(前80): " + signContent.substring(0, Math.min(80, signContent.length())) + "...");

        // Step5: 发送 HTTP POST
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(BASE_URL + path))
                .header("Content-Type", "application/json")
                .header("Authorization", CLIENT_ID)
                .header("X-Nonce-Str", nonceStr)
                .header("X-Timestamp", timestamp)
                .header("X-Signature", "sha256 " + signature)
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .build();

        HttpResponse<String> httpResp = client.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println("  ← HTTP " + httpResp.statusCode());
        System.out.println("  ← Body: " + httpResp.body());

        // Step6: 解析并解密响应
        return parseAndDecryptResponse(httpResp.body());
    }

    static ApiResponse parseAndDecryptResponse(String json) {
        ApiResponse resp = new ApiResponse();
        try {
            resp.code = Integer.parseInt(extractField(json, "code"));
        } catch (Exception ignored) {}
        resp.message = extractField(json, "message");
        String encryptedData = extractField(json, "encryptedData");
        if (encryptedData != null && !encryptedData.isEmpty()) {
            try {
                resp.plainData = aesDecrypt(encryptedData, SYMMETRIC_KEY);
            } catch (Exception e) {
                resp.plainData = "[解密失败] " + e.getMessage();
            }
        }
        return resp;
    }

    // ──────────────────────────────────────────────────────────────
    // AES-256-CBC(Key=前32字节,IV=Key前16字节,输出HEX)
    // ──────────────────────────────────────────────────────────────

    static String aesEncrypt(String plainText, String symmetricKey) {
        try {
            byte[] keyData = buildKeyBytes(symmetricKey);
            byte[] ivData = new byte[16];
            System.arraycopy(keyData, 0, ivData, 0, 16);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyData, "AES"), new IvParameterSpec(ivData));
            return HexFormat.of().formatHex(cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)));
        } catch (Exception e) {
            throw new RuntimeException("AES encrypt failed: " + e.getMessage(), e);
        }
    }

    static String aesDecrypt(String hexCipher, String symmetricKey) {
        try {
            byte[] keyData = buildKeyBytes(symmetricKey);
            byte[] ivData = new byte[16];
            System.arraycopy(keyData, 0, ivData, 0, 16);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyData, "AES"), new IvParameterSpec(ivData));
            return new String(cipher.doFinal(HexFormat.of().parseHex(hexCipher)), StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("AES decrypt failed: " + e.getMessage(), e);
        }
    }

    static byte[] buildKeyBytes(String symmetricKey) {
        byte[] raw = symmetricKey.getBytes(StandardCharsets.UTF_8);
        byte[] keyData = new byte[32];
        System.arraycopy(raw, 0, keyData, 0, Math.min(raw.length, 32));
        return keyData;
    }

    // ──────────────────────────────────────────────────────────────
    // RSA-SHA256 签名
    // ──────────────────────────────────────────────────────────────

    static String rsaSign(String content, String privateKeyBase64) throws Exception {
        String clean = privateKeyBase64
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s+", "");
        PrivateKey key = KeyFactory.getInstance("RSA")
                .generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(clean)));
        Signature sig = Signature.getInstance("SHA256withRSA");
        sig.initSign(key);
        sig.update(content.getBytes(StandardCharsets.UTF_8));
        return Base64.getEncoder().encodeToString(sig.sign());
    }

    // ──────────────────────────────────────────────────────────────
    // 简单 JSON 字段提取(无第三方依赖)
    // ──────────────────────────────────────────────────────────────

    static String extractField(String json, String key) {
        if (json == null) return null;
        int idx = json.indexOf("\"" + key + "\"");
        if (idx < 0) return null;
        int colon = json.indexOf(':', idx);
        if (colon < 0) return null;
        int start = colon + 1;
        while (start < json.length() && json.charAt(start) == ' ') start++;
        if (start >= json.length()) return null;
        if (json.charAt(start) == '"') {
            int end = json.indexOf('"', start + 1);
            return end < 0 ? null : json.substring(start + 1, end);
        } else {
            int end = start;
            while (end < json.length() && ",}]".indexOf(json.charAt(end)) < 0) end++;
            return json.substring(start, end).trim();
        }
    }

    static class ApiResponse {
        int code;
        String message;
        String plainData;
    }
}

默认依次演示:下单 → 查询代收状态。可在 main 中取消注释以测试提现接口。


附录:接口速查

方法路径功能
POST/openapi/v1/createPayment商户下单(代收)
POST/openapi/v1/getTransactionStatusById查询代收状态
POST/openapi/v1/withdrawRequest发起提现(代付)
POST/openapi/v1/getPayoutStatusById查询提现状态

文档版本:v1.0 | 与代码库 OpenApiV1Controller / OpenApiServiceImpl 同步

基于 VitePress 构建,Markdown 内容由 Git 管理。