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