# 易支付兼容接入

新接入推荐使用本站原生 JSON API；已有易支付程序可备用兼容接口。以下说明对应本站实际实现，不代表支持易支付所有扩展接口。`{BASE}` 代表本站基础地址，下载文档时会自动替换为当前站点域名（含端口）。

### 选择接入方式与地址

| 用途 | 完整地址 | 方法与结果 |
| --- | --- | --- |
| 原生下单（推荐） | `{BASE}/api/gateway/orders` | 推荐 POST JSON，也支持 GET；成功 code=0 |
| 原生查询（推荐） | `{BASE}/api/gateway/orders/query` | 推荐 POST JSON，也支持 GET；成功 code=0 |
| 易支付 API 下单 / 拉单 | `{BASE}/mapi.php` | GET 或表单 POST；成功 code=1，返回 payurl |
| 易支付页面跳转下单 | `{BASE}/submit.php` | GET 或表单 POST；成功 HTTP 302 跳转收银台 |
| 易支付订单查询 | `{BASE}/api.php` | GET、表单 POST 或 JSON POST；act 可省略，必须签名 |
| 平台 RSA 公钥 | `{BASE}/api/epay/platform-public-key` | GET；成功直接返回 PEM 文本 |

原生接口使用 AppKey / AppSecret，金额 amount 单位为分；易支付兼容接口使用 pid / key，金额 money 单位为元。不要将两种协议的字段、成功码、金额和签名混用。原生协议的参数和 V2 签名见本站完整开放文档 `{BASE}/docs#sign`。

### 商户配置与彩虹易支付

| 调用方配置项 | 应填写的内容 |
| --- | --- |
| 接口地址 / 网关地址 | 本站基础地址 `{BASE}`，是否需要路径取决于程序是否自动追加文件名 |
| pid / 商户 ID | 本站商户号；从商户后台「接入中心」复制，不要填写 AppKey |
| key / 商户密钥 | 本站 AppSecret；在接入中心验证支付密码后查看或复制 |
| 签名方式 | 一般使用 MD5；已有 RSA 商户按下文 RSA 配置说明接入 |
| notify_url | 您自己系统接收支付结果的公网 HTTPS 地址，不是本站下单地址 |
| return_url | 您自己系统的支付结果页面，可选；不能作为到账依据 |

**使用彩虹易支付直接对接，请开启 mapi，再进行拉单。** 同时核实所用插件或 SDK 文件内是否已经自带或拼接 `mapi.php` 后缀：若已经带了，对接接口只填写本站域名 `{BASE}`；若配置项明确要求完整下单地址且程序不会追加路径，才填写 `{BASE}/mapi.php`。避免形成 `/mapi.php/mapi.php` 或把原生下单路径后面再拼上 `/mapi.php`。

密钥只保存在业务服务器安全配置中，不传给浏览器、不写进前端代码和日志，也不要把 key 明文作为请求参数发送。兼容接口无需 X-App-Key 请求头。仅修改地址不能把一个只支持其他扩展协议的 SDK 变成完全兼容，请逐项核对本节接口和响应。

### 请求格式与 MD5 签名

`/mapi.php` 与 `/submit.php` 的 GET、POST 下单频率统一遵循平台后台“单 IP 每分钟下单次数”配置：未配置或为 `0` 时不限制下单次数，配置正整数时按该配置检查。下单入口没有额外写死的来源 IP 次数上限，查单限流不影响下单。

推荐服务器使用 POST，`Content-Type: application/x-www-form-urlencoded`，UTF-8 编码；也支持 GET 查询串。不要发送 JSON 或 multipart 表单。参数名使用小写，不重复提交同名字段，也不要将同一字段混在查询串和请求体中。

1. 使用即将发送的原始字段值；排除 sign、sign_type，以及空字符串或仅空白的值。字符串 `"0"` 必须参与签名。
2. 其余字段按字段名 ASCII 升序排列，拼接为 `k1=v1&k2=v2`。不要预先 URL 编码，不要自行去除非空值前后的空格。
3. 在拼接文本末尾**直接追加 AppSecret**，中间没有 `&key=`，按 UTF-8 计算小写 MD5。
4. 加入 sign 和 `sign_type=MD5`，最后进行表单 URL 编码并发送。中文、空格、加号等由表单编码器处理，不做二次编码。

本站也兼容部分旧客户端的 MD5 拼接变体；新代码统一按上述经典规则实现，不要套用原生旧 MD5 或 HMAC-SHA256-V2 的签名函数。额外提交的非空字段同样参与请求签名，即使它没有业务映射。

**参数限制：** URL 解码后，参数名不得包含 `&` 或 `=`，参数值不得包含 `&`。因此回调地址带多个查询参数时，即使在传输中写成 `%26`，解码后仍会被拒绝；请使用不带复杂查询串的回调地址，或改用原生 V2。RSA 请求也遵守这项限制。

### 下单参数与返回值

`/mapi.php` 与 `/submit.php` 使用同一组业务参数和签名规则：

| 参数 | 必填 | 说明 / 示例 |
| --- | --- | --- |
| pid | 是 | 本站商户号，如 `10001`；始终按字符串处理 |
| type | 建议明确传入 | 支付方式，如 `wxpay` / `alipay`；需有对应可用通道，未传时按平台路由处理 |
| out_trade_no | 是 | 您的业务订单号，如 `SHOP202609200001`；商户内唯一，发送前持久化 |
| name | 是 | 商品或订单标题，如 `测试商品` |
| money | 是 | 元，正数且最多两位小数；推荐字符串 `"1.23"`，不要传原生接口的分金额 |
| notify_url | 是 | 您的异步通知地址，如 `https://shop.example.com/payment/notify` |
| return_url | 否 | 支付完成后的浏览器回跳地址，如 `https://shop.example.com/payment/return` |
| sign | 是 | 按本节算法计算的签名 |
| sign_type | 建议传入 | `MD5`（省略时默认 MD5）；已有 RSA 商户可用 RSA / RSA2 |

本站兼容入口没有把 clientip、device、param 等扩展字段映射为业务配置，不承诺透传；付款来源 IP 使用实际请求来源。默认订单有效期为 30 分钟，不能靠未声明的易支付字段覆盖。代理关闭收款、商户停用、通道授权、额度和风控限制均继续生效。

以下为 `/mapi.php` 成功响应的结构示例，订单号与地址以实际返回为准：

```json
{
  "code": 1,
  "msg": "下单成功",
  "trade_no": "X202609200001",
  "payurl": "{BASE}/pay/X202609200001",
  "qrcode": "",
  "urlscheme": "",
  "img": ""
}
```

使用返回的 payurl 打开收银台，不要自行拼接付款地址；它可能使用已启用的收银台域名。当前 qrcode、urlscheme、img 为空，不能当作微信 JSAPI 参数或上游原生二维码使用。**下单响应不带签名；code=1 仅表示订单创建成功，不代表已付款。**

`/mapi.php` 业务失败通常为 HTTP 200、`{"code":-1,"msg":"具体错误"}`，须同时检查 HTTP 状态、JSON 格式和 code。`/submit.php` 成功为 HTTP 302，失败为 HTTP 400 的 HTML 错误页，不要将它当成 JSON API 解析。

同一笔订单重试保留原 out_trade_no。未支付且可复用的订单会返回原订单；已存在但不可复用时会提示订单号已存在，应查询原单。超时或响应丢失不能直接认定失败，也不能随意更换业务订单号重复扣款。

### 查询订单与响应验签

向 `{BASE}/api.php` 发送以下字段，支持 GET、表单 POST 和 `application/json` POST。推荐仅对 `pid` 与所用订单号字段签名，再加入 sign / sign_type：

| 参数 | 必填 | 说明 |
| --- | --- | --- |
| pid | 是 | 本站商户号 |
| act | 否 | 省略时按 `order` 查询；如填写，只支持 `order`；推荐不参与签名 |
| out_trade_no | 二选一 | 您的业务订单号 |
| trade_no | 二选一 | 本站返回的平台订单号 |

只传一种订单号；两者同时传入时服务器优先 out_trade_no。接口只能查询当前商户的订单。**不支持 `pid + key` 明文查单，也不支持 refund、balance 等其他 act。**

查询签名排除 `sign`、`sign_type`、`act` 和 `key`，其余非空字段按 ASCII 升序拼接，随后按所选 MD5 或 RSA 规则签名。旧客户端把 `act=order` 和/或 `key` 加入签名的方式仍兼容。地址中附带的 `key` 不会替代系统保存的商户密钥，不能用它绕过 sign 校验；新接入无需在地址中传 key。查询以外的下单、通知签名规则不变。

例如只发送 `pid=10001`、`out_trade_no=SHOP001`，推荐的 MD5 原文为 `out_trade_no=SHOP001&pid=10001`，末尾直接追加 AppSecret 后计算 MD5。JSON 中订单号建议使用字符串，以免调用方先转换为浮点数造成精度丢失。

```json
{
  "code": 1,
  "msg": "查询订单号成功！",
  "trade_no": "X202609200001",
  "out_trade_no": "SHOP202609200001",
  "type": "wxpay",
  "pid": "10001",
  "addtime": "2026-09-20 12:00:00",
  "name": "测试商品",
  "money": "1.23",
  "endtime": "2026-09-20 12:01:00",
  "status": 1,
  "sign": "实际响应签名",
  "sign_type": "MD5"
}
```

查询响应**只对固定字段** `trade_no、out_trade_no、type、pid、name、money、status` 签名。取出这七个字段，将 status 转为字符串，再按 sign_type 验签；不要把 code、msg、addtime、endtime 加入验签，也不要丢掉 status=0。验签通过后仍须核对 pid、订单号和金额是否对应本地订单。

status=0 表示没有支付完成时间，status=1 表示已记录支付完成时间；该字段不能区分关闭、退款或部分退款，已退款订单仍可能为 1。endtime 未付款时为空，日期文本不带时区偏移，不用日期字段判断到账。需要完整状态、累计退款金额或申请退款时，使用原生查询 / V2 退款接口。

### 异步通知、同步回跳与幂等处理

支付成功后，平台使用 **HTTP GET** 向订单的 notify_url 发送以下参数。通知可能重复或延迟，您的接口应无需登录即可接收，并在服务端校验：

| 参数 | 含义 |
| --- | --- |
| pid | 本站商户号 |
| trade_no / out_trade_no | 平台订单号 / 您的业务订单号 |
| type / name | 支付方式 / 订单标题 |
| money | 商户订单金额，元；核对本地确认的应收金额，不使用手续费后净额 |
| trade_status | 成功时固定为 `TRADE_SUCCESS` |
| sign / sign_type | 通知签名及算法 |

通知签名覆盖上述所有业务字段，排除 sign / sign_type 和空值。若 notify_url 自带您的路由参数，它们不属于平台签名字段；请明确提取上述字段验签，不要把路由参数混入。金额比较请用整数分或十进制定点运算，避免浮点误差。

处理顺序：验签 → 核对商户、订单号、金额和 TRADE_SUCCESS → 在数据库事务中将本地订单从未支付变为已支付并持久化后续履约任务 → 返回 HTTP 200，纯文本 `success`。用订单唯一约束和状态条件更新防止重复发货；已处理的合法重复通知仍返回 success。

应答正文只返回 success，不要返回 JSON、HTML、调试信息，也不要只因为请求到了就返回成功。无法验签或落库失败时不返回 success，记录不含密钥的错误并核对订单；不要依赖固定重试次数或时间，通知遗漏时主动查询。

填写 return_url 后，成功订单的浏览器回跳携带同组签名参数。但用户可关闭页面，浏览器访问也可以被伪造；回跳页面只展示状态，业务到账以验签后的异步通知或查单结果为准。

`return_url` 可省略；相对路径、整串预编码地址等无效值不会阻断下单，也不会传给上游或用于浏览器跳转，支付完成后停留在平台结果页。`/mapi.php` 和 `/submit.php` 的 GET、表单 POST 均按此规则处理，先按原始参数验签。有效地址保持原样，表单编码只做正常的一次解码。

### PHP：经典 MD5 签名与验签函数

以下函数适用于 MD5 商户，使用 PHP 7.4+。传入字符串参数；不要用 `empty()` 过滤字段，否则会漏掉 status=0。RSA 商户须使用下文 RSA 验签流程。

```php
<?php
function epaySign(array $params, string $secret): string {
    unset($params['sign'], $params['sign_type']);
    $params = array_filter($params, static function ($value) {
        return trim((string)$value) !== '';
    });
    ksort($params, SORT_STRING);
    $pairs = [];
    foreach ($params as $key => $value) {
        $pairs[] = $key . '=' . (string)$value;
    }
    return md5(implode('&', $pairs) . $secret);
}

// 仅用于 MD5；$fields 是根据上文表格提取的查询或通知业务字段。
function epayVerifyMD5(array $fields, array $response, string $secret): bool {
    return strtoupper((string)($response['sign_type'] ?? '')) === 'MD5'
        && hash_equals(epaySign($fields, $secret),
            strtolower((string)($response['sign'] ?? '')));
}

// 发送前：$params 为已保存的业务订单参数，$secret 从服务端配置读取。
// $params['sign'] = epaySign($params, $secret);
// $params['sign_type'] = 'MD5';
// $body = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
// 用 HTTP 客户端 POST $body 至本站 /mapi.php，并设置表单 Content-Type。
```

### Python：MD5 下单、查单与验签客户端

仅使用标准库；将 PAYMENT_EPAY_PID 和 PAYMENT_APP_SECRET 配置为服务端环境变量。下单与查单函数不会自动执行；下面调用示例中的 ORDER_ID 必须来自已持久化的本地业务订单。此代码适用于未配置 RSA 公钥的 MD5 商户。

```python
import hashlib, hmac, json, os, urllib.parse, urllib.request

BASE = '{BASE}'
PID = os.environ['PAYMENT_EPAY_PID']
SECRET = os.environ['PAYMENT_APP_SECRET']
QUERY_FIELDS = ('trade_no', 'out_trade_no', 'type', 'pid', 'name', 'money', 'status')
NOTIFY_FIELDS = ('pid', 'type', 'name', 'money', 'trade_no', 'out_trade_no', 'trade_status')

def epay_sign(params, secret):
    parts = [f'{k}={params[k]}' for k in sorted(params)
             if k not in ('sign', 'sign_type') and str(params[k]).strip() != '']
    return hashlib.md5(('&'.join(parts) + secret).encode('utf-8')).hexdigest()

def epay_verify(data, fields):
    if str(data.get('sign_type', '')).upper() != 'MD5':
        raise ValueError('当前示例仅支持 MD5，请按商户配置使用正确算法')
    selected = {k: str(data[k]) for k in fields}
    if not hmac.compare_digest(epay_sign(selected, SECRET), str(data.get('sign', '')).lower()):
        raise ValueError('响应验签失败')
    return selected

def epay_post(path, fields):
    params = {k: str(v) for k, v in fields.items()}
    params['pid'] = PID
    params['sign'] = epay_sign(params, SECRET)
    params['sign_type'] = 'MD5'
    request = urllib.request.Request(BASE + path,
        data=urllib.parse.urlencode(params).encode('utf-8'),
        headers={'Content-Type': 'application/x-www-form-urlencoded'})
    with urllib.request.urlopen(request, timeout=20) as response:
        result = json.load(response)
    if result.get('code') != 1:
        raise RuntimeError(result.get('msg', '接口请求失败'))
    return result

def epay_create(order_id, name, money, notify_url, return_url=''):
    return epay_post('/mapi.php', {
        'out_trade_no': order_id, 'type': 'wxpay', 'name': name, 'money': money,
        'notify_url': notify_url, 'return_url': return_url})

def epay_query(order_id):
    result = epay_post('/api.php', {'out_trade_no': order_id})
    fields = epay_verify(result, QUERY_FIELDS)
    if fields['pid'] != PID or fields['out_trade_no'] != order_id:
        raise ValueError('商户或订单号不匹配')
    return result  # 调用方继续核对本地金额、status，再执行幂等业务处理

# created = epay_create(ORDER_ID, '测试商品', '1.23',
#     'https://shop.example.com/payment/notify',
#     'https://shop.example.com/payment/return')
# 使用 created['payurl'] 打开收银台，不能此时标记已付款。
# result = epay_query(ORDER_ID)
# 通知处理：从 HTTP GET 查询参数取单个字符串值，拒绝重复字段，
# 调用 epay_verify(params, NOTIFY_FIELDS)，再完成前述业务核对和事务。
# 网络异常交由业务记录待核对状态；保留原订单号查单，不自动换单重试。
```

### RSA / RSA2 已有商户配置

已有商户 RSA 公钥配置继续有效。商户请求使用自己的私钥签名，平台使用已保存的商户公钥验签；通知和查询响应使用平台私钥签名，商户使用平台公钥验签。**两个方向的公钥不可互换。** 新接 RSA 前先联系平台确认商户公钥已正确配置，不要仅修改 sign_type。

RSA 与 RSA2 在本站均为 SHA-256 + RSA PKCS#1 v1.5，签名原文按本节排序和字段排除规则生成，**不追加 AppSecret**，签名结果为标准 Base64。表单编码必须保留 Base64 的加号（由编码器转为 `%2B`），不能手动拼接导致它变成空格。查询响应仍只验上述七个固定字段。

从 `{BASE}/api/epay/platform-public-key` 通过可信 HTTPS 获取并保存平台 PEM 公钥。原平台私钥保存在服务端数据目录 `epay_platform_rsa_private.pem`，升级须保留原数据目录，不要随意删除或重建。公钥发生变化时先联系平台核实，不在每次通知中盲目信任新下载的公钥。

**响应 / 通知使用哪种算法，取决于商户已保存的有效 RSA 公钥配置，并非本次请求 sign_type。** 已配置有效 RSA 公钥时，平台返回 sign_type=RSA；否则使用 MD5。调用方应按已确认的商户配置验签，不能验签失败后跳过校验或降级接受。

### 联调步骤与常见问题

1. 在接入中心确认基础地址、商户号和密钥；检查通道、支付方式及收款开关。
2. 使用新业务订单号和小额金额拉单，确认 code=1、保存 trade_no，打开 payurl。
3. 支付前查单确认 status=0，并验证查询响应签名，特别保留字段值 `0`。
4. 完成支付后验证通知签名、核对订单金额；检查本地订单持久化后返回 success。
5. 重放同一合法通知确认只处理一次；模拟通知遗漏，验证主动查单可核对到账。
6. 联调关闭收款、错误密钥、不存在订单等失败路径；退款走原生 V2。

| 现象 | 排查方法 |
| --- | --- |
| 404 / not found，或外层 code=500 内含 404 | 核对实际请求路径及是否重复拼接 mapi.php；本站 v0.9.125 缺少兼容入口，需使用已恢复入口的版本（如 v0.9.126）。检查反向代理是否把 .php 路径转给 PHP/FastCGI，应将这些路径转发给本站 Go 服务 |
| 缺少 pid / 商户不存在 | 填接入中心的商户号，不填 AppKey；确认请求是表单而非 JSON |
| 缺少签名 / MD5 签名校验失败 | 检查 AppSecret、排序、空值、status=0、中文编码；签名后再 URL 编码，不把密钥作为参数发送 |
| 含未转义分隔符 | 解码后的参数值包含 &；简化回调查询串或使用原生 V2，不能靠二次 URL 编码绕过 |
| 查询请求验签失败 | 推荐仅对 pid 和一种订单号签名，不加入 act 或 URL 中的 key；旧版含 act 的签名仍兼容 |
| 查询响应验签失败 | 只取固定七字段；保留 status=0；不要把 code、msg 或日期字段参与验签 |
| 商户未启用 / 代理商已关闭收款 | 联系平台或所属代理恢复相应收款权限；更换接口路径不会解除限制 |
| 下单成功但 SDK 提示失败 | 检查是否误用原生 code=0、要求非空 qrcode，或把 payurl 当作 JSAPI 参数 |
| 回调一直重发 | 核对最终地址可达、验签和落库成功、HTTP 200 且正文仅 success；不要输出 JSON/调试信息 |
| 不支持的 act | 当前兼容查询仅支持 order；明文 key 查单、余额和兼容退款等扩展未实现 |

### 易支付 Pro 原生插件安装

易支付站点也可通过本站原生 xypay 插件接入。原生插件与以上兼容入口是不同配置方式：

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