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:
1 parent
e4275368ea
commit
f87ec65541
46 files changed
+4378
No files matched your search
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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` 幂等)。
|
||||
@@ -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,官方明确不建议 |
|
||||
| 让付款方必须回填订单号 | 体验差、漏填率高 → 降级为兜底入口,主链路零动作 |
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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)。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/qianyubtc/BinancePayTool/sdk/go
|
||||
|
||||
go 1.21
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
@@ -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";
|
||||
@@ -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)。
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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] 已退出")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)) }
|
||||
@@ -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
@@ -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) }
|
||||
@@ -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
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user