# Webhook 事件接入

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

## 地址与投递模型

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

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

## 请求头与消息体

```http
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
```

```json
{
  "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` 可能因事件类型和迁移关联情况为空/省略，不要假设所有事件都含用户身份。

签名规则：

```text
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、产品归属检查和事务幂等。

```go
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、处理状态、处理时间和安全错误码。不要只使用内存去重，否则重启后会重复发权益。

处理业务事务的伪代码：

```text
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 或完整原始正文。
