|
|
@@ -0,0 +1,724 @@
|
|
|
+# 收付款 OpenAPI 接入文档
|
|
|
+
|
|
|
+> 适用对象:接入本平台「收付款」能力的商户 / 服务商技术对接人员
|
|
|
+> 版本:v1 | 最后更新:2026-09-22
|
|
|
+
|
|
|
+本文档描述的是**本平台对外提供的接口**。你只需要按本文档实现,不需要、也不会接触到平台内部的任何实现细节。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 1. 概述
|
|
|
+
|
|
|
+平台提供四类能力,共 7 个接口:
|
|
|
+
|
|
|
+| 能力 | 场景 | 接口 |
|
|
|
+|---|---|---|
|
|
|
+| 收款 | 付款码支付(被扫)—— 用户出示付款码,收银员扫码 | `POST /payment/openapi/pay/barcode/pay` |
|
|
|
+| 收款 | 二维码支付(主扫)—— 平台出码,用户扫码 | `POST /payment/openapi/pay/qrcode/create` |
|
|
|
+| 收款 | H5 支付 —— 浏览器内拉起支付 | `POST /payment/openapi/pay/h5/create` |
|
|
|
+| 查询 | 订单查询 | `POST /payment/openapi/pay/order/query` |
|
|
|
+| 撤销 | 撤销 / 关单 | `POST /payment/openapi/pay/order/close` |
|
|
|
+| 退款 | 发起退款 | `POST /payment/openapi/pay/refund` |
|
|
|
+| 退款 | 退款查询 | `POST /payment/openapi/pay/refund/query` |
|
|
|
+
|
|
|
+所有接口均为 **POST + JSON**,请求体必须带签名。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. 接入准备
|
|
|
+
|
|
|
+对接前需要平台方为你开通并提供三样东西:
|
|
|
+
|
|
|
+| 项 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `api_key` | 你的身份标识,放在 `Authorization` 头 |
|
|
|
+| `api_secret` | 签名密钥,**只用于本地计算签名,任何情况下都不上网传输** |
|
|
|
+| 商户号 | 该 `api_key` 名下,每一个收款主体(企业)对应的商户号,下单时用 `enterprise_id` 指定 |
|
|
|
+
|
|
|
+还需向平台方提供一个**回调地址**(`notify_url`),或在下单时逐笔传入。
|
|
|
+
|
|
|
+> **`enterprise_id` 从哪来**:它是你在本平台的**企业标识**。下单前该企业必须已在平台开通报备,否则下单会失败并提示「该企业未配置商户号」。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. 请求约定
|
|
|
+
|
|
|
+### 3.1 地址
|
|
|
+
|
|
|
+```
|
|
|
+{BASE}/payment/openapi/pay/<接口名>
|
|
|
+```
|
|
|
+
|
|
|
+生产环境 `{BASE}` 为 `https://qcsj88888.com/api/v1`;联调环境的地址由平台方另行提供。
|
|
|
+
|
|
|
+例如付款码支付的完整地址是:
|
|
|
+
|
|
|
+```
|
|
|
+https://qcsj88888.com/api/v1/payment/openapi/pay/barcode/pay
|
|
|
+```
|
|
|
+
|
|
|
+### 3.2 请求头
|
|
|
+
|
|
|
+| 头 | 值 |
|
|
|
+|---|---|
|
|
|
+| `Content-Type` | `application/json` |
|
|
|
+| `Authorization` | `ApiKey <你的 api_key>`(`ApiKey` 与 key 之间是**一个空格**) |
|
|
|
+| `Signature` | 见 3.3,**64 位小写十六进制** |
|
|
|
+
|
|
|
+`Signature` 必须小写。大写十六进制会被判为签名错误。
|
|
|
+
|
|
|
+### 3.3 签名算法
|
|
|
+
|
|
|
+对**请求体的顶层字段**做签名,与 HTTP 无关(URL、查询串、请求头都不参与)。
|
|
|
+
|
|
|
+1. **取字段**:取请求体 JSON 的顶层键值对。
|
|
|
+2. **排除**:丢弃值为 `null`、空字符串 `""`、空对象 `{}`、空数组 `[]` 的字段。名为 `sign` 的字段也一律排除(本平台不收该字段,列出仅为对齐规则)。
|
|
|
+3. **排序**:按**键名的 ASCII 字典序**升序。
|
|
|
+4. **编码值**:
|
|
|
+ - 标量(字符串、数字、布尔)→ 先转成字符串,再做 URL 编码;
|
|
|
+ - 对象 / 数组 → 先序列化为**紧凑 JSON**(键按字典序递归排序,分隔符 `,` 和 `:` 后**不带空格**),再对整个 JSON 串做 URL 编码。
|
|
|
+ - URL 编码按 RFC 3986:空格编码为 `%20`(**不是** `+`),`*` 编码为 `%2A`,`~` **不编码**,其余非 ASCII 字符按 UTF-8 逐字节大写 `%XX`。
|
|
|
+5. **拼接**:用 `&` 连成 `键1=值1&键2=值2&...`(值已是编码后的形式)。
|
|
|
+6. **摘要**:以 `api_secret` 为密钥,对上面这串做 `HMAC-SHA256`,输出**小写十六进制**。
|
|
|
+7. 该十六进制即 `Signature` 请求头的值。
|
|
|
+
|
|
|
+> ⚠️ **数字必须原样传输。** 签名是对**你实际发出的 JSON 里那个值**做字符串化后计算的。`total_amount` 传 `100` 和传 `100.0` 会得到两个不同的签名 —— 后者会因为签名不符被判 401,而报错信息是「签名验证失败」,不会提示你写法有问题。请一律用整数。
|
|
|
+
|
|
|
+#### 参考实现
|
|
|
+
|
|
|
+Python:
|
|
|
+
|
|
|
+```python
|
|
|
+import hmac, hashlib, json
|
|
|
+from urllib.parse import quote
|
|
|
+
|
|
|
+def build_signature(api_secret: str, body: dict) -> str:
|
|
|
+ parts = []
|
|
|
+ for key in sorted(body):
|
|
|
+ value = body[key]
|
|
|
+ if value is None or value == "" or value == {} or value == [] or key == "sign":
|
|
|
+ continue
|
|
|
+ if isinstance(value, (dict, list)):
|
|
|
+ text = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
|
+ else:
|
|
|
+ text = str(value)
|
|
|
+ parts.append(f"{key}={quote(text, safe='')}")
|
|
|
+ sign_str = "&".join(parts)
|
|
|
+ return hmac.new(api_secret.encode("utf-8"), sign_str.encode("utf-8"),
|
|
|
+ hashlib.sha256).hexdigest()
|
|
|
+```
|
|
|
+
|
|
|
+Java:
|
|
|
+
|
|
|
+```java
|
|
|
+// 注意:数值字段请用整型(如 Long),不要用 double,否则字符串化结果不同
|
|
|
+private static String buildSignature(String apiSecret, Map<String, Object> body) throws Exception {
|
|
|
+ TreeMap<String, String> filtered = new TreeMap<>();
|
|
|
+ for (Map.Entry<String, Object> e : body.entrySet()) {
|
|
|
+ Object v = e.getValue();
|
|
|
+ if (v == null || "sign".equals(e.getKey())) continue;
|
|
|
+ if (v instanceof String s && s.isEmpty()) continue;
|
|
|
+ if (v instanceof Map<?, ?> m && m.isEmpty()) continue;
|
|
|
+ if (v instanceof Collection<?> c && c.isEmpty()) continue;
|
|
|
+ String text = (v instanceof Map || v instanceof Collection)
|
|
|
+ ? new ObjectMapper()
|
|
|
+ .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
|
|
|
+ .writeValueAsString(v)
|
|
|
+ : String.valueOf(v);
|
|
|
+ filtered.put(e.getKey(), URLEncoder.encode(text, StandardCharsets.UTF_8)
|
|
|
+ .replace("+", "%20").replace("*", "%2A").replace("%7E", "~"));
|
|
|
+ }
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (Map.Entry<String, String> e : filtered.entrySet()) {
|
|
|
+ if (sb.length() > 0) sb.append('&');
|
|
|
+ sb.append(e.getKey()).append('=').append(e.getValue());
|
|
|
+ }
|
|
|
+ Mac mac = Mac.getInstance("HmacSHA256");
|
|
|
+ mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
|
+ return HexFormat.of().formatHex(mac.doFinal(sb.toString().getBytes(StandardCharsets.UTF_8)));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Node.js:
|
|
|
+
|
|
|
+```js
|
|
|
+const crypto = require('crypto');
|
|
|
+
|
|
|
+function buildSignature(apiSecret, body) {
|
|
|
+ const parts = [];
|
|
|
+ for (const key of Object.keys(body).sort()) {
|
|
|
+ const v = body[key];
|
|
|
+ if (v === null || v === undefined || v === '') continue;
|
|
|
+ if (Array.isArray(v) && v.length === 0) continue;
|
|
|
+ if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
|
|
|
+ if (key === 'sign') continue;
|
|
|
+ const text = (typeof v === 'object') ? sortStringify(v) : String(v);
|
|
|
+ parts.push(`${key}=${encodeURIComponent(text)}`);
|
|
|
+ }
|
|
|
+ return crypto.createHmac('sha256', apiSecret)
|
|
|
+ .update(parts.join('&'), 'utf8').digest('hex');
|
|
|
+}
|
|
|
+// encodeURIComponent 与 RFC 3986 的差异仅在 ! ' ( ) * 五个字符,需要按需转义 *
|
|
|
+function sortStringify(obj) {
|
|
|
+ if (Array.isArray(obj)) return '[' + obj.map(sortStringify).join(',') + ']';
|
|
|
+ if (obj && typeof obj === 'object') {
|
|
|
+ return '{' + Object.keys(obj).sort()
|
|
|
+ .map(k => JSON.stringify(k) + ':' + sortStringify(obj[k])).join(',') + '}';
|
|
|
+ }
|
|
|
+ return JSON.stringify(obj);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 上面三段都请先跑通 3.4 的样例再接入。
|
|
|
+
|
|
|
+### 3.4 签名样例(可自测)
|
|
|
+
|
|
|
+**请求体**(付款码支付):
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "out_trade_no": "T20260922001",
|
|
|
+ "enterprise_id": "ENT10001",
|
|
|
+ "auth_code": "280812345678901234",
|
|
|
+ "total_amount": 100,
|
|
|
+ "pay_type": "000",
|
|
|
+ "order_body": "测试商品",
|
|
|
+ "attach": "table-1"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**api_secret**(仅用于本样例演示,不是真实密钥):
|
|
|
+
|
|
|
+```
|
|
|
+doc_demo_secret_9f8e7d6c5b4a
|
|
|
+```
|
|
|
+
|
|
|
+**排序后逐项编码**(注意 `order_body` 的中文被编码):
|
|
|
+
|
|
|
+| 键 | 编码后的值 |
|
|
|
+|---|---|
|
|
|
+| `attach` | `table-1` |
|
|
|
+| `auth_code` | `280812345678901234` |
|
|
|
+| `enterprise_id` | `ENT10001` |
|
|
|
+| `order_body` | `%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81` |
|
|
|
+| `out_trade_no` | `T20260922001` |
|
|
|
+| `pay_type` | `000` |
|
|
|
+| `total_amount` | `100` |
|
|
|
+
|
|
|
+**待签串**:
|
|
|
+
|
|
|
+```
|
|
|
+attach=table-1&auth_code=280812345678901234&enterprise_id=ENT10001&order_body=%E6%B5%8B%E8%AF%95%E5%95%86%E5%93%81&out_trade_no=T20260922001&pay_type=000&total_amount=100
|
|
|
+```
|
|
|
+
|
|
|
+**Signature**:
|
|
|
+
|
|
|
+```
|
|
|
+2454f99173419a38a41a573bee26e555673d12beee61f9f4481af7d8d2eb3a70
|
|
|
+```
|
|
|
+
|
|
|
+如果算出来不是这个值,**先别调接口** —— 把待签串打印出来逐字符比对,多半是编码规则或数字格式不一致。这个样例已由平台侧的自动化测试钉住,不会无故变化。
|
|
|
+
|
|
|
+### 3.5 响应格式
|
|
|
+
|
|
|
+平台返回两种信封,按接口区分:
|
|
|
+
|
|
|
+**A 型 —— 三个下单接口**(`barcode/pay`、`qrcode/create`、`h5/create`)直接返回业务对象:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "out_trade_no": "T20260922001",
|
|
|
+ "status": "SUCCESS",
|
|
|
+ "trade_no": "2026092222001",
|
|
|
+ "total_amount": 100,
|
|
|
+ "receipt_amount": 100,
|
|
|
+ "code_url": null,
|
|
|
+ "message": null
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**B 型 —— 其余四个接口**用统一信封:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "code": 0,
|
|
|
+ "msg": "成功",
|
|
|
+ "data": { },
|
|
|
+ "status_code": 200,
|
|
|
+ "success": true
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> `code` 为 `0` 表示成功,非 `0` 表示失败(见 3.6)。
|
|
|
+> 信封字段名是 `status_code`(下划线),不是 `statusCode`。
|
|
|
+
|
|
|
+### 3.6 错误处理
|
|
|
+
|
|
|
+**判成败一律读响应体里的 `code`,不要依赖 HTTP 状态码。** 平台有两套错误出口,HTTP 状态码不一致:
|
|
|
+
|
|
|
+| 出错环节 | HTTP 状态码 | 响应体 |
|
|
|
+|---|---|---|
|
|
|
+| 参数校验失败(缺字段、金额非正、单号超长、**多了不认识的字段**等) | **400** | `{"code":400,"msg":"字段名: 具体原因", ...}` |
|
|
|
+| 认证 / 签名失败 | **401** | `{"code":10401,"msg":"...", ...}` |
|
|
|
+| 业务逻辑错误(订单不存在、状态不允许、退款超余额等) | **200** | `{"code":404 或 400 或 500,"msg":"...", ...}` |
|
|
|
+| 路径不存在(URL 拼错) | **404** | `{"code":404,"msg":"接口不存在", ...}` |
|
|
|
+| 服务端未预期异常 | **500** | `{"code":500,"msg":"服务器内部错误", ...}` |
|
|
|
+
|
|
|
+> ⚠️ **多传一个字段和少传一个字段一样,都是 400。** 平台**不忽略**不认识的字段 ——
|
|
|
+> 请求体里出现 DTO 没定义的键会被直接拒绝,`msg` 会指出是哪个键,例如
|
|
|
+> `{"code":400,"msg":"未知字段 \"totalAmount\",报文里的字段名一律用下划线式,请核对"}`。
|
|
|
+> 这是有意的:静默忽略会让你以为某个参数生效了,而它其实从头到尾没被读。
|
|
|
+> 所以**字段名拼错不会静默通过**,请以 `msg` 指出的键名为准改回下划线式。
|
|
|
+
|
|
|
+所以请以 `code == 0` 作为唯一成功判据。第三行是最容易被误判的一类:**HTTP 200 不等于成功**。
|
|
|
+
|
|
|
+> ⚠️ **校验错误的 `msg` 里,字段名是驼峰式,不是下划线式。** 例如少传 `enterprise_id`,返回的是
|
|
|
+> `{"code":400,"msg":"enterpriseId: 不能为空"}`;`total_amount` 传 0,返回
|
|
|
+> `{"code":400,"msg":"totalAmount: 必须是正数"}`。报文全用下划线,但这条消息里的字段名沿用服务端
|
|
|
+> 内部命名,**不要拿它直接去匹配你请求体里的键名**。要据 `msg` 定位参数时,请忽略大小写与下划线差异。
|
|
|
+
|
|
|
+常见错误码:
|
|
|
+
|
|
|
+| `code` | 含义 |
|
|
|
+|---|---|
|
|
|
+| `0` | 成功 |
|
|
|
+| `400` | 参数错误,`msg` 会指明是哪个字段 |
|
|
|
+| `401` | 认证上下文缺失(正常情况下由 10401 覆盖,此码防御性保留) |
|
|
|
+| `404` | 订单 / 退款单不存在,或不属于你的 `api_key` |
|
|
|
+| `500` | 业务处理未成功(如关单未生效、撤销被拒),**可重试** |
|
|
|
+| `10401` | 认证失败:缺少 `Authorization` / `Signature` 头、API Key 无效或已过期、签名验证不通过 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 4. 业务约定
|
|
|
+
|
|
|
+**金额单位是「分」。** 所有金额字段(`total_amount`、`receipt_amount`、`refund_fee`、`refunded_amount`)都是**整数,单位为分**。`100` 表示 1.00 元。
|
|
|
+
|
|
|
+**单号规则。** `out_trade_no`(商户订单号)与 `out_refund_no`(商户退款单号)由**你方生成**,要求:
|
|
|
+
|
|
|
+- 长度 1–64 字符;
|
|
|
+- 不含换行符与其它控制字符;
|
|
|
+- 在你自己的 `api_key` 范围内**唯一**。
|
|
|
+
|
|
|
+**下单幂等。** 同一个 `api_key` 下,用相同的 `out_trade_no` 重复调用任一**下单**接口,**不会**新建订单、**不会**重复扣款:平台直接返回已存在订单的当前结果,且 `message` 为「订单已存在,返回原结果」。所以网络超时后可以放心重试。
|
|
|
+
|
|
|
+**退款幂等**由 `out_refund_no` 承担,规则同上;此外:
|
|
|
+
|
|
|
+- 同一笔订单**同时最多只有一笔在途退款**,在途期间再发起会失败并提示「该订单有退款正在处理中」;
|
|
|
+- 累计退款金额不得超过订单金额,超出会失败并提示可退余额;
|
|
|
+- 一笔订单**可以先退一部分、成功后再退剩下的**(部分退款是允许的,见 5.6)。
|
|
|
+
|
|
|
+**超时与重试。** 请对下单接口设置合理的连接/读取超时(建议 15 秒),超时后用**同一个 `out_trade_no`** 重试或调用订单查询确认结果,不要换单号重发。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. 接口详情
|
|
|
+
|
|
|
+### 5.1 付款码支付(被扫)
|
|
|
+
|
|
|
+用户出示付款码,你用扫码枪读取后调用本接口完成收款。
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/barcode/pay
|
|
|
+```
|
|
|
+
|
|
|
+**请求**
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_trade_no` | string | 是 | 你的商户订单号,见 4 |
|
|
|
+| `enterprise_id` | string | 是 | 收款企业标识 |
|
|
|
+| `auth_code` | string | 是 | 用户的付款码,由扫码枪读取 |
|
|
|
+| `total_amount` | long | 是 | 金额(分),必须 > 0 |
|
|
|
+| `pay_type` | string | 否 | `000` 自动识别(默认)/ `300` 支付宝 / `400` 微信 |
|
|
|
+| `order_body` | string | 否 | 商品描述,≤128 字符 |
|
|
|
+| `attach` | string | 否 | 你的透传字段,会原样出现在回调里,≤128 字符 |
|
|
|
+| `notify_url` | string | 否 | 本笔的回调地址,≤512 字符;不传则用默认配置 |
|
|
|
+
|
|
|
+**响应**(A 型)
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `out_trade_no` | 原样返回 |
|
|
|
+| `status` | 订单状态,见 6。**当场扣款成功为 `SUCCESS`**;若返回 `PAYING` 表示用户仍在支付中,平台会自动轮询确认,最终结果通过回调通知你 |
|
|
|
+| `trade_no` | 平台交易号,后续查询/退款可用 |
|
|
|
+| `total_amount` | 订单金额(分) |
|
|
|
+| `receipt_amount` | 实收金额(分),支付成功时返回,否则为 `null` |
|
|
|
+| `code_url` | 被扫场景恒为 `null` |
|
|
|
+| `message` | 附加说明,可能为空 |
|
|
|
+
|
|
|
+**典型时序**:绝大多数情况下同步就能拿到 `SUCCESS`。若同步返回 `PAYING`,说明这笔支付还在处理中,平台会自动轮询确认(约 30 秒一次),确认后回调你 —— 此时**不要**把 `PAYING` 当作失败。
|
|
|
+
|
|
|
+> ⚠️ **未付款的订单会被自动撤销。** 若轮询约 15 次(合计约 7–8 分钟)后仍未确认收款,平台会**自动撤销**该笔订单并把状态回调给你(`CANCELED`)。
|
|
|
+> 如果你的业务需要更长的等待时间,请改用 **5.2 主扫**(二维码有效期默认 15 分钟,可延长),而不是依赖被扫长时间挂单。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.2 二维码支付(主扫)
|
|
|
+
|
|
|
+平台生成一张二维码,你把它展示给用户,用户用支付宝/微信扫码付款。
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/qrcode/create
|
|
|
+```
|
|
|
+
|
|
|
+**请求**
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_trade_no` | string | 是 | 你的商户订单号 |
|
|
|
+| `enterprise_id` | string | 是 | 收款企业标识 |
|
|
|
+| `total_amount` | long | 是 | 金额(分),必须 > 0 |
|
|
|
+| `order_body` | string | 否 | 商品描述,≤128 字符 |
|
|
|
+| `attach` | string | 否 | 透传字段,≤128 字符 |
|
|
|
+| `notify_url` | string | 否 | 本笔回调地址,≤512 字符 |
|
|
|
+| `time_expire` | int | 否 | 二维码有效期(**分钟**),不传默认 15 分钟 |
|
|
|
+
|
|
|
+本接口**不接受** `pay_type`,由用户在扫码后自行选择支付方式。
|
|
|
+
|
|
|
+**响应**(A 型)
|
|
|
+
|
|
|
+收到 `code_url` 后,把它渲染成二维码即可(`code_url` 是可直接编码为二维码的字符串)。
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `status` | **下单成功为 `PAYING`** —— 下单成功 ≠ 收款成功。用户付款前一直是 `PAYING`,付款后通过回调通知你 |
|
|
|
+| `code_url` | 二维码内容 |
|
|
|
+| `receipt_amount` | 用户付款前为 `null` |
|
|
|
+| 其余 | 同 5.1 |
|
|
|
+
|
|
|
+> ⚠️ **不要把 `PAYING` 当成失败。** 主扫的下单响应**永远**是 `PAYING`;收款是否成功只能看回调(或主动查询)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.3 H5 支付
|
|
|
+
|
|
|
+在手机浏览器里拉起支付。
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/h5/create
|
|
|
+```
|
|
|
+
|
|
|
+**请求**
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_trade_no` | string | 是 | 你的商户订单号 |
|
|
|
+| `enterprise_id` | string | 是 | 收款企业标识 |
|
|
|
+| `total_amount` | long | 是 | 金额(分),必须 > 0 |
|
|
|
+| `pay_type` | string | 是 | **`300` 支付宝 / `400` 微信**,必须明确指定 |
|
|
|
+| `order_body` | string | 否 | 商品描述,≤128 字符 |
|
|
|
+| `attach` | string | 否 | 透传字段,≤128 字符 |
|
|
|
+| `notify_url` | string | 否 | 本笔回调地址,≤512 字符 |
|
|
|
+
|
|
|
+**响应**(A 型)
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `status` | 下单成功为 `PAYING` |
|
|
|
+| `code_url` | **跳转链接**,把它作为 URL 打开即可进入支付页 |
|
|
|
+| 其余 | 同 5.1 |
|
|
|
+
|
|
|
+> H5 场景用户会被跳转到外部页面,**不支持**主动关单(见 5.5)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.4 订单查询
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/order/query
|
|
|
+```
|
|
|
+
|
|
|
+**请求**(两个字段**至少提供一个**;都提供时以 `out_trade_no` 为准)
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_trade_no` | string | 二选一 | 你的商户订单号 |
|
|
|
+| `trade_no` | string | 二选一 | 平台交易号 |
|
|
|
+
|
|
|
+**响应**(B 型,`data` 内)
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `local_status` | 平台侧当前状态,见 6 |
|
|
|
+| `trade_no` | 平台交易号 |
|
|
|
+| `detail` | 实时回查的结果,用于对账核对(字段见下) |
|
|
|
+
|
|
|
+`detail` 的字段(固定这几个,取不到的为 `null`):
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `pay_result` | 实时回查的支付结果:`SUCCESS` 成功 / `PROCESSING` 处理中 / `FAILED` 失败 / `UNKNOWN` 无法判定 |
|
|
|
+| `total_amount` | 订单金额(分) |
|
|
|
+| `receipt_amount` | 实收金额(分) |
|
|
|
+| `discount_amount` | 优惠金额(分) |
|
|
|
+| `pay_time` | 支付时间,格式 `yyyyMMddHHmmss` |
|
|
|
+| `code_url` | 二维码内容(主扫场景) |
|
|
|
+| `code_url_expire_time` | 二维码过期时间 |
|
|
|
+
|
|
|
+> `pay_result` 是**本次实时查询**的结果,`local_status` 是**平台已记录**的状态。两者通常一致;不一致时以 `local_status` 为准(例如已经收款、但回调尚未落库的瞬间)。
|
|
|
+
|
|
|
+> 当回调可能丢失、或你收到回调但状态未落定时,用本接口兜底确认。**本接口会实时发起一次支付结果查询**,不要在紧密循环里高频调用。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.5 撤销 / 关单
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/order/close
|
|
|
+```
|
|
|
+
|
|
|
+**请求**
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_trade_no` | string | 是 | 你的商户订单号 |
|
|
|
+
|
|
|
+**行为**(按订单类型自动路由,无需你区分):
|
|
|
+
|
|
|
+| 订单类型 | 行为 |
|
|
|
+|---|---|
|
|
|
+| 付款码支付 | 先查后撤。**已支付**则不会撤销,而是提示你改走退款;未支付才真正撤销 |
|
|
|
+| 二维码支付 | 按二维码关单 |
|
|
|
+| H5 | **不支持主动关单**,平台仅把本地状态收敛为 `EXPIRED`。若用户之后仍完成支付,回调会把状态更正回来 |
|
|
|
+
|
|
|
+**响应**(B 型)
|
|
|
+
|
|
|
+成功时 `data.status` 为目标状态(`CANCELED` / `CLOSED` / `EXPIRED`)。
|
|
|
+
|
|
|
+两种特殊情形,都以**失败**返回(`code=500`):
|
|
|
+
|
|
|
+| `msg` | 含义与处理 |
|
|
|
+|---|---|
|
|
|
+| `该订单已支付,已转退款流程,请调用退款接口` | 订单已经收款成功,撤销无意义。**改调 5.6 退款** |
|
|
|
+| `关单结果未生效:订单状态已变更(可能已支付),请重新查询订单状态` | 关单动作与一笔刚到达的支付结果撞车,平台拒绝覆盖已收款的状态。**先查 5.4 确认,再决定是否退款** |
|
|
|
+| `撤销/关单未成功,请稍后重试` | 本次未成功,可稍后重试 |
|
|
|
+
|
|
|
+> 关单是**资金动作**:平台在无法确定订单是否已支付时(例如用户正在输密码)会**拒绝撤销**而不是冒险执行。收到 `code=500` 时请勿盲目重试,先查询订单状态。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.6 退款
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/refund
|
|
|
+```
|
|
|
+
|
|
|
+**请求**
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_trade_no` | string | 是 | 要退款的订单号 |
|
|
|
+| `out_refund_no` | string | 是 | 你的商户退款单号,全局唯一 |
|
|
|
+| `refund_fee` | long | 是 | 退款金额(分),必须 > 0,且不超过可退余额 |
|
|
|
+
|
|
|
+**响应**(B 型,`data` 内)
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `out_refund_no` | 原样返回 |
|
|
|
+| `refund_no` | 平台退款单号 |
|
|
|
+| `status` | 退款状态,见 6 |
|
|
|
+
|
|
|
+**退款是异步的。** 同步返回时 `status` 可能是:
|
|
|
+
|
|
|
+- `SUCCESS` —— 已退款成功;
|
|
|
+- `REFUNDING` —— 已受理,结果稍后通过**退款通知**(见 7.3)送达。
|
|
|
+
|
|
|
+无论哪种,退款到达终态时都会推送退款通知,请不要仅凭同步返回就记账。
|
|
|
+
|
|
|
+**部分退款**:一笔订单可以多次退款,只要累计不超过订单金额,且上一笔已出终态(不在 `REFUNDING`)。退款成功会改变订单状态(见 6),并通过订单回调携带更新后的已退金额。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 5.7 退款查询
|
|
|
+
|
|
|
+```
|
|
|
+POST /payment/openapi/pay/refund/query
|
|
|
+```
|
|
|
+
|
|
|
+**请求**
|
|
|
+
|
|
|
+| 字段 | 类型 | 必填 | 说明 |
|
|
|
+|---|---|---|---|
|
|
|
+| `out_refund_no` | string | **是** | 你的商户退款单号 |
|
|
|
+| `out_trade_no` | string | 否 | 请求体接受该字段,但**当前不参与查询** |
|
|
|
+
|
|
|
+> 只传 `out_trade_no` 查不到任何结果,会返回「退款单不存在」。请始终带上 `out_refund_no`。
|
|
|
+
|
|
|
+**响应**(B 型,`data` 内)
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `local_status` | 平台侧退款状态 |
|
|
|
+| `detail` | 实时回查的结果(字段见下) |
|
|
|
+
|
|
|
+`detail` 的字段:
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `refund_result` | 实时回查的退款结果,取值同 `pay_result` |
|
|
|
+| `refund_no` | 退款单号(平台侧) |
|
|
|
+| `refund_fee` | 退款金额(分) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 6. 状态说明
|
|
|
+
|
|
|
+### 订单状态
|
|
|
+
|
|
|
+| 状态 | 含义 | 是否终态 |
|
|
|
+|---|---|---|
|
|
|
+| `CREATED` | 已创建,尚未发起支付 | 否 |
|
|
|
+| `PAYING` | 已发起支付,等待用户付款 | 否 |
|
|
|
+| `SUCCESS` | **收款成功,钱已到账** | 是 |
|
|
|
+| `FAIL` | 支付失败 | 是 |
|
|
|
+| `CANCELED` | 已撤销 | 是 |
|
|
|
+| `CLOSED` | 已关单 | 是 |
|
|
|
+| `EXPIRED` | 已过期 | 是 |
|
|
|
+| `PARTIAL_REFUNDED` | 部分退款(**钱已到账过**,现在退了一部分) | 是 |
|
|
|
+| `REFUNDED` | 全额退款 | 是 |
|
|
|
+
|
|
|
+判断「是否收到过钱」用 `SUCCESS` / `PARTIAL_REFUNDED` / `REFUNDED` 三者;判断「是否还需要等结果」用 `CREATED` / `PAYING`。
|
|
|
+
|
|
|
+### 退款状态
|
|
|
+
|
|
|
+| 状态 | 含义 |
|
|
|
+|---|---|
|
|
|
+| `CREATED` | 已创建 |
|
|
|
+| `REFUNDING` | 退款处理中 |
|
|
|
+| `SUCCESS` | 退款成功 |
|
|
|
+| `FAIL` | 退款失败 |
|
|
|
+
|
|
|
+> 退款结果的正常到达途径是**退款通知**(见 7.3)。与订单侧不同,平台**没有**针对退款状态的兜底轮询任务 —— 平台不会主动去查询一笔在途退款的最新结果。
|
|
|
+> 因此若你的退款长时间停在 `REFUNDING` 且未收到通知,请用 **5.7 退款查询** 主动确认,并向平台方反馈。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 7. 异步通知(回调)
|
|
|
+
|
|
|
+### 7.1 投递方式
|
|
|
+
|
|
|
+平台会推送两类通知:**订单通知**(订单状态发生变化:收款成功、关闭、过期,以及退款导致的订单状态变化)与**退款通知**(退款到达终态,见 7.3)。两者投递方式相同:
|
|
|
+
|
|
|
+```
|
|
|
+POST <你的 notify_url>
|
|
|
+Content-Type: application/x-www-form-urlencoded
|
|
|
+```
|
|
|
+
|
|
|
+**请求体**(表单编码,三个字段):
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `notify_id` | 本次通知的唯一 ID,可用于去重 |
|
|
|
+| `timestamp` | 平台发出通知的时间,Unix 毫秒 |
|
|
|
+| `content` | **JSON 字符串**,业务内容见下(注意:是字符串,需要再解析一次) |
|
|
|
+
|
|
|
+**订单通知的 `content`**:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "out_trade_no": "T20260922001",
|
|
|
+ "trade_no": "2026092222001",
|
|
|
+ "status": "SUCCESS",
|
|
|
+ "total_amount": 100,
|
|
|
+ "receipt_amount": 100,
|
|
|
+ "refunded_amount": 0,
|
|
|
+ "pay_scene": "BARCODE",
|
|
|
+ "attach": "table-1"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`pay_scene` 取值:`BARCODE`(被扫)/ `QRCODE`(主扫)/ `H5`。
|
|
|
+
|
|
|
+**退款通知的 `content`**:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "out_trade_no": "T20260922001",
|
|
|
+ "out_refund_no": "R20260922001",
|
|
|
+ "refund_no": "2026092299001",
|
|
|
+ "status": "SUCCESS",
|
|
|
+ "refund_fee": 100
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 7.2 你方需要做的
|
|
|
+
|
|
|
+1. **应答**:处理成功后返回 **HTTP 2xx**(例如空 `200`)。返回非 2xx 或超时,平台视为投递失败。
|
|
|
+2. **去重**:同一笔业务**可能收到多次通知**(重试、或状态多次变化)。订单通知用 `out_trade_no` + `status`、退款通知用 `out_refund_no` + `status` 做幂等处理,不要重复记账。
|
|
|
+3. **快速应答**:先应答再异步处理,避免你的处理耗时导致超时被判失败。
|
|
|
+4. **重试**:投递失败时平台会重试,由兜底任务每 **5 分钟**捞一次,累计最多 **8 次**投递尝试(约 35 分钟内)。最终仍未成功则不再投递,需要你主动查询。
|
|
|
+
|
|
|
+> ⚠️ **回调目前不带签名。**
|
|
|
+> 因此**不能**仅凭回调报文认定收款成功。请务必在收到 `status` 为 `SUCCESS` / `PARTIAL_REFUNDED` / `REFUNDED` 的回调后,调用 **5.4 订单查询**核对一次;或以你方主动查询的结果为准,把回调当作「该查了」的触发信号。
|
|
|
+> 平台方已在计划中为回调补充签名机制,届时会另行通知。
|
|
|
+
|
|
|
+### 7.3 退款通知
|
|
|
+
|
|
|
+退款到达终态(成功或失败)时,平台还会**单独推送一条退款通知**,`content` 如下:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "out_trade_no": "T20260922001",
|
|
|
+ "out_refund_no": "R20260922001",
|
|
|
+ "refund_no": "2026092299001",
|
|
|
+ "status": "SUCCESS",
|
|
|
+ "refund_fee": 100
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+| 字段 | 说明 |
|
|
|
+|---|---|
|
|
|
+| `out_trade_no` | 关联的商户订单号 |
|
|
|
+| `out_refund_no` | 你的商户退款单号 |
|
|
|
+| `refund_no` | 退款单号(平台侧) |
|
|
|
+| `status` | 退款状态:`SUCCESS` / `FAIL` |
|
|
|
+| `refund_fee` | 退款金额(分) |
|
|
|
+
|
|
|
+退款成功后,你还会收到一条**订单通知**(`status` 变为 `PARTIAL_REFUNDED` 或 `REFUNDED`,`refunded_amount` 已更新)。
|
|
|
+
|
|
|
+> **两条通知的先后顺序不保证。** 请分别做幂等处理,不要假设退款通知一定先到或后到。
|
|
|
+
|
|
|
+注意退款**失败**也会推送(`status` 为 `FAIL`)—— 请按 `status` 分支处理,不要只看「收到通知」就当作退款成功。
|
|
|
+
|
|
|
+> 退款通知与订单通知共用同一套投递与重试规则(7.1 / 7.2),同样**不带签名**。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8. 联调检查清单
|
|
|
+
|
|
|
+- [ ] 用 3.4 的样例自测签名,算出的十六进制与文档一致
|
|
|
+- [ ] `Authorization` 头格式为 `ApiKey <key>`(一个空格)
|
|
|
+- [ ] `Signature` 为**小写**十六进制
|
|
|
+- [ ] 金额参与签名时按**整数**发送(不要出现 `100.0`)
|
|
|
+- [ ] 以响应体 `code == 0` 判成败,而非 HTTP 状态码
|
|
|
+- [ ] 下单使用业务唯一的 `out_trade_no`,重复调用能接受「订单已存在」的返回
|
|
|
+- [ ] 回调接口能正确处理表单编码,并把 `content` 字段**再解析一次** JSON
|
|
|
+- [ ] 回调处理实现了幂等(同一订单可能收到多次)
|
|
|
+- [ ] 收到成功类回调后,调用订单查询核对一次
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 9. 常见问题
|
|
|
+
|
|
|
+**Q:一直返回「签名验证失败」(`code=10401`)怎么办?**
|
|
|
+把待签串打印出来与 3.4 的样例逐字符比对。绝大多数情况是这三类:数字被序列化成了 `100.0`;URL 编码把空格编成了 `+` 而不是 `%20`;或者 `Signature` 用了大写。
|
|
|
+
|
|
|
+**Q:`total_amount` 到底是元还是分?**
|
|
|
+分。`100` = 1.00 元。
|
|
|
+
|
|
|
+**Q:主扫下单返回 `PAYING`,是不是失败了?**
|
|
|
+不是。主扫下单成功**必然**返回 `PAYING`。等回调,或主动查询。
|
|
|
+
|
|
|
+**Q:用户一直不付款,二维码会怎样?**
|
|
|
+有效期默认 15 分钟(可用 `time_expire` 指定)。超时后订单状态变为 `EXPIRED`。可以调 5.5 显式关单。
|
|
|
+
|
|
|
+**Q:下单请求超时了,能直接重发吗?**
|
|
|
+可以,用**同一个** `out_trade_no` 重发。平台会返回已存在订单的结果,不会重复扣款。
|
|
|
+
|
|
|
+**Q:一笔订单能退几次?**
|
|
|
+不限次数,但同一时刻只允许一笔在途退款,且累计退款金额不得超过订单金额。
|
|
|
+
|
|
|
+**Q:回调丢了怎么办?**
|
|
|
+平台最多重试 8 次。之后请主动调用 5.4 订单查询。建议你在自己的系统里对长时间处于 `CREATED` / `PAYING` 的订单做轮询兜底。
|