# 格力支付收款 API 接入文档

| 项目 | 说明 |
| --- | --- |
| 接口版本 | v1 |
| 文档版本 | 1.0 |
| 报文编码 | JSON / UTF-8 |
| 请求认证 | RSA 2048 / SHA-256 / PKCS#1 v1.5 |

本文档定义格力支付收款 API 的认证规则、接口参数、响应字段和支付结果处理方式。当前统一收款商户为豪运到，接口凭证和订单按账号隔离。当前版本提供 USDT 提现申请，不提供渠道分账、退款或银行卡结算查询。

## 1. 开通与调用流程

1. 向管理员申请邀请码，在平台完成邀请注册并登录，进入「API 凭证页面」。邀请码过期、停用或次数用尽时无法注册；已有账号可正常登录。
2. 输入登录密码，申请 API 公钥与私钥，下载凭证 JSON。
3. 将凭证文件保存在接入方服务器。私钥只返回一次，平台不提供找回；重新申请会立即停用旧私钥，App ID 不变，已有订单不受影响。
4. 服务端签名创建订单，下载二维码 PNG，再通过自己的页面展示图片。
5. 服务端每 5 秒查询订单，核对金额与业务订单号，取得 `paid=true` 后幂等完成发货、开通等业务。

正式 API 基地址：`https://glzf.top`。本机开发可用 `http://127.0.0.1:5000`；公网必须使用 HTTPS。示例中的 `https://pay.example.com` 只是占位符。本站没有沙箱参数，创建订单会生成真实商户的收款码。

API 凭证与网站登录密码不同。`public_key` 用于验证签名，`private_key` 用于发起签名。不要将私钥放入浏览器 JavaScript、移动端包、前端环境变量、Git 仓库或请求参数。请求只发送签名，不发送私钥。

## 2. 请求签名

算法为 **RSA 2048 / SHA-256 / PKCS#1 v1.5**（不是 RSA-PSS）。私钥为 PKCS#8 PEM，公钥为 SubjectPublicKeyInfo PEM。签名结果用标准 Base64 编码，保留末尾 `=`，不要转换为 Base64URL。

每个 API 请求都必须包含以下请求头，包含 GET 查询与 PNG 下载：

| 请求头 | 内容 |
| --- | --- |
| `X-App-Id` | API 凭证页面的 App ID，如 `app_` 加 24 位十六进制字符 |
| `X-Timestamp` | 10 位 Unix 秒，允许与服务器相差不超过 300 秒 |
| `X-Nonce` | 每次请求独立的 16–64 位随机字符串，只允许字母、数字、`_`、`-` |
| `X-Signature` | 对下述签名原文签名后的 Base64 字符串 |
| `Content-Type` | JSON 请求使用 `application/json` |

签名原文由六行组成，行之间使用单个 LF（`\n`）；最后一行后面**不加换行**：

```text
App ID
大写 HTTP 方法
接口路径及原始查询字符串
Unix 秒
Nonce
请求体字节的 SHA-256 小写十六进制摘要
```

以下原文示例对应 UTF-8 请求体 `{"amount":"1.00","out_trade_no":"ORDER_001"}`（时间戳与 nonce 仅用于说明，实际调用须重新生成）：

```text
app_0123456789abcdef01234567
POST
/api/v1/orders
1790553600
f805cb72bde94638a38ad4d90a712706
e7b53745760c8b65d1ccb3ce7b879f19e026061ed42549dac0c44059b90bb6f1
```

- JSON 先序列化为 UTF-8 字节，再计算摘要，签名后发送**同一份字节**；不要在签名后改变空格、字段顺序或换行。
- 路径不包含协议、域名或端口；文档中的路径均为 ASCII。存在查询参数时，包含 `?` 后的原始 URL 编码字符串，顺序和大小写必须与实际发送一致，不排序，不二次解码。例如 `/api/v1/qrcode?amount=1.00&out_trade_no=ORDER_001`。
- GET 没有请求体，使用零字节空内容的 SHA-256：`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。
- 每次请求，包括网络重试、查询和下载，都重新生成时间戳、nonce、签名。同一 nonce 不可重用；**业务订单号重用，nonce 不重用**。
- SDK 已实现以上规则。API 不接受网站 Cookie 替代签名，不提供跨域前端直接调用。

## 3. 接口总览

| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | `/api/v1/orders` | 创建订单，返回 JSON |
| GET | `/api/v1/orders/{order_id}` | 查询并刷新支付结果 |
| GET | `/api/v1/orders/by-reference/{out_trade_no}` | 通过业务订单号查单，用于丢失创建响应等情况 |
| GET | `/api/v1/orders/{order_id}/qrcode` | 下载同一订单的 PNG，不重新下单 |
| GET | `/api/v1/qrcode?amount=1.00&out_trade_no=ORDER_001` | 创建或复用订单并直接返回 PNG |

HEAD 不用于创建订单，不应将 HEAD 当作 GET 的替代。保留旧路径 `/api/orders` 和 `/qrcode`，但同样要求签名及业务订单号；新接入请统一使用 v1。

## 4. 创建订单

```http
POST /api/v1/orders
Content-Type: application/json
X-App-Id: ...
X-Timestamp: ...
X-Nonce: ...
X-Signature: ...

{"amount":"1.00","out_trade_no":"ORDER_20260928_001","pay_type":"auto"}
```

| 字段 | 类型 | 规则 |
| --- | --- | --- |
| `amount` | 字符串（推荐）或 number | 单位元，大于 0，最多两位小数、13 位整数；实际可支付金额受商户和支付渠道限额约束 |
| `out_trade_no` | 字符串 | 必填，1–64 位，字母、数字、下划线或横线；同一账号内永久唯一 |
| `pay_type` | 字符串 | 可选，`auto`（自动，默认）、`wechat`（微信）、`alipay`（支付宝）；区分大小写 |

`auto` 根据顾客使用的扫码应用自动适配。`wechat`、`alipay` 用于记录接入方的支付方式偏好，生成的仍为聚合收款码，不限制其他受支持的扫码应用；实际可用方式以收银台为准。省略参数兼容原有调用，空字符串、null、其他值或重复的 GET 参数返回 HTTP 400。传入的参数属于签名报文，不得签名后修改。

订单响应中的 `pay_type` 为首次创建时保存的偏好，不代表最终实际付款渠道。同一业务单号重试沿用原偏好，即使省略或修改 `pay_type`，也不会重新下单或更改原订单。

首次下单成功 HTTP **201**。同一账号、同一业务订单号与相同金额的重试返回原订单，HTTP **200**，不会重置二维码有效期。相同业务订单号改金额返回 **409**。不同账号可使用相同业务订单号。

正在创建或创建结果不明确时 HTTP **202**，状态为 `creating` 或 `unknown`，可能没有二维码。请保留原业务订单号并查单；不能将超时理解为订单不存在，也不得更换业务订单号重复收款。已明确拒绝的请求返回 HTTP 200、状态 `failed`，因此不能仅凭 HTTP 2xx 判断二维码创建成功。

响应示例：

```json
{
  "order_id": "0123456789abcdef0123456789abcdef",
  "out_trade_no": "ORDER_20260928_001",
  "pay_type": "auto",
  "amount": "1.00",
  "amount_fen": 100,
  "status": "pending",
  "paid": false,
  "message": "等待顾客扫码付款",
  "created_at": 1790553600,
  "expires_at": 1790553900,
  "confirmed_at": null,
  "receipt_id": null,
  "qrcode_url": "/api/v1/orders/0123456789abcdef0123456789abcdef/qrcode",
  "status_url": "/api/v1/orders/0123456789abcdef0123456789abcdef",
  "poll_after_ms": 5000
}
```

### 订单响应字段

| 字段 | 类型 | 可空 | 说明 |
| --- | --- | --- | --- |
| `order_id` | string | 否 | 平台订单号，32 位小写十六进制 |
| `out_trade_no` | string | 否 | 接入方业务订单号 |
| `pay_type` | string | 否 | 首次创建保存的支付偏好：auto / wechat / alipay；旧订单为 auto，不代表实际付款渠道 |
| `amount` | string | 否 | 订单金额（元），固定两位小数 |
| `amount_fen` | integer | 否 | 订单金额（分），用于核对金额 |
| `status` | string | 否 | 订单状态，枚举值见第 6 节 |
| `paid` | boolean | 否 | 仅已确认付款时为 true |
| `message` | string | 否 | 展示文案，不用于业务分支判断 |
| `created_at` | integer | 否 | 订单创建时间，Unix 秒 |
| `expires_at` | integer | 否 | 二维码到期时间，Unix 秒 |
| `confirmed_at` | integer | 是 | 本平台确认支付成功的时间，Unix 秒 |
| `receipt_id` | string | 是 | 支付成功后生成的平台回执号 |
| `qrcode_url` | string | 是 | PNG 图片接口相对路径；无图片时为 null |
| `status_url` | string | 否 | 查询订单的相对路径 |
| `poll_after_ms` | integer | 否 | 建议查询间隔，当前为 5000 毫秒 |

可空字段无值时返回 JSON `null`。所有时间为 Unix 秒。二维码创建起 5 分钟有效。`message` 仅用于展示，不用于业务判断；状态判断使用 `status` 和 `paid`。`amount_fen` 是整数分，推荐用于金额核对。`receipt_id` 在确认支付后生成，为平台回执号。

## 5. 展示二维码

### 5.1 获取已有订单的 PNG

| 项目 | 说明 |
| --- | --- |
| 方法及路径 | `GET /api/v1/orders/{order_id}/qrcode` |
| 路径参数 | `order_id` 必填，32 位小写十六进制平台订单号 |
| 请求体 | 无 |
| 成功响应 | HTTP 200，PNG 二进制内容 |
| 无图片 | HTTP 409，JSON 错误对象 |

对 `qrcode_url` 发起新的签名 GET，请求成功返回 `Content-Type: image/png`。响应头包含 `X-Order-Id`、`X-Order-Status-Url` 与 `X-Pay-Type`（首次创建保存的支付偏好）。二维码地址是相对路径，与 API 基地址拼接后使用。

不要把 `qrcode_url` 直接放入公开网页 `<img>`：图片接口需要签名请求头。由接入方后端下载 PNG，通过你自己的图片接口或文件服务展示给顾客，私钥留在后端。二维码包含支付收银台链接，扫码后的支付渠道与真实收款方以收银台展示为准。

仅当 `status=pending` 且 `qrcode_url` 非空时向顾客展示支付码。重复下载同一订单不会再次创建订单。GET 一步返回 PNG 的接口在没有可用二维码时返回 JSON 错误 HTTP 409，此时用业务订单号查单。

### 5.2 GET 创建二维码

`GET /api/v1/qrcode?amount=1.00&out_trade_no=ORDER_001&pay_type=wechat`

查询参数 `amount`、`out_trade_no` 均为必填，`pay_type` 可选，默认 `auto`；微信传 `wechat`，支付宝传 `alipay`。自动适配、偏好记录和验证规则与创建订单相同。签名原文的路径须包含完整查询字符串（包括传入的 `pay_type`），并保持实际编码和顺序。响应图片、响应头与 5.1 节一致。

同单号同金额时复用原订单，包括已到期订单。接入方展示前须核对订单状态，不能通过重复请求延长有效期。

## 6. 查询支付结果

```http
GET /api/v1/orders/{order_id}
```

或：

```http
GET /api/v1/orders/by-reference/ORDER_20260928_001
```

路径参数 `order_id` 为平台订单号，`out_trade_no` 为接入方业务订单号。两个接口均无请求体，均要求签名。订单不存在或不属于当前账号返回 404。

查询成功返回 HTTP 200，结构与创建响应相同。服务端会核对支付结果；同一订单最多每 5 秒刷新一次。已确认支付的订单直接返回保存的成功结果。

| status | 含义 | 接入方处理 |
| --- | --- | --- |
| `creating` | 创建中，尚未取得二维码 | 等待后查原订单 |
| `unknown` | 创建结果尚未确认 | 查原订单，保留原单号继续核对 |
| `pending` | 等待顾客扫码或付款 | 展示二维码，每 5 秒查单 |
| `paid` | 已核验客户付款成功 | 核对金额/业务单号后幂等完成业务 |
| `expired` | 收款码到期且尚未确认支付 | 停止展示二维码；必要时查原订单核对晚到结果 |
| `closed` | 订单已关闭 | 停止展示二维码 |
| `failed` | 创建或支付未完成 | 不发货，必要时核对原订单 |

`paid=true`、`confirmed_at` 非空、`receipt_id` 非空表示已确认付款。其他状态不能发货。已支付状态不会被后来的待支付响应覆盖。过期或失败的订单仍可再次查单，以确认延迟到达的成功结果。

银行卡结算到账不是本接口的支付成功含义。前端动画、扫码截图、浏览器跳转均不能作为发货凭证；请只信任自己服务器查询到的 API 结果。

当前开发者侧通过**主动查单**接收结果，没有对接方 webhook 推送。收款台已自动轮询并展示成功回执；API 接入方请使用示例中的轮询或自己的后台查单任务。关闭收款弹窗会停止页面轮询，重新打开订单即可继续核对。

## 7. 错误、限流与重试

错误结构：`{"error":"说明","code":401}`，`code` 为 HTTP 状态码。

| HTTP | 含义 |
| --- | --- |
| 400 | 金额、业务订单号或 JSON 格式错误 |
| 401 | App ID / 签名错误、密钥未申请或停用、请求时间不正确 |
| 403 | 控制台操作的会话验证或密码错误（服务端签名接口无需 CSRF） |
| 404 | 订单不存在或不属于当前账号 |
| 409 | nonce 重放、同单号金额冲突、订单没有可用二维码 |
| 413 | 请求体超过 64 KiB |
| 415 | POST 请求不是 application/json |
| 429 | 请求过于频繁，遵循 Retry-After 并逐步退避 |
| 502 | 暂时无法确认支付结果，稍后查原订单 |

每个账号 API 请求最多 120 次/分钟，创建尝试最多 30 次/分钟（重试也计数）。同一来源 IP 的签名校验最多 300 次/分钟。控制台注册、登录、密钥申请另有限流；注册必须提供有效邀请码，API 调用不需要邀请码。

查单失败不等于支付失败。网络异常或 502 后每 5 秒重试查询，429 时延长等待。创建超时后先按 `out_trade_no` 查单：若 404，使用相同业务订单号和金额重试创建。每次请求使用新 nonce。

发货表建议对 `out_trade_no` 建唯一约束，在数据库事务中完成状态转换，防止查询重试造成重复发货。

## 8. Python 示例

下载本站 `/downloads/client.py`，安装 `cryptography`。凭证文件使用API 凭证页面下载的 JSON：

```powershell
python -m pip install cryptography
python client.py app-credentials.json 1.00 ORDER_20260928_001 https://glzf.top
```

示例会创建订单、保存 PNG、每 5 秒查单，最多执行 180 轮查询。集成到已有服务：

```python
import json
from pathlib import Path
from client import PaymentClient

credentials = json.loads(Path("app-credentials.json").read_text(encoding="utf-8"))
client = PaymentClient("https://pay.example.com", credentials)
order = client.request("POST", "/api/v1/orders", {
    "amount": "1.00", "out_trade_no": "ORDER_20260928_001", "pay_type": "wechat"
})
if order["status"] == "pending" and order["qrcode_url"]:
    Path("payment.png").write_bytes(client.request("GET", order["qrcode_url"]))
result = client.request("GET", order["status_url"])
# 检查 result['paid']、result['amount_fen'] 和 result['out_trade_no'] 后幂等处理业务。
```

## 9. Node.js 示例

下载 `/downloads/client.mjs`，使用 Node.js 20+，无需安装 npm 包：

```powershell
node client.mjs app-credentials.json 1.00 ORDER_20260928_002 https://glzf.top
```

模块导出 `request(baseUrl, credentials, method, target, payload)` 与 `signHeaders(...)`。调用方式：

```javascript
import {readFileSync, writeFileSync} from 'node:fs';
import {request} from './client.mjs';
const credentials = JSON.parse(readFileSync('app-credentials.json', 'utf8'));
const base = 'https://pay.example.com';
const order = await request(base, credentials, 'POST', '/api/v1/orders', {
  amount: '1.00', out_trade_no: 'ORDER_20260928_002', pay_type: 'alipay'
});
if (order.status === 'pending' && order.qrcode_url) {
  writeFileSync('payment.png', await request(base, credentials, 'GET', order.qrcode_url));
}
const result = await request(base, credentials, 'GET', order.status_url);
```

## 10. 接入验收

- 签名正确时可以下单；不传签名、修改金额、使用旧签名或旧密钥时请求被拒绝。
- 同一业务订单号重复创建返回同一订单；修改金额返回 409。
- 其他账号无法读取你的订单 JSON 或二维码。
- 扫码页面的收款方、金额与当前订单一致。
- 实际完成小额付款后，通过 API 取得 `paid=true`，你的业务只执行一次成功处理。
- 查询超时、到期、重新申请密钥时，业务能保留订单号并正确重试或停止展示二维码。

密钥泄漏或遗失时，在API 凭证页面停用或重新申请，并更新自己服务器的凭证文件。不要重新创建已有业务订单。


## 余额、费率与 USDT 提现 API

以下接口使用同一套 RSA 签名认证，均只访问当前 App ID 所属账号。网页入口为「余额与提现」。API 凭证拥有申请提现能力，应只交给可信服务端。

### 费率与订单记账

默认费率 8%；管理员可设置账号专属费率。费率在订单创建时固定，修改只影响新订单。收款服务费以人民币分逐单四舍五入：`platform_fee_fen = (amount_fen * fee_bps + 5000) // 10000`。只有 `paid` 订单的扣费后金额进入可用余额。重复成功通知不会重复增加余额。

订单对象新增以下字段（所有订单状态均返回；非 paid 时仅为待确认的计算值）：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `fee_rate` | string | 订单费率百分数，例如 `"8.00"` 表示 8% |
| `platform_fee_fen` | integer | 本笔平台服务费，人民币分 |
| `net_amount_fen` | integer | 扣除本笔服务费后的人民币分 |

### 1. 查询余额

`GET /api/v1/balance`，请求体为空，HTTP 200。

```json
{"gross_fen":10000,"fee_fen":800,"earned_fen":9200,"available_fen":9200,"frozen_fen":0,"withdrawn_fen":0,"fee_rate":"8.00"}
```

| 字段 | 含义 |
| --- | --- |
| `gross_fen` | 累计已确认收款金额 |
| `fee_fen` | 累计收款服务费 |
| `earned_fen` | 累计扣费后收入 |
| `available_fen` | 当前可申请提现金额 |
| `frozen_fen` | 审核中、待打款申请冻结金额 |
| `withdrawn_fen` | 已登记打款申请扣减的人民币金额 |
| `fee_rate` | 新创建订单使用的费率百分数 |

以上 `_fen` 字段均为整数人民币分，不是 USDT。可用余额由服务器账目计算，不能通过请求参数修改。

### 2. 查询参考汇率

`GET /api/v1/exchange-rate`，请求体为空，HTTP 200。示例汇率仅用于说明：

```json
{"rate":"7.000000","unit":"CNY/USDT","source":"CoinGecko","updated_at":1790637500,"minimum_usdt":"10.000000","fee_usdt":"1.000000"}
```

`rate` 为 1 USDT 对应的人民币元；`updated_at` 为行情源更新时间（Unix 秒）。`minimum_usdt` 为扣除提现手续费前的最低申请数量，`fee_usdt` 为固定手续费。每 60 秒更新缓存，超过 300 秒或异常的源报价不接受。行情来自 [CoinGecko](https://docs.coingecko.com/demo/reference/simple-price)，不代表场外成交价。

### 3. 创建提现报价

`POST /api/v1/withdrawal-quotes`，Content-Type 为 application/json。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `amount` | string / integer | 是 | 要冻结的人民币元，建议字符串，正数、最多两位小数、13 位整数，不超过可用余额 |
| `address` | string | 是 | 接收 USDT 的 TRC20 地址，T 开头、34 字符、Base58Check 校验有效 |

```json
{"amount":"92.00","address":"这里填写真实有效的TRC20收款地址"}
```

上面的地址是占位说明，调用时必须替换。不支持其他网络或客户端指定汇率、手续费、到账金额。

HTTP 201 返回报价对象：

```json
{
  "quote_id":"0123456789abcdef0123456789abcdef",
  "amount_fen":9200,
  "network":"TRC20",
  "address":"实际TRC20地址",
  "rate":"7.000000",
  "rate_source":"CoinGecko",
  "rate_updated_at":1790637500,
  "gross_usdt":"13.142857",
  "fee_usdt":"1.000000",
  "net_usdt":"12.142857",
  "expires_at":1790637620
}
```

| 字段 | 说明 |
| --- | --- |
| `quote_id` | 32 位十六进制报价编号，绑定当前账号 |
| `amount_fen` | 确认申请后冻结的人民币分 |
| `network` / `address` | 收款网络和地址 |
| `rate` / `rate_source` / `rate_updated_at` | 锁定的 CNY/USDT 汇率、来源及源报价时间 |
| `gross_usdt` | 人民币元除以汇率，向下保留六位小数 |
| `fee_usdt` | 固定 1 USDT |
| `net_usdt` | 实际需要转给用户的 USDT 数量，等于 gross − fee |
| `expires_at` | 报价到期时间，创建后 120 秒 |

`gross_usdt` 至少 10.000000，最低 `net_usdt` 为 9.000000。此步不冻结余额，不代表提现申请成功。

### 4. 确认申请并冻结余额

`POST /api/v1/withdrawals`：

```json
{"quote_id":"0123456789abcdef0123456789abcdef"}
```

首次成功返回 HTTP 201 与提现对象；相同 `quote_id` 重试返回 HTTP 200 与原申请，不会重复扣减或重新申请（即使原申请已退回）。重试需重新生成签名时间戳和 nonce。服务器重新检查归属、报价有效期和可用余额；过期或余额不足返回 409。提交后金额、地址及汇率均不能更改。

提现对象包含报价对象除 `expires_at` 外的全部字段，并增加：

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `withdrawal_id` | string | 提现单号，32 位十六进制 |
| `status` | string | pending / processing / paid / rejected |
| `created_at` / `updated_at` | integer | Unix 秒 |
| `txid` | string / null | 管理员登记的 TRON 交易哈希，已打款时非空 |
| `note` | string | 审核备注或退回原因，可能为空 |

### 5. 查询申请

- `GET /api/v1/withdrawals`：HTTP 200，`{"withdrawals":[提现对象,...]}`，返回当前账号最近 50 笔，按创建时间倒序。
- `GET /api/v1/withdrawals/{withdrawal_id}`：HTTP 200，返回单笔提现对象。非本人申请或不存在时返回 404。

| 状态 | 说明 | 余额处理 |
| --- | --- | --- |
| `pending` | 审核中 | 冻结申请人民币金额 |
| `processing` | 已通过审核，待人工打款 | 继续冻结 |
| `paid` | 管理员已登记打款与 TXID | 计入已提现，不返还余额 |
| `rejected` | 已退回 | 释放冻结金额 |

本系统提供申请与人工审核记账，不自动发送 USDT，也不自动验证链上 TXID。应同时核对链上实际到账，勿将接口状态当作链上确认次数。审核完成后不会因为汇率变化重新计算申请金额。

### 错误与频率

除公共错误外：400 表示金额低于门槛、超过可用余额或地址无效；409 表示报价过期、不属于当前账号或确认时余额已不足；503 表示暂时无法取得新鲜有效的汇率。发生 503 不生成报价，也不冻结余额。创建报价每账号 20 次/分钟，确认申请每账号 10 次/10 分钟，另受公共 API 限流约束。

### Python 集成顺序

```python
balance = client.request("GET", "/api/v1/balance")
quote = client.request("POST", "/api/v1/withdrawal-quotes", {
    "amount": "92.00", "address": trc20_address
})
# 先向用户展示 quote 的金额、网络、地址、手续费与 net_usdt，取得用户确认。
withdrawal = client.request("POST", "/api/v1/withdrawals", {
    "quote_id": quote["quote_id"]
})
result = client.request("GET", "/api/v1/withdrawals/" + withdrawal["withdrawal_id"])
```

报价过期应重新计算并重新展示金额，不自动确认变化后的报价。提交超时先按原 `quote_id` 重试，或查询申请列表，避免另建报价重复提现。
