"""OG Webhook 协议接收器:验签、校验事件包络、调用业务事务、提交后回签。 Python 3.10+,需配套 og_quota.py,两者只用标准库。它不启动 HTTP 服务,也不包含假账本。 从现有框架取得原始 body、未解码的 path/query、保留重复项的请求头后调用 receive。 commit(event, raw_body) 必须校验业务并持久提交后才正常返回;失败必须抛异常。 """ from dataclasses import dataclass import hashlib import hmac import inspect import json import re import time import sys from pathlib import Path # 完整下载包保留仓库相对布局,或将 og_quota.py 与本文件放在同一目录。 try: from og_quota import decode_rejection except ModuleNotFoundError as error: if error.name != "og_quota": raise sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "scripts")) from og_quota import decode_rejection from typing import Callable # type -> (status, phase)。None 表示字段必须缺失,不能为 null。 EVENTS = { "deposit_detected": ("deposit_detected", "progress"), "deposit_confirming": ("deposit_confirming", "progress"), "deposit_confirmed": ("confirmed", None), "withdrawal_accepted": ("accepted", "progress"), "withdrawal_sending": ("sending", "progress"), "withdrawal_confirming": ("confirming", "progress"), "withdrawal_held": ("held", "progress"), "withdrawal_manual_pending": ("manual_pending", "progress"), "withdrawal_auto_sending": ("auto_sending", "progress"), "withdrawal_voucher_checking": ("voucher_checking", "progress"), "withdrawal_voucher_failed": ("voucher_failed", "progress"), "withdrawal_succeeded": ("succeeded", None), "withdrawal_failed": ("failed", None), "withdrawal_refunded": ("refunded", None), } MAX_BODY = 1024 * 1024 @dataclass(frozen=True) class Event: family: str # deposit 或 withdrawal;与链无关,不硬编码 ETH/TRON。 event_id: str type: str fields: dict @dataclass(frozen=True) class HttpResult: status: int headers: dict[str, str] body: bytes = b"{}" def _object(pairs): """拒绝重复 JSON 键,避免不同解析器对同一正文产生不同解释。""" result = {} for key, value in pairs: if key in result: raise ValueError("重复 JSON 键") result[key] = value return result class WebhookReceiver: def __init__(self, access_key: str, secret: str, commit: Callable[[Event, bytes], None]): if (not access_key or not secret or not callable(commit) or inspect.iscoroutinefunction(commit) or inspect.iscoroutinefunction(getattr(commit, "__call__", None))): raise ValueError("必须提供独立 Webhook 凭据和已实现的同步 commit") self.access_key = access_key self.secret = secret.encode("utf-8") self.commit = commit def receive(self, method: str, raw_target: str, headers: list[tuple[str, str]], raw_body: bytes) -> HttpResult: """raw_target 包含实际转义路径及原 query;禁止传框架已解码的 path。""" reject = lambda status: HttpResult(status, {"Content-Type": "application/json"}) if method != "POST": return reject(405) if not isinstance(raw_body, bytes) or len(raw_body) > MAX_BODY: return reject(413) if not isinstance(raw_target, str) or not raw_target.startswith("/") or "#" in raw_target: return reject(400) try: selected = {} for name in ("x-access-key", "x-timestamp", "x-signature"): values = [value for key, value in headers if key.lower() == name] if len(values) != 1 or not isinstance(values[0], str): return reject(401) selected[name] = values[0] stamp = selected["x-timestamp"] signature = selected["x-signature"] if (selected["x-access-key"] != self.access_key or not re.fullmatch(r"[0-9]{1,20}", stamp) or abs(int(time.time()) - int(stamp)) > 300 or not re.fullmatch(r"[0-9a-f]{64}", signature)): return reject(401) path, _, query = raw_target.partition("?") prefix = f"POST|{path}|{stamp}|{query}|".encode("ascii") expected = hmac.new(self.secret, prefix + raw_body, hashlib.sha256).hexdigest() if not hmac.compare_digest(signature, expected): return reject(401) except (ValueError, TypeError, UnicodeError): return reject(401) try: def invalid_constant(_value): raise ValueError("不是合法 JSON 数值") fields = json.loads(raw_body.decode("utf-8"), object_pairs_hook=_object, parse_constant=invalid_constant) if not isinstance(fields, dict): return reject(400) if (any(key in fields for key in ("schemaVersion", "decisionId", "quota", "final", "paymentInitiated", "retryMode", "accepted", "broadcast")) or fields.get("type") == "withdrawal_rejected" or fields.get("status") == "rejected"): fields = decode_rejection(raw_body) family = "withdrawal" else: for field in ("eventId", "type", "status", "amount"): if not isinstance(fields.get(field), str) or not fields[field].strip(): return reject(400) pair = EVENTS.get(fields["type"]) if pair is None or fields["status"] != pair[0]: return reject(400) if (pair[1] is None and "phase" in fields) or (pair[1] is not None and fields.get("phase") != pair[1]): return reject(400) if not re.fullmatch(r"[1-9][0-9]{0,77}", fields["amount"]) or int(fields["amount"]) >= 2**256: return reject(400) family = "deposit" if fields["type"].startswith("deposit_") else "withdrawal" if family == "deposit": if any(not isinstance(fields.get(k), str) or not fields[k].strip() for k in ("playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef")): return reject(400) if pair[1] == "progress" and any(type(fields.get(k)) is not int or fields[k] < floor for k, floor in (("confirmations", 0), ("confirmationsRequired", 1))): return reject(400) elif not isinstance(fields.get("withdrawOrderId"), str) or not re.fullmatch(r"[0-9]{30}", fields["withdrawOrderId"]): return reject(400) if fields["type"] == "withdrawal_succeeded" and not (isinstance(fields.get("txHash"), str) and fields["txHash"].strip()): if fields.get("verificationStage") != "payments_succeeded" or not isinstance(fields.get("paymentDetails"), dict): return reject(400) except (ValueError, UnicodeError, RecursionError): return reject(400) event = Event(family, fields["eventId"], fields["type"], fields) try: # 必须接真实业务事务:完整字段/玩家/资产/原单核对,eventId 与业务键防重, # 事件和账务一起提交。重复事件也进入此函数,由事务确认已处理。 # v2 拒绝须另绑定可信 scope、原请求及 eventId;无 accepted/付款证据的 held # 才能转 released 一次。三渠道共用事务,同 ID 异文/不同决定/付款冲突持久隔离。 # 异常事实已持久隔离且未改账时可正常返回;ACK 不代表该笔已结账。 # 不要把此回调实现成 pass、后台提交或仅写日志。 result = self.commit(event, raw_body) # 同步组件不能把未 await 的事务当成成功,装饰器返回 awaitable 也拒绝。 if inspect.isawaitable(result): if inspect.iscoroutine(result): result.close() return reject(503) except Exception: return reject(503) # 不输出凭据或原文;无 X-Signature,平台可按策略重试。 return HttpResult(200, {"Content-Type": "application/json", "X-Signature": expected})