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再验签;先反序列化再重新编码会改变空格/顺序,导致签名不同。
服务端校验顺序
- 限制请求体大小(建议 64 KiB),只允许 POST;时间戳、事件 ID、签名头各出现一次。
- 使用部署配置中的预期产品 Secret;不得让未验签的正文指定任意密钥或认证服务器。
- 时间窗口建议当前时间前后 5 分钟,服务器保持时钟同步。检查投递时间戳,不用
occurred_at判断重试请求是否过期。 - 解码 64 位十六进制摘要,用
hmac.Equal常量时间比较,不直接用字符串==。 - 验签成功后解析 JSON,检查
product_code=wyc、正文id与X-Tuyi-Event-ID一致、必需字段与类型正确。 - 按事件 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 或完整原始正文。