# Webhooks 与事件订阅

本文介绍开发者平台的 Webhooks 事件信封格式、签名校验实现、重试策略及应用生命周期事件清单。

## 理解 Webhook 事件信封

所有向外投递的事件均采用统一的事件信封结构：

```json
{
  "id": "evt_01JBCDEF0123456789ABCDEF01",
  "type": "app.installed",
  "createdAt": "2026-10-28T12:00:00Z",
  "apiVersion": "2026-10-28",
  "tenant": "acme_corp",
  "environment": "live",
  "app": "shop-sync",
  "data": {
    "version": "1.0.0",
    "scopes": ["tables.base:read", "platform:ping"]
  }
}
```

字段说明：
- `id`：事件的唯一标识。
- `type`：事件类型。
- `createdAt`：事件发生的 UTC 时间（RFC 3339 格式）。
- `apiVersion`：生成该事件的接口版本。
- `tenant`：触发事件的租户标识。当商店应用收到订阅事件时，平台会在信封中附带安装该应用的真实租户 ID。
- `environment`：事件所属环境，为 `test` 或 `live`。
- `app`：事件接收方的应用 ID。
- `data`：与具体事件类型相关的业务载荷数据。

## 校验 Webhook 签名

每个 Webhook 请求均包含以下 HTTP 头部：
- `X-Kwork-Signature`：格式为 `t=<时间戳>,v1=<十六进制签名>`。
- `X-Kwork-Event`：事件类型，与信封中 `type` 一致。
- `X-Kwork-Delivery`：这次投递的唯一标识，同一次投递的重试不变。

### HMAC 校验原理

签名计算规则如下：
1. 从 `X-Kwork-Signature` 头中分别提取 Unix 时间戳 `t` 与签名字符串 `v1`。
2. 构造待签名字符串：`"<t>.<原始请求正文>"`。必须使用未经 JSON 解析的原始请求正文字节流。
3. 使用你的 Webhook 密钥作为 Key，计算待签名字符串的 HMAC-SHA256 哈希值，输出为小写十六进制字符串。
4. 使用恒定时间比较函数核对计算值与 `v1` 是否一致。
5. 核对时间戳 `t` 与当前时间的差值（如不超过 300 秒），防止重放攻击。

### 使用 Go SDK 验签

Go SDK 提供了内置的验签辅助函数：

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/21stware/raise-go"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "无法读取正文", http.StatusBadRequest)
		return
	}

	sigHeader := r.Header.Get("X-Kwork-Signature")
	secret := "${WEBHOOK_SECRET}"

	// 使用 SDK 验签
	event, err := raise.VerifyWebhook(body, sigHeader, secret, 300*time.Second)
	if err != nil {
		http.Error(w, "验签失败", http.StatusUnauthorized)
		return
	}
	_ = event // event.Type、event.Data：按 event.ID 去重

	w.WriteHeader(http.StatusOK)
}

// 原生 HMAC 验签参考实现
func verifyPlainHMAC(body []byte, sigHeader, secret string, tolerance time.Duration) bool {
	var timestampStr, signature string
	for _, part := range strings.Split(sigHeader, ",") {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) == 2 {
			switch kv[0] {
			case "t":
				timestampStr = kv[1]
			case "v1":
				signature = kv[1]
			}
		}
	}
	ts, err := strconv.ParseInt(timestampStr, 10, 64)
	if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestampStr + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}
```

### 使用 TypeScript SDK 验签

TypeScript SDK 提供了零依赖的 `webhooks.verify` 方法：

```typescript
import http from 'node:http';
import { webhooks } from '@21stware/raise-sdk';

const secret = process.env.WEBHOOK_SECRET || '${WEBHOOK_SECRET}';

const server = http.createServer(async (req, res) => {
  if (req.method === 'POST' && req.url === '/webhooks') {
    const chunks: Buffer[] = [];
    for await (const chunk of req) {
      chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
    }
    const rawBody = Buffer.concat(chunks).toString('utf8');
    const signature = req.headers['x-kwork-signature'] as string;

    try {
      const event = await webhooks.verify(rawBody, signature, secret, 300);
      console.log('接收到事件:', event.type, event.id);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ received: true }));
    } catch (err) {
      console.error('签名校验失败:', err);
      res.writeHead(401, { 'Content-Type': 'text/plain' });
      res.end('Invalid signature');
    }
  } else {
    res.writeHead(404).end();
  }
});

server.listen(3000);
```

## 处理事件推送重试

你的 Webhook 服务接收到事件后，必须返回 HTTP 2xx 状态码以确认接收。

如果你的服务返回非 2xx 状态码、连不上，或者 10 秒内没有答完，平台会重试：依次等 30 秒、2 分钟、10 分钟、1 小时、6 小时再发。一次投递最多发 6 次（第一次加 5 次重试），从第一次到最后一次大约 7 小时 12 分钟；第 6 次仍然失败，这次投递标为失败，不再发送。响应正文平台不保存（最多读 64 KiB 后丢弃），所以收到后尽快回答 2xx，慢的工作放在回答之后。

重试时请求正文不变，`X-Kwork-Delivery` 与信封里的 `id` 也不变；`X-Kwork-Signature` 里的时间戳和签名每次重新计算。按 `id` 做成幂等：第二次收到同一个 `id` 仍回答 2xx，不要再执行一遍副作用。

不保证顺序：几条投递会并行发出，重试的那条可能晚于之后发生的事件到达。

在开发者平台应用的「事件订阅」里，每条订阅的「投递记录」列出最近 50 条投递：事件类型、时间、已发次数、最后一次的响应状态码或错误、状态（等待、已送达、失败）。没有投递耗时，也没有响应正文。已经结束（已送达或失败）的记录在 30 天后删除；删除订阅时，它的投递记录一起删除。

目前没有重新投递：失败的投递不能在控制台或通过接口再发一次。「发送测试」会向这条订阅发一个 `open.ping` 测试事件，用来检查地址和验签，它不是重发。漏掉的事件要靠你自己调用接口取最新状态来补。

## 监听应用生命周期事件

应用生命周期事件由平台自动发给应用专属的开发者订阅，无需单独申请权限范围：

| 事件类型 (`type`) | 数据载荷 (`data`) | 触发场景 |
|---|---|---|
| `app.installed` | `{"version": "1.0.0", "scopes": [...]}` | 租户管理员在组织内安装了该应用，附带初始生效的权限范围。 |
| `app.uninstalled` | `{}` | 租户管理员从组织内卸载了该应用。 |
| `app.upgraded` | `{"from": "1.0.0", "to": "1.1.0", "scopes": [...]}` | 租户内的应用升级到了新版本。 |
| `app.scopes_changed` | `{"scopes": [...], "added": [...], "removed": [...]}` | 这个租户里应用生效的权限范围变了：管理员收回或重新授予了已安装版本所请求的某些范围，或者升级后生效的范围和升级前不同。只发给没有页面的后端应用（`integration`）；管理员不能授予版本没有请求的范围。有页面的应用的范围来自清单的 `uses`（见 [App API](/developers/guide/app-api)），不单独授予，也收不到这个事件。 |
