跳转到正文
图奕科技 开发文档 平台登录
接入指南 / Webhook 事件下载本章 Markdown ↓

本实例 Issuer https://platform.too1.net

Webhook 事件接入

Webhook 是平台服务端发送给产品服务端的事件通知,不是浏览器回跳,也不是微信/支付宝直接通知产品。产品无需持有支付渠道证书;它只保存平台分配给该产品的 Webhook Secret。

地址与投递模型

产品向导建议地址 https://api.example.com/api/tuyi/events。产品必须实现 POST handler,公网 HTTPS 可达,不跳转、不要求用户 Cookie/CSRF 登录。此入口以 HMAC 验签作为认证;不要把其他业务接口的认证一起关闭。

平台按至少一次语义投递,不保证顺序;签名每次按实际投递时间生成。超过最大尝试次数(当前 12 次)记录进入 dead,后台可见;不是永远自动重试。没有已启用端点的事件保留待投递。产品应监控积压,并为重要资金状态提供查询补偿和人工处理流程。

请求头与消息体

POST /api/tuyi/events
Content-Type: application/json
X-Tuyi-Event-ID: 00000000-0000-4000-8000-000000000010
X-Tuyi-Timestamp: 1788400000
X-Tuyi-Signature: sha256=HEX_DIGEST
{
  "id": "00000000-0000-4000-8000-000000000010",
  "type": "payment.succeeded",
  "occurred_at": "2026-09-03T00:00:00Z",
  "sub": "00000000-0000-4000-8000-000000000001",
  "product_code": "wyc",
  "data": {
    "payment_id": "00000000-0000-4000-8000-000000000020",
    "external_order_id": "WYC-ORDER-001",
    "amount_fen": 9900,
    "currency": "CNY"
  }
}

示例时间戳和签名不能直接用于验证。sub 可能因事件类型和迁移关联情况为空/省略,不要假设所有事件都含用户身份。

签名规则:

message = ASCII(X-Tuyi-Timestamp) + "." + 请求正文原始字节
signature = "sha256=" + lowercase_hex(HMAC_SHA256(webhook_secret, message))

必须先读取受限长度的原始 body再验签;先反序列化再重新编码会改变空格/顺序,导致签名不同。

服务端校验顺序

  1. 限制请求体大小(建议 64 KiB),只允许 POST;时间戳、事件 ID、签名头各出现一次。
  2. 使用部署配置中的预期产品 Secret;不得让未验签的正文指定任意密钥或认证服务器。
  3. 时间窗口建议当前时间前后 5 分钟,服务器保持时钟同步。检查投递时间戳,不用 occurred_at 判断重试请求是否过期。
  4. 解码 64 位十六进制摘要,用 hmac.Equal 常量时间比较,不直接用字符串 ==。
  5. 验签成功后解析 JSON,检查 product_code=wyc、正文 id 与 X-Tuyi-Event-ID 一致、必需字段与类型正确。
  6. 按事件 ID 去重入库,与业务状态/待处理任务在同一事务提交;成功后返回 200 或 204。

Go 验签函数示例

以下函数仅负责签名及时间校验,调用方仍须实施请求体限长、单值 header、产品归属检查和事务幂等。

package integration

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "strings"
    "time"
)

func VerifyWebhook(secret, timestamp, signature string, body []byte, now time.Time) bool {
    if len(secret) < 32 || len(body) > 64<<10 || len(timestamp) > 12 {
        return false
    }
    unix, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || unix < now.Unix()-300 || unix > now.Unix()+300 {
        return false
    }
    if !strings.HasPrefix(signature, "sha256=") || len(signature) != 71 {
        return false
    }
    got, err := hex.DecodeString(strings.TrimPrefix(signature, "sha256="))
    if err != nil {
        return false
    }
    mac := hmac.New(sha256.New, []byte(secret))
    _, _ = mac.Write([]byte(timestamp + "."))
    _, _ = mac.Write(body)
    return hmac.Equal(got, mac.Sum(nil))
}

密钥不是用户密码,平台生成随机值;不要自行换成弱口令。函数返回 false 时不要回显原签名/正文/Secret。

产品侧幂等落库

建议建立 platform_event_inbox:以 event_id 唯一,保存产品代码、事件类型、平台订单 ID、处理状态、处理时间和安全错误码。不要只使用内存去重,否则重启后会重复发权益。

处理业务事务的伪代码:

BEGIN
  INSERT inbox(event_id, status='processing') ON CONFLICT DO NOTHING
  如果未插入:校验已存事件一致,已处理则直接成功
  锁定对应产品业务订单
  校验 payment_id / external_order_id / amount_fen / currency
  如果业务订单尚未交付:写入订单已支付 + 权益流水(唯一业务键)
  标记 inbox 已处理
COMMIT
返回 204

数据库失败返回非 2xx,让平台稍后重试;不能先返回成功再将事件只放进不可靠的内存队列。若采用异步处理,必须先可靠落库再返回 2xx。

事件与产品动作

事件 产品处理
user.disabled 禁用本地登录/会话;不删除业务资产
user.enabled 更新本地禁用状态,不复活已失效会话
user.phone_changed 撤销本地认证状态;手机号本身不在事件中明文同步
user.sessions_revoked 撤销产品自己的会话
mapping.created 更新映射同步任务,核查已有绑定,禁止覆盖冲突
product_membership.activated 按 sub 与 external_user_id 建立或确认产品业务账号
product_membership.suspended 撤销当前产品的本地会话;不删除业务资产
product_membership.reactivated 恢复登录资格;不自动改变产品内角色、代理或资源关系
payment.succeeded 核验订单金额/币种/归属后幂等交付权益
payment.closed 结束待支付状态,不覆盖已确认支付结果
payment.duplicate_suspected 人工核对,不二次发权益
refund.pending_approval 展示等待审批,不当作退款成功
refund.succeeded 核验退款单并按产品规则调整权益
refund.failed 展示失败、排障,不重复盲目申请

不要假设退款事件携带完整支付订单字段;收到 refund_id 后可调用退款查询,再关联自己的业务退款单。金额字段不能用浮点计算。

由于事件可能乱序,已支付状态不能被迟到的关闭事件覆盖;收到互相矛盾的资金事件要服务端查询平台确认。账号和成员启停事件没有全局递增版本保证,产品应串行处理同一用户,并通过产品成员查询接口补偿,不能按网络到达顺序随意覆盖安全状态。成员事件只表达平台登录资格,不表达产品内代理树、角色或资源归属。

验收

  • 篡改正文、签名、产品代码和事件 ID 必须拒绝。
  • 拒绝超窗时间、超大正文及重复 header。
  • 同一事件投递 10 次只交付一次;同订单两个相关事件也不能二次发权益。
  • 验证进程重启、数据库回滚、平台重试、乱序、未知事件类型和支付查询补偿。
  • 未识别但验签正确的事件可可靠记录并告警后确认,不能尝试以默认分支发权益。
  • 日志只记录请求编号、事件 ID、业务错误码等允许字段,不记录 Token、签名 Secret 或完整原始正文。