先从 AI 自包含接入包 下载公开规范、配置模板、Python + SQLite 参考实现与固定验收。把语言/框架、数据库、登录主体和既有余额接口填入适配输入后再生成代码;离线验收是主路径,流程模拟器是可选演示。未分别实测的 AI 产品或语言不承诺一次生成成功。

1. 一页看懂接入

你需要开发的是 OG 业务后端。 它调用支付平台 API、保存订单与余额、接收 Webhook,再把业务结果提供给手机页面。API 密钥只放在后端。

两条独立业务流程,各自接收 Webhook

充值流程

把链上充值计入业务余额

  1. OG → 平台取址与二维码

    按玩家和网络取址

  2. 玩家 → 充值地址钱包转账

    选择同网络登记资产

  3. 平台 → OG接收充值结果

    充值 Webhook + 查询

  4. OG 账本确认后入账

    txRef 幂等入账一次

提现流程

从业务余额申请向客户提现

  1. OG 账本建单并预扣

    保存原请求和订单号

  2. OG → 平台提交与出款

    受理后等待人工流程

  3. 平台 → OG接收提现结果

    提现 Webhook + 查询

  4. OG 账本终态结账

    原预扣扣款或释放一次

共用 Webhook 入口,按 type 分开处理充值与提现

充值和提现是两条独立流程,各自查询、各自接收通知。提现只要求玩家已准备好并且 OG 业务余额充足,不要求先发生一笔充值。

先记住三个词:入账是增加玩家余额;预扣是提现时占用可用余额;释放预扣是解除这次占用,不是额外送一笔余额,也不是链上退款。

支付平台提供OG 需要实现
充值地址、链上状态、提现受理、查询与 Webhook玩家登录与授权、业务余额、订单、完整历史与页面更新
一笔交易的状态变化同一笔业务只记一次账,重复消息只更新原记录

建议先完成第 2 节的公共准备,再分别实现充值、提现;Webhook 接收端要在测试转账前准备好。

2. 开始前:准备环境与签名

拿齐当前环境的配置

配置用途
API baseUrl、API Access Key / secretOG 后端主动调用平台
Webhook Access Key / secret验证平台通知并回签,与 API 凭据独立
平台提供的 environment / accessNamespace固定环境与稳定接入方身份,用于拒绝事件绑定;不是 API Access Key,不自行命名或采信通知自报值
每条开放链的 chainId / chainName / tokenContract / tokenDecimals选网络、核对资产和换算金额
你们的完整 Webhook URL平台 POST 通知到此处;固定 HTTPS 路径,不带 query

先用已鉴权的查询 Webhook 配置核对 environment/accessId 与平台材料一致,再将 environment 和对应 accessId 的 accessNamespace 固定保存。API Access Key 轮换不改变这项稳定身份;不一致先请平台确认。

文档地址、API 地址和模拟器地址分别使用,不能从文档域名推断 baseUrl。本地回环 HTTP 只适合本机联调,远程平台不能通过它访问你的电脑。

当前接入代码与模拟器按测试环境准入,联调用 Ethereum Sepolia 与 TRON Nile。主网接入需由平台提供支持主网的版本和开放材料,不能直接替换 chainId 切换。

主网资料可用于核对配置:

公开参考Ethereum MainnetTRON Mainnet
网络 ID1728126428
官方 USDT 合约0xdac17f958d2ee523a2206206994597c13d831ec7TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
官方 USDT 精度66
转账费用资产/资源ETH Gas带宽、能量或 TRX

合约来源:Tether 官方清单;网络参考:Ethereum EIP-2228、TronLink 网络参数。代币合约不是玩家充值地址,测试网也不能使用主网合约代替登记资产。实际开放网络、合约、精度均以平台材料为准。

封装一次签名,所有 API 复用

POST 使用 UTF-8 JSON,设置 Content-Type: application/json;GET 不发送正文。每次请求带三个头,各发送一份:X-Access-Key、X-Timestamp、X-Signature。时间戳用 Unix 秒,与平台时间相差不超过 300 秒。

TEXT
签名原文 = METHOD + "|" + PATH + "|" + TIMESTAMP + "|" + QUERY + "|" + BODY
X-Signature = HMAC-SHA256(secret, 签名原文) 的小写十六进制

PATH 使用最终请求的转义路径;QUERY 不含 ?,保留原顺序;BODY 使用实际发送的原始字节。GET 的正文为空,空段及分隔符也保留。重试更新时间戳、重新签名,不改原订单内容。

下面的 JSON 都是业务模板:替换 REPLACE_ 后,使用同一份正文签名并发送。可直接复用 API 页的“完整签名 cURL”;算法与可运行例子见签名规则和第一个请求。

金额统一使用最小单位的正整数字符串。精度为 6 时,1 USDT = "1000000",不用浮点数、小数或科学计数。玩家使用稳定的 playerId,推荐简短 ASCII 标识,避免 /;提现订单号为恰好 30 位数字的字符串,保留前导零。

完成标志:正确签名能够调用取址接口。401 先查原文、时间戳和凭据;取址 422 先查两项字段和开放网络,再请平台检查地址服务。

3. 充值:自动取址到到账

充值:自动取址 → 玩家转账 → 确认到账
  1. OG → 平台

    取址并显示二维码

    POST /v1/addresses

  2. 玩家 → 充值地址

    玩家钱包转账

    核对网络与 USDT 合约

  3. 平台 → OG

    获得充值状态

    Webhook 推送 + API 查询

  4. OG → 业务余额

    确认后入账一次

    核对资产,按 txRef 入账

打开充值页,自动获取地址

OG 后端从登录态确定玩家,调用获取充值地址:

TEXT
POST /v1/addresses
JSON
{
  "playerId": "REPLACE_PLAYER_ID",
  "chainId": "REPLACE_REGISTERED_CHAIN_ID"
}

只传这两个字段。成功 HTTP 200 直接返回对象,没有 data 包装:

JSON
{
  "address": "REPLACE_RETURNED_DEPOSIT_ADDRESS",
  "chainName": "REPLACE_RETURNED_CHAIN_NAME",
  "chainId": "REPLACE_RETURNED_CHAIN_ID",
  "tokenContract": "REPLACE_RETURNED_TOKEN_CONTRACT"
}

保存玩家、环境及这四个返回字段,再由同一个 address 生成二维码。进入充值页、切换玩家或网络时自动获取或复用地址,不用让玩家点击“取址”;平时刷新交易状态不反复取址。地址可能因登记资产或收款合约更换而变化,以最新成功响应为准。

手机二维码只需要包含充值地址。 Ethereum 和 TRON 都要让玩家在外部钱包另选相同网络、登记 USDT 和金额;二维码本身不保证选对网络。地址原样保存,TRON 地址严格区分大小写。

玩家从自己控制的钱包转入返回的地址。没有“提交充值”API;平台观察并同步交易后,才会出现充值记录。

查询或通知到达后,更新这一笔充值

什么时候用调用哪个接口如何处理
还不知道这笔的 txRef玩家充值列表:GET /v1/players/{playerId}/deposits?chainId=...从 items 发现记录,按 txRef 合并到 OG 账本
已知道 txRef单笔充值:GET /v1/deposits/{txRef}复核当前已同步状态
平台主动通知deposit_* Webhook验签后按同一个 txRef 更新,接收方法见第 5 节

玩家路径和 query 分别编码,再按最终 URL 签名。玩家充值列表只有 chainId 参数,没有分页、时间或地址筛选;单笔查询不带 query/body。

充值查询 status页面展示OG 账务动作
pending处理中 / 等待确认不增加余额
confirmed已到账核对玩家、环境、链、登记合约和金额,按 txRef 入账一次
invalidated已失效,核查中已入账则冲正一次;未入账则不动余额

充值进度通知 deposit_detected、deposit_confirming 只更新展示;deposit_confirmed 的 status 是 confirmed,不是 deposit_confirmed。通知可能直接从已确认状态开始,不必等齐每个中间事件。

充值失效没有单独 Webhook,需后台查询复核。首次成功通知也可能已经过期,参考模式须显式选择查询复核后入账。充值已消费而可用余额不足冲正时,持久人工核查并阻止新增预扣,不自动造欠款或释放其他订单 held;其他策略须由接入方明确提供。 已失效或已冲正后收到旧成功通知,先在事务外查询同一 txRef;只有当前仍为 confirmed 且事实匹配,才锁定原记录并检查版本后恢复。查询失败、404 或列表缺失,都不能触发冲正或恢复。

完成标志:一笔转入显示一条记录,确认后只增加一次余额。没看到记录时,先查钱包网络、目标地址和合约,再请平台核对扫描与同步;不要再转一笔来“验证”。

4. 提现:提交申请到完成

提现:预扣 → 提交受理 → 等待结果 → 结账
  1. OG → 业务账本

    保存原单并预扣

    固定订单号与首次请求

  2. OG → 平台

    提交提现申请

    受理后等待人工出款

  3. 平台 → OG

    获取原单结果

    Webhook 推送 + API 查询

  4. OG → 业务账本

    原预扣结账一次

    succeeded 扣款;failed/refunded 释放

先保存原单和预扣,再提交

  1. 从登录态取得玩家,校验可用业务余额、网络、收款地址和金额。玩家尚未准备时,先用同一 playerId 成功取址;无需先充值。
  2. 客户端为一次主动申请保存稳定 clientRequestId。OG 以环境、接入方、可信登录主体和此键建立持久唯一约束;同键同内容返回原30位订单号,同键异内容冲突。账户级事务/条件更新同时检查余额、保存原单并预扣,真实并发的不同订单也不能超占。提交后丢响应重试仍复用此键,不再次预扣。
  3. 调用 POST /v1/withdrawals,正文恰好包含下面五个字段。
JSON
{
  "withdrawOrderId": "REPLACE_30_DIGIT_WITHDRAW_ORDER_ID",
  "playerId": "REPLACE_PLAYER_ID",
  "chainId": "REPLACE_REGISTERED_CHAIN_ID",
  "amount": "1000000",
  "toAddress": "REPLACE_CUSTOMER_RECEIVING_ADDRESS"
}

toAddress 是玩家希望收款的钱包地址,必须属于所选网络;不传 chainName/tokenContract/scope。金额示例仅在精度为 6 时表示 1 USDT。

明确受理后,允许继续下一笔

首次成功响应为 accepted=true、broadcast=false、status=manual_pending、route=manual,不含交易哈希。这表示受理,尚未提现成功。 当前人工流程还需要审批、链下出款和确认。

OG 后端核对响应中的原订单号、链、合约、金额、收款地址及受理字段,玩家归属由本地原单确定。持久保存结果后,再让手机清空金额/地址、自动准备下一笔。下方新增原订单的“处理中”记录,原预扣保持;下一笔使用新订单号,并按扣除已有预扣后的可用余额校验。

同单同内容再次 POST 返回保存的首次受理响应,不能用它覆盖后来的成功或失败。最新状态通过单笔提现查询或提现 Webhook 获取。

提现查询 statusOG 如何处理
manual_pending、held处理中或待核查,保留预扣
voucher_checking、voucher_failed凭证核验中或本次凭证未通过,仍保留预扣;不是提现失败终态
auto_sending兼容自动流程状态,仍保留预扣;当前人工主线不要求启用自动提现
succeeded将原预扣确认为已扣款,仅一次
failed、refunded释放原预扣,仅一次;不额外增加余额

提现进度通知的 accepted/sending/confirming 不是查询状态。有交易哈希、broadcast=false 或等待很久,都不能单独作为结账或释放预扣的依据。

超时、取消怎么处理

已确认部分付款时不得按失败退全额。 例如原单20已确认支付10,随后收到基础 failed/refunded,应视为与既有付款事实矛盾:保留已确认付款记录和整单 held,持久记录人工核查责任;不得进入通用释放分支、删除付款事实或把确认金额清零。预期重复通知与重启后仍不释放;失败先查原单持久付款记录、首次观察原文及核查任务。

提交超时、泛化 422,或查询 404/503 时,保留原单、原五字段和预扣,使用原订单查询或重新签名重试。不能换新订单号重试这笔未知结果,也不能因为历史列表缺单就退款。

如业务需要撤回,可单独接入取消未广播提现:POST /v1/withdrawals/{withdrawOrderId}/cancel,无 query、空 body。明确返回 refunded 才释放一次,并与 withdrawal_refunded 通知共用去重。409 的正文是 {"error":"conflict"};未终态时继续保持预扣。取消超时或 503 先查原单。退款状态表示业务预扣解除,不表示链上自动转币退款。

完成标志:明确受理后可连续填写下一笔,每笔原单始终独立更新、只结账一次。长期处理中时先查原单、人工审批和出款回执,再查 OG 原单与预扣记录。

接上“用户每日提现上限”

管理后台按链配置统一上限,每个用户分别累计,同一用户同一链的所有收款地址共用额度。例如同链上限相同,甲用户已提取的金额不会减少乙用户额度;甲更换收款地址仍使用甲的原额度。以太坊与波场分别计算。

当天按北京时间 00:00 重置,按钱包服务端记录的申请时间所属北京时间日累计,不按 OG 创建订单、手机点击或请求签名时间。首次保存上限之后、申请日期在当天的处理中和成功提现计入;昨天未完成的单不占今天。确定取消、失败或拒绝的申请不计入。再次保存只改金额,不清零当天已计入金额;未设置或已清空不限制,清空后重新保存才从新的保存时刻起算。

接入方保持原来的五字段提交与预扣流程,再增加下面的拒绝处理:

  1. 提交前保存原单和预扣。 平台可能先投递通知、后返回提交结果,接收端必须能找到原订单。
  2. 通用 422 先保留预扣并核实请求。 当前普通通道超限返回 {"error":"rejected"},其中没有原因、额度或重置时间;不能直接显示“确定超限”或退款。参数、链等预校验失败也会返回通用 422,但不会生成拒绝通知,不能只等待一个未生成的 Webhook。
  3. 收到完整 `withdrawal_rejected` 后确认拒绝。 先验签、校验全部字段并核对原单,再在一个持久事务中保存拒绝并释放原预扣一次。它不是 withdrawal_failed,不能只看 code 或 final=true。
  4. 通知里展示原因和等待时间。 保存原通知快照,更新原订单的“已拒绝”记录;晚到的通用 422/503 不得把它改回“处理中”或再释放。

当前普通通道保存完整每日上限拒绝回执时,单笔 GET 返回 HTTP 200 与通知相同的原文,可补查漏通知。GET 只读,不重算额度、不重开原单或调用钱包付款。旧泛化拒单、损坏或身份不匹配回执仍为 503,保留预扣并交平台核查。玩家提现列表不包含未受理拒单,列表缺失不是退款证明。

手机如何提示超限

code页面提示下一步
USER_DAILY_WITHDRAWAL_LIMIT_EXCEEDED已超过每日提现上限,请等待额度重置;显示重置时刻与剩余时间retryAt=quota.resetAt 提示本次快照的重置时间,不是提交冷却期;额度恢复后由玩家决定是否发起新申请
REQUEST_AMOUNT_EXCEEDS_DAILY_LIMIT本次金额超过每日上限,请调整金额retryAt/retryAfterSeconds=null;不能提示“等明天就可提这笔金额”

从完整通知的 quota 读取 limitBase、spentBase、availableBase、requestedBase,先核对 quota.decimals 与可信资产配置的 tokenDecimals 一致,再按精度换算显示,不能只信通知自带精度。当前 spentBase 包含当天计入的处理中和成功申请金额,不只代表已付款;reservedBase="0"、blocked=false。policyVersion 是兼容字段,当前保存配置不会递增,不拿它判断快照新旧。

evaluatedAt 是判断时间,resetAt 是下一次北京时间 00:00,正文使用 UTC 时间字符串。倒数由 OG 后端按可信当前时间计算 max(0, ceil(resetAt - 当前时间)),手机使用校准后的时间;不要从收到通知开始重数 resetInSeconds。通知迟到且重置时间已过时,提示“额度重置时间已到,可重新申请”。快照不是实时额度,倒数到 0 不保证下一笔受理,也不自动发起付款。若已计入订单后来取消或明确失败,或平台提高上限,额度也可能在零点前恢复;retryAt 不禁止这时由玩家发起新申请,但旧单仍不能自动重发。

原拒绝订单在跨日、提额或清空配置后仍然是拒绝。玩家明确重新申请时,才用新的订单号重新检查余额与预扣;不要给旧单自动换号重发。

完整拒绝怎样只释放一次

拒绝通知的 schemaVersion=2 是正文格式版本,不表示普通通道的提交和查询也返回同一正文。完整字段、固定值和离线示例见Webhook API 参考:status=rejected,accepted/broadcast/paymentInitiated=false,final=true,retryMode=new_order,并包含 decisionId/eventId、玩家、原订单、链/合约、地址、金额、code/quota。用下载示例附带的解析器严格检查完整形状、nullable、重复键、UTF-8 和金额字符串,不能只解析自己需要的几个字段。

从自己的可信配置取得 environment/accessNamespace 身份范围(scope),与本地原单一起核对玩家、登记链与合约、按链规范比较的地址、金额、可信资产精度与决定标识;核验由该 scope 与订单派生的 eventId。同 ID 异原文、不同决定、身份不符或已有受理、凭证、付款证据时,保留首次证据和原账,持久记录冲突并核查,不能自动释放。

锁定原单后,只有无受理或付款证据的原预扣 held 才转为 released 一次,不额外增加余额。保存原文、摘要、decisionId/eventId、拒绝状态和账务后提交,再按第 5 节回签;保存失败不 ACK。rejected 不能加入通用 RELEASE;泛化错误、不完整拒绝、404/409/503、超时或额度查询都不能触发释放。重复通知与重启后重投仍验签,但已释放的原单不再改账。

完成标志:通知早于响应、晚于响应、重复、并发或重启后到达,都只释放原单一次;页面能正确提示原因与等待时间。失败先查完整原文、可信 scope 和原单绑定,再查事务、预扣与平台投递记录。

提现页展示当前每日上限

打开提现页或切换网络时,从登录态确定玩家,使用查询每日上限 GET /v1/withdrawal-quota?playerId=…&chainId=…,两个 query 各一次、空 body,按原始路径/query 签名。它读取管理后台保存的同一规则,不是提现前置条件,也不会提交订单。

quota 必返。对象中的 spentBase 是当前北京时间自然日已计入的申请额,含处理中和成功,排除取消、拒绝和失败;昨天未完成订单不占今天。首次保存前申请排除,修改保留起点,清空重存从新起点计入,包含等于起点的申请。reservedBase="0"、blocked=false,剩余钳到 0。decimals=6 时 1000000000 表示 1000 USDT,全程使用精确整数字符串。

quota:null 表示尚未设置或已清空,即不限额;此时不显示剩余额度和重置倒计时,不造一个 0 上限。有对象时 serverNow=quota.evaluatedAt,requestedBase/retryAt/retryAfterSeconds 均为 null,按 resetAt 与可信当前时间展示。当前快照不保证下一笔受理,不用于扣款/释放预扣,不重开已拒绝原单。

401 查签名,422 查登记链、两个 query 与空 body,404 表示玩家不存在,503/超时保持未知,稍后重查;先核对服务版本、目录与数据库只读权限,不把未知改成不限额。

历史特殊通道兼容

仅处理历史特殊通道原单时需要本节。已经保存完整拒绝回执的这类原单,其 POST 422、原单 GET 200 和 Webhook 使用同一份完整拒绝正文,三渠道共用上述一次释放事务;之后暂停能力,历史原单仍保留完整拒绝响应。处理时按原单已保存的通道判断,不能仅看当前开关。保存当前“用户每日提现上限”不会自动启用此能力。

历史特殊通道可能保留跨日未决占用,或返回 USER_WITHDRAWAL_QUOTA_BLOCKED;阻断时 retryAt=null,累计超限也不保证次日有足够额度。这些规则不能套用到当前普通通道。真实 PHP 或其他语言接入方启用该兼容模式前,要独立验证三渠道乱序/重复的一次释放与持久 ACK,不能以本地示例通过代替实际业务验收。

5. Webhook:接收、验签与回签

Webhook 是平台主动调用 OG 后端。先实现一个接收 URL,再由平台登记目标与独立通知凭据。两条链、充值和提现可共用这个 URL,按 type 分别处理;不向平台的 /notify 发请求“订阅”。

Webhook:先验签,再提交,最后回签
  1. 平台 → OG

    推送通知

    原始 JSON + 三个签名头

  2. OG 接收端

    验证原文签名

    使用独立 Webhook 凭据

  3. OG → 业务账本

    校验并持久提交

    eventId 防重,更新原交易

  4. OG → 平台

    返回正确回签

    200 + 原请求 X-Signature

最小接收顺序

  1. 读取原始 path、query 和 body,检查三个签名头各只有一份,通知 Access Key 匹配且时间戳在 300 秒内。
  2. 用 Webhook secret 对原请求五段原文计算 HMAC,恒定时间比较。验签失败不改账,返回非 2xx。
  3. 验签后校验事件及字段,充值核对玩家和资产;提现关联本地原单并核对原金额。按 eventId 防通知重复,按业务标识防重复改账。
  4. 在事务中保存通知、业务状态与账务结果,提交成功后才回签。保存失败不成功 ACK;未知订单、事实不足或冲突终态保持原账务,可靠记录异常并交给补查任务。
TEXT
HTTP/1.1 200 OK
X-Signature: REPLACE_COMPUTED_REQUEST_HMAC
Content-Length: 0

回签值是本次原请求 HMAC 的小写十六进制;不签应答 body,不改用当前时间。必须恰好一个 X-Signature,5 秒内返回 2xx,应答体不超过 4096 字节。重复通知仍要验签并正确回签。仅返回 200 不代表投递成功。

先用查询 Webhook 配置核对目标、启用状态和签名配置。它是只读接口,availability=ready 只说明具备发送前提,不证明送达。

事件怎么分流

每一行是一条独立通知,type/status/phase 按对应关系处理,不能交叉组合。进度只更新展示,充值与提现的账务各自独立。

typestatusphaseOG 处理
deposit_detecteddeposit_detectedprogress记录充值已发现,不入账
deposit_confirmingdeposit_confirmingprogress更新确认进度,不入账
deposit_confirmedconfirmed不出现核对后按 txRef 入账一次
withdrawal_acceptedacceptedprogress保留预扣
withdrawal_sendingsendingprogress保留预扣,不证明已广播
withdrawal_confirmingconfirmingprogress保留预扣
withdrawal_heldheldprogress等待核查,保留预扣
withdrawal_manual_pendingmanual_pendingprogress等待人工流程,保留预扣
withdrawal_auto_sendingauto_sendingprogress兼容自动流程,保留预扣
withdrawal_voucher_checkingvoucher_checkingprogress凭证核验中,保留预扣
withdrawal_voucher_failedvoucher_failedprogress凭证未通过,仍保留预扣
withdrawal_succeededsucceeded不出现确认原预扣为扣款一次
withdrawal_failedfailed不出现释放原预扣一次
withdrawal_refundedrefunded不出现释放原预扣一次
withdrawal_rejectedrejected不出现每日上限等受理前拒绝;完整正文严格校验及原单/scope 绑定后,专用事务一次释放

旧提现付款终态没有 playerId,由本地原单关联玩家;单笔成功另含 txHash,多笔成功可无顶层哈希,须有 verificationStage=payments_succeeded 与 paymentDetails。核对 paymentState=succeeded、完整事实总额等于原单、remaining/excess为零、没有待确认付款及冻结精度,部分付款不结清。有凭证阶段时可能带扩展字段,按本次通知校验,不能假定固定字段数。字段与配对见Webhook API 参考。确认数字段缺失表示未知,不补 0;充值使用 confirmationsRequired,提现使用 requiredConfirmations。

选择一个语言版本,接上真实事务

目的:复用原文验签、事件分流和提交后回签的接收边界。Go、Java、Python 任选一个,同一份可处理双链及充值/提现;新增拒绝先严格验证完整 wire,再由你们的 commit 完成业务绑定和账务。下载 ZIP 保持目录布局,内含 Go 共享 walletprotocol module、Python og_quota.py、Java QuotaRejection.java 及固定依赖脚本;单独复制接收器源码不包含这些依赖。

版本接入方式你们必须实现
Go 1.24+,共享 walletprotocol/quota将 NewHandler(key, secret, commit) 注册到实际通知路径commit 提交完成返回 nil,失败返回 error
Java 17+,Jackson + QuotaRejection.java构造 OgWebhook,将原始请求交给 receive,原样写回 HttpResultCommit.save 提交完成才返回,失败抛异常
Python 3.10+,配套 og_quota.py(标准库)调用 WebhookReceiver.receive,保留原始 bytes 和完整头列表同步 commit 提交完成才返回,失败抛异常

Java 示例需引入 com.fasterxml.jackson.core:jackson-databind:2.22.3。Maven 项目可添加以下依赖;已有项目应通过统一依赖管理保持 Jackson 各模块兼容。

XML
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.22.3</version>
</dependency>
Go · HTTP Handler + 共享 quota codec展开接收器源码

下载完整示例与校验依赖 ZIP ↗ · 下载 receiver.go ↗ · 单个源码需配套校验依赖;先接入真实 commit 事务,再用于通知接收。

Go · HTTP Handler + 共享 quota codec
// Package ogwebhook 提供 OG 通知接收边界:验签、事件分流、事务提交后回签。
// 用 NewHandler 将它接入你们的 HTTP 服务;数据库和账务由 commit 实现。
package ogwebhook

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"math/big"
	"net/http"
	"regexp"
	"strconv"
	"time"
	"unicode/utf8"

	"walletprotocol/quota"
)

const maxBody = 1 << 20 // 本示例的接收端上限:1 MiB。

// Event 保留原始 JSON 字段;金额、链 ID、订单号等不能转为浮点数。
// Family 是 deposit 或 withdrawal;提现所属链和玩家从本地原订单获取。
type Event struct {
	Family string
	Type   string
	ID     string
	Fields map[string]json.RawMessage
}

// String 只读取 JSON 字符串,数字/null/缺失字段均返回 false。
func (e Event) String(name string) (string, bool) {
	var value string
	err := json.Unmarshal(e.Fields[name], &value)
	return value, err == nil && value != ""
}

// NewHandler 必须传入独立的通知凭据和真实的事务处理函数,不能使用空实现。
// commit 的职责(详见配套接入文档):
//   - 按当前 OpenAPI 验证完整字段、玩家/链/合约及本地原订单金额。
//   - v2 rejected 独立核对可信 environment/accessNamespace、原请求全部身份与 eventId,
//     三渠道共用事务;仅无受理/付款证据的原 held 可转 released 一次。保存原 bytes/摘要/决定,
//     同 ID 异文、不同决定、受理或付款冲突须持久隔离,不覆盖原证据;不能放进通用 RELEASE。
//   - 同一事务持久化通知、按 eventId 去重、按 txRef/withdrawOrderId 幂等改账。
//   - 进度不改余额;充值确认按 txRef 入账;提现成功确认预扣,失败/退款释放预扣。
//   - 拒绝旧进度覆盖终态;异常和终态冲突持久化核查;失效充值恢复先查询复核。
//   - 事务 COMMIT 成功后才返回 nil;合法重复也须核对并确认已持久化,再返回 nil。
//   - 无法自动结账的异常可持久化到待核查表,保持余额不变,提交后返回 nil。
//     此时 ACK 仅代表可靠接收,不代表充值已入账或提现已结账。
//
// 不要启动 goroutine 后立即返回 nil,不要只写内存;失败返回 error,接收端不会回签。
// 建议服务端设置超时,并保证事务和响应在发送方的 5 秒超时内完成。
func NewHandler(accessKey, secret string, commit func(context.Context, Event, []byte) error) (http.Handler, error) {
	if accessKey == "" || secret == "" || commit == nil {
		return nil, errors.New("通知 access key、secret 和事务 commit 均不能为空")
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			w.Header().Set("Allow", http.MethodPost)
			http.Error(w, "POST required", http.StatusMethodNotAllowed)
			return
		}
		key, keyOK := singleHeader(r, "X-Access-Key")
		timestamp, timeOK := singleHeader(r, "X-Timestamp")
		signature, sigOK := singleHeader(r, "X-Signature")
		unix, err := strconv.ParseInt(timestamp, 10, 64)
		now := time.Now().Unix()
		if !keyOK || !timeOK || !sigOK || !hmac.Equal([]byte(key), []byte(accessKey)) ||
			!digits.MatchString(timestamp) || err != nil || unix < now-300 || unix > now+300 ||
			!lowerSignature.MatchString(signature) {
			http.Error(w, "invalid authentication", http.StatusUnauthorized)
			return
		}
		raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
		if err != nil {
			var tooLarge *http.MaxBytesError
			if errors.As(err, &tooLarge) {
				http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
			} else {
				http.Error(w, "cannot read body", http.StatusBadRequest)
			}
			return
		}
		// 使用 HTTP 请求的原始路径、查询串与正文;反向代理须保留外部路径前缀。
		// 登记的通知 URL 目前不允许 query,因此实际投递 RawQuery 通常为空。
		// 仍保留 QUERY 段及分隔符。不要用 URL.Path、解码后的 query 或重序列化 JSON。
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write([]byte(r.Method + "|" + r.URL.EscapedPath() + "|" + timestamp + "|" + r.URL.RawQuery + "|"))
		mac.Write(raw)
		expected := mac.Sum(nil)
		provided, _ := hex.DecodeString(signature) // 上面的格式检查已限定 64 个小写 hex。
		if !hmac.Equal(expected, provided) {
			http.Error(w, "invalid authentication", http.StatusUnauthorized)
			return
		}
		event, err := parseEvent(raw)
		if err != nil {
			http.Error(w, "invalid event", http.StatusBadRequest)
			return
		}
		// 重复通知也执行 commit,由数据库唯一约束和事务完成幂等处理。
		if err := commit(r.Context(), event, raw); err != nil {
			http.Error(w, "transaction not committed", http.StatusServiceUnavailable)
			return
		}
		// 签原通知请求,绝不签下面的响应正文;恰好返回一份签名。
		w.Header().Set("X-Signature", hex.EncodeToString(expected))
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_, _ = io.WriteString(w, "{}")
	}), nil
}

func singleHeader(r *http.Request, name string) (string, bool) {
	values := r.Header.Values(name)
	if len(values) != 1 || values[0] == "" {
		return "", false
	}
	return values[0], true
}

var (
	digits         = regexp.MustCompile(`^[0-9]+$`)
	positiveAmount = regexp.MustCompile(`^[1-9][0-9]*$`)
	orderID        = regexp.MustCompile(`^[0-9]{30}$`)
	lowerSignature = regexp.MustCompile(`^[0-9a-f]{64}$`)
)

type eventRule struct {
	family, status string
	progress       bool
}

// 充值和提现是独立流程,两类均由同一接收地址接收 Webhook。
var eventRules = map[string]eventRule{
	"deposit_detected":            {"deposit", "deposit_detected", true},
	"deposit_confirming":          {"deposit", "deposit_confirming", true},
	"deposit_confirmed":           {"deposit", "confirmed", false},
	"withdrawal_accepted":         {"withdrawal", "accepted", true},
	"withdrawal_sending":          {"withdrawal", "sending", true},
	"withdrawal_confirming":       {"withdrawal", "confirming", true},
	"withdrawal_held":             {"withdrawal", "held", true},
	"withdrawal_manual_pending":   {"withdrawal", "manual_pending", true},
	"withdrawal_auto_sending":     {"withdrawal", "auto_sending", true},
	"withdrawal_voucher_checking": {"withdrawal", "voucher_checking", true},
	"withdrawal_voucher_failed":   {"withdrawal", "voucher_failed", true},
	"withdrawal_succeeded":        {"withdrawal", "succeeded", false},
	"withdrawal_failed":           {"withdrawal", "failed", false},
	"withdrawal_refunded":         {"withdrawal", "refunded", false},
}

// 这里校验分流所需最小字段,不替代完整的 OpenAPI 校验与业务核对。
func parseEvent(raw []byte) (Event, error) {
	var event Event
	bad := errors.New("invalid event")
	// Unmarshal 保留原有的单对象、语法和 10000 层深度边界,再拒绝各层重复键。
	if !utf8.Valid(raw) || json.Unmarshal(raw, &event.Fields) != nil || event.Fields == nil || !uniqueJSONKeys(raw) {
		return event, bad
	}
	event.ID, _ = event.String("eventId")
	event.Type, _ = event.String("type")
	statusValue, _ := event.String("status")
	v2 := false
	for _, key := range []string{"schemaVersion", "decisionId", "quota", "final", "paymentInitiated", "retryMode", "accepted", "broadcast"} {
		if _, present := event.Fields[key]; present {
			v2 = true
		}
	}
	if v2 || event.Type == "withdrawal_rejected" || statusValue == "rejected" {
		// 共享 Go codec 是完整 v2 wire 的唯一权威;原单/scope 仍须 commit 从可信记录绑定。
		rejection, err := quota.DecodeRejection(raw)
		if err != nil {
			return event, bad
		}
		event.Family, event.Type, event.ID = "withdrawal", rejection.Type, rejection.EventID
		return event, nil
	}
	status, _ := event.String("status")
	amount, _ := event.String("amount")
	rule, known := eventRules[event.Type]
	if !known || event.ID == "" || status != rule.status || len(amount) > 78 || !positiveAmount.MatchString(amount) {
		return event, bad
	}
	n, validAmount := new(big.Int).SetString(amount, 10)
	if !validAmount || n.BitLen() > 256 {
		return event, bad
	}
	event.Family = rule.family
	phase, _ := event.String("phase")
	_, hasPhase := event.Fields["phase"]
	if (rule.progress && phase != "progress") || (!rule.progress && hasPhase) {
		return event, bad // 终态必须省略 phase;null 也不接受。
	}
	if event.Family == "deposit" {
		for _, field := range []string{"playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef"} {
			if _, ok := event.String(field); !ok {
				return event, bad
			}
		}
		if rule.progress && (!integerAtLeast(event.Fields["confirmations"], 0) || !integerAtLeast(event.Fields["confirmationsRequired"], 1)) {
			return event, bad
		}
	} else {
		id, _ := event.String("withdrawOrderId")
		if !orderID.MatchString(id) {
			return event, bad
		}
		if event.Type == "withdrawal_succeeded" {
			if _, ok := event.String("txHash"); !ok {
				stage, _ := event.String("verificationStage")
				var details map[string]json.RawMessage
				if stage != "payments_succeeded" || json.Unmarshal(event.Fields["paymentDetails"], &details) != nil || details == nil {
					return event, bad
				}
				// Complete arithmetic and frozen-order binding belong to durable commit.
			}
		}
	}
	return event, nil
}

// 只检查键的唯一性,不改写参与验签和持久化的原始正文。
func uniqueJSONKeys(raw []byte) bool {
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.UseNumber()
	var value func() error
	value = func() error {
		token, err := decoder.Token()
		if err != nil {
			return err
		}
		switch token {
		case json.Delim('{'):
			seen := map[string]bool{}
			for decoder.More() {
				key, err := decoder.Token()
				if err != nil {
					return err
				}
				name, ok := key.(string)
				if !ok || seen[name] {
					return errors.New("重复 JSON 键")
				}
				seen[name] = true
				if err := value(); err != nil {
					return err
				}
			}
			_, err = decoder.Token()
		case json.Delim('['):
			for decoder.More() {
				if err := value(); err != nil {
					return err
				}
			}
			_, err = decoder.Token()
		}
		return err
	}
	if value() != nil {
		return false
	}
	_, err := decoder.Token()
	return err == io.EOF
}

func integerAtLeast(raw json.RawMessage, minimum int64) bool {
	var value int64
	return string(raw) != "null" && json.Unmarshal(raw, &value) == nil && value >= minimum
}
Java · Java 17 + Jackson + QuotaRejection展开接收器源码

下载完整示例与校验依赖 ZIP ↗ · 下载 OgWebhook.java ↗ · 单个源码需配套校验依赖;先接入真实 commit 事务,再用于通知接收。

Java · Java 17 + Jackson + QuotaRejection
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;

/**
 * 目的:把 ETH / TRON 的充值、提现通知安全交给 OG 的业务事务,提交完成才回签。
 * Java 17 + Jackson;框架无关。充值与提现是独立业务,提现不要求先发生充值。
 * 旧14事件验证最小包络,v2 拒绝验证完整 wire;业务绑定和账务由必填 Commit 实现。
 */
public final class OgWebhook {
    public record Header(String name, String value) {}
    public record HttpResult(int status, Map<String, String> headers, byte[] body) {}

    @FunctionalInterface
    public interface Commit {
        /**
         * 返回前必须完成持久事务;抛错会得到 503 且不回签。
         * 事务须绑定 OG/环境,验证玩家、链、资产、原订单金额及各事件必需字段;
         * 按 eventId 保存原文摘要、检测同 ID 异文,并按 txRef / withdrawOrderId 幂等改账。
         * 充值进度不入账;confirmed 入账一次,失效/冲正后恢复须先查询复核。
         * 提现进度(含 voucher_failed)保留预扣;succeeded 确认扣款一次;
         * failed / refunded 释放原预扣一次;乱序进度不得覆盖终态,冲突终态须核查。
         * 重复已提交事件可直接返回;异常事实也可持久化隔离、安排核查后返回,不改余额。
         * v2 rejected 必须另绑定可信 scope、原请求全部身份与 eventId;三渠道共用事务,
         * 仅无 accepted/付款证据的原 held 可转 released 一次。原 bytes/摘要/决定同次保存,
         * 同 ID 异文/不同决定/付款冲突持久隔离,不覆盖首次证据,不纳入通用 RELEASE。
         * 禁止用空实现、内存集合或日志替代持久事务后就返回成功。
         */
        void save(JsonNode event, byte[] originalBody) throws Exception;
    }

    private record Rule(String status, boolean progress) {}
    private static final Map<String, Rule> RULES = Map.ofEntries(
        Map.entry("deposit_detected", new Rule("deposit_detected", true)),
        Map.entry("deposit_confirming", new Rule("deposit_confirming", true)),
        Map.entry("deposit_confirmed", new Rule("confirmed", false)),
        Map.entry("withdrawal_accepted", new Rule("accepted", true)),
        Map.entry("withdrawal_sending", new Rule("sending", true)),
        Map.entry("withdrawal_confirming", new Rule("confirming", true)),
        Map.entry("withdrawal_held", new Rule("held", true)),
        Map.entry("withdrawal_manual_pending", new Rule("manual_pending", true)),
        Map.entry("withdrawal_auto_sending", new Rule("auto_sending", true)),
        Map.entry("withdrawal_voucher_checking", new Rule("voucher_checking", true)),
        Map.entry("withdrawal_voucher_failed", new Rule("voucher_failed", true)),
        Map.entry("withdrawal_succeeded", new Rule("succeeded", false)),
        Map.entry("withdrawal_failed", new Rule("failed", false)),
        Map.entry("withdrawal_refunded", new Rule("refunded", false))
    );
    private static final ObjectMapper JSON = new ObjectMapper(JsonFactory.builder()
        .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build())
        .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
    private final byte[] accessKey, secret;
    private final Clock clock;

    /** 传入独立 Webhook 凭据;不能传 OG 调用平台 API 的密钥。配置不完整时拒绝启动。 */
    public OgWebhook(String webhookAccessKey, String webhookSecret, Clock clock) {
        if (webhookAccessKey == null || webhookAccessKey.isBlank()
                || webhookSecret == null || webhookSecret.isEmpty()
                || webhookAccessKey.startsWith("REPLACE_") || webhookSecret.startsWith("REPLACE_")
                || clock == null) throw new IllegalArgumentException("Webhook configuration required");
        this.accessKey = bytes(webhookAccessKey);
        this.secret = bytes(webhookSecret);
        this.clock = clock;
    }

    /**
     * rawTarget = 原始转义路径 + 可选 '?' + 原始 query;不能 URL 解码、排序或重新编码。
     * Spring/Servlet 适配时用 getRequestURI() 和 getQueryString() 保留原样,
     * 不要使用 getParameterMap();代理也必须保留签名时的外部路径与 query。
     * headers 必须保留每个头的全部值(getHeaders),不能用单值 Map 隐藏重复头。
     * rawBody 从输入流按最多 1 MiB + 1 字节读取;超限立即拒绝,不能先无限读取。
     * 先传原字节进来验签,不能让框架解析 JSON 后再序列化。
     */
    public HttpResult receive(String method, String rawTarget, List<Header> headers,
                              byte[] rawBody, Commit commit) {
        if (!"POST".equals(method)) return reject(405);
        if (rawBody == null || headers == null) return reject(400);
        if (rawBody.length > 1_048_576) return reject(413);
        if (commit == null) return reject(503);
        if (rawTarget == null || !rawTarget.startsWith("/")
                || !rawTarget.matches("[\\x21-\\x7e]+") || rawTarget.contains("#")) return reject(400);
        String key, timestamp, supplied;
        try {
            key = unique(headers, "X-Access-Key");
            timestamp = unique(headers, "X-Timestamp");
            supplied = unique(headers, "X-Signature");
        } catch (IllegalArgumentException e) { return reject(401); }
        if (!MessageDigest.isEqual(accessKey, bytes(key))
                || !timestamp.matches("[0-9]{1,20}") || !supplied.matches("[0-9a-f]{64}")) return reject(401);
        try {
            long ts = Long.parseLong(timestamp), now = clock.instant().getEpochSecond();
            if (ts < now - 300 || ts > now + 300) return reject(401);
        } catch (NumberFormatException e) { return reject(401); }

        int mark = rawTarget.indexOf('?');
        String path = mark < 0 ? rawTarget : rawTarget.substring(0, mark);
        String query = mark < 0 ? "" : rawTarget.substring(mark + 1);
        byte[] body = rawBody.clone();
        byte[] expected;
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret, "HmacSHA256"));
            mac.update(bytes(method + "|" + path + "|" + timestamp + "|" + query + "|"));
            expected = mac.doFinal(body);
        } catch (java.security.GeneralSecurityException e) { return reject(503); }
        if (!MessageDigest.isEqual(expected, HexFormat.of().parseHex(supplied))) return reject(401);

        JsonNode event;
        try {
            // 协议正文为 UTF-8;先严格解码,避免 Jackson 自动识别并接受 UTF-16 等编码。
            String utf8 = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
                .decode(ByteBuffer.wrap(body)).toString();
            event = JSON.readTree(utf8);
            if (event != null && event.isObject() && (event.has("schemaVersion") || event.has("decisionId") || event.has("quota")
                    || event.has("final") || event.has("paymentInitiated") || event.has("retryMode")
                    || event.has("accepted") || event.has("broadcast")
                    || "withdrawal_rejected".equals(event.path("type").textValue())
                    || "rejected".equals(event.path("status").textValue()))) {
                event = QuotaRejection.decode(body);
            } else {
                if (event == null || !event.isObject() || !text(event, "eventId")
                        || !text(event, "type") || !text(event, "status") || !text(event, "amount")
                        || !event.get("amount").textValue().matches("[1-9][0-9]{0,77}")
                        || new BigInteger(event.get("amount").textValue()).bitLength() > 256) return reject(400);
                String type = event.get("type").textValue();
                Rule rule = RULES.get(type);
                if (rule == null || !rule.status().equals(event.get("status").textValue())) return reject(400);
                // 终态 phase 必须整个字段缺失;null 或空字符串也不接受。
                if (rule.progress() ? !"progress".equals(event.path("phase").textValue()) : event.has("phase"))
                    return reject(400);
                if (type.startsWith("deposit_")) {
                    for (String field : List.of("playerId", "chainName", "chainId", "txHash", "logIndex", "tokenContract", "blockNumber", "txRef")) {
                        if (!text(event, field)) return reject(400);
                    }
                    if (rule.progress() && (!event.path("confirmations").isIntegralNumber() || event.path("confirmations").bigIntegerValue().signum() < 0
                        || !event.path("confirmationsRequired").isIntegralNumber() || event.path("confirmationsRequired").bigIntegerValue().signum() <= 0)) return reject(400);
                } else if (!text(event, "withdrawOrderId")
                        || !event.get("withdrawOrderId").textValue().matches("[0-9]{30}")) return reject(400);
                if (type.equals("withdrawal_succeeded") && !text(event, "txHash")
                        && !("payments_succeeded".equals(event.path("verificationStage").textValue()) && event.path("paymentDetails").isObject())) return reject(400);
            }
        } catch (java.io.IOException | IllegalArgumentException e) { return reject(400); }

        try { commit.save(event, body); }
        catch (Exception e) { return reject(503); } // 可记录固定错误分类;不要记录密钥或原始敏感正文。
        // 回签覆盖「本次原请求」,不签 '{}';只发送一个 X-Signature,用 setHeader 而非 addHeader。
        return new HttpResult(200, Map.of("Content-Type", "application/json",
            "X-Signature", HexFormat.of().formatHex(expected)), bytes("{}"));
    }

    private static String unique(List<Header> headers, String name) {
        String value = null;
        for (Header h : headers) {
            if (h == null || h.name() == null) throw new IllegalArgumentException("Invalid header");
            if (name.equalsIgnoreCase(h.name())) {
                if (value != null || h.value() == null || h.value().isEmpty())
                    throw new IllegalArgumentException("Missing or repeated signature header");
                value = h.value();
            }
        }
        if (value == null) throw new IllegalArgumentException("Missing signature header");
        return value;
    }
    private static boolean text(JsonNode event, String name) {
        return event.has(name) && event.get(name).isTextual() && !event.get(name).textValue().isEmpty();
    }
    private static byte[] bytes(String value) { return value.getBytes(StandardCharsets.UTF_8); }
    private static HttpResult reject(int status) {
        return new HttpResult(status, Map.of("Content-Type", "application/json"), bytes("{}"));
    }
}
Python · Python 3.10+ + og_quota.py展开接收器源码

下载完整示例与校验依赖 ZIP ↗ · 下载 receiver.py ↗ · 单个源码需配套校验依赖;先接入真实 commit 事务,再用于通知接收。

Python · Python 3.10+ + og_quota.py
"""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})

commit 不能是空函数、打印日志或启动后台任务后立即返回。三份示例都需要真实持久事务;框架须保留原路径/原始正文与重复头,不能解析 JSON 后再重组来验签。Python 示例不接受异步 commit 直接传入,ASGI 需在线程池调用同步组件,或实现真正等待提交的异步接收器。框架适配与固定签名演算见接收端指南。

通知可能重复、乱序或跳过进度,迟到进度不能覆盖终态。已持久记录的事件跨重启保留;提现终态(含 withdrawal_rejected)累计 3 次确定投递失败后停止自动重试,不能无限等待重投。DNS/发送前阻塞、进程取消等未知结果的区别及详细退避见通知重试规则。已受理订单继续查询补偿;当前普通通道每日上限拒单缺通知时,要请平台核查拒绝及投递记录。

完成标志:合法通知提交后得到正确回签,重复不重复改账;不符时先查原始字节、独立凭据、事务提交和应答签名,再查平台投递记录。

新建原单或确认充值归属时保存不可变的环境、接入方、玩家、链、合约与精度快照;新配置不覆盖历史资产。历史单查询/通知用旧快照核对,无已登记历史绑定的旧资产进入人工核查。

补查责任要落库:原单、预扣/收件箱和任务同事务创建;任务以 scope+原单/txRef 去重,worker 以租约领取、失败退避、过期重领,结束时核对租约版本。超过重试预算转持久人工核查且有负责人与处理证据;ACK 之前必须已保存任务。通知/查询任意先到与重启重投共用持久幂等账务事务。

6. 历史记录:每笔只保留一条

平台提供当前状态,OG 保存完整业务历史。Webhook 是更新消息,不是一笔新交易。 手机只读取 OG 的授权账本。

本地记录唯一标识收到查询或通知后
充值txRef更新原记录的状态、确认数及入账结果
提现withdrawOrderId更新原订单、预扣与结账结果
通知收件箱当前接入方/环境及 eventId保存原始通知与处理结果,同 ID 内容冲突转核查

交易哈希只用于定位链上交易,不能代替 txRef 去重:同一交易可能包含多条转账日志。三个标识均按完整字符串保存。

TEXT
平台 Webhook → OG 验签、幂等事务提交 → 正确回签
OG 提交后 → 通知该玩家页面刷新(SSE / WebSocket / 短轮询任选)
手机重读 OG 账本 → 找到同一 txRef / withdrawOrderId → 更新原记录

页面断线或重新打开时,先重读当前玩家、当前网络的账本。迟到响应应核对玩家/网络与当前申请,不能覆盖正在填写的新提现;终态不被旧进度改回“处理中”。实时通道由 OG 自行实现,不是新增平台公共 API。

后台持续查询补偿

补偿任务平台接口注意什么
发现充值,复核已知充值玩家充值列表 + 单笔充值列表最多 1000 条、无下一页,只是当前同步视图;已入账充值仍要复核失效
补查已受理提现玩家提现列表:GET /v1/players/{playerId}/withdrawals?chainId=...&page=1&pageSize=20返回 {items,total,page,pageSize},按原订单合并,保留 OG 自己的未知提交
跟踪处理中或结果未知的提现单笔提现:GET /v1/withdrawals/{withdrawOrderId}原单独立补查,空页、404/503 或超时均不释放预扣;每日上限完整拒绝可由 GET 200 原文补查,旧泛化或损坏回执交平台核查

提现列表不包含每日上限拒单,OG 应保留自己的拒绝记录和原通知。提现列表按首次受理时间 acceptedAt 倒序,同秒按订单号倒序;默认每页 20,可选 50,是 offset 分页,新单可能移动边界,须去重并刷新首页。其余页码与范围上限见 API 参考。充值列表按首次发现倒序;区块/日志序号是大整数字符串,不用 JavaScript Number 排序。

平台充值响应没有精确链上时间或逐笔收款地址。页面不要把通知接收时间当作充值时间,也不要把当前充值地址填进全部历史。查询失败或空列表不代表历史不存在,已有本地记录继续保留。

后台补偿独立于手机是否在线运行,与通知共用同一套账务方法。列表达到上限、同步长期中断或发现缺口时记录告警,向平台申请历史核查;不能声称这些列表能回补任意历史。旧近 24 小时充值对账接口仍保留兼容,手机主流程无需依赖它。

完成标志:重复通知和查询都只更新原记录,刷新后历史及余额仍一致。异常先查原业务标识、唯一约束、原单和事务,不按通知条数增加余额或记录。

7. 跟着 OG 模拟器验证

打开OG 流程模拟器,进入交易所 / 第三方接入。按当前服务实际开放网络操作;以下测试步骤分别在 Sepolia、Nile 执行。

链接打不开时,先查模拟器是否已启动以及 /og/ 代理路径。独立本机预览需将文档服务的 OG_DOCS_SIMULATOR_URL 设置为模拟器实际入口,再重启文档服务;预期链接指向实际模拟器,而不是文档服务的 404 页面。

步骤你做什么应看到什么不符时先查
1. 玩家与网络用右侧“OG 模拟 ID”切换玩家,点击充值/提现网络打开选择框手机显示当前玩家、实际网络与资产当前玩家和登记网络是否可用
2. 自动取址打开充值标签自动显示地址和纯地址二维码,无需手动取址取址错误与网络/资产配置
3. 充值客户外部钱包向该地址转入同网络的测试资产同一笔记录从“处理中”变为“已到账”,只入账一次钱包目标、网络、合约与平台同步
4. 提现有业务余额的玩家填金额和同网络收款地址,提交明确受理后表单自动清空,历史增加一条“处理中”;可直接填下一笔原订单、受理字段、持久保存与可用余额
5. 等待结果由授权出款人完成审批和实际出款,等待查询/通知原提现记录更新成功或失败,预扣仅结算一次人工队列、回执、确认状态和原单
6. 观察更新同笔重复查询,等待后续 Webhook,再刷新页面多次消息仍只有一条业务记录,历史与余额不重复变化通知记录、验签、事务与原标识
7. 换另一条链网络弹框切换 Sepolia/Nile,重新核对地址并重复验证两条链各自的记录和状态正确,不能沿用旧网络参数玩家、网络、合约和完整资产范围

模拟器收到 Webhook 并持久保存后,通过 SSE 提示手机重读账本;同时有查询补偿。取址不是实时接口,交易记录才需要持续更新。二维码不携带网络与金额;客户钱包仍须自行核对。

当前模拟器的提现历史来自自身持久订单,按原单查询补偿;没有取消按钮,也尚未调用公共玩家提现分页接口。第 4、6 节分别说明正式 OG 可以接入的取消与历史分页,不能据此认为模拟器已有这些操作。

遇到结果未知的提现,从历史“打开这笔原订单”,查询或按原内容重试;不要更换订单号。模拟器若收到事实不足的 TRON 基础终态,会可靠保存异常、保持预扣并回签,此时用原订单查询补齐事实,不能把“已回签”当成“已结账”。这是模拟器额外采用的保守核查策略,不改变公共契约:三语言适配器在签名、基础终态和本地冻结原单绑定均有效时,可按 succeeded/failed/refunded 结账;只有未知原单、身份/金额冲突、缺必需字段或付款事实矛盾才必须补查/隔离,不因 TRON 基础终态省略可选链字段一律拒绝。

模拟器是 OG 后端的参考实现,它的 /api/*、SSE、二维码和账务字段均是工具自己的接口;正式接入使用本文链接的 /v1/*,密钥与账务仍由你们后端管理。

完成标志:两条链分别验证充值、提现及各自 Webhook,保留真实 txRef、订单号、交易哈希、事件和账务结果。页面可打开或配置显示 ready,不能代替资金流程验收。

8. 验收与常见问题

每条链分别核对

  • 自动取址与网络切换正确,二维码与复制地址一致。
  • 一笔充值只入账一次,进度不入账;失效与恢复通过单笔查询核实。
  • 一笔提现只预扣一次,明确受理后可继续新申请;未知结果始终查原单。
  • succeeded 只确认扣款一次,failed/refunded 只释放一次;凭证失败保持预扣。
  • 充值、提现都能独立收到 Webhook,持久提交后正确回签。
  • 每日上限同用户同链跨地址累计,用户和链独立;通用 422/503 不释放,完整拒绝通知仅释放一次。
  • 超限通知早到、迟到、重复和重启后重投均正确处理;单笔大于上限不提示“明天一定可提”。
  • 北京时间跨日后昨日未完成单不占今日;再次保存不清零,清空后重存从新时刻起算,旧拒单始终不重开。
  • 重复、乱序、漏通知及页面重开后,原记录与余额仍一致。

最常见的排错顺序

现象先做什么
API 401对照原方法、路径、Unix 秒、query、body 和 API 凭据,重签原请求
取址 422核对两个请求字段、玩家格式和开放网络;仍失败请平台查地址服务
钱包已转出,充值没出现核对网络、资产、目标,再请平台查扫描/同步;不再补转一笔
Webhook 重发查原请求 HMAC、事务是否提交、唯一应答 X-Signature,不是只看 200
提现超时、泛化 422、查单 404/503保留原单和预扣,查询或原内容重试;不换单号。疑似每日上限拒绝但无通知时,请平台核查拒绝与投递记录
已收到完整超限拒绝,但倒数明显过长核对 resetAt 与 OG 服务端当前时间,不能从收件时间重数旧 resetInSeconds
上限已调高或已跨日,旧单仍被拒绝这是原单的固定拒绝结果;由玩家明确发起新申请,不自动换号重发
提现一直处理中或 held查人工审批、实际出款与链上确认,等待期间保持预扣
取消 409当前不能取消;未终态时保持预扣,按原单查询,不当作退款成功
旧进度覆盖成功、余额重复查事件/业务唯一键、锁或版本及共享账务事务,冲突时保留证据核查

需要平台协助时,提供环境、玩家、网络、txRef 或原订单号、交易哈希、eventId 及脱敏响应;不发送密钥。全部字段、限制与错误码以API 参考和OpenAPI为准。

输入关键词搜索指南、接口和字段。