# 支付 API 完整接入说明

本文的 `{BASE}` 表示平台基础地址。在线文档会自动使用当前访问站点的协议、域名和端口；从在线文档下载的副本也会带上本站地址。直接读取源码或静态 Markdown 时，请自行替换 `{BASE}`。生产接入请使用 HTTPS。

## 接入地址与准备

| 用途 | 地址 / 配置 |
| --- | --- |
| 平台基础地址 | `{BASE}` |
| 商户登录 | `{BASE}/merchant/login` |
| 在线接口文档 | `{BASE}/docs` |
| 创建订单 | `POST {BASE}/api/gateway/orders` |
| 查询订单 | `POST {BASE}/api/gateway/orders/query` |
| 申请退款 | `POST {BASE}/api/gateway/refunds` |
| 支付收银台 | 使用下单响应中的 `cashierUrl`，不要自行拼接 |
| 异步回调地址 | 商户自行部署，例如 `https://shop.example.com/payment/notify` |
| 支付完成跳转地址 | 商户自行部署，例如 `https://shop.example.com/payment/return` |

1. 开通商户账号，完成后台要求的账户验证。
2. 进入商户后台「接入中心」，获取 AppKey 和 AppSecret。查看密钥需要再次验证商户登录密码。
3. 确认支付方式、通道和本人子商户已经开通并开启收款；可用能力以实际授权为准。
4. 在自己的服务器部署回调接口和结果页面。AppSecret 只保存在服务端配置中，不写入网页、小程序、公开代码或日志。

**回调地址由接入方提供。** 下单时把自己网站的通知接口填入 `notifyUrl`，平台支付成功后向该地址通知。它不是平台网关地址，也不是上游渠道通知平台的地址。

## 完整操作流程

1. 商户网站创建本地订单，保存唯一商户订单号和应付金额。
2. 商户服务端签名，调用平台下单接口。
3. 检查响应 code，保存平台订单号 `orderNo`，引导用户进入返回的 `cashierUrl`。
4. 用户完成支付，平台向 `notifyUrl` 发送异步通知。
5. 商户验证签名、订单归属、订单号和金额，确认支付成功，持久化支付结果和发货任务。
6. 成功处理后返回 HTTP 200 和纯文本 `SUCCESS`；重复通知不能重复发货。
7. 用户回到 `returnUrl` 后，由商户服务端查单，再展示真实支付结果。
8. 对漏通知或请求超时的订单主动查单补偿；售后退款按唯一商户退款编号申请并核对结果。

**接口调用成功不等于付款成功。** `code=0` 只说明本次原生接口调用成功，订单付款结果需要检查 `data.status`。

## 请求与 V2 签名

原生下单、查询和退款统一使用 POST、UTF-8 JSON，请求头如下：

```http
Content-Type: application/json
X-App-Key: 你的AppKey
```

原生接口金额均为整数「分」：1.00 元传 `100`，10.00 元传 `1000`。请求金额不要传小数、浮点元或数字字符串。时间字段通常为 RFC3339，时间戳使用 Unix 秒。

```json
{"code": 0, "message": "ok", "data": {}}
```

非 0 时读取 message 并保留业务订单编号；不要只检查 HTTP 状态码。

### V2 公共字段

新接入推荐统一使用 V2，退款必须使用 V2。以下字段要添加到每个接口的业务参数中：

| 字段 | 类型 | 要求 |
| --- | --- | --- |
| signType | string | 固定 `HMAC-SHA256-V2` |
| timestamp | string | 当前 Unix 秒级时间戳；允许过去 300 秒、未来 30 秒 |
| nonce | string | 每次请求新生成的 16～64 位字母、数字、下划线或连字符 |
| sign | string | 按下面规则计算的签名 |

同一商户跨接口也不能复用 nonce。请求重试时重新生成 timestamp、nonce 和 sign，业务订单号或退款编号按原业务保留。

### 签名计算步骤

1. 排除 `sign`，其余字段都参与签名，**包括空字符串、signType、timestamp、nonce**。不要传 null、数组或嵌套对象；extra 先序列化为 JSON 字符串。
2. 定义 `frame(s)` 为「s 的 UTF-8 字节长度的十进制文本 + 冒号 + s」。中文按字节计算，不按字符数。
3. 原文以 `fourpay-gateway-v2\n` 开头，其中 `\n` 为一个真实换行字符。
4. 依次追加 `frame("POST")`、`frame(接口路径)`、`frame(AppKey)`。接口路径不含域名、查询串或末尾多余斜杠。
5. 参数按字段名 ASCII 升序排列，逐个追加 `frame(字段名) + frame(字段值)`，整数转为无小数点的十进制文本。不再添加其他分隔符。
6. 使用 AppSecret 原始 UTF-8 字节作为密钥计算 HMAC-SHA256，结果用无填充 Base64URL 编码，得到 sign。

```text
原文 = "fourpay-gateway-v2\n"
     + frame("POST") + frame("/api/gateway/orders") + frame(AppKey)
     + 按字段名排序追加 frame(字段名) + frame(字段值)
sign = Base64URL无填充(HMAC-SHA256(AppSecret, 原文))
```

### 旧 MD5 兼容说明

原生下单、查单仍兼容旧 MD5：排除 sign 和空值，按字段名排序拼接 `k=v&k=v`，追加 `&key=AppSecret`，取 MD5 大写。参数名不能包含 `&` 或 `=`，参数值不能包含 `&`。复杂回调 URL 或 extra 请用 V2。**旧 MD5 不能申请退款。平台原生回调仍用 MD5 验签，与请求使用 V2 无关。**

## 创建支付订单

```http
POST {BASE}/api/gateway/orders
```

| 业务字段 | 必填 | 类型 / 说明 |
| --- | --- | --- |
| merchantOrderNo | 是 | string，商户系统内唯一订单号 |
| amount | 是 | int，正整数分，10.00 元传 1000 |
| subject | 是 | string，商品标题 |
| notifyUrl | 是 | string，商户接收通知的公网 HTTPS 地址 |
| returnUrl | 否 | string，商户支付结果页面，仅作展示 |
| payMethod | 否 | string，alipay / wxpay / qqpay 等，以实际开通为准；留空由收银台选择 |
| channelCode | 否 | string，通常留空；wxpay / alipay / qqpay 按对应品牌轮询本商户可用子商户并覆盖 payMethod；留空或 0 按 payMethod 路由；其他编码指定通道 |
| expireMinutes | 否 | int，1～1440 分钟，未传或超出范围按 30 分钟处理 |
| extra | 否 | string，JSON 字符串，可传 openid / buyerId / payer 等支付账号信息，按通道要求提供 |
| clientIp | 否 | string，真实付款用户 IP；未传时使用请求 IP，服务端代下单时可传真实客户端 IP |

以上字段还需加上 V2 公共字段。下方 timestamp、nonce、sign 是说明占位值，实际调用请使用示例函数生成：

```json
{
  "merchantOrderNo": "SHOP202609140001",
  "amount": 1000,
  "subject": "测试商品",
  "notifyUrl": "https://shop.example.com/payment/notify",
  "returnUrl": "https://shop.example.com/payment/return",
  "payMethod": "alipay",
  "expireMinutes": 30,
  "signType": "HMAC-SHA256-V2",
  "timestamp": "发送时的Unix秒级时间戳",
  "nonce": "每次请求新生成的随机字符串",
  "sign": "计算得到的签名"
}
```

成功响应示例（部分通道还会返回 payUrl、payType）：

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "orderNo": "Pxxxxxxxx",
    "merchantOrderNo": "SHOP202609140001",
    "cashierUrl": "{BASE}/pay/Pxxxxxxxx"
  }
}
```

保存 orderNo 与本地订单的对应关系，然后跳转返回的 cashierUrl。同一 merchantOrderNo 原单待支付时可返回原单；已终态会报订单号已存在。**下单超时先用原订单号查单，不要直接换号重复创建。**

## 查询支付订单

```http
POST {BASE}/api/gateway/orders/query
```

| 业务字段 | 要求 |
| --- | --- |
| orderNo | 平台订单号，与 merchantOrderNo 二选一 |
| merchantOrderNo | 商户订单号，与 orderNo 二选一 |

请求体除下面的业务字段外，还需带 V2 公共字段，并携带 X-App-Key：

```json
{"merchantOrderNo": "SHOP202609140001"}
```

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "orderNo": "Pxxxxxxxx",
    "merchantOrderNo": "SHOP202609140001",
    "amount": 1000,
    "fee": 0,
    "actualAmount": 1000,
    "refundedAmount": 0,
    "status": "SUCCESS",
    "channelCode": "实际通道编码",
    "payMethod": "alipay",
    "createdAt": "2026-09-14T02:00:00Z",
    "paidAt": "2026-09-14T02:01:00Z"
  }
}
```

amount 是原订单金额，actualAmount 是商户实收金额，refundedAmount 是累计已退款金额，均为分。手续费不改变回调中用于核验的原订单金额。未付款时 paidAt 可能为 null。

原生查询对待支付订单会尝试向上游核实；是否更新取决于通道查询能力及响应。不要把待支付或查询失败解释为支付成功。

## 订单状态

| 状态 | 含义 |
| --- | --- |
| PENDING | 待支付 |
| SUCCESS | 支付成功 |
| FAILED | 支付失败 |
| CLOSED | 已关闭 |
| REFUNDING | 退款中 |
| REFUNDED | 已全额退款 |
| PARTIAL_REFUND | 部分退款 |

支付、退款结果均以实际返回的订单状态和金额核对，不能仅凭接口 code 判断。

## 异步回调与验签

回调地址由商户自行部署，通过下单字段 notifyUrl 传入，例如 `https://shop.example.com/payment/notify`。使用可公网访问的最终 HTTPS 地址，避免 301/302 跳转、登录校验或页面验证码阻断通知。

平台向该地址发送 POST JSON：

```json
{
  "orderNo": "Pxxxxxxxx",
  "merchantOrderNo": "SHOP202609140001",
  "amount": "1000",
  "status": "SUCCESS",
  "channelCode": "实际通道编码",
  "payTime": "2026-09-14T02:01:00Z",
  "sign": "平台生成的MD5大写签名"
}
```

amount 为原订单金额（分的字符串）；payTime 可能为空字符串。部分订单还带 payerOpenid、payerAppid。**验签使用实际收到的全部字段，不能只选择示例里的字段。**

### 回调签名规则（MD5 大写）

1. 从收到的完整 JSON 中取出 sign；其余字段仅排除空字符串。
2. 按字段名 ASCII 升序排列，拼接 `k=v&k=v`。
3. 末尾追加 `&key=你的AppSecret`，计算 MD5 并转成 32 位大写十六进制。
4. 使用常量时间比较函数与收到的 sign 比较。

**下单用 V2，回调仍用 MD5。易支付兼容回调又是另一套格式，见对应章节。**

### 商户处理顺序

1. 验证请求格式和签名。
2. 根据商户订单号查找本地订单，核对平台订单号、订单归属和金额。
3. 仅在 status=SUCCESS 且检查全部通过后，更新订单并持久化发货或到账任务。
4. 使用数据库事务或等价机制去重，确保同一订单只产生一次业务到账/发货任务。
5. 持久化成功后应答；已成功处理的合法重复通知也应答成功。

```http
HTTP/1.1 200 OK
Content-Type: text/plain

SUCCESS
```

正文直接返回 SUCCESS，不要返回 JSON、HTML、NOT SUCCESS 或带解释的句子。处理失败时不能先应答成功再丢弃业务。

启用持久化通知队列后，新支付成功订单每轮最多尝试 3 次，最多自动执行 5 轮，后续间隔依次为 1 分钟、5 分钟、30 分钟、2 小时。网络结果不确定、发送中断或结果保存失败时停止自动重试，需核实后人工补发；商户必须按订单号幂等处理。历史未加入队列的订单保留每次最多 3 次的原规则，不自动补发。未确认订单可主动查单核对；后台通知记录显示队列状态及下次执行时间。

## 同步跳转与上游回调

### returnUrl：商户支付结果页

例如 `https://shop.example.com/payment/return`，由商户自行部署并在下单时传入。页面应调用自己的服务端查单后展示真实结果。**用户访问此页面、页面参数或浏览器跳转都不能作为发货依据。**

### 上游渠道通知平台

这类地址供配置微信、支付宝或其他支付插件使用，普通接入商户不需要填到自己的 notifyUrl。具体使用插件配置页或平台下单时生成的地址，不要自行猜测替换。

| 用途 | 路由说明 |
| --- | --- |
| 插件支付通知入口 | `{BASE}/api/plugin/notify/{pluginCode}`，pluginCode 使用实际插件编码 |
| 旧通道通知入口 | `{BASE}/api/gateway/notify/{channelCode}`，仅按对应旧通道配置使用 |
| 商户收款通知 | 商户自行提供的 notifyUrl，不能填写上述平台内部入口 |

## 申请退款与结果核对

```http
POST {BASE}/api/gateway/refunds
```

| 业务字段 | 要求 |
| --- | --- |
| orderNo / merchantOrderNo | 二选一，目标支付订单 |
| merchantRefundNo | 必填，本次退款唯一编号，1～64 位字母、数字、下划线或连字符 |
| refundAmount | 必填，正整数分；支持部分退款，累计不能超过原单金额 |
| reason | 可选，退款原因 |

退款必须加上 V2 公共字段并携带 X-App-Key，旧 MD5 退款会被拒绝。业务参数示例：

```json
{
  "merchantOrderNo": "SHOP202609140001",
  "merchantRefundNo": "REFUND202609140001",
  "refundAmount": 1000,
  "reason": "用户申请退款"
}
```

保存响应中的 refundNo 和 merchantRefundNo，核对订单状态及 refundedAmount。通道是否支持退款、是否即时完成，以实际能力和响应为准。

**同一次退款超时、失败或结果未知时，保留原 merchantRefundNo、原订单、金额和原因，只更新 timestamp、nonce、sign。不能更换退款编号重试未知结果，更换编号代表一笔新的退款。**

重复成功请求会返回原退款编号；处理中或未知结果需要继续核对，不能仅因接口返回就认定资金已退。当前公开网关没有单独的退款单查询端点，可通过订单查询核对累计已退款金额，异常退款到平台后台进一步核查。

## 易支付兼容接入

系统同时提供原生 JSON 网关和易支付兼容接口。两种协议的字段、金额单位、成功码和签名规则不能混用。

| 用途 | 地址 |
| --- | --- |
| 页面跳转下单 | `GET/POST {BASE}/submit.php`，成功后跳转收银台 |
| API 下单 | `GET/POST {BASE}/mapi.php`，返回 JSON |
| 查询订单 | `GET/POST {BASE}/api.php`，act=order |
| 平台 RSA 公钥 | `{BASE}/api/epay/platform-public-key` |

请求使用 Query 或 application/x-www-form-urlencoded 表单，不是 JSON。

| 配置项 / 下单字段 | 内容 |
| --- | --- |
| 网关地址 | `{BASE}`，配置基础地址时不要附带 /docs 或接口路径 |
| pid | 本平台商户号（也支持商户 ID），不是原生 X-App-Key |
| 商户密钥 | 经典 MD5 模式使用本商户 AppSecret |
| type | alipay / wxpay / qqpay 等已开通方式 |
| out_trade_no | 商户唯一订单号 |
| name | 商品标题 |
| money | 金额单位为元，例如 10.00，不是 1000 分 |
| notify_url | 商户自己的公网 HTTPS 通知地址 |
| return_url | 可选，商户支付结果页 |
| sign_type / sign | 签名类型与签名 |

### 经典 MD5 签名

排除 sign、sign_type 和空值，按字段名 ASCII 升序拼接 `k=v&k=v`，**直接追加 AppSecret**，计算 MD5 小写。不要混用原生接口的 `&key=` 规则。参数值不能包含 `&`；需要复杂 URL 或附加参数时改用原生 V2。

```text
sign = MD5小写(排序后的参数原文 + AppSecret)
sign_type = MD5
```

RSA/RSA2 模式需先配置商户公钥，由商户私钥签请求；通知签名可能采用 RSA，应按实际 sign_type 使用平台公钥验签，不能把 RSA 通知当成 MD5。

### 下单、查询和通知

mapi.php 成功返回 `code=1`、trade_no、payurl，使用 payurl 跳转；失败返回 code=-1 和 msg。

查单传 pid、act=order，以及 trade_no / out_trade_no 二选一，并计算签名。**act=order 也要参与签名，不支持只传 pid + key 查单。** 当前 api.php 只支持 order，不支持退款等其他 act。status=1 表示已有支付时间，status=0 表示尚无支付时间；它不是原生接口完整的支付/退款状态枚举，也不执行原生查询的上游补单流程。

查单响应附带的签名只覆盖 trade_no、out_trade_no、type、pid、name、money、status，按此字段集合验签，不把 code、msg、addtime、endtime 加入响应验签原文。

易支付异步通知采用 GET，包含 pid、type、name、money、trade_no、out_trade_no、trade_status、sign、sign_type。验签并核对订单、元金额及 trade_status=TRADE_SUCCESS 后持久化处理，返回 HTTP 200 和纯文本 `success`。同步回跳仅作展示，不是发货依据。

### 易支付 Pro 插件安装

1. 从本页插件目录下载适用版本，按包内说明安装。常见 Pro 目录为 plugins/payment/xypay；自定义发行版以实际插件目录为准。
2. 在易支付后台刷新插件列表，启用插件并配置通道。
3. 网关地址填 `{BASE}`，原生 xypay 插件填写本商户 AppKey / AppSecret，通道编码通常留空。
4. 启用对应支付方式和商户分组，确认易支付站点使用公网 HTTPS。
5. 使用新订单联调下单、查单和通知。需要退款时确认插件版本已实现 V2 和 merchantRefundNo；旧插件不能仅靠改地址获得退款能力。

## 服务端示例代码

以下示例只负责请求签名和发送，不会在浏览器中运行。替换商户通知地址，并从服务端环境变量读取 FOURPAY_APP_KEY、FOURPAY_APP_SECRET。订单号由业务生成并持久化，重试时不要生成新订单号。退款示例仅展示调用方式，不会自动执行。

### Python：下单、查询、退款共用客户端

```python
import base64, hashlib, hmac, json, os, secrets, time, urllib.request

BASE = '{BASE}'
APP_KEY = os.environ['FOURPAY_APP_KEY']
SECRET = os.environ['FOURPAY_APP_SECRET']

def frame(value):
    data = str(value).encode('utf-8')
    return str(len(data)).encode('ascii') + b':' + data

def sign_v2(params, path):
    raw = b'fourpay-gateway-v2\n' + frame('POST') + frame(path) + frame(APP_KEY)
    for key in sorted(k for k in params if k != 'sign'):
        raw += frame(key) + frame(params[key])
    digest = hmac.new(SECRET.encode('utf-8'), raw, hashlib.sha256).digest()
    return base64.urlsafe_b64encode(digest).decode('ascii').rstrip('=')

def request(path, business):
    params = dict(business)
    params.update(signType='HMAC-SHA256-V2',
                  timestamp=str(int(time.time())), nonce=secrets.token_hex(16))
    params['sign'] = sign_v2(params, path)
    req = urllib.request.Request(BASE + path,
        data=json.dumps(params, ensure_ascii=False).encode('utf-8'),
        headers={'Content-Type': 'application/json', 'X-App-Key': APP_KEY},
        method='POST')
    with urllib.request.urlopen(req, timeout=30) as response:
        result = json.load(response)
    if result.get('code') != 0:
        raise RuntimeError(result.get('message', '网关请求失败；请核对订单状态'))
    return result['data']

# 以下调用放入你的服务端业务处理函数，不要反复执行固定示例订单。
# data = request('/api/gateway/orders', {
#     'merchantOrderNo': persisted_order_no, 'amount': 1000, 'subject': '测试商品',
#     'notifyUrl': 'https://shop.example.com/payment/notify', 'payMethod': 'alipay'})
# 保存 data['orderNo']，跳转 data['cashierUrl']。
# result = request('/api/gateway/orders/query', {'merchantOrderNo': persisted_order_no})
# refund = request('/api/gateway/refunds', {
#     'merchantOrderNo': persisted_order_no, 'merchantRefundNo': persisted_refund_no,
#     'refundAmount': 1000, 'reason': '用户申请退款'})
# 超时/异常时保留原业务编号，先核对状态，不能换退款编号重试。
```

### PHP：通用 V2 请求函数（PHP 7.4+，cURL）

```php
<?php
function fourpay_request(string $path, array $business): array {
    $base = '{BASE}';
    $appKey = getenv('FOURPAY_APP_KEY');
    $secret = getenv('FOURPAY_APP_SECRET');
    if (!$appKey || !$secret) throw new RuntimeException('请配置商户凭据');
    $p = $business;
    unset($p['sign']);
    $p['signType'] = 'HMAC-SHA256-V2';
    $p['timestamp'] = (string) time();
    $p['nonce'] = bin2hex(random_bytes(16));
    $frame = static function ($v): string {
        $s = (string) $v;
        return strlen($s) . ':' . $s; // PHP 字符串使用 UTF-8 字节
    };
    ksort($p, SORT_STRING);
    $raw = "fourpay-gateway-v2\n" . $frame('POST') . $frame($path) . $frame($appKey);
    foreach ($p as $k => $v) $raw .= $frame($k) . $frame($v);
    $p['sign'] = rtrim(strtr(base64_encode(hash_hmac('sha256', $raw, $secret, true)), '+/', '-_'), '=');
    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [
            'Content-Type: application/json', 'X-App-Key: ' . $appKey],
        CURLOPT_POSTFIELDS => json_encode($p, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
    $body = curl_exec($ch);
    $error = curl_error($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($body === false || $status !== 200) throw new RuntimeException('请求结果未知，请查单：' . $error);
    $result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    if (($result['code'] ?? -1) !== 0) throw new RuntimeException($result['message'] ?? '请求失败');
    return $result['data'];
}
// fourpay_request('/api/gateway/orders/query', ['merchantOrderNo' => $persistedOrderNo]);
// 下单/退款替换 path 和业务参数；amount/refundAmount 使用 PHP 整数。
```

### Node.js：通用 V2 请求函数（Node.js 18+）

```javascript
const crypto = require('node:crypto');
const BASE = '{BASE}';
async function fourpayRequest(path, business) {
  const appKey = process.env.FOURPAY_APP_KEY, secret = process.env.FOURPAY_APP_SECRET;
  if (!appKey || !secret) throw new Error('请配置商户凭据');
  const p = { ...business, signType: 'HMAC-SHA256-V2',
    timestamp: String(Math.floor(Date.now() / 1000)), nonce: crypto.randomBytes(16).toString('hex') };
  delete p.sign;
  const frame = v => { const s = String(v); return Buffer.byteLength(s, 'utf8') + ':' + s; };
  let raw = 'fourpay-gateway-v2\n' + frame('POST') + frame(path) + frame(appKey);
  for (const k of Object.keys(p).sort()) raw += frame(k) + frame(p[k]);
  p.sign = crypto.createHmac('sha256', secret).update(raw, 'utf8').digest('base64url');
  const response = await fetch(BASE + path, { method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-App-Key': appKey },
    body: JSON.stringify(p), signal: AbortSignal.timeout(30000) });
  if (!response.ok) throw new Error('请求结果未知，请查单：HTTP ' + response.status);
  const result = await response.json();
  if (result.code !== 0) throw new Error(result.message || '网关请求失败');
  return result.data;
}
// await fourpayRequest('/api/gateway/orders/query', { merchantOrderNo: persistedOrderNo });
// 在 async 业务函数内调用。amount/refundAmount 使用安全范围内的整数，extra 使用 JSON.stringify。
```

### 原生回调验签示例（Python）

```python
import hashlib, hmac

def verify_notify(payload, secret):
    if not isinstance(payload, dict):
        return False
    received = payload.get('sign')
    if not isinstance(received, str) or len(received) != 32 or not received.isascii():
        return False
    # 当前平台通知字段都是字符串，拒绝异常类型。
    if any(not isinstance(v, str) for v in payload.values()):
        return False
    items = sorted((k, v) for k, v in payload.items() if k != 'sign' and v != '')
    raw = '&'.join(k + '=' + v for k, v in items) + '&key=' + secret
    expected = hashlib.md5(raw.encode('utf-8')).hexdigest().upper()
    return hmac.compare_digest(expected, received)

# payload = 框架解析的请求JSON
# 验签通过后，必须查本地订单并核对 orderNo、merchantOrderNo、amount、status。
# 事务内去重，保存支付结果和可靠发货任务，提交成功后返回 HTTP 200 / SUCCESS。
# 本函数仅验签，不能替代业务金额核对、持久化和防重复发货。
```

### Java / Go / C# / Ruby 接入要点

各语言按同一 V2 规则实现，重点核对 UTF-8 字节长度、HMAC 原始摘要和 Base64URL 无填充编码。发送 JSON 时金额必须保持整数类型；签名时才转为十进制文本。

| 语言 | UTF-8 字节长度 | HMAC-SHA256 / 编码 |
| --- | --- | --- |
| Java | s.getBytes(StandardCharsets.UTF_8).length | Mac.getInstance("HmacSHA256")；Base64.getUrlEncoder().withoutPadding() |
| Go | len(s)，字符串内容须为 UTF-8 | hmac.New(sha256.New, key)；base64.RawURLEncoding |
| C# | Encoding.UTF8.GetByteCount(s) | HMACSHA256；Base64 后替换 + 为 -、/ 为 _ 并移除末尾 = |
| Ruby | s.encode('UTF-8').bytesize | OpenSSL::HMAC.digest('SHA256', secret, raw)；Base64.urlsafe_encode64(..., padding: false) |

## 联调验收与常见问题

先检查商户开通状态和请求签名，再用本人已开通的通道做小额联调。接入中心的自检不创建真实订单，也不证明支付、退款或回调已经可用。

1. 下单成功，平台订单号保存正确，用户能够打开收银台。
2. 实际付款后，回调验签、订单号及金额核对通过，本地订单只更新一次。
3. 重复发送同一合法通知，不重复发货，仍返回 SUCCESS。
4. 篡改金额、订单号或签名的通知不能触发发货。
5. 用户先返回结果页、通知稍后到达时，页面能通过服务端查单展示结果。
6. 下单超时后按原订单号核对；退款超时后保留同一退款编号，避免重复退款。
7. 如需退款，核对部分/全额退款结果与累计退款金额。

| 现象 | 排查方式 |
| --- | --- |
| 商户鉴权失败 | 检查 X-App-Key、商户启用状态及账户验证；不要把商户号当作 AppKey |
| V2 签名失败 | 检查 POST/路径/AppKey 是否一致，是否按 UTF-8 字节计数，是否包含空值及公共字段，是否使用无填充 Base64URL |
| 请求过期 / nonce 已使用 | 校准服务器时间；每次请求和跨接口调用生成新的 nonce |
| MD5 参数存在歧义 | 参数中包含 &，改用原生 V2；不要自行替换字符后仍按旧值发送 |
| notifyUrl 无效 / 回调 301 | 使用可公网访问的最终 HTTPS 地址，检查证书、重定向、登录和站点防护规则 |
| HTTP 200 仍显示通知失败 | 正文直接返回 SUCCESS；不返回 JSON、HTML 或附加说明 |
| 已付款却未发货 | 查看通知记录、商户处理日志并主动查单；排查签名、订单金额和事务去重 |
| merchantOrderNo 已存在 | 按原订单号查询核对；仅新的业务订单使用新编号 |
| 无可用支付通道 | 检查通道启用、支付品牌、商户分组授权、本人子商户收款开关和额度 |
| 风控拦截 | 检查单笔/单日限额、付款 IP、支付账号限制和下单频率 |
| 退款要求 V2 | 升级调用方或插件，增加 merchantRefundNo 和 V2 公共字段 |
| 退款超时 / 状态未知 | 保留原退款编号核对订单与后台退款记录，不用新编号再次退款 |
