BinancePayTool v0.1.0:币安支付个人收款网关(Go 核心 + 四语言 SDK)

- Go 服务端:唯一金额/备注码/回填订单编号三路匹配、托管收银页、HMAC 回调七级重试、SQLite 单二进制
- SDK:Go / Java / PHP / Python,共享签名测试向量(docs/test_vectors.json)
- 跨语言 e2e(真实二进制 × Python SDK × mock 币安)、GitHub Actions CI、Dockerfile
- 协议文档 docs/protocol.md、调研与实测结论 docs/research.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
qianyubtcandClaude Fable 5 committed 2026-09-02 14:09:32 +08:00
1 parent e4275368ea
commit f87ec65541
46 files changed
+4378

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
core-and-sdks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
cache-dependency-path: server/go.sum
- name: 服务端 vet + 测试
run: cd server && go vet ./... && go test ./... -count=1
- name: 编译服务端二进制
run: cd server && go build -o bpaygate .
- name: Go SDK 测试
run: cd sdk/go && go vet ./... && go test ./... -count=1
- name: Go 示例编译
run: cd examples/go && go build ./... && rm -f bpaygate-example
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Python SDK 向量测试
run: python3 sdk/python/test_bpaygate.py
- name: 跨语言端到端(真实二进制 × Python SDK × mock 币安)
run: python3 e2e/cross_e2e.py server/bpaygate
php-sdk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
- name: 语法检查
run: php -l sdk/php/BPayGate.php && php -l sdk/php/test_vectors.php && php -l examples/php/index.php && php -l examples/php/notify.php
- name: 向量测试
run: php sdk/php/test_vectors.php
java-sdk:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- name: 下载 Gson
run: curl -sSLo gson.jar https://repo1.maven.org/maven2/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar
- name: 编译 SDK 与示例(-source 8 兼容校验交给 pom;此处用当前 JDK)
run: mkdir -p out && javac -encoding UTF-8 -cp gson.jar -d out sdk/java/BPayGateClient.java sdk/java/VectorTest.java examples/java/ExampleServer.java
- name: 向量测试
run: cd out && java -cp .:../gson.jar VectorTest ../docs/test_vectors.json
+21
View File
@@ -0,0 +1,21 @@
# 本地私有内容(不进开源仓库)
CLAUDE.md
docs_币安支付个人收款网关_可行性调研.md
config.env
*.db
*.db-journal
*.db-wal
*.db-shm
qr.png
qr.jpg
# 临时工具链与构建产物
.toolchain/
bpaygate
bpaygate-*
server/bpaygate
*.class
sdk/java/*.jar
__pycache__/
*.pyc
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
# BinancePayTool 服务端镜像(二进制名沿用 bpaygate)
# docker build -t bpaygate .
# docker run -d -v $(pwd)/data:/data -p 8080:8080 bpaygate
# /data 内放 config.env(参考 config.example.env),数据库默认也写在 /data。
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY server/ ./server/
RUN cd server && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /bpaygate .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && adduser -D bpg
USER bpg
WORKDIR /data
COPY --from=build /bpaygate /usr/local/bin/bpaygate
EXPOSE 8080
ENTRYPOINT ["bpaygate", "-config", "/data/config.env"]
+138
View File
@@ -0,0 +1,138 @@
# BinancePayTool · 币安支付个人收款网关
> 用一个**普通币安个人账户**收 Binance Pay 转账,自动确认到账、回调你的业务系统。
> 单二进制部署,官方只读 API,零手续费、即时到账。
> 附 **Go / Java / PHP / Python** 四语言接入 SDK。
**本项目为社区项目,与 Binance 无任何关联。** 仓库名中的 “Binance” 仅为描述所对接的支付渠道,不代表官方授权或合作。
## 它解决什么问题
币安官方的 Binance Pay 商户 API 只对**企业实体账户**开放(个人商户账户已于 2025-02-28 终止)。
个人开发者想用币安支付收款,只剩两条野路子:逆向私有接口(违反条款、随时失效),或人工对账。
BinancePayTool 走第三条路:**只用官方公开的只读接口** `GET /sapi/v1/pay/transactions`
轮询自己账号的收款流水,用三把钥匙自动核销订单:
| 优先级 | 匹配方式 | 说明 |
|---|---|---|
| ① | **唯一金额** | 每笔订单分配唯一尾数(如 10 → 10.0037),付款金额即订单指纹,付完全自动确认 |
| ② | **备注码** | 付款方转账备注带上 6 位码,金额不准也能对上 |
| ③ | **回填编号** | 收银页里粘贴币安订单编号(18 位数字),兜底一切例外 |
关键行为均经真实账号实测(2026-09):币安 Pay 金额支持至少 4 位小数、
付款备注原样回传、付款方 App 显示的订单编号 = 接口的 `orderId`、到账后数秒内接口可见。
## 原理
```mermaid
sequenceDiagram
participant U as 付款方(币安App)
participant S as 商户系统
participant G as BinancePayTool
participant B as 币安官方API(只读)
S->>G: 创建订单(10 USDT)
G-->>S: pay_amount=10.0037, pay_url
S->>U: 跳转收银页
U->>U: 币安App按金额转账(0手续费,即时)
loop 每 POLL_INTERVAL 秒
G->>B: GET /sapi/v1/pay/transactions
B-->>G: 收款流水(金额/备注/orderId/付款方)
end
G->>G: 三规则匹配 → 订单已支付
G->>S: 回调(HMAC签名, 失败按梯度重试)
G-->>U: 收银页变绿 → 跳转 return_url
```
## 特性
- **单二进制 + SQLite**,无外部依赖;Docker 一行起
- 托管收银页:大字金额一键复制、二维码展示、倒计时、状态轮询、回填入口,适配移动端与深色模式
- 唯一金额**递进式尾数**:优先 0.0001–0.0099 档(付款方最多多付一分钱级别),档位用满自动升位,同价并发容量最高 9999
- 金额冷却:订单结束后该金额 24h 不复用,杜绝迟付错配
- 回调 HMAC-SHA256 签名 + 时间戳 + nonce 防重放,`0s/15s/1m/5m/15m/1h/3h` 七次重试
- 幂等:一笔币安流水只核销一单;重复回调由商户侧按 `merchant_order_id` 幂等
- 少付/多付/过期后迟付都有明确定义(见 [docs/protocol.md](docs/protocol.md))
- 币安 API Key **只需读取权限**,建议绑 IP 白名单;网关永不发起转账
## 快速开始
```bash
# 1. 编译(或用 Docker,见 Dockerfile 注释)
cd server && go build -o bpaygate . && cd ..
# 2. 配置
cp config.example.env config.env
./server/bpaygate -gen-key # 生成 API_AUTH_KEY 填入 config.env
# 再填 BINANCE_API_KEY / BINANCE_API_SECRET(只勾"允许读取")和 BINANCE_UID
# 3. 运行
./server/bpaygate -config config.env
```
创建一笔测试订单(也可直接用四语言 SDK,见下):
```bash
# 签名规则见 docs/protocol.md §1.1,SDK 已封装,无需手写
```
```python
import sys; sys.path.insert(0, "sdk/python")
from bpaygate import BinancePayTool
gw = BinancePayTool("http://127.0.0.1:8080", "你的API_AUTH_KEY")
print(gw.create_order("TEST-1", "1", currency="USDT")["pay_url"])
```
打开返回的 `pay_url`,用另一个币安账号按页面金额转账,几秒后页面自动变为已支付。
## 商户接入
| 语言 | SDK | 示例 |
|---|---|---|
| Python | [sdk/python](sdk/python)(仅标准库) | [examples/python](examples/python) |
| PHP | [sdk/php](sdk/php)(单文件) | [examples/php](examples/php) |
| Go | [sdk/go](sdk/go)(仅标准库) | [examples/go](examples/go) |
| Java | [sdk/java](sdk/java)(Java 8+,Gson) | [examples/java](examples/java) |
接入只有三步:**创建订单 → 跳转 pay_url → 处理回调(验签 + 幂等发货)**。
协议细节(签名、字段、状态机、错误码):[docs/protocol.md](docs/protocol.md)。
调研背景与官方接口实测结论:[docs/research.md](docs/research.md)。
## 配置
所有配置项见 [config.example.env](config.example.env),每项都有注释。最常调的:
| 配置 | 默认 | 说明 |
|---|---|---|
| `POLL_INTERVAL` | 4 | 轮询秒数。接口权重 3000/次、限额 180000/分钟,最低可到 1 |
| `ORDER_TTL` | 900 | 订单有效期(秒) |
| `AMOUNT_DECIMALS` | 4 | 唯一金额小数位(实测币安支持至少 4) |
| `SUFFIX_MODE` | add | add=多收尾数 / sub=少收尾数 |
| `EXPIRED_GRACE` | 1800 | 过期后宽限期,期内精确到账仍自动确认 |
## 必读:风险与边界
- **条款风险**:币安条款不允许未经书面同意将服务用于商业目的(Terms §28(c))。
个人账户跑收款网关属于灰色地带,**账号被风控的后果自担**。建议:专号收款、
控制频率、余额及时转走;量大后注册企业实体走官方商户 API。
- **本网关只读**:不代付、不退款。退款需在币安 App 里手动转回(流水里有付款方信息)。
- **备注不可靠**:部分付款路径(如扫码)可能填不了备注,所以备注只是第二钥匙。
- **underpaid 需人工**:实付少于应付只报状态不自动补齐,商户自行决定放行或退回。
- 收款账户的 API Key 只给读取权限;`API_AUTH_KEY` 泄漏 = 任何人都能创建订单和伪造回调,妥善保管。
## Roadmap
- [ ] 易支付(epay)兼容层:独角数卡等现有系统零代码接入
- [ ] MySQL 支持与多收款账号轮换
- [ ] 简易管理面板(订单查询、手动补单、Telegram 通知)
- [ ] 多商户 key
## 相关项目
链上收款方案(不依赖币安账号):[BEpusdt](https://github.com/v03413/BEpusdt) ·
[epusdt](https://github.com/GMWalletApp/epusdt) · [TokenPay](https://github.com/LightCountry/TokenPay)。
本项目与它们互补:Pay 内部转账零手续费、即时、无链上确认等待,但绑定币安生态。
## License
[MIT](LICENSE)
+46
View File
@@ -0,0 +1,46 @@
# ========= BinancePayTool 配置示例 =========
# 复制为 config.env 后填写。config.env 已被 .gitignore 排除,勿提交。
# 同名环境变量优先于本文件。
# 监听地址与对外基础 URL(收银页链接用它拼接)
LISTEN=0.0.0.0:8080
BASE_URL=http://127.0.0.1:8080
# 商户接入密钥:商户调用 API 与校验回调签名共用。
# 生成方法: ./bpaygate -gen-key
API_AUTH_KEY=
# ---- 币安(收款账号)----
# 现货 API Key,只勾选「允许读取」,强烈建议绑定服务器 IP 白名单
BINANCE_API_KEY=
BINANCE_API_SECRET=
# 你的币安 UID(App 头像页可见),用于收银页展示与方向判断
BINANCE_UID=
# 测试时可指向 mock 服务,生产留默认
BINANCE_API_BASE=https://api.binance.com
# ---- 收银页展示 ----
# 可选:静态收款二维码图片路径(币安 App「收款」保存的那张);配置后收银页显示扫码
QR_IMAGE=
# 可选:额外展示的收款邮箱(不填则只展示 UID)
RECEIVE_EMAIL=
# ---- 订单与匹配 ----
# 允许的收款币种(逗号分隔)
CURRENCIES=USDT,USDC
# 订单有效期(秒),可被下单请求覆盖
ORDER_TTL=900
# 过期宽限(秒):过期后这段时间内到账的精确金额仍自动确认
EXPIRED_GRACE=1800
# 唯一金额小数位(实测币安 Pay 支持至少 4 位)
AMOUNT_DECIMALS=4
# 尾数方向:add=在基础金额上加尾数(默认,商户实收略多);sub=减尾数
SUFFIX_MODE=add
# 尾数冷却(秒):订单结束后该金额多久内不复用
SUFFIX_COOLDOWN=86400
# 币安流水轮询间隔(秒)。接口权重 3000/次、UID 限额 180000/分钟,最低可到 1
POLL_INTERVAL=4
# ---- 存储与日志 ----
DB_PATH=./bpaygate.db
LOG_LEVEL=info
+136
View File
@@ -0,0 +1,136 @@
# BinancePayTool 接入协议 v1
> 签名头与错误码沿用 `X-BPG-` / `BPG` 前缀,它们是协议稳定标识,与仓库名无关。
所有接口均为 HTTP + JSON(UTF-8)。金额一律为**字符串**十进制(如 `"10.0037"`),最多 8 位小数。
时间戳一律为**毫秒** Unix 时间。
## 1. 签名
商户与网关共享一个密钥 `API_AUTH_KEY`(下称 `secret`),同时用于「商户调用网关」与「网关回调商户」。
### 1.1 商户 → 网关(API 请求签名)
请求头:
| Header | 说明 |
|---|---|
| `X-BPG-Timestamp` | 当前毫秒时间戳;与服务器偏差超过 300 秒拒绝 |
| `X-BPG-Nonce` | 随机串(≥16 字符);5 分钟内不得重复 |
| `X-BPG-Signature` | 小写十六进制签名 |
签名串(`\n` 为换行符;`path` 只含路径不含查询串与域名;GET/无 body 时 body 视为空串):
```
string_to_sign = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + SHA256_HEX(body)
signature = HMAC_SHA256_HEX(secret, string_to_sign)
```
### 1.2 网关 → 商户(回调签名)
回调为 `POST callback_url`,`Content-Type: application/json`,请求头同上三个,签名串少 METHOD 与 path:
```
string_to_sign = timestamp + "\n" + nonce + "\n" + SHA256_HEX(body)
```
商户校验:时间戳偏差 ≤300 秒、签名一致(常数时间比较)。校验通过返回 HTTP 2xx 即视为成功;
否则网关按 `0s, 15s, 60s, 5m, 15m, 1h, 3h` 重试 7 次后放弃。**回调可能重复投递,商户须按
`order_id` 幂等处理。**
## 2. 接口
### 2.1 创建订单 `POST /api/v1/orders`
请求体:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `merchant_order_id` | string | 是 | 商户侧唯一订单号(≤64 字符);重复返回 `ERR_DUPLICATE` |
| `currency` | string | 是 | 币种,须在网关 `CURRENCIES` 白名单内(如 `USDT`)|
| `amount` | string | 是 | 基础金额,>0,小数位 ≤ 网关 `AMOUNT_DECIMALS` |
| `callback_url` | string | 否 | 支付结果回调地址 |
| `return_url` | string | 否 | 支付成功后收银页跳转地址(自动附加 `?order_id=&status=`)|
| `timeout` | int | 否 | 有效期秒数,120–86400,缺省用网关 `ORDER_TTL` |
响应 `data`:
| 字段 | 说明 |
|---|---|
| `order_id` | 网关订单号 |
| `merchant_order_id` | 原样返回 |
| `status` | `pending` |
| `currency` / `base_amount` / `pay_amount` | `pay_amount` 为**实际应付的唯一金额**,付款方必须严格按它支付 |
| `note_code` | 6 位备注码;付款方转账备注里带上它可加速/兜底确认(可选)|
| `pay_url` | 网关托管收银页地址,直接跳转即可 |
| `receive_uid` | 收款方币安 UID |
| `created_at` / `expires_at` | 毫秒时间戳 |
### 2.2 查询订单 `GET /api/v1/orders/{order_id}`
响应 `data` 在 2.1 基础上增加:`actual_amount`、`paid_at`、`matched_by`(`amount`/`note`/`claim`)、
`binance_order_id`(币安 18 位订单编号)、`payer_id`(付款方 Pay 账户 ID)、`overpaid`(bool)。
也可用 `GET /api/v1/orders/by-merchant/{merchant_order_id}` 按商户单号查询。
### 2.3 关闭订单 `POST /api/v1/orders/{order_id}/close`
仅 `pending` 可关闭;成功后状态为 `closed`,唯一金额进入冷却。
### 2.4 收银页(免签名,公开)
- `GET /pay/{token}`:托管收银页(token 含在 `pay_url` 中)
- `GET /pay/{token}/status`:`{status, paid_at, return_url}`,页面轮询用
- `POST /pay/{token}/claim`:`{"binance_order_id":"18位数字"}`——「我已付款」回填入口,限流 5 次/分钟
### 2.5 响应外层
```json
{"code":"OK","data":{...}}
{"code":"ERR_AUTH","message":"..."}
```
`code` 取值:`OK`、`ERR_AUTH`、`ERR_PARAM`、`ERR_DUPLICATE`、`ERR_NOT_FOUND`、`ERR_STATE`、
`ERR_RATE_LIMIT`、`ERR_ALLOC`(唯一金额耗尽)、`ERR_INTERNAL`。
## 3. 回调体
```json
{
"event": "paid", // paid | underpaid | expired
"order_id": "…",
"merchant_order_id": "…",
"status": "paid",
"currency": "USDT",
"base_amount": "10",
"pay_amount": "10.0037",
"actual_amount": "10.0037",
"overpaid": false,
"matched_by": "amount",
"binance_order_id": "452021922068888888",
"binance_txn_id": "P_EXAMPLE1234567890",
"payer_id": "1160000000",
"paid_at": 1788325910559,
"timestamp": 1788325912000
}
```
`underpaid`:备注/回填命中但实付低于基础金额,需人工处理;`expired`:超时未支付。
## 4. 订单状态机
```
pending ──精确金额/备注码/回填命中──▶ paid
pending ──实付不足(备注/回填)──▶ underpaid
pending ──到期──▶ expired ──宽限期内精确金额到账/回填──▶ paid
pending ──商户关闭──▶ closed
```
## 5. 匹配规则(网关内部,供理解)
对每条**入账**流水(金额>0、币种在白名单、币安 `orderId` 未被消费过):
1. 金额与某待支付订单的 `pay_amount` 完全相等,且到账时间在订单窗口内 → 自动确认;
2. 否则备注含某订单 `note_code`(不区分大小写)→ 实付 ≥ 基础金额则确认,否则 `underpaid`;
3. 否则保留,等付款方在收银页回填币安订单编号(7 天内有效)。
同一笔币安流水只能核销一个订单(以币安 `orderId` 幂等)。
+49
View File
@@ -0,0 +1,49 @@
# 调研与实测结论(为什么这样设计)
时间:2026-09。以下结论来自币安官方文档、官方开发者论坛与真实个人账号实测。
## 官方能力边界
| 事实 | 来源 |
|---|---|
| Binance Pay Merchant API 功能完整(下单/退款/webhook/收银台),但入驻需**企业实体账户**+ 公司注册文件 | [商户申请 FAQ](https://www.binance.com/en/support/faq/detail/7a49148912214defa816f14ee51b9f9f) |
| 个人商户账户 2024-11-05 停止新申请、2025-02-28 终止 | 多家集成商公告转述 |
| 商户后台的「收款链接」只能后台手工创建,无 API | [merchant.binance.com 文档](https://merchant.binance.com/en/docs/functionalities/payment-links) |
| 个人版收款链接(可设币种金额、30 天过期)与定额收款码存在,但无创建 API | 官方 FAQ |
| 网页/App 的私有 bapi 接口被官方明确表态「非公开、不建议使用」;条款禁止脚本化访问非公开功能 | [官方论坛](https://dev.binance.vision/t/is-there-a-way-to-use-bapi/19804)、Terms §28(i) |
| 个人账户可用现货 API Key(只读)调 `GET /sapi/v1/pay/transactions` 读取 Pay 流水 | [官方文档](https://developers.binance.com/docs/pay/rest-api) |
| Pay ID 已于 2025-01-01 停用;收款标识为 UID / 邮箱 / 手机号 / 二维码 | 官方公告 |
| Pay 内部转账 0 手续费、即时到账、不可撤销;接收无限额 | 官方 FAQ |
→ 结论:个人账户唯一合规的程序化通道是**只读流水接口**,所以本网关是「被动对账」模型,
不创建支付单、不发起任何资金操作。
## 关键实测(真实个人账号 + 真实转账)
文档没写或写错、但被实测确认的行为:
| 实测项 | 结果 |
|---|---|
| 返回字段比文档多 | 实测有 `orderId`(18 位数字)、`note`(付款备注原文)、`counterpartyId`(对方 Pay 账户 ID,每条都有) |
| 付款方 App 显示的「订单编号」 | = 接口返回的 `orderId`(现场对照确认)→ 回填核验直接比对该字段 |
| 金额精度 | 0.1234 USDT 原样到账 → 至少 4 位小数,唯一尾数容量充足 |
| 备注 | 付款方填的备注通过 `note` 原样回传 → 备注码匹配成立 |
| 到账延迟 | 到账即出现在接口,检测延迟 ≈ 轮询间隔 |
| 方向 | `amount` 收款为正、付款为负 |
| `payerInfo.binanceId` | 不一定返回(部分记录缺失)→ 网关用 `counterpartyId` 兜底标识付款方 |
| 文档示例的 `M_P_` 前缀 transactionId、"无备注字段" | 与实测不符,实测为 `P_` 前缀且有 note 字段 |
## 接口约束(设计输入)
- 签名接口,权重 3000(按 UID 计,限额 180000/分钟 → 理论最高约 1 次/秒)
- 不传时间返回最近 90 天;时间跨度最长 90 天;最多查 18 个月;`limit` 最大 100、**无游标分页**
→ 网关用时间窗口滑动 + 满页向前翻页
- 有地区差异风险:论坛有账号反馈该接口恒返回空 → 部署前先跑 `tools/pay_history_probe.py` 自测
## 被否决的方案
| 方案 | 否决原因 |
|---|---|
| cookie / JWT 监听网页流水 | 有官方只读 API 平替;登录态会过期;违反条款;风控风险 |
| 程序化创建个人收款链接 | 无公开 API,只能逆向私有 bapi,官方明确不建议 |
| 让付款方必须回填订单号 | 体验差、漏填率高 → 降级为兜底入口,主链路零动作 |
+52
View File
@@ -0,0 +1,52 @@
{
"secret": "test-secret-0123456789abcdef",
"note": "所有 SDK 与服务端的签名实现必须通过本文件全部用例",
"cases": [
{
"type": "request",
"timestamp": "1788326000000",
"nonce": "nonce-0123456789abcdef",
"method": "POST",
"path": "/api/v1/orders",
"body": "{\"merchant_order_id\":\"M1001\",\"currency\":\"USDT\",\"amount\":\"10\",\"callback_url\":\"https://shop.example.com/notify\"}",
"body_sha256": "22b3f243103ddcb2ad8d915ff9ef2a963f8975a753f62baa97c3ddee62b2ab10",
"signature": "9f1f26f95c7996cd3a225cc40ad79b29ec863de58e282ea7f77c6a4509d31925"
},
{
"type": "request",
"timestamp": "1788326001234",
"nonce": "9f8e7d6c5b4a39281706fedcba",
"method": "GET",
"path": "/api/v1/orders/01hxyzabcdefghjkmnpqrs",
"body": "",
"body_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"signature": "433d6094d014157c9883e99781c93afab4d3c10a0ef605fe1840552038538ee4"
},
{
"type": "request",
"timestamp": "1788326002000",
"nonce": "unicode-测试-nonce-000001",
"method": "POST",
"path": "/api/v1/orders/01hxyzabcdefghjkmnpqrs/close",
"body": "{}",
"body_sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"signature": "5db7765bc4562c3ab279f23b8688557158cdf4d73941657c63b1666ba3aa16d2"
},
{
"type": "callback",
"timestamp": "1788326100000",
"nonce": "cbnonce-abcdef0123456789",
"body": "{\"event\":\"paid\",\"order_id\":\"01hxyzabcdefghjkmnpqrs\",\"merchant_order_id\":\"M1001\",\"status\":\"paid\",\"currency\":\"USDT\",\"base_amount\":\"10\",\"pay_amount\":\"10.0037\",\"actual_amount\":\"10.0037\",\"overpaid\":false,\"matched_by\":\"amount\",\"binance_order_id\":\"452021922068888888\",\"binance_txn_id\":\"P_EXAMPLE1234567890\",\"payer_id\":\"1160000000\",\"paid_at\":1788325910559,\"timestamp\":1788326100000}",
"body_sha256": "aa8e1b00794fb83de98fefa39d37d070930db888602a7de5e50d71804beed493",
"signature": "88d62e1ff11da171fc185f8f04ef9ec74df7251fc96c833fb786d82b024ff383"
},
{
"type": "callback",
"timestamp": "1788326200000",
"nonce": "cbnonce-fedcba9876543210",
"body": "{\"event\":\"expired\",\"order_id\":\"01habcdefghjkmnpqrstuv\",\"merchant_order_id\":\"M1002\",\"status\":\"expired\",\"currency\":\"USDC\",\"base_amount\":\"5.5\",\"pay_amount\":\"5.5123\",\"actual_amount\":\"\",\"overpaid\":false,\"matched_by\":\"\",\"binance_order_id\":\"\",\"binance_txn_id\":\"\",\"payer_id\":\"\",\"paid_at\":0,\"timestamp\":1788326200000}",
"body_sha256": "205e708b5c9939e16bed46acedd2d0ba0a668a3b30979cce51f274a17a732074",
"signature": "726ac40414e5c461f06211148db7166e32703c22d1b0ad3dad29b1e1c87a61d4"
}
]
}
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""跨语言端到端测试:真实网关二进制 × Python SDK × mock 币安。
用法: python3 e2e/cross_e2e.py [网关二进制路径,默认 server/bpaygate]
流程: 起 mock 币安与回调接收器 → 起网关子进程 → SDK 下单 → 注入到账流水
→ 等回调并验签 → 查单断言 paid → 再测回填(claim)路径。
"""
import json
import os
import queue
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "sdk", "python"))
from bpaygate import BPayGate
SECRET = "e2e-secret-0123456789abcdef"
TXNS = []
CB_QUEUE = queue.Queue()
class MockBinance(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/sapi/v1/pay/transactions"):
body = json.dumps({"code": "000000", "message": "success", "data": TXNS, "success": True}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *a):
pass
class CallbackReceiver(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
CB_QUEUE.put((dict(self.headers), body))
self.send_response(200)
self.end_headers()
def log_message(self, *a):
pass
def free_port():
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
return p
def wait_http(url, seconds=15):
for _ in range(seconds * 10):
try:
urllib.request.urlopen(url, timeout=1)
return True
except Exception:
time.sleep(0.1)
return False
def main():
binary = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), "..", "server", "bpaygate")
binary = os.path.abspath(binary)
if not os.path.isfile(binary):
sys.exit(f"找不到网关二进制: {binary}(先在 server/ 下 go build -o bpaygate .)")
mock = ThreadingHTTPServer(("127.0.0.1", 0), MockBinance)
threading.Thread(target=mock.serve_forever, daemon=True).start()
cbs = ThreadingHTTPServer(("127.0.0.1", 0), CallbackReceiver)
threading.Thread(target=cbs.serve_forever, daemon=True).start()
gw_port = free_port()
base = f"http://127.0.0.1:{gw_port}"
cfg = tempfile.NamedTemporaryFile("w", suffix=".env", delete=False)
cfg.write(f"""LISTEN=127.0.0.1:{gw_port}
BASE_URL={base}
API_AUTH_KEY={SECRET}
BINANCE_API_KEY=k
BINANCE_API_SECRET=s
BINANCE_UID=90000001
BINANCE_API_BASE=http://127.0.0.1:{mock.server_address[1]}
CURRENCIES=USDT,USDC
POLL_INTERVAL=1
DB_PATH={tempfile.mkdtemp()}/e2e.db
""")
cfg.close()
proc = subprocess.Popen([binary, "-config", cfg.name], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
try:
assert wait_http(base + "/healthz"), "网关未就绪"
gw = BPayGate(base, SECRET)
cb_url = f"http://127.0.0.1:{cbs.server_address[1]}/notify"
# ① 唯一金额自动匹配
order = gw.create_order("E2E-1", "12", currency="USDT", callback_url=cb_url, return_url="https://shop.test/done")
assert order["status"] == "pending" and order["pay_amount"] != "12", order
assert order["pay_amount"].startswith("12.00"), order # 首单应落在最小尾数档 0.0001–0.0099
TXNS.append({"orderId": "452100000000000001", "note": "", "orderType": "C2C",
"transactionId": "P_E2E000001", "transactionTime": int(time.time() * 1000),
"amount": order["pay_amount"], "currency": "USDT",
"counterpartyId": 1163000000, "payerInfo": {"name": "e2e-payer"}})
headers, body = CB_QUEUE.get(timeout=15)
payload = BPayGate.verify_callback(headers, body, SECRET)
assert payload["event"] == "paid" and payload["merchant_order_id"] == "E2E-1", payload
got = gw.get_order(order["order_id"])
assert got["status"] == "paid" and got["matched_by"] == "amount", got
got2 = gw.get_order_by_merchant_id("E2E-1")
assert got2["order_id"] == order["order_id"]
print("① 金额匹配 + 回调验签 ... PASS")
# ② 回填 claim
order2 = gw.create_order("E2E-2", "33", currency="USDT", callback_url=cb_url)
TXNS.append({"orderId": "452100000000000002", "note": "", "orderType": "C2C",
"transactionId": "P_E2E000002", "transactionTime": int(time.time() * 1000),
"amount": "33", "currency": "USDT",
"counterpartyId": 1163000000, "payerInfo": {"name": "e2e-payer"}})
token = order2["pay_url"].rsplit("/", 1)[1]
req = urllib.request.Request(base + f"/pay/{token}/claim",
data=json.dumps({"binance_order_id": "452100000000000002"}).encode(),
headers={"Content-Type": "application/json"}, method="POST")
out = json.loads(urllib.request.urlopen(req, timeout=10).read())
assert out["data"]["code"] == "OK" and out["data"]["status"] == "paid", out
headers, body = CB_QUEUE.get(timeout=15)
payload = BPayGate.verify_callback(headers, body, SECRET)
assert payload["event"] == "paid" and payload["matched_by"] == "claim", payload
print("② 回填核验 + 回调验签 ... PASS")
# ③ 收银页可渲染
html = urllib.request.urlopen(base + "/pay/" + token, timeout=5).read().decode()
assert "币安" in html and order2["note_code"] in html
print("③ 收银页渲染 ... PASS")
print("CROSS-E2E ALL PASS")
finally:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
os.unlink(cfg.name)
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
module bpaygate-example
go 1.21
require github.com/qianyubtc/BinancePayTool/sdk/go v0.0.0
replace github.com/qianyubtc/BinancePayTool/sdk/go => ../../sdk/go
+58
View File
@@ -0,0 +1,58 @@
// 最小商户示例:go run . 后访问 http://127.0.0.1:9000/buy
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"time"
bpaygate "github.com/qianyubtc/BinancePayTool/sdk/go"
)
func main() {
base := envOr("BPG_BASE", "http://127.0.0.1:8080")
secret := os.Getenv("BPG_SECRET")
gw := bpaygate.New(base, secret)
http.HandleFunc("/buy", func(w http.ResponseWriter, r *http.Request) {
order, err := gw.CreateOrder(bpaygate.CreateOrderReq{
MerchantOrderID: fmt.Sprintf("DEMO-%d", time.Now().Unix()),
Currency: "USDT",
Amount: "1",
CallbackURL: "http://127.0.0.1:9000/notify",
ReturnURL: "http://127.0.0.1:9000/done",
})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
http.Redirect(w, r, order.PayURL, http.StatusFound)
})
http.HandleFunc("/notify", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
cb, err := bpaygate.VerifyCallback(r.Header, body, secret, 5*time.Minute)
if err != nil {
http.Error(w, "bad signature", 401)
return
}
// TODO: 按 cb.MerchantOrderID 幂等发货
log.Printf("回调: %s %s %s%s", cb.Event, cb.MerchantOrderID, cb.ActualAmount, cb.Currency)
w.WriteHeader(200)
})
http.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<h1>支付完成,感谢购买!</h1>")
})
log.Println("商户示例: http://127.0.0.1:9000/buy")
log.Fatal(http.ListenAndServe("127.0.0.1:9000", nil))
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+62
View File
@@ -0,0 +1,62 @@
import com.bpaygate.sdk.BPayGateClient;
import com.google.gson.JsonObject;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 最小商户示例(Java 8+,无 Web 框架)。
* 编译运行后访问 http://127.0.0.1:9000/buy
*/
public class ExampleServer {
public static void main(String[] args) throws Exception {
String base = System.getenv().getOrDefault("BPG_BASE", "http://127.0.0.1:8080");
String secret = System.getenv().getOrDefault("BPG_SECRET", "");
BPayGateClient gw = new BPayGateClient(base, secret);
HttpServer srv = HttpServer.create(new InetSocketAddress("127.0.0.1", 9000), 0);
srv.createContext("/buy", ex -> {
JsonObject order = gw.createOrder("DEMO-" + System.currentTimeMillis(), "USDT", "1",
"http://127.0.0.1:9000/notify", "http://127.0.0.1:9000/done", 0);
ex.getResponseHeaders().add("Location", order.get("pay_url").getAsString());
ex.sendResponseHeaders(302, -1);
ex.close();
});
srv.createContext("/notify", ex -> {
String body = readAll(ex.getRequestBody());
Map<String, String> headers = new HashMap<>();
ex.getRequestHeaders().forEach((k, v) -> headers.put(k, v.isEmpty() ? "" : v.get(0)));
try {
JsonObject payload = BPayGateClient.verifyCallback(headers, body, secret, 300000);
// TODO: 按 merchant_order_id 幂等发货
System.out.println("回调: " + payload.get("event") + " " + payload.get("merchant_order_id"));
ex.sendResponseHeaders(200, -1);
} catch (BPayGateClient.BPayGateException e) {
ex.sendResponseHeaders(401, -1);
}
ex.close();
});
srv.createContext("/done", ex -> {
byte[] b = "<h1>支付完成,感谢购买!</h1>".getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
ex.sendResponseHeaders(200, b.length);
try (OutputStream os = ex.getResponseBody()) { os.write(b); }
});
System.out.println("商户示例: http://127.0.0.1:9000/buy");
srv.start();
}
private static String readAll(InputStream is) throws java.io.IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) > 0) bos.write(buf, 0, n);
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
/* 最小商户示例:创建订单并跳转收银页。
php -S 127.0.0.1:9000 后访问 http://127.0.0.1:9000/index.php */
require __DIR__ . '/../../sdk/php/BPayGate.php';
$gw = new BPayGate(getenv('BPG_BASE') ?: 'http://127.0.0.1:8080', getenv('BPG_SECRET') ?: '');
$order = $gw->createOrder([
'merchant_order_id' => 'DEMO-' . time(),
'currency' => 'USDT',
'amount' => '1',
'callback_url' => 'https://你的域名/notify.php',
'return_url' => 'https://你的域名/done.php',
]);
header('Location: ' . $order['pay_url']);
+15
View File
@@ -0,0 +1,15 @@
<?php
/* 回调处理示例 */
require __DIR__ . '/../../sdk/php/BPayGate.php';
try {
$payload = BPayGate::verifyCallback(getallheaders(), file_get_contents('php://input'), getenv('BPG_SECRET') ?: '');
} catch (BPayGateException $e) {
http_response_code(401);
exit;
}
if ($payload['event'] === 'paid') {
// TODO: 按 merchant_order_id 幂等发货
error_log('已支付: ' . $payload['merchant_order_id'] . ' ' . $payload['actual_amount'] . $payload['currency']);
}
http_response_code(200);
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""最小商户示例(仅标准库)。
export BPG_BASE=http://127.0.0.1:8080 BPG_SECRET=你的API_AUTH_KEY
python3 app.py # 然后浏览器打开 http://127.0.0.1:9000/buy
"""
import json
import os
import sys
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "sdk", "python"))
from bpaygate import BPayGate, BPayGateError
BASE = os.environ.get("BPG_BASE", "http://127.0.0.1:8080")
SECRET = os.environ.get("BPG_SECRET", "")
PORT = int(os.environ.get("PORT", "9000"))
gw = BPayGate(BASE, SECRET)
class Shop(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/buy"):
mid = f"DEMO-{int(time.time())}"
try:
order = gw.create_order(
mid, "1", currency="USDT",
callback_url=f"http://127.0.0.1:{PORT}/notify",
return_url=f"http://127.0.0.1:{PORT}/done",
)
except BPayGateError as e:
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
return
self.send_response(302)
self.send_header("Location", order["pay_url"])
self.end_headers()
elif self.path.startswith("/done"):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write("<h1>支付完成,感谢购买!</h1>".encode())
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
if self.path != "/notify":
self.send_response(404)
self.end_headers()
return
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
try:
payload = BPayGate.verify_callback(self.headers, body, SECRET)
except BPayGateError as e:
print("回调验签失败:", e)
self.send_response(401)
self.end_headers()
return
# TODO: 按 merchant_order_id 幂等发货
print("收到回调:", payload["event"], payload["merchant_order_id"], payload.get("actual_amount"))
self.send_response(200)
self.end_headers()
def log_message(self, *a):
pass
if __name__ == "__main__":
print(f"商户示例: http://127.0.0.1:{PORT}/buy")
ThreadingHTTPServer(("127.0.0.1", PORT), Shop).serve_forever()
+17
View File
@@ -0,0 +1,17 @@
# BinancePayTool Go SDK
仅标准库。模块路径 `github.com/qianyubtc/BinancePayTool/sdk/go`;仓库发布前本地可用 `replace` 指到目录(见 examples/go/go.mod)。
```go
gw := bpaygate.New("https://pay.example.com", "你的API_AUTH_KEY")
order, err := gw.CreateOrder(bpaygate.CreateOrderReq{
MerchantOrderID: "SHOP-1001", Currency: "USDT", Amount: "10",
CallbackURL: "https://shop.example.com/bpg/notify",
})
// 跳转用户到 order.PayURL
// 回调:
cb, err := bpaygate.VerifyCallback(r.Header, body, secret, 5*time.Minute)
```
完整字段见 [docs/protocol.md](../../docs/protocol.md)。示例:[examples/go](../../examples/go)。
+214
View File
@@ -0,0 +1,214 @@
// Package bpaygate 是 BinancePayTool 网关的 Go 接入 SDK(仅标准库)。
//
// gw := bpaygate.New("https://pay.example.com", "你的API_AUTH_KEY")
// order, err := gw.CreateOrder(bpaygate.CreateOrderReq{
// MerchantOrderID: "SHOP-1001", Currency: "USDT", Amount: "10",
// CallbackURL: "https://shop.example.com/bpg/notify",
// })
// // 跳转用户到 order.PayURL;回调用 VerifyCallback 验签后按 MerchantOrderID 幂等发货。
package bpaygate
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
type Client struct {
BaseURL string
Secret string
HC *http.Client
}
func New(baseURL, secret string) *Client {
for len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' {
baseURL = baseURL[:len(baseURL)-1]
}
return &Client{BaseURL: baseURL, Secret: secret, HC: &http.Client{Timeout: 10 * time.Second}}
}
type Order struct {
OrderID string `json:"order_id"`
MerchantOrderID string `json:"merchant_order_id"`
Status string `json:"status"`
Currency string `json:"currency"`
BaseAmount string `json:"base_amount"`
PayAmount string `json:"pay_amount"`
ActualAmount string `json:"actual_amount"`
NoteCode string `json:"note_code"`
PayURL string `json:"pay_url"`
ReceiveUID string `json:"receive_uid"`
MatchedBy string `json:"matched_by"`
BinanceOrderID string `json:"binance_order_id"`
BinanceTxnID string `json:"binance_txn_id"`
PayerID string `json:"payer_id"`
Overpaid bool `json:"overpaid"`
CreatedAt int64 `json:"created_at"`
ExpiresAt int64 `json:"expires_at"`
PaidAt int64 `json:"paid_at"`
}
type Callback struct {
Event string `json:"event"` // paid | underpaid | expired
OrderID string `json:"order_id"`
MerchantOrderID string `json:"merchant_order_id"`
Status string `json:"status"`
Currency string `json:"currency"`
BaseAmount string `json:"base_amount"`
PayAmount string `json:"pay_amount"`
ActualAmount string `json:"actual_amount"`
Overpaid bool `json:"overpaid"`
MatchedBy string `json:"matched_by"`
BinanceOrderID string `json:"binance_order_id"`
BinanceTxnID string `json:"binance_txn_id"`
PayerID string `json:"payer_id"`
PaidAt int64 `json:"paid_at"`
Timestamp int64 `json:"timestamp"`
}
type APIError struct {
Code string
Message string
HTTPStatus int
}
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
type CreateOrderReq struct {
MerchantOrderID string `json:"merchant_order_id"`
Currency string `json:"currency"`
Amount string `json:"amount"`
CallbackURL string `json:"callback_url,omitempty"`
ReturnURL string `json:"return_url,omitempty"`
Timeout int64 `json:"timeout,omitempty"`
}
func (c *Client) CreateOrder(req CreateOrderReq) (*Order, error) {
return c.do("POST", "/api/v1/orders", req)
}
func (c *Client) GetOrder(orderID string) (*Order, error) {
return c.do("GET", "/api/v1/orders/"+orderID, nil)
}
func (c *Client) GetOrderByMerchantID(mid string) (*Order, error) {
return c.do("GET", "/api/v1/orders/by-merchant/"+mid, nil)
}
func (c *Client) CloseOrder(orderID string) (*Order, error) {
return c.do("POST", "/api/v1/orders/"+orderID+"/close", struct{}{})
}
func (c *Client) do(method, path string, payload any) (*Order, error) {
var body []byte
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = b
}
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
nonce := randNonce()
req, err := http.NewRequest(method, c.BaseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-BPG-Timestamp", ts)
req.Header.Set("X-BPG-Nonce", nonce)
req.Header.Set("X-BPG-Signature", SignRequest(c.Secret, ts, nonce, method, path, body))
resp, err := c.HC.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
var env struct {
Code string `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("响应解析失败 (HTTP %d): %w", resp.StatusCode, err)
}
if env.Code != "OK" {
return nil, &APIError{Code: env.Code, Message: env.Message, HTTPStatus: resp.StatusCode}
}
var o Order
if err := json.Unmarshal(env.Data, &o); err != nil {
return nil, err
}
return &o, nil
}
// VerifyCallback 校验网关回调签名并解析。商户须按 MerchantOrderID 幂等处理。
func VerifyCallback(header http.Header, body []byte, secret string, maxSkew time.Duration) (*Callback, error) {
ts := header.Get("X-BPG-Timestamp")
nonce := header.Get("X-BPG-Nonce")
sig := header.Get("X-BPG-Signature")
if ts == "" || nonce == "" || sig == "" {
return nil, errors.New("缺少签名头")
}
tsv, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return nil, errors.New("时间戳非法")
}
skew := time.Now().UnixMilli() - tsv
if skew < 0 {
skew = -skew
}
if maxSkew <= 0 {
maxSkew = 5 * time.Minute
}
if skew > maxSkew.Milliseconds() {
return nil, errors.New("时间戳偏差过大")
}
if !hmac.Equal([]byte(SignCallback(secret, ts, nonce, body)), []byte(sig)) {
return nil, errors.New("签名错误")
}
var cb Callback
if err := json.Unmarshal(body, &cb); err != nil {
return nil, err
}
return &cb, nil
}
func sha256Hex(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func hmacHex(secret, msg string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(msg))
return hex.EncodeToString(m.Sum(nil))
}
// SignRequest 商户→网关请求签名(docs/protocol.md §1.1)。
func SignRequest(secret, ts, nonce, method, path string, body []byte) string {
return hmacHex(secret, ts+"\n"+nonce+"\n"+method+"\n"+path+"\n"+sha256Hex(body))
}
// SignCallback 网关→商户回调签名(docs/protocol.md §1.2)。
func SignCallback(secret, ts, nonce string, body []byte) string {
return hmacHex(secret, ts+"\n"+nonce+"\n"+sha256Hex(body))
}
func randNonce() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}
+34
View File
@@ -0,0 +1,34 @@
package bpaygate
import (
"encoding/json"
"os"
"testing"
)
func TestVectors(t *testing.T) {
data, err := os.ReadFile("../../docs/test_vectors.json")
if err != nil {
t.Fatal(err)
}
var vf struct {
Secret string `json:"secret"`
Cases []struct {
Type, Timestamp, Nonce, Method, Path, Body, Signature string
} `json:"cases"`
}
if err := json.Unmarshal(data, &vf); err != nil {
t.Fatal(err)
}
for i, c := range vf.Cases {
var got string
if c.Type == "request" {
got = SignRequest(vf.Secret, c.Timestamp, c.Nonce, c.Method, c.Path, []byte(c.Body))
} else {
got = SignCallback(vf.Secret, c.Timestamp, c.Nonce, []byte(c.Body))
}
if got != c.Signature {
t.Errorf("case %d: got %s want %s", i, got, c.Signature)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/qianyubtc/BinancePayTool/sdk/go
go 1.21
+228
View File
@@ -0,0 +1,228 @@
package com.bpaygate.sdk;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Map;
/**
* BPayGate Java SDK(Java 8+,依赖 Gson)。
*
* <pre>
* BPayGateClient gw = new BPayGateClient("https://pay.example.com", "你的API_AUTH_KEY");
* JsonObject order = gw.createOrder("SHOP-1001", "USDT", "10",
* "https://shop.example.com/bpg/notify", "https://shop.example.com/orders/1001", 0);
* String payUrl = order.get("pay_url").getAsString(); // 跳转用户
*
* // 回调处理:
* JsonObject payload = BPayGateClient.verifyCallback(tsHeader, nonceHeader, sigHeader, rawBody, SECRET, 300000);
* if ("paid".equals(payload.get("event").getAsString())) { ... } // 按 merchant_order_id 幂等发货
* </pre>
*/
public class BPayGateClient {
public static class BPayGateException extends RuntimeException {
public final String code;
public final int httpStatus;
public BPayGateException(String code, String message, int httpStatus) {
super(code + ": " + message);
this.code = code;
this.httpStatus = httpStatus;
}
}
private static final Gson GSON = new Gson();
private static final SecureRandom RND = new SecureRandom();
private final String baseUrl;
private final String secret;
private final int timeoutMs;
public BPayGateClient(String baseUrl, String secret) {
this(baseUrl, secret, 10000);
}
public BPayGateClient(String baseUrl, String secret, int timeoutMs) {
String b = baseUrl;
while (b.endsWith("/")) b = b.substring(0, b.length() - 1);
this.baseUrl = b;
this.secret = secret;
this.timeoutMs = timeoutMs;
}
/* ---------- API ---------- */
public JsonObject createOrder(String merchantOrderId, String currency, String amount,
String callbackUrl, String returnUrl, long timeoutSec) {
JsonObject p = new JsonObject();
p.addProperty("merchant_order_id", merchantOrderId);
p.addProperty("currency", currency);
p.addProperty("amount", amount);
if (callbackUrl != null && !callbackUrl.isEmpty()) p.addProperty("callback_url", callbackUrl);
if (returnUrl != null && !returnUrl.isEmpty()) p.addProperty("return_url", returnUrl);
if (timeoutSec > 0) p.addProperty("timeout", timeoutSec);
return request("POST", "/api/v1/orders", GSON.toJson(p));
}
public JsonObject getOrder(String orderId) {
return request("GET", "/api/v1/orders/" + orderId, "");
}
public JsonObject getOrderByMerchantId(String merchantOrderId) {
return request("GET", "/api/v1/orders/by-merchant/" + merchantOrderId, "");
}
public JsonObject closeOrder(String orderId) {
return request("POST", "/api/v1/orders/" + orderId + "/close", "{}");
}
/* ---------- 签名 ---------- */
public static String signRequest(String secret, String ts, String nonce, String method, String path, String body) {
String sts = ts + "\n" + nonce + "\n" + method + "\n" + path + "\n" + sha256Hex(body.getBytes(StandardCharsets.UTF_8));
return hmacHex(secret, sts);
}
public static String signCallback(String secret, String ts, String nonce, String body) {
String sts = ts + "\n" + nonce + "\n" + sha256Hex(body.getBytes(StandardCharsets.UTF_8));
return hmacHex(secret, sts);
}
/**
* 校验网关回调,通过返回回调 JSON,失败抛 BPayGateException。
* 注意:回调可能重复投递,商户须按 merchant_order_id 幂等处理。
*/
public static JsonObject verifyCallback(String ts, String nonce, String sig, String body,
String secret, long maxSkewMs) {
if (ts == null || nonce == null || sig == null || ts.isEmpty() || nonce.isEmpty() || sig.isEmpty()) {
throw new BPayGateException("ERR_AUTH", "缺少签名头", 0);
}
long tsv;
try {
tsv = Long.parseLong(ts);
} catch (NumberFormatException e) {
throw new BPayGateException("ERR_AUTH", "时间戳非法", 0);
}
if (Math.abs(System.currentTimeMillis() - tsv) > maxSkewMs) {
throw new BPayGateException("ERR_AUTH", "时间戳偏差过大", 0);
}
if (!constantTimeEquals(signCallback(secret, ts, nonce, body), sig)) {
throw new BPayGateException("ERR_AUTH", "签名错误", 0);
}
return GSON.fromJson(body, JsonObject.class);
}
/** 便捷重载:直接传 header map(键不区分大小写)。 */
public static JsonObject verifyCallback(Map<String, String> headers, String body, String secret, long maxSkewMs) {
String ts = null, nonce = null, sig = null;
for (Map.Entry<String, String> e : headers.entrySet()) {
String k = e.getKey() == null ? "" : e.getKey().toLowerCase();
if (k.equals("x-bpg-timestamp")) ts = e.getValue();
else if (k.equals("x-bpg-nonce")) nonce = e.getValue();
else if (k.equals("x-bpg-signature")) sig = e.getValue();
}
return verifyCallback(ts, nonce, sig, body, secret, maxSkewMs);
}
/* ---------- 内部 ---------- */
private JsonObject request(String method, String path, String body) {
try {
String ts = String.valueOf(System.currentTimeMillis());
byte[] nb = new byte[16];
RND.nextBytes(nb);
String nonce = hex(nb);
String sig = signRequest(secret, ts, nonce, method, path, body);
HttpURLConnection conn = (HttpURLConnection) new URL(baseUrl + path).openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(timeoutMs);
conn.setReadTimeout(timeoutMs);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("X-BPG-Timestamp", ts);
conn.setRequestProperty("X-BPG-Nonce", nonce);
conn.setRequestProperty("X-BPG-Signature", sig);
if (!body.isEmpty()) {
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) {
os.write(body.getBytes(StandardCharsets.UTF_8));
}
}
int status = conn.getResponseCode();
InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
String raw = readAll(is);
JsonObject out;
try {
out = GSON.fromJson(raw, JsonObject.class);
} catch (Exception e) {
throw new BPayGateException("ERR_HTTP", "HTTP " + status, status);
}
if (out == null || out.get("code") == null) {
throw new BPayGateException("ERR_HTTP", "HTTP " + status, status);
}
String code = out.get("code").getAsString();
if (!"OK".equals(code)) {
String msg = out.get("message") == null ? "" : out.get("message").getAsString();
throw new BPayGateException(code, msg, status);
}
return out.getAsJsonObject("data");
} catch (BPayGateException e) {
throw e;
} catch (Exception e) {
throw new BPayGateException("ERR_NETWORK", e.getMessage() == null ? e.toString() : e.getMessage(), 0);
}
}
private static String readAll(InputStream is) throws Exception {
if (is == null) return "";
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) > 0) bos.write(buf, 0, n);
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
}
private static String sha256Hex(byte[] b) {
try {
return hex(MessageDigest.getInstance("SHA-256").digest(b));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static String hmacHex(String secret, String msg) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return hex(mac.doFinal(msg.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static String hex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
private static boolean constantTimeEquals(String a, String b) {
byte[] x = a.getBytes(StandardCharsets.UTF_8);
byte[] y = b.getBytes(StandardCharsets.UTF_8);
if (x.length != y.length) return false;
int r = 0;
for (int i = 0; i < x.length; i++) r |= x[i] ^ y[i];
return r == 0;
}
}
+15
View File
@@ -0,0 +1,15 @@
# BinancePayTool Java SDK
Java 8+,依赖 Gson(见 pom.xml)。也可直接把 `BPayGateClient.java` 拷进项目。
```java
BPayGateClient gw = new BPayGateClient("https://pay.example.com", "你的API_AUTH_KEY");
JsonObject order = gw.createOrder("SHOP-1001", "USDT", "10",
"https://shop.example.com/bpg/notify", null, 0);
String payUrl = order.get("pay_url").getAsString();
// 回调:
JsonObject payload = BPayGateClient.verifyCallback(headersMap, rawBody, SECRET, 300000);
```
完整字段见 [docs/protocol.md](../../docs/protocol.md)。示例:[examples/java](../../examples/java)。
+54
View File
@@ -0,0 +1,54 @@
import com.bpaygate.sdk.BPayGateClient;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
/** 签名向量测试(无 JUnit 依赖,CI 中直接 java 运行;非零退出即失败)。 */
public class VectorTest {
public static void main(String[] args) throws Exception {
String path = args.length > 0 ? args[0] : "../../docs/test_vectors.json";
String raw = new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8);
JsonObject vf = new Gson().fromJson(raw, JsonObject.class);
String secret = vf.get("secret").getAsString();
JsonArray cases = vf.getAsJsonArray("cases");
int fails = 0;
for (int i = 0; i < cases.size(); i++) {
JsonObject c = cases.get(i).getAsJsonObject();
String got;
if ("request".equals(c.get("type").getAsString())) {
got = BPayGateClient.signRequest(secret, c.get("timestamp").getAsString(),
c.get("nonce").getAsString(), c.get("method").getAsString(),
c.get("path").getAsString(), c.get("body").getAsString());
} else {
got = BPayGateClient.signCallback(secret, c.get("timestamp").getAsString(),
c.get("nonce").getAsString(), c.get("body").getAsString());
}
if (!got.equals(c.get("signature").getAsString())) {
System.err.println("case " + i + ": got " + got + " want " + c.get("signature").getAsString());
fails++;
}
}
// verifyCallback 正反例
String body = "{\"event\":\"paid\",\"merchant_order_id\":\"M1\"}";
String ts = String.valueOf(System.currentTimeMillis());
String sig = BPayGateClient.signCallback(secret, ts, "nnnnnnnnnnnnnnnnnnnn", body);
JsonObject payload = BPayGateClient.verifyCallback(ts, "nnnnnnnnnnnnnnnnnnnn", sig, body, secret, 300000);
if (!"paid".equals(payload.get("event").getAsString())) {
System.err.println("verifyCallback 解析失败");
fails++;
}
try {
BPayGateClient.verifyCallback(ts, "nnnnnnnnnnnnnnnnnnnn", "bad", body, secret, 300000);
System.err.println("坏签名未被拒绝");
fails++;
} catch (BPayGateClient.BPayGateException e) {
// 预期
}
if (fails > 0) System.exit(1);
System.out.println("Java SDK vectors: ALL PASS");
}
}
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.bpaygate</groupId>
<artifactId>bpaygate-sdk</artifactId>
<version>0.1.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
</dependencies>
<build>
<sourceDirectory>.</sourceDirectory>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.1</version>
<configuration><excludes><exclude>VectorTest*</exclude></excludes></configuration>
</plugin>
</plugins>
</build>
</project>
+157
View File
@@ -0,0 +1,157 @@
<?php
/**
* BPayGate PHP SDK(单文件,PHP 7.4+,需 curl 与 json 扩展)
*
* 用法:
* require 'BPayGate.php';
* $gw = new BPayGate('https://pay.example.com', '你的API_AUTH_KEY');
* $order = $gw->createOrder([
* 'merchant_order_id' => 'SHOP-1001',
* 'currency' => 'USDT',
* 'amount' => '10',
* 'callback_url' => 'https://shop.example.com/bpg_notify.php',
* 'return_url' => 'https://shop.example.com/orders/1001',
* ]);
* header('Location: ' . $order['pay_url']);
*
* 回调处理(bpg_notify.php):
* $payload = BPayGate::verifyCallback(getallheaders(), file_get_contents('php://input'), $secret);
* if ($payload['event'] === 'paid') { ... } // 按 merchant_order_id 幂等发货
* http_response_code(200);
*/
class BPayGateException extends \Exception
{
public $errCode;
public $httpStatus;
public function __construct(string $errCode, string $message = '', int $httpStatus = 0)
{
parent::__construct($errCode . ': ' . $message);
$this->errCode = $errCode;
$this->httpStatus = $httpStatus;
}
}
class BPayGate
{
private $baseUrl;
private $secret;
private $timeout;
public function __construct(string $baseUrl, string $secret, int $timeout = 10)
{
$this->baseUrl = rtrim($baseUrl, '/');
$this->secret = $secret;
$this->timeout = $timeout;
}
/* ---------- API ---------- */
public function createOrder(array $params): array
{
return $this->request('POST', '/api/v1/orders', $params);
}
public function getOrder(string $orderId): array
{
return $this->request('GET', '/api/v1/orders/' . $orderId);
}
public function getOrderByMerchantId(string $merchantOrderId): array
{
return $this->request('GET', '/api/v1/orders/by-merchant/' . $merchantOrderId);
}
public function closeOrder(string $orderId): array
{
return $this->request('POST', '/api/v1/orders/' . $orderId . '/close', []);
}
/* ---------- 签名 ---------- */
public static function signRequest(string $secret, string $ts, string $nonce, string $method, string $path, string $body): string
{
$sts = $ts . "\n" . $nonce . "\n" . $method . "\n" . $path . "\n" . hash('sha256', $body);
return hash_hmac('sha256', $sts, $secret);
}
public static function signCallback(string $secret, string $ts, string $nonce, string $body): string
{
$sts = $ts . "\n" . $nonce . "\n" . hash('sha256', $body);
return hash_hmac('sha256', $sts, $secret);
}
/**
* 校验网关回调,成功返回回调数组,失败抛 BPayGateException。
* $headers 传 getallheaders() 即可(键大小写不敏感)。
* 注意:回调可能重复投递,商户须按 merchant_order_id 幂等处理。
*/
public static function verifyCallback(array $headers, string $body, string $secret, int $maxSkewMs = 300000): array
{
$h = [];
foreach ($headers as $k => $v) {
$h[strtolower($k)] = $v;
}
$ts = $h['x-bpg-timestamp'] ?? '';
$nonce = $h['x-bpg-nonce'] ?? '';
$sig = $h['x-bpg-signature'] ?? '';
if ($ts === '' || $nonce === '' || $sig === '') {
throw new BPayGateException('ERR_AUTH', '缺少签名头');
}
if (!ctype_digit($ts) || abs((int)(microtime(true) * 1000) - (int)$ts) > $maxSkewMs) {
throw new BPayGateException('ERR_AUTH', '时间戳非法或偏差过大');
}
if (!hash_equals(self::signCallback($secret, $ts, $nonce, $body), $sig)) {
throw new BPayGateException('ERR_AUTH', '签名错误');
}
$payload = json_decode($body, true);
if (!is_array($payload)) {
throw new BPayGateException('ERR_PARAM', '回调体解析失败');
}
return $payload;
}
/* ---------- 内部 ---------- */
private function request(string $method, string $path, ?array $payload = null): array
{
$body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ts = (string)(int)(microtime(true) * 1000);
$nonce = bin2hex(random_bytes(12));
$sig = self::signRequest($this->secret, $ts, $nonce, $method, $path, $body);
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-BPG-Timestamp: ' . $ts,
'X-BPG-Nonce: ' . $nonce,
'X-BPG-Signature: ' . $sig,
],
]);
if ($body !== '') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
if ($raw === false) {
$err = curl_error($ch);
curl_close($ch);
throw new BPayGateException('ERR_NETWORK', $err);
}
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$out = json_decode($raw, true);
if (!is_array($out)) {
throw new BPayGateException('ERR_HTTP', 'HTTP ' . $status, $status);
}
if (($out['code'] ?? '') !== 'OK') {
throw new BPayGateException($out['code'] ?? 'ERR_UNKNOWN', $out['message'] ?? '', $status);
}
return $out['data'];
}
}
+19
View File
@@ -0,0 +1,19 @@
# BinancePayTool PHP SDK
单文件 `BPayGate.php`,PHP 7.4+,需 curl 扩展。
```php
require 'BPayGate.php';
$gw = new BPayGate('https://pay.example.com', '你的API_AUTH_KEY');
$order = $gw->createOrder([
'merchant_order_id' => 'SHOP-1001',
'currency' => 'USDT',
'amount' => '10',
'callback_url' => 'https://shop.example.com/bpg_notify.php',
]);
header('Location: ' . $order['pay_url']);
```
回调:`BPayGate::verifyCallback(getallheaders(), file_get_contents('php://input'), $secret)`,
通过返回数组、失败抛异常;按 `merchant_order_id` 幂等发货后回 200。
完整字段见 [docs/protocol.md](../../docs/protocol.md)。示例:[examples/php](../../examples/php)。
+48
View File
@@ -0,0 +1,48 @@
<?php
/* 签名向量测试:php sdk/php/test_vectors.php (CI 中运行) */
require __DIR__ . '/BPayGate.php';
$vf = json_decode(file_get_contents(__DIR__ . '/../../docs/test_vectors.json'), true);
if (!$vf) {
fwrite(STDERR, "无法读取向量文件\n");
exit(1);
}
$fails = 0;
foreach ($vf['cases'] as $i => $c) {
if ($c['type'] === 'request') {
$got = BPayGate::signRequest($vf['secret'], $c['timestamp'], $c['nonce'], $c['method'], $c['path'], $c['body']);
} else {
$got = BPayGate::signCallback($vf['secret'], $c['timestamp'], $c['nonce'], $c['body']);
}
if ($got !== $c['signature']) {
fwrite(STDERR, "case $i ({$c['type']}): got $got want {$c['signature']}\n");
$fails++;
}
}
// verifyCallback 正反例
$body = '{"event":"paid","merchant_order_id":"M1"}';
$ts = (string)(int)(microtime(true) * 1000);
$nonce = str_repeat('n', 20);
$sig = BPayGate::signCallback($vf['secret'], $ts, $nonce, $body);
$payload = BPayGate::verifyCallback(
['X-Bpg-Timestamp' => $ts, 'X-Bpg-Nonce' => $nonce, 'X-Bpg-Signature' => $sig],
$body, $vf['secret']
);
if (($payload['event'] ?? '') !== 'paid') {
fwrite(STDERR, "verifyCallback 解析失败\n");
$fails++;
}
try {
BPayGate::verifyCallback(
['X-Bpg-Timestamp' => $ts, 'X-Bpg-Nonce' => $nonce, 'X-Bpg-Signature' => 'bad'],
$body, $vf['secret']
);
fwrite(STDERR, "坏签名未被拒绝\n");
$fails++;
} catch (BPayGateException $e) {
// 预期
}
if ($fails > 0) {
exit(1);
}
echo "PHP SDK vectors: ALL PASS\n";
+20
View File
@@ -0,0 +1,20 @@
# BinancePayTool Python SDK
仅标准库,Python 3.7+。把 `bpaygate.py` 拷进项目或按路径引入即可。
```python
from bpaygate import BPayGate, BPayGateError
gw = BPayGate("https://pay.example.com", "你的API_AUTH_KEY")
order = gw.create_order("SHOP-1001", "10", currency="USDT",
callback_url="https://shop.example.com/bpg/notify")
# 跳转用户到 order["pay_url"]
# 回调(Flask 写法):
payload = BPayGate.verify_callback(request.headers, request.get_data(), SECRET)
if payload["event"] == "paid":
... # 按 merchant_order_id 幂等发货
```
方法:`create_order` / `get_order` / `get_order_by_merchant_id` / `close_order` / `verify_callback`。
完整字段见 [docs/protocol.md](../../docs/protocol.md)。示例:[examples/python](../../examples/python)。
+133
View File
@@ -0,0 +1,133 @@
"""BPayGate Python SDK(仅标准库,Python 3.7+)
用法:
from bpaygate import BPayGate
gw = BPayGate("https://pay.example.com", "你的API_AUTH_KEY")
order = gw.create_order("SHOP-1001", "10", currency="USDT",
callback_url="https://shop.example.com/bpg/notify",
return_url="https://shop.example.com/orders/1001")
# 跳转用户到 order["pay_url"]
# 回调处理(Flask 示例见 examples/python/app.py):
payload = BPayGate.verify_callback(request.headers, request.get_data(), SECRET)
if payload["event"] == "paid": ... # 按 merchant_order_id 幂等发货
"""
import hashlib
import hmac
import json
import secrets
import time
import urllib.error
import urllib.request
__all__ = ["BPayGate", "BPayGateError"]
class BPayGateError(Exception):
def __init__(self, code, message="", http_status=0):
super().__init__(f"{code}: {message}")
self.code = code
self.message = message
self.http_status = http_status
def _sha256_hex(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def _hmac_hex(secret: str, msg: str) -> str:
return hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
def sign_request(secret, timestamp, nonce, method, path, body: bytes) -> str:
sts = "\n".join([timestamp, nonce, method, path, _sha256_hex(body)])
return _hmac_hex(secret, sts)
def sign_callback(secret, timestamp, nonce, body: bytes) -> str:
sts = "\n".join([timestamp, nonce, _sha256_hex(body)])
return _hmac_hex(secret, sts)
class BPayGate:
def __init__(self, base_url: str, secret: str, timeout: int = 10):
self.base_url = base_url.rstrip("/")
self.secret = secret
self.timeout = timeout
# ---- API ----
def create_order(self, merchant_order_id: str, amount: str, currency: str = "USDT",
callback_url: str = None, return_url: str = None, timeout: int = None) -> dict:
payload = {"merchant_order_id": merchant_order_id, "amount": str(amount), "currency": currency}
if callback_url:
payload["callback_url"] = callback_url
if return_url:
payload["return_url"] = return_url
if timeout:
payload["timeout"] = int(timeout)
return self._request("POST", "/api/v1/orders", payload)
def get_order(self, order_id: str) -> dict:
return self._request("GET", "/api/v1/orders/" + order_id)
def get_order_by_merchant_id(self, merchant_order_id: str) -> dict:
return self._request("GET", "/api/v1/orders/by-merchant/" + merchant_order_id)
def close_order(self, order_id: str) -> dict:
return self._request("POST", "/api/v1/orders/" + order_id + "/close", {})
# ---- 回调验签 ----
@staticmethod
def verify_callback(headers, body: bytes, secret: str, max_skew_ms: int = 300000) -> dict:
"""校验网关回调。headers 可传 dict 或任意支持 get 的对象(大小写不敏感)。
校验通过返回回调 JSON;失败抛 BPayGateError。商户须按 merchant_order_id 幂等处理。"""
def h(name):
for k in (name, name.lower(), name.upper()):
v = headers.get(k) if hasattr(headers, "get") else None
if v:
return v
return ""
ts, nonce, sig = h("X-Bpg-Timestamp") or h("X-BPG-Timestamp"), h("X-Bpg-Nonce") or h("X-BPG-Nonce"), \
h("X-Bpg-Signature") or h("X-BPG-Signature")
if not ts or not nonce or not sig:
raise BPayGateError("ERR_AUTH", "缺少签名头")
try:
skew = abs(int(time.time() * 1000) - int(ts))
except ValueError:
raise BPayGateError("ERR_AUTH", "时间戳非法")
if skew > max_skew_ms:
raise BPayGateError("ERR_AUTH", "时间戳偏差过大")
if not isinstance(body, bytes):
body = body.encode()
if not hmac.compare_digest(sign_callback(secret, ts, nonce, body), sig):
raise BPayGateError("ERR_AUTH", "签名错误")
return json.loads(body.decode())
# ---- 内部 ----
def _request(self, method: str, path: str, payload: dict = None) -> dict:
body = json.dumps(payload, separators=(",", ":")).encode() if payload is not None else b""
ts = str(int(time.time() * 1000))
nonce = secrets.token_hex(12)
req = urllib.request.Request(self.base_url + path, data=body if body else None, method=method)
req.add_header("Content-Type", "application/json")
req.add_header("X-BPG-Timestamp", ts)
req.add_header("X-BPG-Nonce", nonce)
req.add_header("X-BPG-Signature", sign_request(self.secret, ts, nonce, method, path, body))
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
out = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
try:
out = json.loads(e.read().decode())
except Exception:
raise BPayGateError("ERR_HTTP", f"HTTP {e.code}", e.code)
raise BPayGateError(out.get("code", "ERR_HTTP"), out.get("message", ""), e.code)
except urllib.error.URLError as e:
raise BPayGateError("ERR_NETWORK", str(e))
if out.get("code") != "OK":
raise BPayGateError(out.get("code", "ERR_UNKNOWN"), out.get("message", ""))
return out["data"]
+50
View File
@@ -0,0 +1,50 @@
"""签名向量测试:python3 sdk/python/test_bpaygate.py"""
import json
import os
import sys
import time
import unittest
sys.path.insert(0, os.path.dirname(__file__))
from bpaygate import BPayGate, BPayGateError, sign_callback, sign_request
VEC = os.path.join(os.path.dirname(__file__), "..", "..", "docs", "test_vectors.json")
class TestVectors(unittest.TestCase):
def setUp(self):
with open(VEC, encoding="utf-8") as f:
self.vf = json.load(f)
def test_vectors(self):
for i, c in enumerate(self.vf["cases"]):
if c["type"] == "request":
got = sign_request(self.vf["secret"], c["timestamp"], c["nonce"], c["method"], c["path"], c["body"].encode())
else:
got = sign_callback(self.vf["secret"], c["timestamp"], c["nonce"], c["body"].encode())
self.assertEqual(got, c["signature"], f"case {i} ({c['type']})")
def test_verify_callback(self):
secret = self.vf["secret"]
body = b'{"event":"paid","merchant_order_id":"M1"}'
ts = str(int(time.time() * 1000))
nonce = "n" * 20
headers = {
"X-Bpg-Timestamp": ts,
"X-Bpg-Nonce": nonce,
"X-Bpg-Signature": sign_callback(secret, ts, nonce, body),
}
payload = BPayGate.verify_callback(headers, body, secret)
self.assertEqual(payload["event"], "paid")
headers["X-Bpg-Signature"] = "bad"
with self.assertRaises(BPayGateError):
BPayGate.verify_callback(headers, body, secret)
# 过期时间戳
old = {"X-Bpg-Timestamp": "1000000", "X-Bpg-Nonce": nonce,
"X-Bpg-Signature": sign_callback(secret, "1000000", nonce, body)}
with self.assertRaises(BPayGateError):
BPayGate.verify_callback(old, body, secret)
if __name__ == "__main__":
unittest.main(verbosity=2)
+280
View File
@@ -0,0 +1,280 @@
package main
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type App struct {
cfg *Config
st *Store
bc *binanceClient
matcher *Matcher
notifier *Notifier
mux *http.ServeMux
nonceMu sync.Mutex
nonces map[string]int64 // nonce → 过期时间 ms
rlMu sync.Mutex
rl map[string][]int64 // 限流:key → 时间戳窗口
}
func newApp(cfg *Config) (*App, error) {
st, err := openStore(cfg.DBPath)
if err != nil {
return nil, err
}
bc := newBinanceClient(cfg)
a := &App{
cfg: cfg, st: st, bc: bc,
matcher: newMatcher(cfg, st, bc),
notifier: newNotifier(cfg, st),
nonces: map[string]int64{},
rl: map[string][]int64{},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/orders", a.auth(a.handleCreate))
mux.HandleFunc("GET /api/v1/orders/{id}", a.auth(a.handleGet))
mux.HandleFunc("GET /api/v1/orders/by-merchant/{mid}", a.auth(a.handleGetByMerchant))
mux.HandleFunc("POST /api/v1/orders/{id}/close", a.auth(a.handleClose))
mux.HandleFunc("GET /pay/{token}", a.handleCashier)
mux.HandleFunc("GET /pay/{token}/status", a.handleStatus)
mux.HandleFunc("POST /pay/{token}/claim", a.handleClaim)
mux.HandleFunc("GET /pay/{token}/qr", a.handleQR)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": true, "version": version})
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("BinancePayTool " + version + " running\n"))
})
a.mux = mux
return a, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func ok(w http.ResponseWriter, data any) {
writeJSON(w, 200, map[string]any{"code": "OK", "data": data})
}
func fail(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, map[string]any{"code": code, "message": msg})
}
// auth 校验商户请求签名(docs/protocol.md §1.1)。
func (a *App) auth(next func(http.ResponseWriter, *http.Request, []byte)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
fail(w, 400, "ERR_PARAM", "读取请求体失败")
return
}
ts := r.Header.Get("X-BPG-Timestamp")
nonce := r.Header.Get("X-BPG-Nonce")
sig := r.Header.Get("X-BPG-Signature")
if ts == "" || len(nonce) < 16 || sig == "" {
fail(w, 401, "ERR_AUTH", "缺少签名头")
return
}
tsv, err := strconv.ParseInt(ts, 10, 64)
if err != nil || tsv < nowMs()-300000 || tsv > nowMs()+300000 {
fail(w, 401, "ERR_AUTH", "时间戳无效或偏差过大")
return
}
expect := signRequest(a.cfg.APIAuthKey, ts, nonce, r.Method, r.URL.Path, body)
if !sigEqual(expect, sig) {
fail(w, 401, "ERR_AUTH", "签名错误")
return
}
a.nonceMu.Lock()
now := nowMs()
if len(a.nonces) > 10000 {
for k, exp := range a.nonces {
if exp < now {
delete(a.nonces, k)
}
}
}
if exp, seen := a.nonces[nonce]; seen && exp > now {
a.nonceMu.Unlock()
fail(w, 401, "ERR_AUTH", "nonce 重复")
return
}
a.nonces[nonce] = now + 300000
a.nonceMu.Unlock()
next(w, r, body)
}
}
// allow 简单滑动窗口限流。
func (a *App) allow(key string, max int, windowSec int64) bool {
a.rlMu.Lock()
defer a.rlMu.Unlock()
now := nowMs()
lo := now - windowSec*1000
win := a.rl[key]
var kept []int64
for _, t := range win {
if t >= lo {
kept = append(kept, t)
}
}
if len(kept) >= max {
a.rl[key] = kept
return false
}
a.rl[key] = append(kept, now)
return true
}
func (a *App) orderJSON(o *Order) map[string]any {
actual := ""
if o.ActualAmount > 0 {
actual = fmtAmount(o.ActualAmount)
}
return map[string]any{
"order_id": o.ID,
"merchant_order_id": o.MerchantOrderID,
"status": o.Status,
"currency": o.Currency,
"base_amount": fmtAmount(o.BaseAmount),
"pay_amount": fmtAmount(o.PayAmount),
"actual_amount": actual,
"note_code": o.NoteCode,
"pay_url": a.cfg.BaseURL + "/pay/" + o.Token,
"receive_uid": a.cfg.BinanceUID,
"matched_by": o.MatchedBy,
"binance_order_id": o.BinanceOrderID,
"binance_txn_id": o.BinanceTxnID,
"payer_id": o.PayerID,
"overpaid": o.Overpaid,
"created_at": o.CreatedAt,
"expires_at": o.ExpiresAt,
"paid_at": o.PaidAt,
}
}
type createReq struct {
MerchantOrderID string `json:"merchant_order_id"`
Currency string `json:"currency"`
Amount string `json:"amount"`
CallbackURL string `json:"callback_url"`
ReturnURL string `json:"return_url"`
Timeout int64 `json:"timeout"`
}
func (a *App) handleCreate(w http.ResponseWriter, r *http.Request, body []byte) {
var req createReq
if err := json.Unmarshal(body, &req); err != nil {
fail(w, 400, "ERR_PARAM", "JSON 解析失败")
return
}
req.MerchantOrderID = strings.TrimSpace(req.MerchantOrderID)
if req.MerchantOrderID == "" || len(req.MerchantOrderID) > 64 {
fail(w, 400, "ERR_PARAM", "merchant_order_id 必填且 ≤64 字符")
return
}
cur := strings.ToUpper(strings.TrimSpace(req.Currency))
if !a.cfg.Currencies[cur] {
fail(w, 400, "ERR_PARAM", "currency 不在允许列表内")
return
}
base, err := parseAmount(req.Amount)
if err != nil || base <= 0 {
fail(w, 400, "ERR_PARAM", "amount 非法")
return
}
if decimalsOf(base) > a.cfg.AmountDecimals {
fail(w, 400, "ERR_PARAM", "amount 小数位超过网关 AMOUNT_DECIMALS")
return
}
ttl := req.Timeout
if ttl == 0 {
ttl = a.cfg.OrderTTL
}
if ttl < 120 || ttl > 86400 {
fail(w, 400, "ERR_PARAM", "timeout 须在 120~86400 秒之间")
return
}
o, err := a.st.CreateOrder(a.cfg, req.MerchantOrderID, cur, base, ttl, req.CallbackURL, req.ReturnURL)
if errors.Is(err, ErrDuplicate) {
fail(w, 409, "ERR_DUPLICATE", "merchant_order_id 已存在")
return
}
if errors.Is(err, ErrAlloc) {
fail(w, 503, "ERR_ALLOC", "该金额档位唯一尾数已耗尽,请稍后或换金额")
return
}
if err != nil {
log.Printf("[error] 创建订单失败: %v", err)
fail(w, 500, "ERR_INTERNAL", "内部错误")
return
}
log.Printf("[info] 新订单 %s %s %s 应付 %s", o.ID, o.Currency, fmtAmount(o.BaseAmount), fmtAmount(o.PayAmount))
ok(w, a.orderJSON(o))
}
func (a *App) handleGet(w http.ResponseWriter, r *http.Request, _ []byte) {
o, err := a.st.OrderByID(r.PathValue("id"))
if errors.Is(err, ErrNotFound) {
fail(w, 404, "ERR_NOT_FOUND", "订单不存在")
return
}
if err != nil {
fail(w, 500, "ERR_INTERNAL", "内部错误")
return
}
ok(w, a.orderJSON(o))
}
func (a *App) handleGetByMerchant(w http.ResponseWriter, r *http.Request, _ []byte) {
o, err := a.st.OrderByMerchantID(r.PathValue("mid"))
if errors.Is(err, ErrNotFound) {
fail(w, 404, "ERR_NOT_FOUND", "订单不存在")
return
}
if err != nil {
fail(w, 500, "ERR_INTERNAL", "内部错误")
return
}
ok(w, a.orderJSON(o))
}
func (a *App) handleClose(w http.ResponseWriter, r *http.Request, _ []byte) {
o, err := a.st.CloseOrder(r.PathValue("id"), a.cfg.SuffixCooldown)
if errors.Is(err, ErrNotFound) {
fail(w, 404, "ERR_NOT_FOUND", "订单不存在")
return
}
if errors.Is(err, ErrState) {
fail(w, 409, "ERR_STATE", "仅待支付订单可关闭")
return
}
if err != nil {
fail(w, 500, "ERR_INTERNAL", "内部错误")
return
}
ok(w, a.orderJSON(o))
}
func clientIP(r *http.Request) string {
if i := strings.LastIndex(r.RemoteAddr, ":"); i > 0 {
return r.RemoteAddr[:i]
}
return r.RemoteAddr
}
var _ = time.Now
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
// payTxn 对应 GET /sapi/v1/pay/transactions 返回的一条流水。
// 字段以 2026-09-02 真实账号实测为准(文档缺 orderId/note/counterpartyId,实测存在)。
type payTxn struct {
OrderID string `json:"orderId"` // 18 位数字,付款方 App 显示的「订单编号」
Note string `json:"note"` // 付款方备注,可能缺省
OrderType string `json:"orderType"` // C2C / PAYOUT / ...
TransactionID string `json:"transactionId"` // P_xxxxxxxxxxxxxxxx
Time int64 `json:"transactionTime"`
Amount string `json:"amount"` // 收款为正、付款为负
Currency string `json:"currency"`
CounterpartyID int64 `json:"counterpartyId"`
PayerInfo struct {
Name string `json:"name"`
BinanceID int64 `json:"binanceId"`
} `json:"payerInfo"`
}
type binanceClient struct {
base string
key string
secret string
hc *http.Client
}
func newBinanceClient(cfg *Config) *binanceClient {
return &binanceClient{
base: cfg.BinanceAPIBase,
key: cfg.BinanceKey,
secret: cfg.BinanceSecret,
hc: &http.Client{Timeout: 15 * time.Second},
}
}
// PayTransactions 拉取 Pay 流水(签名 GET)。limit 上限 100,接口权重 3000/UID。
func (b *binanceClient) PayTransactions(startMs, endMs int64, limit int) ([]payTxn, error) {
q := url.Values{}
q.Set("timestamp", strconv.FormatInt(nowMs(), 10))
q.Set("recvWindow", "10000")
q.Set("limit", strconv.Itoa(limit))
if startMs > 0 {
q.Set("startTime", strconv.FormatInt(startMs, 10))
}
if endMs > 0 {
q.Set("endTime", strconv.FormatInt(endMs, 10))
}
enc := q.Encode()
sig := hmacHex(b.secret, enc)
req, err := http.NewRequest("GET", b.base+"/sapi/v1/pay/transactions?"+enc+"&signature="+sig, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-MBX-APIKEY", b.key)
resp, err := b.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("binance HTTP %d: %s", resp.StatusCode, truncate(string(body), 300))
}
var env struct {
Data []payTxn `json:"data"`
Success bool `json:"success"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("binance 响应解析失败: %w", err)
}
if !env.Success {
return nil, fmt.Errorf("binance 返回失败: %s", truncate(string(body), 300))
}
return env.Data, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"time"
)
var cbSchedule = []int64{0, 15, 60, 300, 900, 3600, 10800} // 秒
type Notifier struct {
cfg *Config
st *Store
hc *http.Client
}
func newNotifier(cfg *Config, st *Store) *Notifier {
return &Notifier{cfg: cfg, st: st, hc: &http.Client{Timeout: 10 * time.Second}}
}
func (n *Notifier) run(ctx context.Context) {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
jobs, err := n.st.DueCallbacks(10)
if err != nil {
log.Printf("[error] 读取回调队列失败: %v", err)
continue
}
for _, j := range jobs {
n.deliver(j)
}
}
}
}
func callbackPayload(o *Order, event string) ([]byte, error) {
actual := ""
if o.ActualAmount > 0 {
actual = fmtAmount(o.ActualAmount)
}
p := map[string]any{
"event": event,
"order_id": o.ID,
"merchant_order_id": o.MerchantOrderID,
"status": o.Status,
"currency": o.Currency,
"base_amount": fmtAmount(o.BaseAmount),
"pay_amount": fmtAmount(o.PayAmount),
"actual_amount": actual,
"overpaid": o.Overpaid,
"matched_by": o.MatchedBy,
"binance_order_id": o.BinanceOrderID,
"binance_txn_id": o.BinanceTxnID,
"payer_id": o.PayerID,
"paid_at": o.PaidAt,
"timestamp": nowMs(),
}
return json.Marshal(p)
}
func (n *Notifier) deliver(j cbJob) {
o, err := n.st.OrderByID(j.OrderID)
if err != nil {
log.Printf("[error] 回调找不到订单 %s: %v", j.OrderID, err)
n.st.CallbackDone(j.ID)
return
}
if o.CallbackURL == "" {
n.st.CallbackDone(j.ID)
return
}
body, err := callbackPayload(o, j.Event)
if err != nil {
n.st.CallbackDone(j.ID)
return
}
ts := strconv.FormatInt(nowMs(), 10)
nonce := newNonce()
req, err := http.NewRequest("POST", o.CallbackURL, bytes.NewReader(body))
if err != nil {
n.retry(j, fmt.Sprintf("bad url: %v", err))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-BPG-Timestamp", ts)
req.Header.Set("X-BPG-Nonce", nonce)
req.Header.Set("X-BPG-Signature", signCallback(n.cfg.APIAuthKey, ts, nonce, body))
resp, err := n.hc.Do(req)
if err != nil {
n.retry(j, err.Error())
return
}
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
n.st.CallbackDone(j.ID)
log.Printf("[info] 回调成功 order=%s event=%s → %s", o.ID, j.Event, o.CallbackURL)
return
}
n.retry(j, fmt.Sprintf("HTTP %d", resp.StatusCode))
}
func (n *Notifier) retry(j cbJob, errStr string) {
next := j.Attempt + 1
if next >= len(cbSchedule) {
n.st.CallbackRetry(j.ID, next, 0, errStr, true)
log.Printf("[warn] 回调放弃 order=%s attempts=%d lastErr=%s", j.OrderID, next, errStr)
return
}
n.st.CallbackRetry(j.ID, next, nowMs()+cbSchedule[next]*1000, errStr, false)
log.Printf("[warn] 回调失败将重试 order=%s attempt=%d err=%s", j.OrderID, next, errStr)
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
_ "embed"
"encoding/json"
"errors"
"html/template"
"net/http"
"net/url"
"regexp"
"strings"
)
//go:embed templates/cashier.html
var cashierHTML string
var cashierTpl = template.Must(template.New("cashier").Parse(cashierHTML))
var reBinanceOrderID = regexp.MustCompile(`^[0-9]{6,20}$`)
func (a *App) handleCashier(w http.ResponseWriter, r *http.Request) {
o, err := a.st.OrderByToken(r.PathValue("token"))
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "internal error", 500)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
cashierTpl.Execute(w, map[string]any{
"PayAmount": fmtAmount(o.PayAmount),
"Currency": o.Currency,
"UID": a.cfg.BinanceUID,
"Email": a.cfg.ReceiveEmail,
"HasQR": a.cfg.QRImage != "",
"NoteCode": o.NoteCode,
"ExpiresAt": o.ExpiresAt,
"Token": o.Token,
})
}
func (a *App) redirectURL(o *Order) string {
if o.ReturnURL == "" || o.Status != "paid" {
return ""
}
sep := "?"
if strings.Contains(o.ReturnURL, "?") {
sep = "&"
}
return o.ReturnURL + sep + "order_id=" + url.QueryEscape(o.ID) +
"&merchant_order_id=" + url.QueryEscape(o.MerchantOrderID) + "&status=paid"
}
func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) {
o, err := a.st.OrderByToken(r.PathValue("token"))
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "internal error", 500)
return
}
writeJSON(w, 200, map[string]any{
"status": o.Status,
"paid_at": o.PaidAt,
"redirect": a.redirectURL(o),
})
}
func (a *App) handleClaim(w http.ResponseWriter, r *http.Request) {
tok := r.PathValue("token")
o, err := a.st.OrderByToken(tok)
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
fail(w, 500, "ERR_INTERNAL", "内部错误")
return
}
if !a.allow("claim:"+tok, 5, 60) || !a.allow("claimip:"+clientIP(r), 20, 60) {
fail(w, 429, "ERR_RATE_LIMIT", "操作过于频繁")
return
}
var req struct {
BinanceOrderID string `json:"binance_order_id"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
fail(w, 400, "ERR_PARAM", "JSON 解析失败")
return
}
req.BinanceOrderID = strings.TrimSpace(req.BinanceOrderID)
if !reBinanceOrderID.MatchString(req.BinanceOrderID) {
fail(w, 400, "ERR_PARAM", "订单编号格式错误")
return
}
res, err := a.matcher.Claim(o, req.BinanceOrderID)
if err != nil {
fail(w, 502, "ERR_INTERNAL", "查询币安流水失败,请稍后再试")
return
}
ok(w, map[string]any{"code": res.Code, "status": res.Status})
}
func (a *App) handleQR(w http.ResponseWriter, r *http.Request) {
if a.cfg.QRImage == "" {
http.NotFound(w, r)
return
}
if _, err := a.st.OrderByToken(r.PathValue("token")); err != nil {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, a.cfg.QRImage)
}
+121
View File
@@ -0,0 +1,121 @@
package main
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Listen string
BaseURL string
APIAuthKey string
BinanceKey string
BinanceSecret string
BinanceUID string
BinanceAPIBase string
QRImage string
ReceiveEmail string
Currencies map[string]bool
OrderTTL int64 // 秒
ExpiredGrace int64 // 秒
SuffixCooldown int64 // 秒
PollInterval int64 // 秒
AmountDecimals int
SuffixMode string
DBPath string
LogLevel string
}
func loadConfig(path string) (*Config, error) {
vals := map[string]string{}
if path != "" {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置 %s: %w", path, err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
kv := strings.SplitN(line, "=", 2)
if len(kv) != 2 {
continue
}
vals[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
}
get := func(k, def string) string {
if v, ok := os.LookupEnv(k); ok && v != "" {
return v
}
if v, ok := vals[k]; ok && v != "" {
return v
}
return def
}
getInt := func(k string, def int64) int64 {
s := get(k, "")
if s == "" {
return def
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return def
}
return n
}
c := &Config{
Listen: get("LISTEN", "0.0.0.0:8080"),
BaseURL: strings.TrimRight(get("BASE_URL", "http://127.0.0.1:8080"), "/"),
APIAuthKey: get("API_AUTH_KEY", ""),
BinanceKey: get("BINANCE_API_KEY", ""),
BinanceSecret: get("BINANCE_API_SECRET", ""),
BinanceUID: get("BINANCE_UID", ""),
BinanceAPIBase: strings.TrimRight(get("BINANCE_API_BASE", "https://api.binance.com"), "/"),
QRImage: get("QR_IMAGE", ""),
ReceiveEmail: get("RECEIVE_EMAIL", ""),
Currencies: map[string]bool{},
OrderTTL: getInt("ORDER_TTL", 900),
ExpiredGrace: getInt("EXPIRED_GRACE", 1800),
SuffixCooldown: getInt("SUFFIX_COOLDOWN", 86400),
PollInterval: getInt("POLL_INTERVAL", 4),
AmountDecimals: int(getInt("AMOUNT_DECIMALS", 4)),
SuffixMode: get("SUFFIX_MODE", "add"),
DBPath: get("DB_PATH", "./bpaygate.db"),
LogLevel: strings.ToLower(get("LOG_LEVEL", "info")),
}
for _, cur := range strings.Split(get("CURRENCIES", "USDT,USDC"), ",") {
cur = strings.ToUpper(strings.TrimSpace(cur))
if cur != "" {
c.Currencies[cur] = true
}
}
if len(c.APIAuthKey) < 16 {
return nil, errors.New("API_AUTH_KEY 未设置或过短(≥16 字符,可用 ./bpaygate -gen-key 生成)")
}
if c.BinanceKey == "" || c.BinanceSecret == "" {
return nil, errors.New("BINANCE_API_KEY / BINANCE_API_SECRET 未设置(只读权限即可)")
}
if c.BinanceUID == "" {
return nil, errors.New("BINANCE_UID 未设置")
}
if c.AmountDecimals < 2 || c.AmountDecimals > 8 {
return nil, errors.New("AMOUNT_DECIMALS 须在 2~8 之间")
}
if c.SuffixMode != "add" && c.SuffixMode != "sub" {
return nil, errors.New("SUFFIX_MODE 只能是 add 或 sub")
}
if c.PollInterval < 1 {
c.PollInterval = 1
}
if c.QRImage != "" {
if _, err := os.Stat(c.QRImage); err != nil {
return nil, fmt.Errorf("QR_IMAGE 文件不存在: %s", c.QRImage)
}
}
return c, nil
}
+339
View File
@@ -0,0 +1,339 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"time"
)
const testSecret = "test-secret-0123456789abcdef"
// ---- mock 币安 ----
type mockBinance struct {
mu sync.Mutex
txns []payTxn
srv *httptest.Server
}
func newMockBinance() *mockBinance {
m := &mockBinance{}
mux := http.NewServeMux()
mux.HandleFunc("/sapi/v1/pay/transactions", func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
writeJSON(w, 200, map[string]any{"code": "000000", "message": "success", "data": m.txns, "success": true})
})
m.srv = httptest.NewServer(mux)
return m
}
func (m *mockBinance) inject(t payTxn) {
m.mu.Lock()
defer m.mu.Unlock()
m.txns = append(m.txns, t)
}
// ---- 商户回调接收器 ----
type cbRecv struct {
body []byte
headers http.Header
}
func newCallbackServer(ch chan cbRecv) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
ch <- cbRecv{body: body, headers: r.Header.Clone()}
w.WriteHeader(200)
}))
}
// ---- 测试环境 ----
type env struct {
t *testing.T
app *App
srv *httptest.Server
mock *mockBinance
cbCh chan cbRecv
cbS *httptest.Server
}
func newEnv(t *testing.T) *env {
mock := newMockBinance()
cfg := &Config{
Listen: "127.0.0.1:0", BaseURL: "http://gw.test", APIAuthKey: testSecret,
BinanceKey: "k", BinanceSecret: "s", BinanceUID: "90000001",
BinanceAPIBase: mock.srv.URL,
Currencies: map[string]bool{"USDT": true, "USDC": true},
OrderTTL: 900, ExpiredGrace: 1800, SuffixCooldown: 86400, PollInterval: 1,
AmountDecimals: 4, SuffixMode: "add",
DBPath: t.TempDir() + "/test.db", LogLevel: "info",
}
app, err := newApp(cfg)
if err != nil {
t.Fatalf("newApp: %v", err)
}
srv := httptest.NewServer(app.mux)
ctx, cancel := context.WithCancel(context.Background())
go app.matcher.run(ctx)
go app.notifier.run(ctx)
ch := make(chan cbRecv, 16)
cbS := newCallbackServer(ch)
e := &env{t: t, app: app, srv: srv, mock: mock, cbCh: ch, cbS: cbS}
t.Cleanup(func() {
cancel()
srv.Close()
cbS.Close()
mock.srv.Close()
app.st.Close()
})
return e
}
func (e *env) doSigned(method, path string, body []byte) (int, map[string]any) {
ts := strconv.FormatInt(nowMs(), 10)
nonce := newNonce()
req, _ := http.NewRequest(method, e.srv.URL+path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-BPG-Timestamp", ts)
req.Header.Set("X-BPG-Nonce", nonce)
req.Header.Set("X-BPG-Signature", signRequest(testSecret, ts, nonce, method, path, body))
resp, err := http.DefaultClient.Do(req)
if err != nil {
e.t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func (e *env) create(mid, currency, amount, cb string) map[string]any {
body, _ := json.Marshal(map[string]any{
"merchant_order_id": mid, "currency": currency, "amount": amount, "callback_url": cb,
})
code, out := e.doSigned("POST", "/api/v1/orders", body)
if code != 200 {
e.t.Fatalf("创建订单失败 %d: %v", code, out)
}
return out["data"].(map[string]any)
}
func (e *env) waitStatus(orderID, want string, timeout time.Duration) map[string]any {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
code, out := e.doSigned("GET", "/api/v1/orders/"+orderID, nil)
if code == 200 {
d := out["data"].(map[string]any)
if d["status"] == want {
return d
}
}
time.Sleep(200 * time.Millisecond)
}
e.t.Fatalf("订单 %s 未在 %v 内达到状态 %s", orderID, timeout, want)
return nil
}
func (e *env) waitCallback(timeout time.Duration) cbRecv {
select {
case r := <-e.cbCh:
return r
case <-time.After(timeout):
e.t.Fatal("等待回调超时")
return cbRecv{}
}
}
var txnSeq int64 = 452000000000000000
var txnSeqMu sync.Mutex
func nextBinanceOrderID() string {
txnSeqMu.Lock()
defer txnSeqMu.Unlock()
txnSeq++
return strconv.FormatInt(txnSeq, 10)
}
func mkTxn(binanceOrderID, amount, currency, note string) payTxn {
var t payTxn
t.OrderID = binanceOrderID
t.Amount = amount
t.Currency = currency
t.Note = note
t.OrderType = "C2C"
t.TransactionID = "P_TEST" + binanceOrderID[len(binanceOrderID)-6:]
t.Time = nowMs()
t.CounterpartyID = 1163000000
t.PayerInfo.Name = "tester"
return t
}
// ① 唯一金额自动匹配 + 回调验签
func TestAmountMatchFlow(t *testing.T) {
e := newEnv(t)
d := e.create("M-amount-1", "USDT", "10", e.cbS.URL)
payAmount := d["pay_amount"].(string)
if payAmount == "10" {
t.Fatalf("pay_amount 应带唯一尾数,得到 %s", payAmount)
}
bid := nextBinanceOrderID()
e.mock.inject(mkTxn(bid, payAmount, "USDT", ""))
got := e.waitStatus(d["order_id"].(string), "paid", 10*time.Second)
if got["matched_by"] != "amount" || got["binance_order_id"] != bid {
t.Fatalf("匹配信息错误: %v", got)
}
cb := e.waitCallback(10 * time.Second)
var payload map[string]any
if err := json.Unmarshal(cb.body, &payload); err != nil {
t.Fatalf("回调体解析: %v", err)
}
if payload["event"] != "paid" || payload["merchant_order_id"] != "M-amount-1" {
t.Fatalf("回调内容错误: %v", payload)
}
ts := cb.headers.Get("X-BPG-Timestamp")
nonce := cb.headers.Get("X-BPG-Nonce")
sig := cb.headers.Get("X-BPG-Signature")
if signCallback(testSecret, ts, nonce, cb.body) != sig {
t.Fatal("回调签名校验失败")
}
}
// ② 备注码匹配(金额不唯一也能确认)
func TestNoteMatchFlow(t *testing.T) {
e := newEnv(t)
d := e.create("M-note-1", "USDT", "25.5", e.cbS.URL)
code := d["note_code"].(string)
bid := nextBinanceOrderID()
e.mock.inject(mkTxn(bid, "25.5", "USDT", "订单 "+code+" 谢谢"))
got := e.waitStatus(d["order_id"].(string), "paid", 10*time.Second)
if got["matched_by"] != "note" || got["actual_amount"] != "25.5" {
t.Fatalf("备注匹配信息错误: %v", got)
}
}
// ②b 备注命中但实付不足 → underpaid
func TestUnderpaidViaNote(t *testing.T) {
e := newEnv(t)
d := e.create("M-under-1", "USDT", "30", e.cbS.URL)
code := d["note_code"].(string)
e.mock.inject(mkTxn(nextBinanceOrderID(), "29", "USDT", code))
got := e.waitStatus(d["order_id"].(string), "underpaid", 10*time.Second)
if got["actual_amount"] != "29" {
t.Fatalf("underpaid 信息错误: %v", got)
}
cb := e.waitCallback(10 * time.Second)
var payload map[string]any
json.Unmarshal(cb.body, &payload)
if payload["event"] != "underpaid" {
t.Fatalf("回调事件应为 underpaid: %v", payload)
}
}
// ③ 收银页回填币安订单编号
func TestClaimFlow(t *testing.T) {
e := newEnv(t)
d := e.create("M-claim-1", "USDT", "40", e.cbS.URL)
bid := nextBinanceOrderID()
e.mock.inject(mkTxn(bid, "40", "USDT", "")) // 金额=基础值且无备注 → 自动路径不命中
payURL := d["pay_url"].(string)
token := payURL[len("http://gw.test/pay/"):]
body, _ := json.Marshal(map[string]string{"binance_order_id": bid})
resp, err := http.Post(e.srv.URL+"/pay/"+token+"/claim", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
data, okCast := out["data"].(map[string]any)
if !okCast {
t.Fatalf("claim 响应异常: %v", out)
}
if data["code"] != "OK" || data["status"] != "paid" {
t.Fatalf("claim 结果错误: %v", out)
}
got := e.waitStatus(d["order_id"].(string), "paid", 5*time.Second)
if got["matched_by"] != "claim" {
t.Fatalf("matched_by 应为 claim: %v", got)
}
}
// 过期 → 宽限期内精确金额到账 → 复活为已支付
func TestExpiredThenLatePayment(t *testing.T) {
e := newEnv(t)
d := e.create("M-exp-1", "USDT", "50", e.cbS.URL)
oid := d["order_id"].(string)
if _, err := e.app.st.db.Exec(`UPDATE orders SET expires_at=? WHERE id=?`, nowMs()-1000, oid); err != nil {
t.Fatal(err)
}
e.waitStatus(oid, "expired", 10*time.Second)
cb := e.waitCallback(10 * time.Second)
var payload map[string]any
json.Unmarshal(cb.body, &payload)
if payload["event"] != "expired" {
t.Fatalf("应先收到 expired 回调: %v", payload)
}
e.mock.inject(mkTxn(nextBinanceOrderID(), d["pay_amount"].(string), "USDT", ""))
e.waitStatus(oid, "paid", 10*time.Second)
}
// 鉴权与幂等
func TestAuthAndDuplicate(t *testing.T) {
e := newEnv(t)
body := []byte(`{"merchant_order_id":"M-auth-1","currency":"USDT","amount":"1"}`)
req, _ := http.NewRequest("POST", e.srv.URL+"/api/v1/orders", bytes.NewReader(body))
req.Header.Set("X-BPG-Timestamp", strconv.FormatInt(nowMs(), 10))
req.Header.Set("X-BPG-Nonce", newNonce())
req.Header.Set("X-BPG-Signature", "bad")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 401 {
t.Fatalf("坏签名应 401,得到 %d", resp.StatusCode)
}
resp.Body.Close()
e.create("M-dup-1", "USDT", "2", "")
b2, _ := json.Marshal(map[string]any{"merchant_order_id": "M-dup-1", "currency": "USDT", "amount": "2"})
code, out := e.doSigned("POST", "/api/v1/orders", b2)
if code != 409 || out["code"] != "ERR_DUPLICATE" {
t.Fatalf("重复单号应 409/ERR_DUPLICATE,得到 %d %v", code, out)
}
}
// 唯一金额分配:不撞、关单冷却不复用
func TestAllocatorUniqueness(t *testing.T) {
e := newEnv(t)
seen := map[string]bool{}
for i := 0; i < 30; i++ {
d := e.create(fmt.Sprintf("M-alloc-%d", i), "USDT", "88", "")
pa := d["pay_amount"].(string)
if seen[pa] {
t.Fatalf("pay_amount 重复: %s", pa)
}
seen[pa] = true
}
d := e.create("M-alloc-close", "USDT", "77", "")
closedAmt := d["pay_amount"].(string)
if code, out := e.doSigned("POST", "/api/v1/orders/"+d["order_id"].(string)+"/close", nil); code != 200 {
t.Fatalf("关单失败: %v", out)
}
for i := 0; i < 20; i++ {
d2 := e.create(fmt.Sprintf("M-alloc2-%d", i), "USDT", "77", "")
if d2["pay_amount"].(string) == closedAmt {
t.Fatalf("冷却期内金额被复用: %s", closedAmt)
}
}
}
+17
View File
@@ -0,0 +1,17 @@
module bpaygate
go 1.25.0
require modernc.org/sqlite v1.57.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+50
View File
@@ -0,0 +1,50 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
const version = "0.1.0"
func main() {
cfgPath := flag.String("config", "./config.env", "配置文件路径(KEY=VALUE 格式,环境变量优先)")
showVer := flag.Bool("version", false, "打印版本")
genKey := flag.Bool("gen-key", false, "生成一个随机 API_AUTH_KEY")
flag.Parse()
if *showVer {
fmt.Println("BinancePayTool", version)
return
}
if *genKey {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatal(err)
}
fmt.Println(base64.RawURLEncoding.EncodeToString(b))
return
}
cfg, err := loadConfig(*cfgPath)
if err != nil {
log.Fatalf("[fatal] %v", err)
}
app, err := newApp(cfg)
if err != nil {
log.Fatalf("[fatal] 初始化失败: %v", err)
}
defer app.st.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go app.matcher.run(ctx)
go app.notifier.run(ctx)
srv := &http.Server{Addr: cfg.Listen, Handler: app.mux}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutCtx)
}()
log.Printf("[info] BinancePayTool %s 监听 %s,收款 UID %s,轮询间隔 %ds", version, cfg.Listen, cfg.BinanceUID, cfg.PollInterval)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[fatal] %v", err)
}
log.Printf("[info] 已退出")
}
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"context"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Matcher 负责轮询币安流水并按三条规则核销订单:
// ① 唯一金额精确匹配 ② 备注含 note_code ③ 收银页回填币安订单编号(claim)。
type Matcher struct {
cfg *Config
st *Store
bc *binanceClient
mu sync.Mutex
cache []payTxn
cacheAt int64
}
func newMatcher(cfg *Config, st *Store, bc *binanceClient) *Matcher {
return &Matcher{cfg: cfg, st: st, bc: bc}
}
func (m *Matcher) run(ctx context.Context) {
t := time.NewTicker(time.Duration(m.cfg.PollInterval) * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := m.st.ExpireDue(m.cfg.SuffixCooldown); err != nil {
log.Printf("[error] 过期处理失败: %v", err)
} else if n > 0 {
log.Printf("[info] %d 个订单已过期", n)
}
if err := m.scan(); err != nil {
log.Printf("[error] 流水扫描失败: %v", err)
}
}
}
}
// fetchWindow 拉取自 startMs 以来的流水,满 100 条时向前翻页(最多 5 页),按时间升序返回。
func (m *Matcher) fetchWindow(startMs int64) ([]payTxn, error) {
seen := map[string]bool{}
var all []payTxn
endMs := int64(0)
for page := 0; page < 5; page++ {
txns, err := m.bc.PayTransactions(startMs, endMs, 100)
if err != nil {
return nil, err
}
minT := int64(0)
for _, t := range txns {
key := t.OrderID
if key == "" {
key = t.TransactionID
}
if key == "" || seen[key] {
continue
}
seen[key] = true
all = append(all, t)
if minT == 0 || t.Time < minT {
minT = t.Time
}
}
if len(txns) < 100 || minT == 0 {
break
}
endMs = minT - 1
}
sort.Slice(all, func(i, j int) bool { return all[i].Time < all[j].Time })
return all, nil
}
func (m *Matcher) scan() error {
now := nowMs()
graceMs := m.cfg.ExpiredGrace * 1000
cands, err := m.st.CandidateOrders(now, graceMs)
if err != nil {
return err
}
if len(cands) == 0 {
return nil
}
minCreated := cands[0].CreatedAt
for _, o := range cands {
if o.CreatedAt < minCreated {
minCreated = o.CreatedAt
}
}
txns, err := m.fetchWindow(minCreated - 10*60*1000)
if err != nil {
return err
}
m.mu.Lock()
m.cache, m.cacheAt = txns, now
m.mu.Unlock()
for _, t := range txns {
if err := m.applyTxn(t); err != nil {
log.Printf("[error] 核销流水 %s 失败: %v", t.OrderID, err)
}
}
return nil
}
// applyTxn 尝试用一笔流水核销订单(规则 ①②)。
func (m *Matcher) applyTxn(t payTxn) error {
amt, err := parseAmount(t.Amount)
if err != nil || amt <= 0 {
return nil // 非入账
}
cur := strings.ToUpper(t.Currency)
if !m.cfg.Currencies[cur] {
return nil
}
if t.OrderID == "" {
return nil
}
if done, err := m.st.Consumed(t.OrderID); err != nil || done {
return err
}
now := nowMs()
cands, err := m.st.CandidateOrders(now, m.cfg.ExpiredGrace*1000)
if err != nil {
return err
}
payerID := strconv64(t.CounterpartyID)
if t.PayerInfo.BinanceID > 0 {
payerID = strconv64(t.PayerInfo.BinanceID)
}
// ① 唯一金额精确匹配
for _, o := range cands {
if o.Currency == cur && o.PayAmount == amt && t.Time >= o.CreatedAt-120*1000 {
ok, err := m.st.Finish(o, "paid", "amount", t.OrderID, t.TransactionID, payerID, t.PayerInfo.Name, amt, m.cfg.SuffixCooldown)
if ok {
log.Printf("[info] 订单 %s 已支付(金额匹配)binanceOrderId=%s amount=%s %s", o.ID, t.OrderID, fmtAmount(amt), cur)
}
return err
}
}
// ② 备注含 note_code
note := strings.ToUpper(t.Note)
if note == "" {
return nil
}
for _, o := range cands {
if o.Currency != cur || !strings.Contains(note, o.NoteCode) {
continue
}
status := "paid"
if amt < o.BaseAmount {
status = "underpaid"
}
ok, err := m.st.Finish(o, status, "note", t.OrderID, t.TransactionID, payerID, t.PayerInfo.Name, amt, m.cfg.SuffixCooldown)
if ok {
log.Printf("[info] 订单 %s → %s(备注匹配)binanceOrderId=%s amount=%s %s", o.ID, status, t.OrderID, fmtAmount(amt), cur)
}
return err
}
return nil
}
type claimResult struct {
Code string // OK / NOT_FOUND / CONSUMED / CURRENCY / UNDERPAID / STATE
Status string // 处理后订单状态
}
// Claim 处理收银页回填的币安订单编号(规则 ③)。
func (m *Matcher) Claim(o *Order, binanceOrderID string) (claimResult, error) {
if o.Status == "paid" {
return claimResult{Code: "OK", Status: "paid"}, nil
}
if o.Status != "pending" && o.Status != "expired" {
return claimResult{Code: "STATE", Status: o.Status}, nil
}
if done, err := m.st.Consumed(binanceOrderID); err != nil {
return claimResult{}, err
} else if done {
return claimResult{Code: "CONSUMED", Status: o.Status}, nil
}
// 先查缓存;未命中则强制现拉一次(付款可能刚到账,缓存虽新但没有它)
find := func(txns []payTxn) *payTxn {
for i := range txns {
if txns[i].OrderID == binanceOrderID {
return &txns[i]
}
}
return nil
}
m.mu.Lock()
hit := find(m.cache)
m.mu.Unlock()
if hit == nil {
fresh, err := m.fetchWindow(o.CreatedAt - 10*60*1000)
if err != nil {
return claimResult{}, err
}
m.mu.Lock()
m.cache, m.cacheAt = fresh, nowMs()
m.mu.Unlock()
hit = find(fresh)
}
if hit != nil {
t := *hit
amt, err := parseAmount(t.Amount)
if err != nil || amt <= 0 {
return claimResult{Code: "NOT_FOUND", Status: o.Status}, nil
}
if strings.ToUpper(t.Currency) != o.Currency {
return claimResult{Code: "CURRENCY", Status: o.Status}, nil
}
payerID := strconv64(t.CounterpartyID)
if t.PayerInfo.BinanceID > 0 {
payerID = strconv64(t.PayerInfo.BinanceID)
}
status := "paid"
code := "OK"
if amt < o.BaseAmount {
status = "underpaid"
code = "UNDERPAID"
}
ok, err := m.st.Finish(o, status, "claim", t.OrderID, t.TransactionID, payerID, t.PayerInfo.Name, amt, m.cfg.SuffixCooldown)
if err != nil {
return claimResult{}, err
}
if !ok {
return claimResult{Code: "CONSUMED", Status: o.Status}, nil
}
log.Printf("[info] 订单 %s → %s(回填匹配)binanceOrderId=%s", o.ID, status, t.OrderID)
return claimResult{Code: code, Status: status}, nil
}
return claimResult{Code: "NOT_FOUND", Status: o.Status}, nil
}
func strconv64(v int64) string {
if v == 0 {
return ""
}
return strconv.FormatInt(v, 10)
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func sha256Hex(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func hmacHex(secret, msg string) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(msg))
return hex.EncodeToString(m.Sum(nil))
}
// signRequest 商户→网关请求签名(见 docs/protocol.md §1.1)
func signRequest(secret, ts, nonce, method, path string, body []byte) string {
return hmacHex(secret, ts+"\n"+nonce+"\n"+method+"\n"+path+"\n"+sha256Hex(body))
}
// signCallback 网关→商户回调签名(见 docs/protocol.md §1.2)
func signCallback(secret, ts, nonce string, body []byte) string {
return hmacHex(secret, ts+"\n"+nonce+"\n"+sha256Hex(body))
}
func sigEqual(a, b string) bool { return hmac.Equal([]byte(a), []byte(b)) }
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"encoding/json"
"os"
"testing"
)
type vectorFile struct {
Secret string `json:"secret"`
Cases []struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Nonce string `json:"nonce"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body"`
Signature string `json:"signature"`
} `json:"cases"`
}
func TestSignatureVectors(t *testing.T) {
data, err := os.ReadFile("../docs/test_vectors.json")
if err != nil {
t.Fatalf("读取向量文件: %v", err)
}
var vf vectorFile
if err := json.Unmarshal(data, &vf); err != nil {
t.Fatalf("解析向量文件: %v", err)
}
if len(vf.Cases) == 0 {
t.Fatal("向量为空")
}
for i, c := range vf.Cases {
var got string
switch c.Type {
case "request":
got = signRequest(vf.Secret, c.Timestamp, c.Nonce, c.Method, c.Path, []byte(c.Body))
case "callback":
got = signCallback(vf.Secret, c.Timestamp, c.Nonce, []byte(c.Body))
default:
t.Fatalf("未知类型 %s", c.Type)
}
if got != c.Signature {
t.Errorf("case %d (%s): got %s want %s", i, c.Type, got, c.Signature)
}
}
}
+470
View File
@@ -0,0 +1,470 @@
package main
import (
"database/sql"
"errors"
"fmt"
"math/rand"
"sync"
_ "modernc.org/sqlite"
)
var (
ErrDuplicate = errors.New("duplicate merchant_order_id")
ErrAlloc = errors.New("no unique amount available")
ErrNotFound = errors.New("not found")
ErrState = errors.New("invalid state")
)
type Order struct {
ID string
Token string
MerchantOrderID string
Currency string
BaseAmount int64
PayAmount int64
ActualAmount int64
Status string // pending|paid|underpaid|expired|closed
NoteCode string
BinanceOrderID string
BinanceTxnID string
PayerID string
PayerName string
MatchedBy string // amount|note|claim
CallbackURL string
ReturnURL string
CreatedAt int64
ExpiresAt int64
PaidAt int64
Overpaid bool
}
type cbJob struct {
ID int64
OrderID string
Event string
Attempt int
}
type Store struct {
db *sql.DB
mu sync.Mutex
}
const schema = `
CREATE TABLE IF NOT EXISTS orders(
id TEXT PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
merchant_order_id TEXT UNIQUE NOT NULL,
currency TEXT NOT NULL,
base_amount INTEGER NOT NULL,
pay_amount INTEGER NOT NULL,
actual_amount INTEGER,
status TEXT NOT NULL,
note_code TEXT NOT NULL,
binance_order_id TEXT,
binance_txn_id TEXT,
payer_id TEXT,
payer_name TEXT,
matched_by TEXT,
callback_url TEXT,
return_url TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
paid_at INTEGER,
overpaid INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_amount ON orders(currency, pay_amount);
CREATE TABLE IF NOT EXISTS consumed(
binance_order_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS cooldown(
currency TEXT NOT NULL,
amount INTEGER NOT NULL,
until INTEGER NOT NULL,
PRIMARY KEY(currency, amount)
);
CREATE TABLE IF NOT EXISTS callbacks(
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL,
event TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
next_at INTEGER NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
last_err TEXT
);
CREATE INDEX IF NOT EXISTS idx_callbacks_due ON callbacks(done, next_at);
`
func openStore(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
for _, p := range []string{
"PRAGMA journal_mode=WAL;",
"PRAGMA busy_timeout=5000;",
"PRAGMA synchronous=NORMAL;",
} {
if _, err := db.Exec(p); err != nil {
return nil, err
}
}
if _, err := db.Exec(schema); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
const orderCols = `id,token,merchant_order_id,currency,base_amount,pay_amount,COALESCE(actual_amount,0),status,note_code,
COALESCE(binance_order_id,''),COALESCE(binance_txn_id,''),COALESCE(payer_id,''),COALESCE(payer_name,''),COALESCE(matched_by,''),
COALESCE(callback_url,''),COALESCE(return_url,''),created_at,expires_at,COALESCE(paid_at,0),overpaid`
type rowScanner interface{ Scan(...any) error }
func scanOrder(row rowScanner) (*Order, error) {
var o Order
var overpaid int
err := row.Scan(&o.ID, &o.Token, &o.MerchantOrderID, &o.Currency, &o.BaseAmount, &o.PayAmount, &o.ActualAmount,
&o.Status, &o.NoteCode, &o.BinanceOrderID, &o.BinanceTxnID, &o.PayerID, &o.PayerName, &o.MatchedBy,
&o.CallbackURL, &o.ReturnURL, &o.CreatedAt, &o.ExpiresAt, &o.PaidAt, &overpaid)
if err != nil {
return nil, err
}
o.Overpaid = overpaid != 0
return &o, nil
}
func (s *Store) orderBy(where string, arg any) (*Order, error) {
row := s.db.QueryRow(`SELECT `+orderCols+` FROM orders WHERE `+where, arg)
o, err := scanOrder(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return o, err
}
func (s *Store) OrderByID(id string) (*Order, error) { return s.orderBy("id=?", id) }
func (s *Store) OrderByToken(tok string) (*Order, error) { return s.orderBy("token=?", tok) }
func (s *Store) OrderByMerchantID(mid string) (*Order, error) {
return s.orderBy("merchant_order_id=?", mid)
}
// CandidateOrders 返回可被入账流水核销的订单:待支付,或过期但仍在宽限期内。
func (s *Store) CandidateOrders(now, graceMs int64) ([]*Order, error) {
rows, err := s.db.Query(`SELECT `+orderCols+` FROM orders
WHERE status='pending' OR (status='expired' AND expires_at+? > ?)`, graceMs, now)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Order
for rows.Next() {
o, err := scanOrder(rows)
if err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}
// CreateOrder 分配唯一金额并落库。分配与插入在同一把锁内,杜绝并发撞金额。
func (s *Store) CreateOrder(cfg *Config, mid, currency string, base int64, ttlSec int64, callbackURL, returnURL string) (*Order, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := nowMs()
var exist string
err := s.db.QueryRow(`SELECT id FROM orders WHERE merchant_order_id=?`, mid).Scan(&exist)
if err == nil {
return nil, ErrDuplicate
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
unit := int64(1)
for i := 0; i < 8-cfg.AmountDecimals; i++ {
unit *= 10
}
maxK := int64(1)
for i := 0; i < cfg.AmountDecimals; i++ {
maxK *= 10
}
maxK--
lo, hi := base+unit, base+maxK*unit
if cfg.SuffixMode == "sub" {
lo, hi = base-maxK*unit, base-unit
if lo < unit {
lo = unit
}
if hi < lo {
return nil, ErrAlloc
}
}
taken := map[int64]bool{}
graceMs := cfg.ExpiredGrace * 1000
rows, err := s.db.Query(`SELECT pay_amount FROM orders WHERE currency=? AND pay_amount BETWEEN ? AND ?
AND (status='pending' OR (status IN ('expired','underpaid') AND expires_at+? > ?))`,
currency, lo, hi, graceMs, now)
if err != nil {
return nil, err
}
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
rows.Close()
return nil, err
}
taken[v] = true
}
rows.Close()
rows, err = s.db.Query(`SELECT amount FROM cooldown WHERE currency=? AND amount BETWEEN ? AND ? AND until > ?`,
currency, lo, hi, now)
if err != nil {
return nil, err
}
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
rows.Close()
return nil, err
}
taken[v] = true
}
rows.Close()
// 递进式尾数:优先最小的两位档(付款方最多多付 0.0099),
// 该档用满自动升到三位、四位档,兼顾「多付最少」与并发容量。
candOf := func(k int64) int64 {
if cfg.SuffixMode == "sub" {
return base - k*unit
}
return base + k*unit
}
pick := int64(0)
rangeLo := int64(1)
for _, rangeHi := range []int64{99, 999, maxK} {
if rangeHi > maxK {
rangeHi = maxK
}
if rangeHi < rangeLo {
break
}
span := rangeHi - rangeLo + 1
for i := int64(0); i < 200 && i < span*2; i++ {
k := rangeLo + rand.Int63n(span)
c := candOf(k)
if c > 0 && !taken[c] {
pick = c
break
}
}
if pick == 0 {
for k := rangeLo; k <= rangeHi; k++ {
c := candOf(k)
if c > 0 && !taken[c] {
pick = c
break
}
}
}
if pick != 0 {
break
}
rangeLo = rangeHi + 1
}
if pick == 0 {
return nil, ErrAlloc
}
o := &Order{
ID: newOrderID(), Token: newToken(), MerchantOrderID: mid, Currency: currency,
BaseAmount: base, PayAmount: pick, Status: "pending", NoteCode: newNoteCode(),
CallbackURL: callbackURL, ReturnURL: returnURL,
CreatedAt: now, ExpiresAt: now + ttlSec*1000,
}
_, err = s.db.Exec(`INSERT INTO orders(id,token,merchant_order_id,currency,base_amount,pay_amount,status,note_code,callback_url,return_url,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
o.ID, o.Token, o.MerchantOrderID, o.Currency, o.BaseAmount, o.PayAmount, o.Status, o.NoteCode,
o.CallbackURL, o.ReturnURL, o.CreatedAt, o.ExpiresAt)
if err != nil {
return nil, err
}
return o, nil
}
// Finish 用一笔币安流水核销订单(paid 或 underpaid)。以币安 orderId 幂等:已消费返回 false。
func (s *Store) Finish(o *Order, status, matchedBy, binanceOrderID, binanceTxnID, payerID, payerName string, actual int64, cooldownSec int64) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := nowMs()
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.Exec(`INSERT INTO consumed(binance_order_id,order_id,at) VALUES(?,?,?)`, binanceOrderID, o.ID, now); err != nil {
return false, nil // 已被消费(唯一键冲突),不视为错误
}
res, err := tx.Exec(`UPDATE orders SET status=?, matched_by=?, binance_order_id=?, binance_txn_id=?, payer_id=?, payer_name=?,
actual_amount=?, paid_at=?, overpaid=? WHERE id=? AND status IN ('pending','expired')`,
status, matchedBy, binanceOrderID, binanceTxnID, payerID, payerName,
actual, now, boolInt(actual > o.PayAmount), o.ID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
if n == 0 {
return false, nil // 订单已被其他流水处理
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO cooldown(currency,amount,until) VALUES(?,?,?)`,
o.Currency, o.PayAmount, now+cooldownSec*1000); err != nil {
return false, err
}
event := status // paid / underpaid
if _, err := tx.Exec(`INSERT INTO callbacks(order_id,event,next_at) VALUES(?,?,?)`, o.ID, event, now); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// ExpireDue 把到期订单置为 expired,并入冷却、发回调。返回处理条数。
func (s *Store) ExpireDue(cooldownSec int64) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := nowMs()
rows, err := s.db.Query(`SELECT `+orderCols+` FROM orders WHERE status='pending' AND expires_at < ?`, now)
if err != nil {
return 0, err
}
var due []*Order
for rows.Next() {
o, err := scanOrder(rows)
if err != nil {
rows.Close()
return 0, err
}
due = append(due, o)
}
rows.Close()
for _, o := range due {
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
if _, err := tx.Exec(`UPDATE orders SET status='expired' WHERE id=? AND status='pending'`, o.ID); err != nil {
tx.Rollback()
return 0, err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO cooldown(currency,amount,until) VALUES(?,?,?)`,
o.Currency, o.PayAmount, now+cooldownSec*1000); err != nil {
tx.Rollback()
return 0, err
}
if _, err := tx.Exec(`INSERT INTO callbacks(order_id,event,next_at) VALUES(?, 'expired', ?)`, o.ID, now); err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
}
return len(due), nil
}
// CloseOrder 商户主动关单。
func (s *Store) CloseOrder(id string, cooldownSec int64) (*Order, error) {
s.mu.Lock()
defer s.mu.Unlock()
o, err := s.OrderByID(id)
if err != nil {
return nil, err
}
if o.Status != "pending" {
return nil, ErrState
}
now := nowMs()
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE orders SET status='closed' WHERE id=? AND status='pending'`, id); err != nil {
return nil, err
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO cooldown(currency,amount,until) VALUES(?,?,?)`,
o.Currency, o.PayAmount, now+cooldownSec*1000); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
o.Status = "closed"
return o, nil
}
func (s *Store) Consumed(binanceOrderID string) (bool, error) {
var x string
err := s.db.QueryRow(`SELECT order_id FROM consumed WHERE binance_order_id=?`, binanceOrderID).Scan(&x)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) DueCallbacks(limit int) ([]cbJob, error) {
rows, err := s.db.Query(`SELECT id,order_id,event,attempt FROM callbacks WHERE done=0 AND next_at<=? ORDER BY id LIMIT ?`, nowMs(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []cbJob
for rows.Next() {
var j cbJob
if err := rows.Scan(&j.ID, &j.OrderID, &j.Event, &j.Attempt); err != nil {
return nil, err
}
out = append(out, j)
}
return out, rows.Err()
}
func (s *Store) CallbackDone(id int64) error {
_, err := s.db.Exec(`UPDATE callbacks SET done=1, last_err='' WHERE id=?`, id)
return err
}
func (s *Store) CallbackRetry(id int64, attempt int, nextAt int64, lastErr string, giveUp bool) error {
if giveUp {
_, err := s.db.Exec(`UPDATE callbacks SET done=1, attempt=?, last_err=? WHERE id=?`, attempt, "GIVE_UP: "+lastErr, id)
return err
}
_, err := s.db.Exec(`UPDATE callbacks SET attempt=?, next_at=?, last_err=? WHERE id=?`, attempt, nextAt, lastErr, id)
return err
}
func (s *Store) String() string { return fmt.Sprintf("Store(%p)", s.db) }
+93
View File
@@ -0,0 +1,93 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>订单支付</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--fg:#16181d;--muted:#6b7280;--brand:#f0b90b;--ok:#16a34a;--err:#dc2626;--line:#e5e7eb}
@media(prefers-color-scheme:dark){:root{--bg:#101216;--card:#181b21;--fg:#e7e9ee;--muted:#9aa1ad;--line:#2a2f38}}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.wrap{max-width:460px;margin:0 auto;padding:20px 16px 40px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:14px}
h1{font-size:17px;margin:0 0 4px}.muted{color:var(--muted);font-size:13px}
.amt{font-size:34px;font-weight:700;margin:10px 0 2px;word-break:break-all}
.amt small{font-size:16px;font-weight:600;margin-left:4px}
.row{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-top:1px dashed var(--line);gap:10px}
.row:first-of-type{border-top:none}
.k{color:var(--muted);font-size:13px;white-space:nowrap}.v{font-weight:600;word-break:break-all;text-align:right}
button{border:0;border-radius:9px;padding:9px 14px;font-size:13px;cursor:pointer;background:var(--brand);color:#111;font-weight:600}
button.ghost{background:transparent;border:1px solid var(--line);color:var(--fg)}
.copy{padding:3px 10px;font-size:12px;margin-left:6px;background:transparent;border:1px solid var(--line);color:var(--fg)}
.qr{display:block;margin:12px auto 4px;width:210px;max-width:80%;border-radius:10px}
.tip{background:rgba(240,185,11,.12);border:1px solid rgba(240,185,11,.4);border-radius:10px;padding:10px 12px;font-size:13px;margin-top:12px}
.status{display:flex;align-items:center;gap:8px;font-weight:600}
.dot{width:9px;height:9px;border-radius:50%;background:var(--brand);animation:b 1.2s infinite}
@keyframes b{50%{opacity:.25}}
.paid .dot{background:var(--ok);animation:none}
.bad{color:var(--err)}.good{color:var(--ok)}
input{width:100%;padding:10px;border:1px solid var(--line);border-radius:9px;background:transparent;color:var(--fg);font-size:14px}
details summary{cursor:pointer;color:var(--muted);font-size:13px}
#claimMsg{font-size:13px;margin-top:8px}
.center{text-align:center}
</style>
</head>
<body>
<div class="wrap">
<div class="card center">
<h1>请用币安 App「支付」转账</h1>
<div class="muted">Binance Pay · 即时到账 · 0 手续费</div>
<div class="amt" id="payAmount">{{.PayAmount}}<small>{{.Currency}}</small></div>
<div class="muted">请务必按上方金额<strong>一分不差</strong>支付,用于自动确认</div>
<button class="copy" onclick="cp('{{.PayAmount}}',this)">复制金额</button>
{{if .HasQR}}<img class="qr" src="qr" alt="收款二维码"><div class="muted">币安 App 扫码,或用下方 UID 转账</div>{{end}}
</div>
<div class="card">
<div class="row"><span class="k">收款人币安 UID</span><span class="v">{{.UID}}<button class="copy" onclick="cp('{{.UID}}',this)">复制</button></span></div>
{{if .Email}}<div class="row"><span class="k">或收款邮箱</span><span class="v">{{.Email}}<button class="copy" onclick="cp('{{.Email}}',this)">复制</button></span></div>{{end}}
<div class="row"><span class="k">转账备注(可选)</span><span class="v">{{.NoteCode}}<button class="copy" onclick="cp('{{.NoteCode}}',this)">复制</button></span></div>
<div class="row"><span class="k">剩余时间</span><span class="v" id="cd">--:--</span></div>
<div class="row"><span class="k">状态</span><span class="v status" id="st"><span class="dot"></span><span id="stTxt">等待支付</span></span></div>
<div class="tip">路径:币安 App → 首页「支付」→ 转账 → 输入 UID → 选 {{.Currency}} → 输入金额。备注填 <b>{{.NoteCode}}</b> 可加速确认。</div>
</div>
<div class="card">
<details>
<summary>已付款但长时间未确认?回填币安订单编号</summary>
<p class="muted" style="margin:10px 0 6px">币安 App → 支付 → 交易记录 → 点开这笔转账,复制「订单编号」(18 位数字)粘贴到此处:</p>
<input id="claimInput" inputmode="numeric" placeholder="例如 452021922068888888">
<div style="margin-top:10px"><button onclick="claim()">提交核验</button></div>
<div id="claimMsg"></div>
</details>
</div>
</div>
<script>
var expiresAt={{.ExpiresAt}}, token={{.Token}}, done=false;
function cp(t,btn){navigator.clipboard&&navigator.clipboard.writeText(t).then(function(){var o=btn.textContent;btn.textContent="已复制";setTimeout(function(){btn.textContent=o},1200)})}
function tick(){var left=Math.max(0,Math.floor((expiresAt-Date.now())/1000));var m=String(Math.floor(left/60)).padStart(2,"0"),s=String(left%60).padStart(2,"0");document.getElementById("cd").textContent=m+":"+s;if(left<=0&&!done){setTxt("已超时,若已付款请回填订单编号","bad")}}
function setTxt(t,cls){var el=document.getElementById("stTxt");el.textContent=t;el.className=cls||""}
function onPaid(d){done=true;document.getElementById("st").classList.add("paid");setTxt("支付成功 ✓","good");if(d.redirect){setTimeout(function(){location.href=d.redirect},1500)}}
function poll(){if(done)return;fetch("status",{cache:"no-store"}).then(function(r){return r.json()}).then(function(d){
if(d.status==="paid"){onPaid(d)}
else if(d.status==="underpaid"){done=true;setTxt("实付金额不足,请联系商家处理","bad")}
else if(d.status==="expired"){setTxt("订单已过期,若已付款请回填订单编号","bad")}
else if(d.status==="closed"){done=true;setTxt("订单已关闭","bad")}
}).catch(function(){})}
function claim(){var v=document.getElementById("claimInput").value.trim();var msg=document.getElementById("claimMsg");
if(!/^[0-9]{6,20}$/.test(v)){msg.textContent="请输入 6~20 位数字订单编号";msg.className="bad";return}
msg.textContent="核验中…";msg.className="";
fetch("claim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({binance_order_id:v})})
.then(function(r){return r.json()}).then(function(d){
var c=(d.data&&d.data.code)||d.code;
if(c==="OK"){msg.textContent="核验成功,订单已支付 ✓";msg.className="good";poll()}
else if(c==="UNDERPAID"){msg.textContent="已到账但金额不足,请联系商家";msg.className="bad"}
else if(c==="NOT_FOUND"){msg.textContent="未找到这笔入账,请确认编号无误、稍等片刻再试";msg.className="bad"}
else if(c==="CONSUMED"){msg.textContent="该笔转账已被使用";msg.className="bad"}
else if(c==="ERR_RATE_LIMIT"){msg.textContent="操作过于频繁,请稍后再试";msg.className="bad"}
else{msg.textContent="核验失败:"+(d.message||c||"未知错误");msg.className="bad"}
}).catch(function(){msg.textContent="网络错误,请重试";msg.className="bad"})}
tick();setInterval(tick,1000);poll();setInterval(poll,3000);
</script>
</body>
</html>
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"crypto/rand"
"errors"
"fmt"
"strings"
"time"
)
const scale = int64(100000000) // 1e8,金额内部一律用 1e-8 定点整数,避免浮点误差
func nowMs() int64 { return time.Now().UnixMilli() }
// parseAmount 把十进制字符串解析为 1e-8 定点整数。拒绝科学计数法、超 8 位小数、非法字符。
func parseAmount(s string) (int64, error) {
s = strings.TrimSpace(s)
neg := false
if strings.HasPrefix(s, "-") {
neg = true
s = s[1:]
}
if s == "" {
return 0, errors.New("empty amount")
}
intPart, frac := s, ""
if i := strings.IndexByte(s, '.'); i >= 0 {
intPart, frac = s[:i], s[i+1:]
}
if intPart == "" {
intPart = "0"
}
if len(frac) > 8 {
return 0, errors.New("more than 8 decimals")
}
for _, c := range intPart + frac {
if c < '0' || c > '9' {
return 0, errors.New("invalid amount")
}
}
var v int64
for _, c := range intPart {
v = v*10 + int64(c-'0')
if v > (int64(1)<<62)/scale {
return 0, errors.New("amount too large")
}
}
v *= scale
f := int64(0)
for _, c := range frac {
f = f*10 + int64(c-'0')
}
for i := len(frac); i < 8; i++ {
f *= 10
}
v += f
if neg {
v = -v
}
return v, nil
}
// fmtAmount 定点整数转字符串,去掉小数尾零。
func fmtAmount(v int64) string {
neg := v < 0
if neg {
v = -v
}
ip := v / scale
fp := v % scale
s := fmt.Sprintf("%d", ip)
if fp > 0 {
fs := strings.TrimRight(fmt.Sprintf("%08d", fp), "0")
s = s + "." + fs
}
if neg {
s = "-" + s
}
return s
}
// decimalsOf 返回该定点金额实际用到的小数位数。
func decimalsOf(v int64) int {
if v < 0 {
v = -v
}
fp := v % scale
if fp == 0 {
return 0
}
d := 8
for fp%10 == 0 {
fp /= 10
d--
}
return d
}
const idAlphabet = "0123456789abcdefghjkmnpqrstvwxyz"
// codeAlphabet 去掉了易混淆的 0O1IL
const codeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
func randString(alphabet string, n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
out := make([]byte, n)
for i, c := range b {
out[i] = alphabet[int(c)%len(alphabet)]
}
return string(out)
}
func newOrderID() string { return "o" + randString(idAlphabet, 25) }
func newToken() string { return randString(idAlphabet, 26) }
func newNoteCode() string { return randString(codeAlphabet, 6) }
func newNonce() string { return randString(idAlphabet, 24) }
+48
View File
@@ -0,0 +1,48 @@
package main
import "testing"
func TestParseFmtAmount(t *testing.T) {
cases := []struct {
in string
want int64
err bool
}{
{"9500", 950000000000, false},
{"0.1234", 12340000, false},
{"10.0037", 1000370000, false},
{"0.00000001", 1, false},
{"-1", -100000000, false},
{"1.123456789", 0, true},
{"abc", 0, true},
{"", 0, true},
{"1e5", 0, true},
}
for _, c := range cases {
got, err := parseAmount(c.in)
if c.err {
if err == nil {
t.Errorf("parseAmount(%q) 应报错", c.in)
}
continue
}
if err != nil || got != c.want {
t.Errorf("parseAmount(%q)=%d,%v want %d", c.in, got, err, c.want)
}
}
for _, s := range []string{"9500", "0.1234", "10.0037", "0.00000001", "1"} {
v, _ := parseAmount(s)
if fmtAmount(v) != s {
t.Errorf("roundtrip %q → %q", s, fmtAmount(v))
}
}
if fmtAmount(1000370000) != "10.0037" {
t.Errorf("fmtAmount 尾零处理错误: %s", fmtAmount(1000370000))
}
if d := decimalsOf(1000370000); d != 4 {
t.Errorf("decimalsOf=%d want 4", d)
}
if d := decimalsOf(950000000000); d != 0 {
t.Errorf("decimalsOf=%d want 0", d)
}
}
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
币安支付(Binance Pay)流水探测脚本 —— 验证“个人账户 + 官方只读 API”方案是否可行。
调用官方接口: GET /sapi/v1/pay/transactions (签名接口, 权重 3000/UID, 默认最近 90 天)
文档: https://developers.binance.com/docs/pay/rest-api
用法(二选一):
A. 环境变量:
export BINANCE_API_KEY=你的APIKey
export BINANCE_API_SECRET=你的Secret # 只需勾选 "Enable Reading",不要开交易/提现权限
export BINANCE_UID=你的币安UID # 可选,用于判定收/付方向
B. 密钥文件(推荐,密钥不经过任何对话/终端历史):
~/.binance_pay_probe.env 内容三行: BINANCE_API_KEY=... BINANCE_API_SECRET=... BINANCE_UID=...
chmod 600 ~/.binance_pay_probe.env
(可用 BINANCE_PROBE_ENV=/path/to/file 指定其他路径)
python3 tools/pay_history_probe.py --days 30 # 打印最近 30 天 Pay 流水
python3 tools/pay_history_probe.py --watch 5 # 每 5 秒轮询,打印新出现的收款
脚本从不打印密钥;测完请在币安后台删除该 API Key 并删掉密钥文件。
只用标准库,不依赖第三方包。
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
BASE_URLS = [
"https://api.binance.com",
"https://api1.binance.com",
"https://api-gcp.binance.com",
]
WALLET_NAMES = {1: "Funding", 2: "Spot", 3: "Fiat", 4: "Card", 5: "Earn", 6: "Card"}
DEFAULT_ENV_FILE = os.path.expanduser("~/.binance_pay_probe.env")
def load_env_file() -> None:
"""若环境变量缺失,则从密钥文件读取 KEY=VALUE 行;不打印任何值。"""
path = os.environ.get("BINANCE_PROBE_ENV", DEFAULT_ENV_FILE)
if os.environ.get("BINANCE_API_KEY") and os.environ.get("BINANCE_API_SECRET"):
return
if not os.path.isfile(path):
return
mode = os.stat(path).st_mode & 0o777
if mode & 0o077:
print(f"[warn] {path} 权限为 {oct(mode)},建议 chmod 600", file=sys.stderr)
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip().strip('"').strip("'")
if k in ("BINANCE_API_KEY", "BINANCE_API_SECRET", "BINANCE_UID") and v:
os.environ.setdefault(k, v)
def signed_get(path: str, params: dict, key: str, secret: str) -> dict:
params = dict(params)
params["timestamp"] = int(time.time() * 1000)
params.setdefault("recvWindow", 10000)
query = urllib.parse.urlencode(params)
sig = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest()
last_err = None
for base in BASE_URLS:
url = f"{base}{path}?{query}&signature={sig}"
req = urllib.request.Request(url, headers={"X-MBX-APIKEY": key})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
# 4xx 是账号/参数/权限问题,换域名没意义,直接抛出
if 400 <= e.code < 500:
raise SystemExit(f"HTTP {e.code} from {base}: {body}")
last_err = f"HTTP {e.code} from {base}: {body}"
except (urllib.error.URLError, TimeoutError) as e:
last_err = f"{base}: {e}"
raise SystemExit(f"所有接入点均失败: {last_err}")
def fetch_pay_transactions(key, secret, start_ms=None, end_ms=None, limit=100):
params = {"limit": limit}
if start_ms is not None:
params["startTime"] = start_ms
if end_ms is not None:
params["endTime"] = end_ms
data = signed_get("/sapi/v1/pay/transactions", params, key, secret)
if not data.get("success", False) and data.get("code") != "000000":
raise SystemExit(f"接口返回失败: {json.dumps(data, ensure_ascii=False)}")
return data.get("data", [])
def direction(tx: dict, my_uid: str | None) -> str:
recv = (tx.get("receiverInfo") or {}).get("binanceId")
payer = (tx.get("payerInfo") or {}).get("binanceId")
if my_uid:
if str(recv) == my_uid:
return "收款 IN "
if str(payer) == my_uid:
return "付款 OUT"
amt = str(tx.get("amount", ""))
if amt.startswith("-"):
return "付款 OUT"
return "? "
def fmt(tx: dict, my_uid: str | None) -> str:
ts = datetime.fromtimestamp(tx.get("transactionTime", 0) / 1000).strftime("%Y-%m-%d %H:%M:%S")
payer = tx.get("payerInfo") or {}
recv = tx.get("receiverInfo") or {}
wallets = ",".join(WALLET_NAMES.get(w, str(w)) for w in (tx.get("walletTypes") or [tx.get("walletType")]) if w)
return (
f"{ts} | {direction(tx, my_uid)} | {tx.get('orderType'):<10} | "
f"{tx.get('amount'):>16} {tx.get('currency'):<6} | wallet={wallets:<12} | "
f"payer={payer.get('name')}({payer.get('type')},uid={payer.get('binanceId')}) -> "
f"recv={recv.get('name')}(uid={recv.get('binanceId')}) | txId={tx.get('transactionId')} | "
f"orderId={tx.get('orderId')} | cp={tx.get('counterpartyId')} | note={tx.get('note')!r}"
)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--days", type=int, default=30, help="回溯天数 (<=90),默认 30")
ap.add_argument("--watch", type=int, metavar="SECONDS", help="轮询模式:每 N 秒拉一次,打印新出现的流水")
ap.add_argument("--raw", action="store_true", help="同时打印原始 JSON(用于确认字段)")
args = ap.parse_args()
load_env_file()
key = os.environ.get("BINANCE_API_KEY")
secret = os.environ.get("BINANCE_API_SECRET")
my_uid = os.environ.get("BINANCE_UID")
if not key or not secret:
sys.exit("未找到密钥:请设置环境变量 BINANCE_API_KEY / BINANCE_API_SECRET,"
f"或创建密钥文件 {DEFAULT_ENV_FILE}(见脚本头部说明)")
if args.watch:
seen = set()
first = True
print(f"[watch] 每 {args.watch}s 轮询一次(接口权重 3000/次,UID 限额 180000/分钟,最快约 1 次/秒)")
while True:
try:
txs = fetch_pay_transactions(key, secret, start_ms=int((time.time() - 3600) * 1000))
except SystemExit as e:
print(f"[error] {e}")
time.sleep(args.watch)
continue
for tx in sorted(txs, key=lambda t: t.get("transactionTime", 0)):
tid = tx.get("transactionId")
if tid in seen:
continue
seen.add(tid)
if not first:
print("[NEW] " + fmt(tx, my_uid))
if args.raw:
print(json.dumps(tx, ensure_ascii=False, indent=2))
if first:
print(f"[watch] 初始基线 {len(seen)} 条(最近 1 小时),等待新流水…")
first = False
time.sleep(args.watch)
days = max(1, min(args.days, 90))
end_ms = int(time.time() * 1000)
start_ms = end_ms - days * 86400 * 1000
txs = fetch_pay_transactions(key, secret, start_ms=start_ms, end_ms=end_ms)
print(f"最近 {days} 天共 {len(txs)} 条 Pay 流水(单次最多 100 条)")
for tx in sorted(txs, key=lambda t: t.get("transactionTime", 0)):
print(fmt(tx, my_uid))
if args.raw:
print(json.dumps(txs, ensure_ascii=False, indent=2))
if not txs:
print("\n提示:返回为空可能是 (a) 该时间段确实没有 Pay 交易 (b) 账号所在地区不支持 Binance Pay,"
"建议先用 App 给自己/朋友转 0.1 USDT 做一笔 C2C 后再跑一次。")
if __name__ == "__main__":
main()