# 支付 API 完整接入说明 本文的 `{BASE}` 表示平台基础地址。在线文档会自动使用当前访问站点的协议、域名和端口;从在线文档下载的副本也会带上本站地址。直接读取源码或静态 Markdown 时,请自行替换 `{BASE}`。生产接入请使用 HTTPS。 ## 接入地址与准备 | 用途 | 地址 / 配置 | | --- | --- | | 平台基础地址 | `{BASE}` | | 商户登录 | `{BASE}/merchant/login` | | 在线接口文档 | `{BASE}/docs` | | 创建订单 | `POST {BASE}/api/gateway/orders`(推荐 POST,也支持 GET) | | 查询订单 | `POST {BASE}/api/gateway/orders/query`(推荐 POST,也支持 GET) | | 申请退款 | `POST {BASE}/api/gateway/refunds`(推荐 POST,也支持 GET;须 V2 签名) | | 支付收银台 | 使用下单响应中的 `cashierUrl`,不要自行拼接 | | 异步回调地址 | 商户自行部署,例如 `https://shop.example.com/payment/notify` | | 支付完成跳转地址 | 商户自行部署,例如 `https://shop.example.com/payment/return` | 1. 开通商户账号,完成后台要求的账户验证。 2. 进入商户后台「接入中心」,获取 AppKey 和 AppSecret。查看密钥需要再次验证支付密码。 3. 确认支付方式、通道和本人子商户已经开通并开启收款;可用能力以实际授权为准。 4. 在自己的服务器部署回调接口和结果页面。AppSecret 只保存在服务端配置中,不写入网页、小程序、公开代码或日志。 **回调地址由接入方提供。** 下单时把自己网站的通知接口填入 `notifyUrl`,平台支付成功后向该地址通知。它不是平台网关地址,也不是上游渠道通知平台的地址。 **下单频率以平台后台风控配置为准。** 原生下单的 GET、POST 均不设置固定的来源 IP 请求次数上限;后台“单 IP 每分钟下单次数”未配置或为 `0` 时不限制下单次数,配置正整数时按该配置检查。查询接口的请求额度不会占用下单额度。 ## 完整操作流程 1. 商户网站创建本地订单,保存唯一商户订单号和应付金额。 2. 商户服务端签名,调用平台下单接口。 3. 检查响应 code,保存平台订单号 `orderNo`,引导用户进入返回的 `cashierUrl`。 4. 用户完成支付,平台向 `notifyUrl` 发送异步通知。 5. 商户验证签名、订单归属、订单号和金额,确认支付成功,持久化支付结果和发货任务。 6. 成功处理后返回 HTTP 200 和纯文本 `SUCCESS`;重复通知不能重复发货。 7. 用户回到 `returnUrl` 后,由商户服务端查单,再展示真实支付结果。 8. 对漏通知或请求超时的订单主动查单补偿;售后退款按唯一商户退款编号申请并核对结果。 **接口调用成功不等于付款成功。** `code=0` 只说明本次原生接口调用成功,订单付款结果需要检查 `data.status`。 ## 请求与 V2 签名 原生下单、查询和退款均支持 GET 和 POST,**推荐使用 POST、UTF-8 JSON**,请求头如下: ```http Content-Type: application/json X-App-Key: 你的AppKey ``` 原生接口金额均为整数「分」:1.00 元传 `100`,10.00 元传 `1000`。POST JSON 中金额必须为数字,不要传小数、浮点元或数字字符串。GET 查询参数用十进制整数文本。时间字段通常为 RFC3339,时间戳使用 Unix 秒。 ### GET 兼容调用 三个原生接口 `/api/gateway/orders`、`/api/gateway/orders/query`、`/api/gateway/refunds` 均可将原生参数放入 URL 查询串。空地址直接打开会提示缺少 AppKey 和签名,并显示「支持 GET 和 POST,推荐使用 POST JSON」。GET 仍执行完整鉴权、验签、商户归属和业务校验;退款仍要求 V2 签名。 - AppKey 可通过 `X-App-Key` 请求头或 `appKey` 查询参数传入;同时提供时必须一致。不要传商户号或 AppSecret。 - **URL 中的 `appKey` 也参与签名**;只通过请求头传 AppKey 时,不额外添加这个参数。`sign` 自身不参与签名。 - V2 原文中的请求方法使用 `GET`,不能沿用按 `POST` 计算的签名;路径仍不含查询串。其余规则见下方签名计算步骤。 - 先用原始字段值计算签名,再将每个键和值进行标准 URL 编码。服务端以 URL 解码后的值验签;同名参数不得重复。金额、退款金额和有效期在验签后按整数解析,金额单位仍是分。 - 每次 V2 请求使用新的秒级 `timestamp` 和 16~64 位 `nonce`;超时重试保留原业务订单号或退款编号。 - GET 响应不缓存,并返回 `X-API-Recommendation: Prefer POST with application/json`。查询串上限为 16 KiB,还会受反向代理长度限制。URL 可能进入浏览器历史和访问日志,因此推荐使用 POST。 以下仅展示结构,占位值须在发送时替换并重新签名: ```text GET {BASE}/api/gateway/orders?appKey=YOUR_APP_KEY&merchantOrderNo=SHOP001&amount=10000&subject=TEST¬ifyUrl=https%3A%2F%2Fshop.example.com%2Fnotify&signType=HMAC-SHA256-V2×tamp=CURRENT_UNIX_SECONDS&nonce=NEW_RANDOM_NONCE&sign=CALCULATED_SIGNATURE ``` 原生参数仍使用 `merchantOrderNo`、`amount`、`notifyUrl` 等字段;易支付的 `pid`、`out_trade_no`、`money` 应提交到 `/mapi.php`。 ```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(实际请求方法)`、`frame(接口路径)`、`frame(AppKey)`。请求方法为大写 `POST` 或 `GET`,接口路径不含域名、查询串或末尾多余斜杠。下方以推荐的 POST 为例。 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 或带解释的句子。处理失败时不能先应答成功再丢弃业务。 启用持久化通知队列后,新支付成功订单最多自动通知 10 次(含首次),每轮发送 1 次;首次立即执行,后续间隔依次为 1、2、5、10、15、30、60、120、120 分钟。网络超时、响应不确定或发送中断也按剩余次数自动重试;商户确认成功立即停止,次数用尽仍未确认后才标记人工处理。中断或结果保存失败的轮次计入上限,但可能没有完整发送记录;前置配置校验失败不计发送次数,仍消耗一次执行机会。商户必须按订单号幂等处理,避免重复入账。历史已停止任务不自动恢复,未加入队列的历史订单保留每次最多 3 次的原规则,不自动补发。后台通知记录显示队列状态及下次执行时间。 ## 同步跳转与上游回调 ### returnUrl:商户支付结果页 例如 `https://shop.example.com/payment/return`,由商户自行部署并在下单时传入。页面应调用自己的服务端查单后展示真实结果。**用户访问此页面、页面参数或浏览器跳转都不能作为发货依据。** `returnUrl` 是可选的完整 HTTP(S) 地址,不要传相对路径或将整个地址预先 URL 编码。未提供或地址无效时,订单仍可正常支付,上游返回系统设置的收银台,支付完成后停留在结果页;不会跳转到无效地址。有效地址的协议、端口、查询参数和片段保持原样。此兼容处理不影响 `notifyUrl` 的必填、域名白名单和公网地址校验。 ### 上游渠道通知平台 这类地址供配置微信、支付宝或其他支付插件使用,普通接入商户不需要填到自己的 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 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 $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;旧插件不能仅靠改地址获得退款能力。 ## 服务端示例代码 以下示例只负责请求签名和发送,不会在浏览器中运行。替换商户通知地址,并从服务端环境变量读取 PAYMENT_APP_KEY、PAYMENT_APP_SECRET。订单号由业务生成并持久化,重试时不要生成新订单号。退款示例仅展示调用方式,不会自动执行。 ### Python:下单、查询、退款共用客户端 ```python import base64, hashlib, hmac, json, os, secrets, time, urllib.request BASE = '{BASE}' APP_KEY = os.environ['PAYMENT_APP_KEY'] SECRET = os.environ['PAYMENT_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 $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']; } // payment_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 paymentRequest(path, business) { const appKey = process.env.PAYMENT_APP_KEY, secret = process.env.PAYMENT_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 paymentRequest('/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. 如需退款,核对部分/全额退款结果与累计退款金额。 | 现象 | 排查方式 | | --- | --- | | 浏览器直接打开原生接口提示缺少参数、推荐 POST | 支持 GET 和 POST。GET 需提交完整原生参数、AppKey 和签名;推荐从服务端以 POST 发送签名 JSON。易支付参数请使用 /mapi.php;旧版直接打开原生接口可能返回 404 或 405 | | 商户鉴权失败:缺少请求头 X-App-Key | POST 必须在 HTTP 请求头传 X-App-Key;GET 也可使用 appKey 查询参数。不要放在 POST JSON 或表单中,检查反向代理是否保留请求头 | | 商户鉴权失败:X-App-Key 不正确或商户未启用 | 使用当前调用站点商户接入中心的 AppKey,不是商户号或 AppSecret;确认商户状态为已启用 | | 商户鉴权暂不可用:商户数据读取失败 | 数据库查询异常,稍后重试或联系管理员检查数据库连接和表结构;无需因此重置密钥 | | V2 签名失败 | 检查 POST/路径/AppKey 是否一致,是否按 UTF-8 字节计数,是否包含空值及公共字段,是否使用无填充 Base64URL | | 请求过期 / nonce 已使用 | 校准服务器时间;每次请求和跨接口调用生成新的 nonce | | MD5 参数存在歧义 | 参数中包含 &,改用原生 V2;不要自行替换字符后仍按旧值发送 | | notifyUrl 无效 / 回调 301 | 使用可公网访问的最终 HTTPS 地址,检查证书、重定向、登录和站点防护规则 | | HTTP 200 仍显示通知失败 | 正文直接返回 SUCCESS;不返回 JSON、HTML 或附加说明 | | 已付款却未发货 | 查看通知记录、商户处理日志并主动查单;排查签名、订单金额和事务去重 | | merchantOrderNo 已存在 | 按原订单号查询核对;仅新的业务订单使用新编号 | | 无可用支付通道 | 检查通道启用、支付品牌、商户分组授权、本人子商户收款开关和额度 | | 风控拦截 | 检查单笔/单日限额、付款 IP、支付账号限制和下单频率 | | 退款要求 V2 | 升级调用方或插件,增加 merchantRefundNo 和 V2 公共字段 | | 退款超时 / 状态未知 | 保留原退款编号核对订单与后台退款记录,不用新编号再次退款 |