Files
qianyuandClaude Fable 5.1 0e0f73e2c7 多收款账号:API 动态添加币安账号,订单按账号隔离轮询与匹配
- 新增 accounts 表与接口:POST /api/v1/accounts(用只读接口试拉流水校验,失败 ERR_BINANCE)、
  GET /api/v1/accounts/{id}、POST .../verify、.../disable、.../enable;Secret 以 AES-256-GCM 加密存储,密钥由 API_AUTH_KEY 派生
- 订单增加 account_id(旧库自动补列,默认 default);唯一金额按账号分配;Matcher 按账号各自轮询、缓存与匹配,
  无待核销订单的账号不调币安接口;回填(claim)只在订单所属账号的流水里找
- 收银页按订单所属账号展示 UID / 收款链接 / 二维码;订单与回调体新增 account_id,订单响应新增 receive_link / receive_email
- Go / Python SDK 同步(AccountID、账号方法);协议文档 §2.6;e2e 覆盖多账号隔离、回填、停用与默认账号保护

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 09:39:48 +08:00

160 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
account_id: str = None) -> dict:
payload = {"merchant_order_id": merchant_order_id, "amount": str(amount), "currency": currency}
if account_id:
payload["account_id"] = account_id # 多账号模式:指定收款账号
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", {})
# ---- 收款账号(多账号模式,docs/protocol.md §2.6)----
def create_account(self, api_key: str, api_secret: str, uid: str, label: str = "",
receive_link: str = None, receive_email: str = None) -> dict:
payload = {"api_key": api_key, "api_secret": api_secret, "uid": str(uid), "label": label}
if receive_link:
payload["receive_link"] = receive_link
if receive_email:
payload["receive_email"] = receive_email
return self._request("POST", "/api/v1/accounts", payload)
def get_account(self, account_id: str) -> dict:
return self._request("GET", "/api/v1/accounts/" + account_id)
def verify_account(self, account_id: str) -> dict:
return self._request("POST", "/api/v1/accounts/" + account_id + "/verify", {})
def disable_account(self, account_id: str) -> dict:
return self._request("POST", "/api/v1/accounts/" + account_id + "/disable", {})
def enable_account(self, account_id: str) -> dict:
return self._request("POST", "/api/v1/accounts/" + account_id + "/enable", {})
# ---- 回调验签 ----
@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"]