OpenAPI v1 商户对接文档
目录
接入准备
通用约定
加解密与签名
接口列表
异步回调(Webhook)
错误码
枚举说明
最佳实践
Java Demo
1. 接入准备
商户在管理后台完成开发设置后,将获得以下信息
| 配置项 | 用途 |
|---|---|
clientId | 放入请求头 Authorization,标识商户身份 |
symmetricKey | 32 位 AES 对称密钥,加解密请求/响应 body |
serverPublicKey | 商户 RSA 公钥(PEM),平台用于验签;商户侧保留对应私钥用于签名 |
depositCallback | 代收(充值)结果异步通知 URL |
withdrawCallback | 代付(提现)结果异步通知 URL |
环境地址(示例,以实际部署为准):
| 环境 | Base URL |
|---|---|
| SIT | https://{``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 请求体(外层)
{
"data": "AES加密后的业务明文(HEX 小写字符串)"
}2.3 公共 HTTP 响应体(外层)
成功:
{
"code": 200,
"encryptedData": "AES加密后的业务明文(HEX 小写字符串)",
"message": "Success"
}失败:
{
"code": 400,
"message": "Missing required parameters"
}失败时通常无
encryptedData字段,请根据code+message处理。
2.4 业务明文加解密流程
明文 JSON → UTF-8 字节 → AES-256-CBC 加密 → HEX 输出(请求 data / 响应 encryptedData)解密为上述流程的逆操作。算法细节见 第 3 节。
3. 加解密与签名
3.1 AES-256-CBC
| 项 | 规则 |
|---|---|
| 算法 | AES/CBC/PKCS5Padding |
| Key | symmetricKey UTF-8 字节的前 32 字节 |
| IV | Key 的前 16 字节 |
| 密文编码 | 二进制密文转 小写 HEX 字符串 |
3.2 RSA-SHA256 签名(商户请求签名)
待签名字符串(原文):
{clientId}\n{nonceStr}\n{timestamp}\n{encryptedData}说明:encryptedData 为请求体 data 字段的 HEX 密文(签名时不解密)。
签名步骤:
使用商户 RSA 私钥(PKCS#8 PEM)对待签名字符串做
SHA256withRSA签名签名结果 Base64 编码
放入 Header:
X-Signature: sha256 {Base64签名}
平台使用商户在后台配置的 serverPublicKey 验签。生产环境务必配置公钥;未配置时平台可能跳过验签(仅测试)。
4. 接口列表
4.1 商户下单(代收)
POST /openapi/v1/createPayment
业务明文(AES 解密后)
{
"order": {
"id": "MCH202603170001",
"title": "商品购买",
"amount": "88.50",
"currencyType": "MYR",
"additionalData": ""
},
"customer": {
"name": "John Doe",
"phone": "60123456789",
"email": "john@example.com"
},
"method": "CIMB_MY"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
order.id | String | 是 | 商户订单号,全局唯一,对应平台 mchtOrderNo |
order.title | String | 否 | 订单标题/描述 |
order.amount | String | 是 | 订单金额,如 "88.50",最小 0.01 |
order.currencyType | String | 否 | 币种,默认 MYR |
order.additionalData | String | 否 | 附加数据 |
customer.name | String | 否 | 客户姓名 |
customer.phone | String | 否 | 客户手机号 |
customer.email | String | 否 | 客户邮箱 |
method | String | 否 | 期望支付方式,见 7.1 代收支付方式 |
业务响应明文(AES 解密 encryptedData 后)
{
"data": {
"paymentUrl": "https://checkout.example.com/...",
"transactionId": "12026031700000001"
}
}| 字段 | 说明 |
|---|---|
paymentUrl | 收银台/支付跳转链接,引导用户完成支付 |
transactionId | 平台订单号,后续查询与对账使用,请持久化 |
典型失败 message
| message | 原因 |
|---|---|
Missing order.id | 未传商户订单号 |
商户未配置代收渠道 | 商户未绑定支付渠道 |
渠道创建订单失败: ... | 下游渠道拒绝或配置错误 |
4.2 查询代收状态
POST /openapi/v1/getTransactionStatusById
业务明文(AES 解密后)
{
"transactionId": "12026031700000001",
"type": 1
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
transactionId | String | 是 | 平台订单号(下单返回的 transactionId) |
type | Integer | 否 | 固定传 1 表示代收查询(预留字段) |
业务响应明文
{
"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 解密后)
{
"order": {
"id": "WD202603170001",
"amount": "100.00",
"currencyType": "MYR"
},
"recipient": {
"name": "James Lee",
"phone": "60123456789",
"email": "james@example.com",
"methodType": "CIBBMYKL",
"methodValue": "8044591766",
"methodRef": ""
}
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
order.id | String | 是 | 商户提现单号,唯一 |
order.amount | String | 是 | 提现金额(元),如 "100.00";平台内部转为分(×100) |
order.currencyType | String | 否 | 币种,默认 MYR |
recipient.name | String | 是 | 收款人姓名 |
recipient.phone | String | 否 | 收款人手机 |
recipient.email | String | 是 | 收款人邮箱 |
recipient.methodType | String | 是 | 收款银行标识,见 7.2 代付银行编码 |
recipient.methodValue | String | 是 | 银行账号 |
recipient.methodRef | String | 否 | 附加参考信息 |
业务响应明文
{
"data": {
"transactionId": "d34e29e57a4c466e9fe033a0430f8f1b"
}
}| 字段 | 说明 |
|---|---|
transactionId | 平台提现流水号(渠道 transactionRefNum) |
4.4 查询提现状态
POST /openapi/v1/getPayoutStatusById
业务明文(AES 解密后)
{
"transactionId": "d34e29e57a4c466e9fe033a0430f8f1b",
"type": 2
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
transactionId | String | 是 | 平台提现流水号 |
type | Integer | 否 | 固定传 2 表示代付查询 |
业务响应明文
{
"message": "Success",
"type": 2,
"data": {
"status": "PENDING",
"transaction": {
"id": "d34e29e57a4c466e9fe033a0430f8f1b",
"amountWithdrawn": 0,
"confirmedAt": 0,
"commissionFee": 0
},
"order": {},
"recipient": {}
}
}注意: 当前实现中提现状态查询为占位逻辑,默认返回
PENDING;完整字段回填依赖后续提现订单表。生产对接请以 提现回调 为准,并配合主动查询做补偿。
5. 异步回调(Webhook)
平台在支付/提现结果确定后,向商户配置的回调 URL 异步 POST 加密通知。商户需:
AES 解密通知体
校验业务字段(订单号、金额、状态)
返回 HTTP 200(建议响应
{"response":true}或纯文本OK)做好 幂等 处理(同一订单可能重复通知)
5.1 代收回调 depositCallback
HTTP Body:
{
"data": "AES加密的HEX密文"
}解密后明文:
{
"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:
{
"encryptedData": "AES加密的HEX密文"
}解密后明文:
{
"message": "Success",
"data": {
"status": "SUCCESS",
"transaction": {
"id": "d34e29e57a4c466e9fe033a0430f8f1b"
}
}
}代收回调字段名为
data,代付回调字段名为encryptedData,对接时请注意区分。
6. 错误码
OpenAPI 外层响应 code 为 HTTP 风格业务码(非 HTTP 状态码):
| code | message(示例) | 说明 | 处理建议 |
|---|---|---|---|
200 | Success | 成功 | 解密 encryptedData 获取业务数据 |
400 | Missing required parameters | Header 或 body 缺失 | 检查四个 Header 及 data 字段 |
400 | Data decryption failed | AES 解密失败 | 检查 symmetricKey、HEX 格式、IV/Key 规则 |
400 | Invalid request body | 明文 JSON 无法解析 | 检查 JSON 格式 |
400 | Missing order.id | 下单缺少商户订单号 | 补全 order.id |
400 | Missing transactionId | 查询缺少平台单号 | 补全 transactionId |
400 | Missing order or recipient | 提现缺少 order/recipient | 补全提现结构 |
401 | Invalid clientId | clientId 非数字 | 检查 Authorization |
401 | Unauthorized: clientId not found | 商户未开通 | 联系运营配置 |
401 | Signature verification failed | RSA 验签失败 | 检查私钥、待签名字符串、密文是否一致 |
404 | Transaction not found | 订单不存在 | 核对 transactionId |
500 | Merchant application not found | 应用配置缺失 | 联系运营 |
500 | Response encryption failed | 服务端加密异常 | 重试或联系技术支持 |
500 | {业务错误信息} | 创单/出款失败 | 根据 message 排查(渠道、金额、银行等) |
下单/出款 500 阶段常见 message:
商户未配置代收渠道渠道创建订单失败: ...email不能为空/bankAccNumber不能为空等(出款参数校验)SecurePays 渠道返回的原始错误 JSON
7. 枚举说明
7.1 代收支付方式 (Deposit)
| 编码 | 说明 | 大类 |
|---|---|---|
FPX | FPX 网银(用户在收银台选择具体银行) | FPX |
CIMB_MY | CIMB 银行 | FPX |
MBB_MY | Maybank | FPX |
PBB_MY | Public Bank | FPX |
RHB_MY | RHB Bank | FPX |
HLB_MY | Hong Leong Bank | FPX |
ABB_MY | Affin Bank | FPX |
AMB_MY | AmBank | FPX |
BSN_MY | Bank Simpanan Nasional | FPX |
TNG_MY | Touch 'n Go eWallet | eWallet |
GRB_MY | GrabPay | eWallet |
BOOST_MY | Boost | eWallet |
SHP_MY | ShopeePay | eWallet |
7.2 代付银行编码 (Payout)
recipient.methodType
| 编码 | 说明 | 大类 |
|---|---|---|
FPX | FPX 网银(用户在收银台选择具体银行) | FPX |
CIMB_MY | CIMB 银行 | FPX |
MBB_MY | Maybank | FPX |
PBB_MY | Public Bank | FPX |
RHB_MY | RHB Bank | FPX |
HLB_MY | Hong Leong Bank | FPX |
ABB_MY | Affin Bank | FPX |
AMB_MY | AmBank | FPX |
BSN_MY | Bank Simpanan Nasional | FPX |
TNG_MY | Touch 'n Go eWallet | eWallet |
GRB_MY | GrabPay | eWallet |
BOOST_MY | Boost | eWallet |
SHP_MY | ShopeePay | eWallet |
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 依赖):
docs/demo/OpenApiClientDemo.java9.1 运行前配置
编辑 OpenApiClientDemo.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 同步
