Files
BinancePayTool/sdk/python/bpaygate.py
T
qianyubtcandClaude Fable 5 f87ec65541 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>
2026-09-02 14:09:32 +08:00

134 lines
5.4 KiB
Python
Raw 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) -> 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"]