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-Type是application/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",
  "callbackUrl": "https://merchant.example.com/notify/deposit"
}
字段类型必填说明
order.idString是商户订单号,全局唯一,对应平台 mchtOrderNo
order.titleString否订单标题/描述
order.amountString是订单金额,如 "88.50",最小 0.01
order.currencyTypeString否币种,默认 MYR
order.additionalDataString否附加数据
customer.nameString否客户姓名
customer.phoneString否客户手机号
customer.emailString否客户邮箱
methodString否支付方式,控制收银台展示范围,详见下方说明
callbackUrlString否本笔订单的代收结果回调地址;不传时使用后台配置的代收回调地址(见 5.1)

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

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

典型失败 message ​

message原因
Missing order.id未传商户订单号
商户未配置代收渠道商户未绑定支付渠道
渠道创建订单失败: ...下游渠道拒绝或配置错误
Payment method must not be specified for this merchant商户配置为 NONE 模式,不允许传 method
Only payment category (e.g. FPX, Ewallet, QR) is allowed商户配置为 CATEGORY_ONLY 模式,仅允许传支付大类
Merchant rate not configured for platform and method商户未配置该支付方式的费率

method 字段说明 ​

method 字段用于控制收银台页面的支付方式展示范围,支持以下三种传值方式:

传值类型示例效果
不传 / 空字符串不传该字段展示该商户所有已配置的可选支付方式,由用户选择
支付大类"FPX"、"Ewallet"、"QR"、"CreditCard"仅展示该大类下的细分支付方式
细分方式编码"CIMB_MY"、"MAE_MY"仅展示该具体支付方式,直接跳转支付

可用支付方式编码见 7.1 代收支付方式

deposit_payment_method_mode 限制 ​

平台可为每个商户配置 deposit_payment_method_mode,限制商户在 method 字段允许传入的值类型:

模式说明method 允许值违规返回
未配置(默认)不限制空 / 大类 / 细分均可—
NONE禁止传入支付方式仅允许不传或空字符串400 Payment method must not be specified for this merchant
CATEGORY_ONLY仅允许传支付大类空 / FPX / Ewallet / QR / CreditCard400 Only payment category (e.g. FPX, Ewallet, QR) is allowed

如需调整模式,请联系运营在 admin 后台修改商户配置。


4.2 查询代收状态 ​

POST /openapi/v1/getTransactionStatusById

业务明文(AES 解密后) ​

JSON
{
  "transactionId": "12026031700000001",
  "type": 1
}

或

JSON
{
  "merchantOrderId": "MCH202603170001",
  "type": 1
}
字段类型必填说明
transactionIdString二选一平台订单号(下单返回的 transactionId)
merchantOrderIdString二选一商户订单号(对应下单接口传入的 order.id)
typeInteger否固定传 1 表示代收查询(预留字段)

transactionId 与 merchantOrderId 二选一,同时传入时优先 transactionId。

业务响应明文 ​

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": ""
  },
  "callbackUrl": "https://merchant.example.com/notify/withdraw"
}
字段类型必填说明
order.idString是商户提现单号,唯一
order.amountString是提现金额(元),如 "100.00";平台内部转为分(×100)
order.currencyTypeString否币种,默认 MYR
recipient.nameString是收款人姓名
recipient.phoneString否收款人手机
recipient.emailString是收款人邮箱
recipient.methodTypeString是收款银行标识,见 7.2 代付银行编码
recipient.methodValueString是银行账号
recipient.methodRefString否附加参考信息
callbackUrlString否本笔提现的代付结果回调地址;不传时使用后台配置的代付回调地址(见 5.2)

业务响应明文 ​

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

4.4 查询提现状态 ​

POST /openapi/v1/getPayoutStatusById

业务明文(AES 解密后) ​

JSON
{
  "transactionId": "WD20260914113420BEDCEF",
  "type": 2
}

或

JSON
{
  "merchantOrderId": "MCH202609140001",
  "type": 2
}
字段类型必填说明
transactionIdString二选一平台提现单号(下单返回的 transactionId)
merchantOrderIdString二选一商户提现单号(对应下单接口传入的 order.id)
typeInteger否固定传 2 表示代付查询

transactionId 与 merchantOrderId 二选一,同时传入时优先 transactionId。

业务响应明文 ​

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. 下单 / 发起提现时传了 callbackUrl,推送到该地址(只对这一笔订单生效)
  2. 没传时,推送到后台为商户配置的回调地址(代收 depositCallback、代付 withdrawCallback)
  3. 两者都没有时,不推送,商户可通过查询接口获取结果

商户需:

  1. AES 解密通知体

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

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

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

5.1 代收回调 depositCallback ​

HTTP Body:

JSON
{
  "data": "AES加密的HEX密文",
  "orderId": "MCH202603170001"
}
字段说明
dataAES 加密的 HEX 密文,解密后见下方明文
orderId商户订单号(对应平台 mchtOrderNo)

解密后明文:

JSON
{
  "transactionId": "12026031700000001",
  "merchantOrderId": "MCH202603170001",
  "status": "SUCCESS",
  "amount": "88.50",
  "currency": "MYR",
  "timestamp": "1710652800000"
}
字段说明
status订单状态:PENDING 待支付 / SUCCESS 成功 / FAILED 失败 / CLOSED 关闭 / REFUNDED 退款
timestamp推送时间毫秒戳

5.2 代付回调 withdrawCallback ​

HTTP Body:

JSON
{
  "encryptedData": "AES加密的HEX密文",
  "orderId": "20260914033419822852948"
}
字段说明
encryptedDataAES 加密的 HEX 密文,解密后见下方明文
orderId商户提现单号(对应下单接口传入的 order.id)

解密后明文:

JSON
{
  "transactionId": "WD20260914113420BEDCEF",
  "merchantOrderId": "20260914033419822852948",
  "status": "SUCCESS",
  "amount": "150.00",
  "currency": "MYR",
  "timestamp": "1758787200000",
  "message": "Success",
  "data": {
    "status": "SUCCESS",
    "transaction": {
      "id": "WD20260914113420BEDCEF"
    }
  }
}
字段说明
transactionId平台提现单号(与下单响应中的 transactionId 一致)
merchantOrderId商户提现单号(对应下单接口传入的 order.id)
status提现状态:SUCCESS / FAILED / PENDING
amount提现金额
currency币种,如 MYR
timestamp回调时间戳(毫秒)
message向下兼容字段,固定值 Success
data.transaction.id向下兼容字段,同 transactionId

代收回调密文字段名为 data,代付回调密文字段名为 encryptedData;两者均携带 orderId(代收=商户订单号,代付=商户提现单号),对接时请注意区分密文字段名。


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 or merchantOrderId查询缺少平台单号或商户单号补全 transactionId 或 merchantOrderId
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) ​

编码说明大类
CARDCredit / Debit CardCreditCard
TNG_MYTouch N GoEwallet
SHOPEEPAY_MYShopeePayEwallet
BOOST_MYBoostEwallet
DUITNOW_QR_MYDuitNOW QREwallet
GRABPAY_MYGrabPayEwallet
MAE_MYMAEEwallet
WECHATPAY_CNWechatPay (China)Ewallet
HLBB_MYHong Leong Bank BerhadFPX
UOB_MYUnited Overseas BankFPX
SCB_MYStandard Chartered BankFPX
RHB_MYRHB BankFPX
PBB_MYPublic Bank BerhadFPX
OCBC_MYOCBC Bank Malaysia BerhadFPX
MB2U_MYMalayan Banking Berhad (M2U)FPX
KFH_MYKuwait Finance House (Malaysia) BerhadFPX
HSBC_MYHSBC Bank Malaysia BerhadFPX
ABB_MYAffin BankFPX
CIMB_MYCIMB Bank BerhadFPX
BSN_MYBank Simpanan NasionalFPX
BMMB_MYBank Muamalat Malaysia BerhadFPX
BKRM_MYBank Kerjasama Rakyat Malaysia BerhadFPX
BIMB_MYBank Islam Malaysia BerhadFPX
ARGO_MYBank Pertanian (Agrobank)FPX
AMBB_MYAmBank Malaysia BerhadFPX
ABMB_MYAlliance Bank Malaysia BerhadFPX

7.2 代付银行编码 (Payout) ​

recipient.methodType

编码说明大类
HLBB_MYHong Leong Bank BerhadFPX
UOB_MYUnited Overseas BankFPX
SCB_MYStandard Chartered BankFPX
RHB_MYRHB BankFPX
PBB_MYPublic Bank BerhadFPX
OCBC_MYOCBC Bank Malaysia BerhadFPX
MB2U_MYMalayan Banking Berhad (M2U)FPX
KFH_MYKuwait Finance House (Malaysia) BerhadFPX
HSBC_MYHSBC Bank Malaysia BerhadFPX
ABB_MYAffin BankFPX
CIMB_MYCIMB Bank BerhadFPX
BSN_MYBank Simpanan NasionalFPX
BMMB_MYBank Muamalat Malaysia BerhadFPX
BKRM_MYBank Kerjasama Rakyat Malaysia BerhadFPX
BIMB_MYBank Islam Malaysia BerhadFPX
ARGO_MYBank Pertanian (Agrobank)FPX
AMBB_MYAmBank Malaysia BerhadFPX
ABMB_MYAlliance Bank Malaysia BerhadFPX

7.3 订单状态 ​

查询接口 data.status

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

代付 status:PENDING / 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 管理。