# FluxDesk Inbound Integration Specification

| | |
|---|---|
| Spec 版本 | `v2.0` |
| 最后更新 | 2026-05-26 |
| 适用 FluxDesk 版本 | `v0.10.16+` |
| 适用范围 | 公司自研业务系统（监控 / OA / CI / 自定义工具）向 FluxDesk 发送事件 |
| 鉴权模型 | 用户自助 Bearer Token（v1 admin + HMAC 模式已废弃） |
| 决策依据 | [`adr/0001-inbound-integration-user-owned-tokens.md`](./adr/0001-inbound-integration-user-owned-tokens.md) |
| 相关文件 | [`INTEGRATIONS.md`](./INTEGRATIONS.md) outbound · [`FEATURES.md`](./FEATURES.md) Charter |

---

## 1. 目的

让公司内部业务系统按**同一个标准**把事件发到指定用户的 FluxDesk inbox，由 FluxDesk 统一进 inbox / 浏览器推送 / Telegram DM 分发。

**三种接入模式(共存,按可控性从高到低):**

| | 谁创建 token | 谁配在业务系统 | 定位 / 适合场景 | 端点 |
|---|---|---|---|---|
| **1 直连(personal)** 🌟 **首选** | 用户自助 | 用户自己 | **内部业务系统的首选模式** —— 监控 / OA / Grafana / CI / 个人脚本。权限收得最紧(token=单 owner,内容由用户自己控),可控性高,出问题责任清晰 | `/inbound/personal` |
| **2 审核(moderation)** | admin | admin 自己 | **外部 / 第三方系统的兜底模式** —— 内容多样、可预测性低、不一定遵守我们 schema 习惯。事件先全部进 admin 的「待转发」队列,admin 审一遍再扇给目标用户(逐条把关) | `/inbound/personal` + admin 在 `/me/integrations/moderation` 转发 |
| **3 半授权(tools-claim)** | admin | CLI / 工具(只配 1 个 tools-token) | **内部定制工具的常用模式** —— 自研 CLI / 自动化脚本要给多用户发事件。一个 tools-token 覆盖多人,用户自助申请 + admin 一次性审批,免去逐人造 token 的麻烦 | `/integrations/claim` 取 personal token → 再走 `/inbound/personal` |

模式选择速记:

- 公司**内部**业务系统(监控 / OA / Grafana / CI) → **1 直连**(首选,权限最稳)
- **外部 / 第三方** 系统接进来(变数大,内容不可控) → **2 审核**(admin 逐条把关)
- 公司**内部定制**工具 / 自研 CLI 要发给**多个用户** → **3 半授权**(一个 token 全包)
- 自己一个人用 → **1 直连**

> 提示:**默认优先采用直连**。审核 / 半授权是给「直连不太合适」的场景兜底 —— 不是 fancier,是更宽松,可控性反而更低。

**两种读 spec 的方式**:
1. 浏览器登录 FluxDesk → `/integrations/protocol`(本页的渲染版,带样式 + 侧边栏)
2. 任何机器直接 `curl https://<your-domain>/api/internal-os/integrations/protocol`(无需鉴权,返回纯 markdown,方便 grep / 入文档生成器)

**这份 spec 不适用于**：
- 第三方 SaaS（GitHub / Sentry / Linear 等）走专用 adapter，不是这套 spec
- FluxDesk 主动拉取外部数据，那是 outbound integration，看 `INTEGRATIONS.md`
- 全文同步 / 评论镜像 / 双向 sync，FluxDesk 永远只做「显示层」（Charter Article 6）
- 跨用户 / 组级 / 团队级广播 —— 由业务系统侧自行 fan-out 到多个 token

---

## 2. Charter 守底（开工前必读）

Binding source: [`CHARTER_V2.md`](./CHARTER_V2.md). Protocol evolution must
stay additive ([R7](./CHARTER_V2.md#r7--additive-only)); FluxDesk remains the
display / coordination layer, not a source-system clone
([Article 6](./CHARTER_V2.md#charter-v2-12-articles)).

接入前请源系统开发者明确：

| Article | 含义 | 落到 spec 上 |
|---|---|---|
| **6** 系统边界 | FluxDesk 不做业务逻辑，只展示入口、摘要、状态、可操作事件 | 只发 `title` / `summary` / `external_url`；详情留在源系统 |
| **7** 不存敏感数据 | 不存完整 prompt / 邮件正文 / 客户内容 / 凭证 | `summary` ≤ 500 字符；禁止字段见 §6；URL 不带 `?token=` 等参数 |
| **5** 项目信息默认隔离 | 通知 scope 到 user | token = owner，事件 = 该 user 的 inbox，无 cross-user 路由 |
| **2** 用户不反感 | 不打扰 | opt-in 是设计起点，每 token 严重度上限 + daily limit |
| **8** 设计简约 | 复用现有 type / 不新增 schema | 事件最终映射到现有 5 种 notification type |
| **9** 作者完整性 | 事件归属清晰 | token owner 即接收主体 + 事件元数据，admin 也看不到内容 |

**违反任何一条会被服务端 reject（4xx）或在 audit log 留警告。**

---

## 3. 接入流程

> 三种模式都走这一节的 §5 schema 发事件;区别只在「token 怎么搞到手」。下面 §3.1 是模式 1,§3.2 是模式 2,§3.3 是模式 3。

### 3.1 模式 1「直连」· 用户在 FluxDesk 申请 token 🌟 首选

> **公司内部业务系统(监控 / OA / Grafana / CI 等)请优先用这个**。原因:token 跟用户 1:1 绑定,内容由用户自己控制 + 业务系统侧维护,出问题责任清晰;FluxDesk 这边权限收得最紧,可控性最高。其他两种模式只在直连不太合适的时候才考虑。

1. 登录 FluxDesk → `/me/integrations`
2. 「+ 申请新接入 token」展开表单
3. 填写：
   - `label`（如「监控告警」，给自己区分多个 token 用）
   - 备注：来自哪个业务系统（自由文字，方便日后回忆）
   - `daily_limit`(50 / 200 / 500 / 1000,**默认 200** —— v0.17.36 起从 50 调高,监控类源都够用;超出当天剩余只入 inbox 不 push;1000 是组织硬顶)
   - ~~`severity_ceiling`~~ —— **v0.17.28 起下线**,token 上不再设阈值;severity 完全由发送方按事件选(`critical` / `warn` / `info` / `success`),FluxDesk 收到就按 severity 渲染对应颜色 + emoji + 走 push。
4. 点「申请并生成 token」→ 系统返回：
   - 端点 URL：`https://my.<your-domain>/api/internal-os/integrations/inbound/personal`
   - Bearer token：`flux-in-pers-XXXXXXXX`（**只显示一次**，复制后必须立即去配置；丢失走「轮换」）

### 3.1.1 用户在业务系统配置 webhook(模式 1)

1. 登录自己的业务系统的「通知 / webhook」设置
2. 添加新 webhook：
   - URL = 端点 URL
   - Header：`Authorization: Bearer flux-in-pers-XXXXXXXX`
   - Content-Type：`application/json; charset=utf-8`
   - Body 格式按 §5 schema
3. 在业务系统侧发一条测试事件
4. 在 FluxDesk inbox / TG 检查是否收到

### 3.2 模式 2「审核」· admin 在 FluxDesk 配审核 token

> **专门给「外部 / 第三方系统接入」兜底用**。这类源的特点是:格式不固定、内容多变、可预测性低、不一定遵守你们的 schema 习惯。admin 把审核 token 给对方后,**事件先全部进 admin 的「待转发」队列** —— 由 admin 逐条审、改文案、再扇出给目标用户。安全网,不是日常通道。

1. admin 登录 → `/team-support`(顶部紫色「🛡 Inbound 审核模式 token」section)
2. 填标签 + 来源备注 + 日上限 → 「生成审核模式 token」
3. 拿到 `flux-in-pers-XXX`(只显示一次)
4. admin 把这条 token 粘到 OA / 监控的 webhook 设置 —— **跟模式 1 完全一样的端点 + body schema**
5. 事件来了:进 admin 的「待转发」队列,**不进入普通 inbox,不 push TG / 浏览器**(v0.17.31 起)
6. admin 去 sidebar `🛡 待转发(N)` 或 `/me/integrations/moderation` 审一遍 → 改文案(可选)→ 「📤 转发给 alice」 → alice 收到一条标准 FluxDesk 通知

模式 2 关键点:
- **token 还是 admin 自己的**(token = owner = admin,v2 不破)
- 转发是写一条 `notifications` 表的 row 给 alice,不在 `inbound_integration_deliveries` 表给 alice 新建 delivery
- alice 看到的「发件人」= admin(看 `notifications.actor_user_id`),原始 event_type 在 tag 里留档
- 适合需要逐条把关的低频高敏感场景

### 3.3 模式 3「半授权」· admin 给内部 CLI 配 tools-token

> **公司内部定制工具的常用模式** —— 自研 CLI / 自动化脚本要给**多个用户**发事件、但又不想给每个用户单独造直连 token。一个 tools-token 覆盖所有目标用户,用户自助申请 + admin 在 team-support 一次性审批,后续 CLI 调 claim API 现取用户 token。

1. admin 登录 → `/team-support`(顶部蓝绿色「🔑 工具对接 tools-token」section)
2. 填:token 标签 + 来源备注 + catalog 显示名(可选)+ catalog 说明(留给用户申请时看)
3. 「生成 tools-claim token + 对应 catalog row」 → 拿到 `flux-tools-XXX`(**注意 prefix 不一样**,这条只能 claim,不能直接发事件)+ /team-support catalog 里自动建好一行「📥 外部工具消息接入 · {标签}」
4. admin 把 `flux-tools-XXX` 粘到 CLI / OA 的配置里
5. **用户**去 `/team-support` 申请刚建好的那个 catalog row(同走现有 team-support 申请流程)
6. **admin** 在 team-support review tab → 「批准」该申请(同走现有审批流)
7. **CLI** 调用 `/api/internal-os/integrations/claim`(单个) 或 `/integrations/claim/batch`(N 个一次):
   ```http
   POST /api/internal-os/integrations/claim
   Authorization: Bearer flux-tools-XXX
   Content-Type: application/json

   {"email": "alice@fluxprimestudio.com"}
   ```
   返回(`201` 首次 / `200` 旋转):
   ```json
   {
     "ok": true,
     "token": "flux-in-pers-YYY...",
     "token_id": "uuid",
     "endpoint": "https://<your-domain>/api/internal-os/integrations/inbound/personal",
     "rotated": false
   }
   ```
8. CLI 拿到 `flux-in-pers-YYY` → 用它走**模式 1 的端点**发事件(alice 自己的 inbox / push)

模式 3 关键点:
- **每个用户还是有自己的 personal token**(claim 时自动 mint 出来),v2 token=owner 守底
- CLI 缓存 personal token,下次发事件直接用;掉了再 claim,FluxDesk 会**旋转**(新 token + 旧的 24h overlap)
- 没批准的邮箱 → `403 no_approved_request`(CLI 收到就提示用户去 /team-support 申请;或 admin 在 catalog row 用「🎯 直接分配给用户」一键预批)
- 适合「一个 CLI 覆盖很多用户」、admin 只想批一次而不是每条审

#### Mode 3 Bulk · `POST /integrations/claim/batch`(v0.17.36 起)

首次部署给 N 个用户 bootstrap token,用 batch 一次性搞定:

**Request**
```http
POST /api/internal-os/integrations/claim/batch
Authorization: Bearer flux-tools-XXX
Content-Type: application/json

{"emails": ["alice@x.com", "bob@x.com", "charlie@x.com"]}
```

最多 50 个一批(超过 → 400 `emails_too_many`)。

**Response**(永远 200,per-row 结果)
```json
{
  "ok": true,
  "endpoint": "https://.../inbound/personal",
  "succeeded": 2,
  "failed": 1,
  "results": [
    {"email": "alice@x.com",   "ok": true,  "token": "flux-in-pers-…", "token_id": "…", "rotated": false},
    {"email": "bob@x.com",     "ok": true,  "token": "flux-in-pers-…", "token_id": "…", "rotated": true},
    {"email": "charlie@x.com", "ok": false, "error": "no_approved_request"}
  ]
}
```

CLI 自己根据 per-row `ok` 决定:已拿到 token 的存进 config,未授权的提示用户去 /team-support 申请。

#### Mode 3 Smoke-test · `POST /integrations/claim/ping`(v0.17.36 起)

不 mint / 不旋转任何 token,纯验证 tools-token 配置 OK。

```http
POST /api/internal-os/integrations/claim/ping
Authorization: Bearer flux-tools-XXX
```

**Response**
```json
{
  "ok": true,
  "kind": "tools_claim",
  "tools_token_id": "uuid",
  "label": "ks-cli-tools",
  "owner": "admin@x.com",
  "now": "2026-05-21T08:32:14.123Z"
}
```

无效 token → 401。CLI 部署完先 ping 一下确认连接 + auth 都对,再 claim 真实数据。

### 3.4 联调失败排查

| 现象 | 可能原因 |
|---|---|
| 业务系统看不到响应 | 网络不通；检查 DNS / 防火墙 |
| 401 | Token 错 / Authorization header 格式错 / token 已被禁用 |
| 400 | Body schema 错；按 §5 校对字段 |
| 413 | Body > 256KB；按 §6 砍 summary |
| 429 | 超过限频；看 `Retry-After` header |
| 收到 202 但 inbox 没显示 | 严重度低于 push 阈值（仅入 inbox 不 push），刷新 inbox 看 |

---

## 4. HTTP 协议

### 4.1 基本要求

| 项 | 值 |
|---|---|
| Method | `POST` |
| URL | `https://my.<your-domain>/api/internal-os/integrations/inbound/personal` |
| Content-Type | `application/json; charset=utf-8` |
| Encoding | UTF-8（**不接受** UTF-16 / GBK） |
| Body 大小上限 | 256 KB（超出 → `413 Payload Too Large`） |
| 单次 POST 事件数 | 1 个事件 / 1 个 POST。**不支持批量** |
| Transfer-Encoding | 不接受 `chunked`，必须明确 `Content-Length` |
| HTTP 版本 | HTTP/1.1 或 HTTP/2 |
| TLS | 必须；TLS 1.2+ |

### 4.2 必需 HTTP headers

```http
POST /api/internal-os/integrations/inbound/personal HTTP/1.1
Host: my.fluxdesk.net
Content-Type: application/json; charset=utf-8
Content-Length: 423
Authorization: Bearer flux-in-pers-A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5
User-Agent: <source-name>/<version>
```

- `Authorization`：必须以 `Bearer ` 前缀（**注意 Bearer 后有一个空格**）
- `User-Agent`：建议 `monitor-sender/1.2`，方便排查

> v0.17.36 起 `X-FluxDesk-Event-Id` header 不再要求(以前是 docs-only 没 enforce,纯冗余)。`event_id` 现在只在 body 里读。

### 4.3 响应状态码

| 码 | 含义 | sender 该怎么办 |
|---|---|---|
| `202 Accepted` | 已入队，将异步分发 | 标记成功 |
| `200 OK` | 已存在的 event_id，幂等返回（详情见 §8） | 标记成功，**不要重发** |
| `400 Bad Request` | schema 错误 / 字段超长 / 禁止字段 | **不要重试**，修代码 |
| `401 Unauthorized` | Token 错 / header 缺 / token 已禁用 / 已吊销 | **不要重试**，找 owner 重申请 |
| `413 Payload Too Large` | body > 256KB | **不要重试**，按 §6 砍 summary |
| `429 Too Many Requests` | 限频 / 熔断 | 按 `Retry-After` header 重试 |
| `5xx` | FluxDesk 内部错误 | 重试（详细策略见 §10） |

### 4.4 400 schema_invalid 响应:全错误一次返回(v0.24.8 起)

之前 schema_invalid 只返回第一条 zod issue,sender 修一条又撞下一条,5 个字段错要 5 次 round-trip。现在 `errors[]` 一次性返回全部 issue:

**Legacy endpoint** `POST /api/internal-os/integrations/inbound/personal`:

```json
{
  "error": "schema_invalid",
  "reason": "...第一条 issue 的 message...",        // ← back-compat,= errors[0].reason
  "field":  "...第一条 issue 的 path...",           // ← back-compat,= errors[0].field
  "errors": [
    { "field": "title",        "reason": "Required" },
    { "field": "occurred_at",  "reason": "occurred_at must be within (now - 24h, now + 5min)" },
    { "field": "actions.0.url", "reason": "actions[].url required https when action_type=url; webhook_url required https when action_type=webhook" }
  ]
}
```

**`/api/v1/events` (RFC 7807)** — `errors[]` 转写进标准 `fieldErrors` 扩展:

```json
{
  "type":   "https://docs.fluxdesk.net/api/v1/errors/validation-failed",
  "title":  "Validation failed",
  "status": 400,
  "detail": "...第一条 issue 的 message...",
  "fieldErrors": {
    "title":         ["Required"],
    "occurred_at":   ["occurred_at must be within (now - 24h, now + 5min)"],
    "actions.0.url": ["actions[].url required https when action_type=url; webhook_url required https when action_type=webhook"]
  }
}
```

**Back-compat**:`reason` / `field` 顶层字段保留 = 第一条 issue,老 sender 完全不用改动;新 sender 切到 `errors[]` / `fieldErrors` 一次拿全。

---

## 5. 标准事件 Schema

完整 TypeScript 定义：

```typescript
interface InboundEventV2 {
  // ===== 必填 =====
  spec_version: "2";

  /**
   * 源系统内稳定唯一的事件 ID。
   * 用于 dedup + 状态更新。**不要用时间戳**。
   * 长度 1-120，字符集 [a-zA-Z0-9_-]
   */
  event_id: string;

  /**
   * 源系统定义的事件类型字符串；仅用于 sender / receiver 双方对照排查。
   * FluxDesk 不解释含义，不做匹配。
   * 长度 1-60，建议小写点号格式：`alert.firing` / `oa.leave.submitted`
   */
  event_type: string;

  severity: "critical" | "warn" | "info";

  /** 标题，显示在 inbox 列表第一行。长度 1-200 */
  title: string;

  /** ISO 8601 with timezone，事件实际发生时间（不是发送时间） */
  occurred_at: string;

  // ===== 选填 =====

  /**
   * 摘要 / 简介；显示在 inbox 第二行 + push body 第一段。
   * **不要塞全文**。长度 ≤ 500
   */
  summary?: string;

  /**
   * 长文本 markdown 正文(可选)。≤ 8000 字符。
   * Inbox UI 默认折叠在「📋 详情」 disclosure 后。
   */
  markdown_body?: string;

  /**
   * markdown_body 的渲染模式提示(可选,默认 "collapsed")。
   *   "collapsed" → 默认行为,藏在「📋 详情」 disclosure 后
   *   "expanded"  → 直接展开,无 disclosure(短/紧急事件)
   *   "preview"   → disclosure,但 toggle 文本是 body 前 ~200 字符
   * 仅在同时带 markdown_body 时生效。
   */
  markdown_body_rendering?: "collapsed" | "expanded" | "preview";

  /**
   * 跳回源系统看详情的 URL。
   * 必须 https://。**不允许带** ?token= / ?secret= / ?api_key= 等敏感参数
   */
  external_url?: string;

  /**
   * 源系统侧的状态码。FluxDesk 用它做 dedup 行的状态显示。
   * 不在枚举内的值会被映射成 null
   */
  external_status?: "firing" | "resolved" | "pending" | "approved" | "rejected" | "withdrawn";

  /**
   * 触发事件的人（可选）；仅作展示用（"由 Carol 提交"），不做 user 映射
   * email 字段允许公司内外，长度 ≤ 120
   */
  actor?: {
    email: string;
    name?: string;  // 长度 ≤ 80
  };

  /**
   * 标签；最多 20 对 k-v。仅展示用，不做匹配
   * key/value 必须是字符串；value ≤ 80 字符
   */
  labels?: Record<string, string>;

  /**
   * v0.19.0 — 结构化收件人提示（替代 legacy `recipient_hint`）。
   * 与 `recipient_hint` 互斥（XOR），同时给两个 → 400。
   * 仅 moderation token 的 admin 转发 UI 会消费；普通 token 不允许带。
   * 未来扩展：type 会加入 "user_id" / "phone" / "telegram_chat_id" 等。
   */
  recipient?: {
    type: "email";
    value: string;  // ≤ 120 字符
  };

  /**
   * v0.19.0 — UI 渲染色调，跟技术 `severity` 解耦。默认 "neutral"。
   * severity 控技术行为（retry / push / alerting），
   * tone 控视觉色调（图标 / 颜色）。
   * 例：severity=info + tone=positive = "🎉 你拿到新权限了"
   */
  tone?: "neutral" | "positive" | "negative";

  /**
   * v0.19.0 — BCP-47 locale tag（"zh-CN" / "en-US" / "en" / …）。
   * Inbox 未来用来做多语言渲染。默认 NULL（继承用户偏好）。
   */
  locale?: string;
}
```

### 5.1 字段限制速查

| 字段 | 类型 | 必填 | 最大长度 / 范围 |
|---|---|---|---|
| `spec_version` | string | ✓ | `"2"` |
| `event_id` | string | ✓ | 1-120 字符 |
| `event_type` | string | ✓ | 1-60 字符，**自由文本 hint**(用于 inbox 分组 / 标签);不是枚举,不要按白名单写 |
| `severity` | enum | ✓ | `critical` / `warn` / `info` / `success`(各自映射到不同颜色 + emoji;`success` 跟 `info` 走相同的 push 行为,只是 UI 渲染绿色) |
| `title` | string | ✓ | 1-200 字符 |
| `occurred_at` | ISO 8601 | ✓ | 必须 < now + 5 分钟，> now - 24 小时 |
| `summary` | string | ✗ | ≤ 500 字符 |
| `markdown_body` | string | ✗ | ≤ 8000 字符,长文本/命令示例;inbox UI 默认折叠在「📋 详情」 disclosure 后 |
| `markdown_body_rendering` | enum | ✗ | `collapsed`(默认,藏 disclosure 后) / `expanded`(直接展开,无 disclosure) / `preview`(disclosure 用 body 前 ~200 字符作 label)。仅在带 `markdown_body` 时有意义 |
| `actions` | array | ✗ | ≤ 4 个按钮,每个 `{label, action_type, url?, webhook_url?}`,详见 §5.3 |
| `recipient` | object | **✓ moderation 必填** / ✗ 其它 | v0.19.0 新增,结构化版本的 `recipient_hint`。`{type:"email", value}`。与 `recipient_hint` 互斥(XOR)。详见 §5.4。**不会触发自动路由** |
| `recipient_hint` | object | legacy ✓ moderation 必填 / ✗ 其它 | **Deprecated · legacy**(v0.17.29) —— 新代码请改用 `recipient: {type, value}`。`{email?, user_id?, display_hint?}`。仍接受但不会再演进;与 `recipient` 互斥(XOR) |
| `tone` | enum | ✗ | v0.19.0 新增。`neutral`(默认) / `positive` / `negative`。UI 渲染色调,跟 `severity` 解耦 |
| `locale` | string | ✗ | v0.19.0 新增。BCP-47 tag(`en` / `zh-CN` / `en-US` …)。Inbox 多语言渲染用,默认 NULL |
| `external_url` | string | ✗ | ≤ 2000 字符，必须 https |
| `external_status` | enum | ✗ | 见枚举 |
| `actor.email` | string | ✗ | ≤ 120 字符 |
| `actor.name` | string | ✗ | ≤ 80 字符 |
| `labels` | object | ✗ | ≤ 20 对，每 value ≤ 80 |

### 5.2 与 v1 相比的变化

- ❌ 去掉 `source` 字段 —— token 已经隐含归属
- ❌ 去掉 `recipients` 字段 —— token owner 即接收人
- 🆕 `spec_version` 从 `"1"` 升到 `"2"`
- 🆕 v1 endpoint `/inbound/<slug>` 改为 `/inbound/personal`

### 5.3 actions(v0.17.27 起)

每个 action 一个 inbox 按钮,最多 4 个。

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | string | ✓ | 按钮文字,≤ 40 字符 |
| `action_type` | enum | ✓(默认 `url`) | `url` / `webhook` |
| `url` | string | type=url 时必填 | https URL,点击在新标签打开 |
| `webhook_url` | string | type=webhook 时必填 | https URL,点击由 FluxDesk 服务端 POST 回调,不允许 localhost / 127.* / 0.0.0.0 |

> **三种模式共享同一个 actions schema**(v0.24.8 文档化):Personal(模式 1)/ Moderation(模式 2)/ Broadcast(模式 3,MCP `broadcast_event` + admin direct push)都走同一个 zod `actionSchema`,顶层字段一致。**不要**用 v1 时代的 flat `action_label` / `action_url` 字段 —— 三种模式都不接受(strict 模式拒未声明字段)。换接入模式不需要改 action 形状。

#### action_type = "webhook" 回调协议

用户点击按钮后,FluxDesk 服务端立刻向 `webhook_url` 发一条 POST(5 秒超时):

**Headers**
```
content-type: application/json; charset=utf-8
x-fluxdesk-signature: sha256=<hex>
user-agent: FluxDesk-Inbox-Callback/v2
```

**Body**
```json
{
  "delivery_id": "uuid",
  "action_label": "Approve",
  "action_index": 0,
  "clicked_at": "2026-05-20T10:23:14.123Z",
  "clicked_by": { "email": "alice@x.com", "name": "Alice" },
  "event_type": "approval.request"
}
```

#### 签名验证

`x-fluxdesk-signature` 头 = `sha256=` + HMAC-SHA256(`token.callback_signing_secret`, raw_body) 的 hex。

`callback_signing_secret` 在 token 申请时随机生成,owner 在 /me/integrations 的 token 详情可以看到,**owner 复制后给 sender(OA / 监控)配在 receiver 端**。FluxDesk 这边一次性生成,后续不变(除非 token 轮换 —— 轮换会同时生成新 secret)。

Receiver 必须用同一个 secret 重算 HMAC-SHA256 然后**常量时间**比较,不一致就 401。这是防止有人伪造点击事件骗 receiver 执行操作的唯一防线。

#### 例子

```json
{
  "spec_version": "2",
  "event_id": "approval-2026-05-20-001",
  "event_type": "approval.request",
  "severity": "warn",
  "title": "Alice 请求 SSH prod-db",
  "summary": "理由:调试客户报的延迟问题",
  "occurred_at": "2026-05-20T10:20:00+08:00",
  "actions": [
    {
      "label": "✓ 批准",
      "action_type": "webhook",
      "webhook_url": "https://oa.fluxprimestudio.internal/approve?req=2026-05-20-001"
    },
    {
      "label": "✗ 拒绝",
      "action_type": "webhook",
      "webhook_url": "https://oa.fluxprimestudio.internal/reject?req=2026-05-20-001"
    },
    {
      "label": "查看详情",
      "action_type": "url",
      "url": "https://oa.fluxprimestudio.internal/req/2026-05-20-001"
    }
  ]
}
```

Owner 在 inbox 看到 3 个按钮,点「✓ 批准」 → FluxDesk POST 到 `https://oa.fluxprimestudio.internal/approve?req=2026-05-20-001`(带签名)→ OA 那边校验 + 执行 → 返回 200 → FluxDesk inbox toast 「✓ 批准 · 200」。

### 5.4 recipient / recipient_hint(moderation 模式必填,v0.17.29 起)

> **v0.19.0 起,新代码请用结构化 `recipient: {type:"email", value}` 字段**。`recipient_hint` 仍然接受(legacy),但不会再演进 —— 见末尾的 v0.19.0 子节。两者**互斥**(XOR),同时给两个 → `400 validationFailed`。

**moderation 模式下必填,v0.17.54 起强制硬约束**。普通 / tools-claim 模式下**不能带**(v0.17.36 起也是硬约束)。两种违反都返回 `400 schema_invalid`。「带 / 不带」按 `recipient` 和 `recipient_hint` 任一存在即视为「带」。

理由:moderation 频道存在的全部意义就是 admin 审一遍后**转发给某用户**;一条事件不告诉 admin 转给谁,admin 就要肉眼读 title 逐条猜目标,把整个工作流退化成「再做一次 OA 三角分发」。强制 sender 在发送时声明意图(email / user_id / display_hint 任一即可),admin 在 `/me/integrations/moderation` 看到的每行都已经预选好目标 / 至少带提示文本。

适用场景:OA / 监控有时候知道「这条事件应该是给 alice 看的」(例如「alice 被加 fluxdesk-frontend repo」),但 OA 本身没有 alice 的个人 token —— 因此 OA 把事件发到 admin 的 moderation token 里,顺便带上 `recipient_hint`,让 admin 在转发页面里**预选**好 alice,不用肉眼读 title 后再去下拉里找。

**schema**

```json
{
  "recipient_hint": {
    "email": "alice@fluxprimestudio.com",
    "user_id": "uuid-v4-optional",
    "display_hint": "Frontend Lead"
  }
}
```

| 字段 | 类型 | 说明 |
|---|---|---|
| `email` | string | 优先级最高,FluxDesk 按 email 反查 user_id;命中就预选下拉 |
| `user_id` | string(UUID) | 知道 user_id 直接传也行(更稳) |
| `display_hint` | string ≤ 80 | 自由文本,例如「设计组组长」、「on-call eng」;email/user_id 都没匹配上时显示给 admin 参考 |

三个字段任选其一(空对象 = 400)。

**重要**:`recipient_hint` **永远不会触发自动路由**。它是 hint 性质,admin 在 moderation 页面还是要点 「📤 转发」 按钮才会真正给 alice 发通知。这条是 Charter Art 5「无 cross-user 路由」的硬约束。

**v0.17.36 起也是硬约束**:非 moderation token(personal / tools-claim 出来的)带 `recipient_hint` 会被 `400 schema_invalid` 拒掉(以前是静默丢弃,违反 Art 7 不存无用数据)。

**v0.17.54 起**:moderation token **不带** `recipient_hint` 也会被 `400 schema_invalid recipient_hint_required` 拒掉。Sender 必须每条事件都告诉 admin 「这是给谁的」。

**例子**

```json
{
  "spec_version": "2",
  "event_id": "repo-grant-2026-05-20-alice",
  "event_type": "repo.permission_granted",
  "severity": "success",
  "title": "alice 被加入 fluxdesk-frontend (write 权限)",
  "summary": "by admin · 用途:参与 v0.18 UI 重构",
  "occurred_at": "2026-05-20T10:30:00+08:00",
  "recipient_hint": {
    "email": "alice@fluxprimestudio.com",
    "display_hint": "Frontend"
  }
}
```

admin 在 `/me/integrations/moderation` 看到这条,「发给」下拉**已经预选了 alice**,旁边有蓝色 chip `提示: ✓ Alice`。admin 检查一下点 📤 即可。如果 email 没匹配上,chip 显 `提示: alice@xxx (未匹配到用户)`,admin 还是要自己挑。

#### v0.19.0 新结构化 `recipient`(推荐)

```json
{
  "spec_version": "2",
  "event_id": "repo-grant-2026-05-20-alice",
  "event_type": "repo.permission_granted",
  "severity": "info",
  "tone": "positive",
  "locale": "zh-CN",
  "title": "🎉 alice 拿到 fluxdesk-frontend write 权限",
  "summary": "by admin · 用途:参与 v0.18 UI 重构",
  "occurred_at": "2026-05-20T10:30:00+08:00",
  "recipient": {
    "type": "email",
    "value": "alice@fluxprimestudio.com"
  }
}
```

跟 legacy `recipient_hint` 版本相比的区别:

- `recipient` 用 `{type, value}` 结构,type 是 enum(目前只有 `"email"`,未来加 `"user_id"` / `"phone"` / `"telegram_chat_id"`),好扩展
- 加了 `tone: "positive"` —— `severity` 依然是 `info`(技术上是常规通知,不 P0),但 UI 渲染走绿色庆祝色而不是中性灰
- 加了 `locale: "zh-CN"` —— inbox 未来要做多语言渲染时这条会按中文模板渲

XOR 约束:同一条事件 `recipient` 和 `recipient_hint` 二选一,**不能同时给**。

---

## 6. 字段白名单与禁止字段

### 6.1 允许的就这些（§5 的 Schema）

**自 v0.17.22 起,任何 schema 外的字段一律 `400 Bad Request` —— strict mode,不再静默忽略。** 历史版本(<v0.17.22)对未知字段是 silent accept;升级后请 sender 删掉 `target` / `to` / `user_id` / `deliver_to` / `source` 等多余字段(token 已经隐含 owner,这些都是 no-op)。注:`recipient` **自 v0.19.0 起是合法字段**(结构化 recipient hint),不在这条清单里。

### 6.2 显式禁止字段

下列字段名出现在 body 顶层 → `400 Bad Request`：

```
body          payload       content       full_text
attachment    attachments   files
secret        token         api_key       password
credential    credentials   private_key
prompt        completion    ai_response   chat_history
recipients
```

理由：Charter Article 7。`recipients` 显式拒绝是为了让 sender 不要试图用 v1 的方式发（请改 token）。

### 6.3 OA 类源的额外字段处理建议

OA 源 sender 在发之前要做这些处理：

- ✓ `title` 用「年假申请」「差旅报销」这类**类别名**
- ✗ 不要把申请理由 / 请假原因当 `title` 或 `summary`
- ✓ 金额放在 `labels` 里**桶化**：`{"amount_bucket": "1k-5k"}`，不是精确数字
- ✗ 不放任何 attachment / 合同链接 / 联系电话

### 6.4 监控类源的额外建议

- ✓ `title` ≤ 80 字符更易读（"web-prod p99 > 2s"）
- ✓ `summary` 放服务名 / 实例 / 区域 / 触发阈值，**不放 stack trace 全文**
- ✓ `labels` 放结构化信息：`{"service":"web-prod","instance":"api-3","region":"ap-se-1"}`
- ✓ `external_url` 链回 Grafana / 监控面板，**带固定 panel 不带临时 token**

---

## 7. Bearer Token 鉴权

### 7.1 Token 格式

```
flux-in-pers-<32 字符随机>
```

总长度 45 字符。前缀 `flux-in-pers-` 是固定的，方便日志和 grep 识别。后 32 字符是 URL-safe base64（去掉 padding）。

### 7.2 FluxDesk 这边的校验逻辑

```typescript
import { compare } from "bcrypt";

async function authenticateInboundRequest(authHeader: string) {
  if (!authHeader?.startsWith("Bearer ")) {
    throw new HTTPError(401, "missing_or_invalid_authorization");
  }
  const token = authHeader.slice(7).trim();
  if (!token.startsWith("flux-in-pers-")) {
    throw new HTTPError(401, "invalid_token_format");
  }

  // 用 token prefix（前 16 字符）做快速查找，再用 bcrypt.compare 验证完整 hash
  const prefix = token.slice(0, 16);
  const record = await db.query.personalInboundTokens.findFirst({
    where: and(
      eq(personalInboundTokens.tokenPrefix, prefix),
      eq(personalInboundTokens.enabled, true),
      isNull(personalInboundTokens.revokedAt),
    ),
  });
  if (!record) throw new HTTPError(401, "token_not_found");

  const matched = await compare(token, record.tokenHash);
  if (!matched) throw new HTTPError(401, "token_mismatch");

  // 更新 last_used_at（low priority，可异步）
  return { ownerUserId: record.ownerUserId, tokenId: record.id, record };
}
```

### 7.3 Token 生命周期

| 状态 | 描述 | 触发 |
|---|---|---|
| `active` | 正常工作 | 创建时默认 |
| `disabled` | owner 主动暂停（保留可恢复） | owner 在 `/me/integrations` 点「禁用」 |
| `revoked` | 永久吊销，不可恢复 | owner 点「删除」/ admin 应急吊销 / 12 个月未使用自动 |
| `auto-expired` | 12 个月未使用自动失效 | cron 每日扫描，提前 30 天邮件提醒 |

吊销 / 自动失效后：用该 token 的请求返回 401 + `error: "token_revoked"`，业务系统侧应自行报警让 owner 重新申请。

### 7.4 轮换

- owner 在 `/me/integrations` → 「轮换」按钮
- 新 token 立即生效，旧 token **保留 24 小时宽限期**
- 24 小时后旧 token 返回 401
- owner 必须在 24h 内把新 token 配进业务系统

### 7.5 Admin 应急吊销

- admin 在 `/settings/integrations` 看到全公司 token 列表（含 owner / label / last_used，**看不到 token 值**）
- admin 可吊销任意 token，写 audit `inbound.token.revoked_by_admin`，自动给 owner 发 inbox 通知（含吊销原因，可选填）
- admin **不能创建他人的 token**，也**不能读取事件内容**

### 7.6 不要做的事

- ✗ 把 token 写进代码 / git commit / 日志
- ✗ 把 token 当 query string 发（`?token=xxx`）—— spec 不接受
- ✗ 多个业务系统共用同一个 token —— 一旦泄漏多源连环失守，应每源一个 token
- ✗ 把 token 转发给同事 —— 同事应自己申请

---

## 8. Dedup 与状态更新

### 8.1 event_id 的稳定性要求

`event_id` 必须满足：**同一逻辑事件**反复触发时**保持不变**。

| 场景 | event_id 应该 |
|---|---|
| 监控 alert 持续 fire（每分钟 webhook） | **相同** event_id（用 alert fingerprint） |
| 监控 alert 从 firing → resolved | **相同** event_id |
| OA 申请提交、审批中、通过 | **相同** event_id（用 OA 单号） |
| 不同的 OA 申请单 | **不同** event_id |
| 不同 alert 实例 | **不同** event_id |

❌ 不要用：`Date.now()` / `uuidv4()` 每次新生成 / hash 包含时间戳 / hash 包含可变字段

✓ 应该用：源系统内的稳定主键 / fingerprint / 业务单号

### 8.2 Dedup 维度

按 `(token_id, event_id)` 联合去重。即：
- 同一用户不同 token 收到同 event_id → 视为不同事件
- 不同用户各自的 token 收到同 event_id → 视为不同事件（业务系统侧 fan-out 必然如此）

### 8.3 同 (token, event_id) 第二次到达

行为：
- 找到原 `inbound_integration_deliveries` 行 → 更新 `fire_count++`、`last_event_at = now()`、`external_status` 同步
- 原 inbox 行的 `body` / `title` 同步更新（**不**新建 inbox 行）
- **不**重发 browser push / Telegram DM（避免噪声）
- 响应 `200 OK`（首次是 `202 Accepted`）

### 8.4 resolved / 终结态的处理

`external_status` 切到 `resolved` / `approved` / `rejected` / `withdrawn`：
- 原 inbox 行加状态色条 + 时间戳「✓ 已解决 13:42」
- **不**发任何新通知（不发"alert resolved" push —— 解决了的事不该再打扰人）
- 行依然存在，可供回溯
- 「升级为需求」按钮仍可用（用户可能想事后归档处理）

---

## 9. 事件归属

### 9.1 单一规则

**事件归属 = token owner**。没有路由，没有 routing rules，没有 fallback。

```
Bearer token → token record → owner_user_id → that user's inbox.
```

### 9.2 业务系统侧的 fan-out 模式

如果同一事件需要让多人收到（如 CRITICAL alert 给整个 on-call 组）：

**业务系统**侧配多个 webhook URL：

```yaml
# 监控系统的告警通道配置示例
critical_alert_webhooks:
  - url: https://my.fluxdesk.net/api/internal-os/integrations/inbound/personal
    auth: Bearer flux-in-pers-AliceTokenXXX
  - url: https://my.fluxdesk.net/api/internal-os/integrations/inbound/personal
    auth: Bearer flux-in-pers-BobTokenYYY
  - url: https://my.fluxdesk.net/api/internal-os/integrations/inbound/personal
    auth: Bearer flux-in-pers-CarolTokenZZZ
```

监控系统 fire alert 时**自己**调 3 次 webhook，FluxDesk 端各自落 3 行 inbox（每行归属各 owner）。

这看起来"低效"，但它是**对的设计**：
- FluxDesk 不需要知道"on-call 轮班"
- escalation 在监控侧用计时器实现，FluxDesk 透明
- 一个 token 失控不影响其他人

### 9.3 OA 类的实践

OA 系统在每个 approver / 申请人入职时，让 ta 在 FluxDesk 申请 token，把 token 存到 OA 用户档案。OA 触发审批通知时：
- 查询当前 approver 的 token → 发 webhook
- 申请人查到自己的 token → 发审批结果 webhook

---

## 10. 错误处理与重试

### 10.1 sender 该实现的重试策略

| 响应 | 重试？ | 间隔 |
|---|---|---|
| 2xx | 不重试，已成功 | — |
| 4xx（除 429） | **不重试**，记录到 sender 侧 dead letter | — |
| 429 | 重试 | 按 `Retry-After` header（秒） |
| 5xx / 网络错误 | 重试 | 指数退避：1s / 5s / 30s / 2min，最多 4 次 |
| 重试都失败 | 进 sender 侧 dead letter；可选告警 | — |

### 10.2 401 的特殊处理

`401 Unauthorized` 通常意味着 token 失效（被 owner 禁用 / 吊销 / admin 应急吊销 / 12 个月未用自动失效）。**不要重试**。

建议业务系统在收到 401 时给 token owner 发邮件 / IM：「你配在 X 系统的 FluxDesk token 已失效，请去 /me/integrations 重新申请」。

#### 10.2.1 422 `inbound-no-approval`（v0.22.3 起，可选）

如果 sender 是 admin-owned CLI（fd1-style 半授权 / tools-claim 模式），在 POST `/api/v1/events` 时可以**额外**传两个信号：

- Body 加 `target_email: "alice@…"`（你想推给哪个用户）
- Header 加 `X-Caller-Tools-Token: flux-tools-…`（你这个 CLI 的 admin tools-token）

服务端遇到 `token_not_found`（per-member 那个 `flux-in-pers-…` 查不到）时，会：

1. 用 tools-token 鉴权，确认你是 admin
2. 用 tools-token 的 `linked_tools_token_id` 找到对应的 catalog row（例如 `inbound-fd1`）
3. 查 `target_email` 对应的用户是否已经申请并被批准

如果**用户存在但没审批**，返回 `422 inbound-no-approval`：

```json
{
  "type": "https://docs.fluxdesk.net/api/v1/errors/inbound-no-approval",
  "title": "Target member missing approval for catalog row `inbound-fd1`",
  "status": 422,
  "detail": "Target member alice@example.com has no approved request for catalog row `inbound-fd1`. Ask them to apply + an admin to approve, then retry.",
  "target_email": "alice@example.com",
  "catalog_slug": "inbound-fd1"
}
```

任何一个前置条件不满足（没 header / 没 target_email / tools-token 鉴权失败 / 不是 admin / 用户不存在 / 用户已审批），都退回到原本的 401，**不会** 泄漏 `target_email` 的存在性（Charter Art 7）。

CLI 收到 422 后应该跳出重试循环，提示 admin「member 还没申请 `inbound-fd1`，让他去 `/team-support` 申请，admin 批了再 retry」。422 跟传统 401 的差别就是给 CLI 一个明确的 next action，而不是糊涂地无限重试。

### 10.3 sender 侧 dead letter 怎么处理

建议：
- 写入源系统内的告警通道（不要循环发回 FluxDesk）
- 每天 admin 看一次 dead letter 量
- > 10 条 / 天 → 排查 sender 实现

---

## 11. 限频与熔断

| 限制 | 默认值 | 可调 |
|---|---|---|
| 单 token 每分钟事件数 | 60 | 不可调 |
| 单 token 每日通知数 | 50 | owner 在 `/me/integrations` 设 |
| 单 user 全部 inbox 通知 / 天 | 500（FluxDesk 全局） | 不可调 |
| 熔断阈值（单 token） | 连续 1h 失败率 > 80% 且失败数 > 20 | 不可调 |

超出限制：
- **每分钟上限超出** → 429，`Retry-After` 给秒数
- **每日上限超出** → 200（仍接受，但**不 push / 不 TG**，仅入 inbox），响应 body 含 `degraded: true`
- **熔断** → 自动暂停发出通道，owner 收到一条「你的 token X 已熔断」inbox 通知（讽刺地，这条不限）

---

## 12. 完整示例 · 监控源

### 12.1 P0 服务延迟告警 firing

```json
POST /api/internal-os/integrations/inbound/personal
Authorization: Bearer flux-in-pers-A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5

{
  "spec_version": "2",
  "event_id": "alert-fp-a3f9e2c1",
  "event_type": "alert.firing",
  "severity": "critical",
  "title": "web-prod p99 latency > 2s（持续 5 分钟）",
  "summary": "service=web-prod · instance=api-3 · region=ap-southeast-1 · threshold=2000ms · current=2840ms",
  "external_url": "https://grafana.fluxprimestudio.internal/d/web-prod-p99",
  "external_status": "firing",
  "occurred_at": "2026-05-18T14:23:01Z",
  "labels": {
    "service": "web-prod",
    "instance": "api-3",
    "region": "ap-southeast-1",
    "team": "infra"
  }
}
```

收件人 = 该 token 的 owner（Alice）。监控系统若要让 Bob 也收到，同样的 body 改 Bob 的 token 再 POST 一次。

### 12.2 同一告警 resolved

```json
{
  "spec_version": "2",
  "event_id": "alert-fp-a3f9e2c1",
  "event_type": "alert.resolved",
  "severity": "info",
  "title": "web-prod p99 latency > 2s（已恢复）",
  "summary": "恢复时长 8 分钟",
  "external_url": "https://grafana.fluxprimestudio.internal/d/web-prod-p99",
  "external_status": "resolved",
  "occurred_at": "2026-05-18T14:31:14Z",
  "labels": { "service": "web-prod" }
}
```

行为：找到原 `(token_id, event_id)` 的 inbox 行 → 更新状态 + 不发新 push。

---

## 13. 完整示例 · OA 源

### 13.1 年假申请提交（OA 把 webhook 发给 approver Alice 的 token）

```json
POST /api/internal-os/integrations/inbound/personal
Authorization: Bearer flux-in-pers-AliceApproverTokenXXX

{
  "spec_version": "2",
  "event_id": "leave-2026-0312",
  "event_type": "oa.leave.submitted",
  "severity": "warn",
  "title": "年假申请 · 待你审批",
  "summary": "申请人 Carol · 2026-06-01 至 2026-06-05（5 个工作日）",
  "external_url": "https://oa.fluxprimestudio.internal/leave/2026-0312",
  "external_status": "pending",
  "occurred_at": "2026-05-18T03:21:00Z",
  "actor": {
    "email": "carol@fluxprimestudio.com",
    "name": "Carol"
  },
  "labels": {
    "leave_type": "annual",
    "days": "5"
  }
}
```

注意：
- ✓ summary 只说人名 + 时段，不放申请理由
- ✓ actor 是申请人 Carol（仅展示用，不做映射）
- ✓ 收件人就是 token owner（Alice，approver）
- ✓ labels 用 leave_type 类别

### 13.2 同 OA 单审批通过（OA 把 webhook 发给申请人 Carol 的 token）

```json
POST /api/internal-os/integrations/inbound/personal
Authorization: Bearer flux-in-pers-CarolApplicantTokenZZZ

{
  "spec_version": "2",
  "event_id": "leave-2026-0312",
  "event_type": "oa.leave.approved",
  "severity": "info",
  "title": "年假申请 · 已通过",
  "summary": "Alice 已批准 · 5 个工作日",
  "external_url": "https://oa.fluxprimestudio.internal/leave/2026-0312",
  "external_status": "approved",
  "occurred_at": "2026-05-18T09:11:00Z"
}
```

注意：
- Alice 的 inbox 里这条仍是 `external_status=approved` 的状态更新（同 token 同 event_id）
- Carol 的 inbox 里是**新一条**（不同 token，dedup 维度不同）
- OA 系统知道每个事件该发给谁，FluxDesk 不参与该决策

---

## 14. Sender 客户端样例

### 14.1 cURL

```bash
#!/usr/bin/env bash
set -euo pipefail

ENDPOINT="https://my.fluxdesk.net/api/internal-os/integrations/inbound/personal"
TOKEN="${FLUXDESK_INBOUND_TOKEN}"

BODY='{
  "spec_version": "2",
  "event_id": "alert-test-001",
  "event_type": "alert.firing",
  "severity": "info",
  "title": "Ping test",
  "occurred_at": "2026-05-18T14:00:00Z"
}'

curl -sS -X POST "$ENDPOINT" \
  -H "Content-Type: application/json; charset=utf-8" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "User-Agent: ping-test/1.0" \
  --data-binary "$BODY"
```

### 14.2 Node.js（fetch）

```javascript
const ENDPOINT = "https://my.fluxdesk.net/api/internal-os/integrations/inbound/personal";
const TOKEN = process.env.FLUXDESK_INBOUND_TOKEN;

async function sendInboundEvent(event) {
  const body = JSON.stringify(event);

  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      "Authorization": `Bearer ${TOKEN}`,
      "User-Agent": "monitor-sender/1.0",
    },
    body,
  });

  if (response.status === 200 || response.status === 202) {
    return { ok: true };
  }
  if (response.status === 429) {
    const retryAfter = parseInt(response.headers.get("Retry-After") ?? "60", 10);
    return { ok: false, retry: true, retryAfter };
  }
  if (response.status === 401) {
    // Token 失效，通知 owner 重新申请
    return { ok: false, retry: false, expired: true };
  }
  if (response.status >= 500) {
    return { ok: false, retry: true, retryAfter: 5 };
  }
  const error = await response.json().catch(() => ({}));
  return { ok: false, retry: false, error };
}
```

### 14.3 Python（requests）

```python
import json
import os
import requests

ENDPOINT = "https://my.fluxdesk.net/api/internal-os/integrations/inbound/personal"
TOKEN = os.environ["FLUXDESK_INBOUND_TOKEN"]


def send_inbound_event(event: dict) -> tuple[bool, dict | None]:
    body = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode("utf-8")

    response = requests.post(
        ENDPOINT,
        data=body,
        headers={
            "Content-Type": "application/json; charset=utf-8",
            "Authorization": f"Bearer {TOKEN}",
            "User-Agent": "oa-sender/1.0",
        },
        timeout=10,
    )

    if response.status_code in (200, 202):
        return True, None
    return False, response.json() if response.content else None
```

---

## 15. 测试与联调

### 15.1 Ping endpoint

每个 active token 自动启用 ping endpoint：

```
POST /api/internal-os/integrations/inbound/personal/ping
（同一 Bearer token，body 任意 JSON）
```

返回 `{ "ok": true, "token_id": "...", "owner": "alice@...", "now": "<ISO timestamp>" }`，**不**生成 inbox 行。

用途：验证 token 配置 + 网络。

### 15.2 联调 checklist

新 token 创建后，sender 端按这个顺序验证：

- [ ] Ping endpoint 返回 200 + ok（验证 token 有效）
- [ ] 错误 token 返回 401（验证 token 校验生效）
- [ ] 故意发禁止字段 `body` → 400
- [ ] 发一条 severity=info 的真事件 → 进 inbox 但**不** push
- [ ] 发一条 severity=critical 的真事件 → inbox + push + Telegram 都收到
- [ ] 同 event_id 重发 → 200（不是 202），fire_count 增加
- [ ] event_id 切 external_status=resolved → inbox 行变绿，无新 push

---

## 16. 反模式（不要这样做）

| ❌ 反模式 | ✓ 正确做法 |
|---|---|
| `summary` 塞 5000 字 stack trace | `summary` 放服务名 + 实例 + 阈值；详情留在源系统 |
| `event_id = Date.now()` | 用源系统稳定主键 / fingerprint / 业务单号 |
| `title = "申请人 Carol 的报销 1234 元，包括打车 50 元差旅..."` | `title = "差旅报销 · 待你审批"`；金额走 labels 桶化 |
| 多个业务系统共用一个 token | 每源单独申请 + 独立 token |
| 同事互相转发 token | 同事应自己在 `/me/integrations` 申请 |
| 把 token 写进 git commit | 用 env / secret manager |
| 用 v1 风格发 body 含 `recipients: [...]` 字段 | spec v2 直接拒绝该字段 |
| `external_url` 带 `?token=xxx` | 用永久 URL；source 端权限校验靠 session |
| 每秒重试失败的 POST | 指数退避 + 4 次上限 + dead letter |
| 把 `chat_history` 当 summary | `body` 类字段名直接被拒绝 |
| OA 把审批意见正文当 title | title 用类别名；意见正文留在 OA |
| 一个 token 让多人共用 | 每人各申请一个 + 业务系统侧 fan-out |

---

## 17. FAQ

**Q: 我们的内部系统能不能用 HMAC 而不是 Bearer？**
A: spec v2 不支持。用户自配场景下 Bearer 已足够。若团队有强需求可在未来 v3 引入 optional HMAC，但目前不计划。

**Q: 能不能批量发？一次 POST 100 条事件？**
A: 不支持。每事件一个 POST。批量发会让 dedup / 限频 / 错误归属都复杂。

**Q: external_url 必须 https 吗？内部域名 http 的能接吗？**
A: 必须 https。内部域名上 TLS 是 2026 年的基本要求。

**Q: token 泄漏了怎么办？**
A: 立即在 `/me/integrations` 点「轮换」，旧 token 24h 后失效。检查业务系统日志看是否有异常请求。如果怀疑 admin 处置失当，找 super_admin 走 audit。

**Q: 同一个我个人的服务可以发到多个我自己的 token 吗？**
A: 可以但不推荐。建议一个业务系统对应一个 token，便于轮换和管理。

**Q: 我的同事希望也收到我配的监控告警怎么办？**
A: 同事自己在 `/me/integrations` 申请 token，然后你（或监控管理员）在监控系统侧加一个 webhook 配置，用同事的 token。**不要把自己的 token 给他**。

**Q: 我离职后我的 token 怎么处理？**
A: offboarding 流程会自动吊销你全部 token。配在业务系统侧的 token 会立即失效，业务系统会收到 401，应通知新接手的同事去申请新 token。

**Q: admin 能看到我 inbox 里的事件内容吗？**
A: 看不到。admin 只看流量元数据（token id / owner / last_used / fire_count）。事件 title/summary 不向 admin 暴露。这是 Charter Article 9。

**Q: 升级到 spec v2 时 v1 的 source 还能用吗？**
A: 不能。v1 endpoint `/inbound/<slug>` 在 v2 部署后返回 410 Gone。这是有意为之 —— v1 / v2 路由模型不兼容，长期共存会变成漏洞。

---

## 18. Changelog

### v0.24.58 · 2026-05-26 · 待转发队列只收 moderation 模式

- 修正 `/me/integrations/moderation` 与 sidebar badge 的查询边界:只有 `is_moderation=true` 的审核模式 token 事件进入「待转发」。1 对 1 `personal` 与 tools-claim 最终 mint 的 personal token 事件进入「消息信箱 → 外部系统」与事件流,不再混入待转发。
- 新建审核 token 时同步写 `kind='moderation'`；对历史 `is_moderation=true AND kind<>'moderation'` 行做 backfill。
- 文档统一术语:Mode 2 是「待转发」队列,不是普通 inbox 的「外部系统」类别。

### v0.24.8 · 2026-05-23 · schema_invalid `errors[]` + actions 三模式统一文档化

fd1 UX#10 #18 + #19:

- 🆕 **`errors: [{field, reason}]`** 数组随 400 schema_invalid 返回,一次拿全所有 zod issue。`reason` / `field` 顶层字段保留 = 第一条 issue(back-compat,老 sender 不动)。`/api/v1/events` 同步把 `errors[]` 转写进 RFC 7807 的 `fieldErrors` 扩展。详见 § 4.4。
- 📝 § 5.3 文档化 — **三种接入模式共享同一个 `actions` schema**。Personal / Moderation / Broadcast(MCP `broadcast_event` + admin direct push)都走 zod `actionSchema`,顶层字段一致。换模式不需要改 action 形状。
- 无 wire-breaking 变化;无 schema 字段增减。

### v0.19.0 · 2026-05-21 · `recipient` 结构化 + `tone` + `locale`

新字段（全部可选，wire-additive，老 sender 无需改动）：

- 🆕 `recipient: { type: "email", value: "alice@…" }` — 结构化版本的 `recipient_hint`。`type` 是 enum，预留 `"user_id" / "phone" / "telegram_chat_id"` 等未来 kind；`value` 是对应类型的具体值。
  - **新代码请用 `recipient`，不要用 `recipient_hint`**。后者标记为 legacy，仍然接受但不会再演进。
  - 同一条事件里 `recipient` 和 `recipient_hint` **不能同时出现**（XOR），同时给两个 → `400 validationFailed`。
  - moderation token 的「必须有 recipient signal」约束现在对两种形状都生效；任一存在即可。
- 🆕 `tone: "neutral" | "positive" | "negative"` — UI 渲染色调，**跟技术 `severity` 解耦**。默认 `"neutral"`。
  - `severity` 决定**技术行为**（retry / push 阈值 / alerting）；`tone` 决定**视觉表现**（图标 / 颜色）。
  - 经典组合：`severity=info` + `tone=positive` = 「🎉 你拿到新权限了」事件 —— 技术上是常规 info，但视觉上是绿色庆祝。
- 🆕 `locale: string` — BCP-47 tag（`"zh-CN"` / `"en-US"` / `"en"` / …），inbox 未来用来做多语言渲染。默认 NULL（继承用户偏好）。

存储：新增 `inbound_integration_deliveries.tone` / `.locale` 列（migration 0107）。`recipient` 用 server 端 normalize 后写回旧 `recipient_hint` jsonb 列（保证 moderation UI 单读路径）。

#### Namespace 约定（仅 `/api/v1/*` 强制）

未来 `/api/v1/events`（V1A 在做）会强制 `event_type` 必须匹配 `<integrator-slug>.<entity>.<action>` 的三段过去式格式，例如 `fd1.repo.permission_granted` / `monitor.alert.fired`。本节描述的老端点 `/api/internal-os/integrations/inbound/personal` **不**强制 namespace，保持向后兼容；只在 v1 端点严格执行。

### v2.0 · 2026-05-18 · 用户自助 token 模式

**Breaking change**：从 admin-registered + HMAC 模式切换到 user-owned + Bearer Token 模式。决策依据 [`adr/0001-inbound-integration-user-owned-tokens.md`](./adr/0001-inbound-integration-user-owned-tokens.md)。

- 端点从 `/inbound/<slug>` 改为 `/inbound/personal`
- 鉴权从 `X-FluxDesk-Signature` HMAC 改为 `Authorization: Bearer ...`
- Schema 去掉 `source` / `recipients` 字段（token 隐含归属）
- `spec_version` 升到 `"2"`
- 路由逻辑改为「事件 = token owner」，无 routing rules
- 限频维度从 source 改为 token

### v1.0 · 2026-05-18 · 初版（已废弃，未上线）

- admin 注册源 + routing rules 模式
- HMAC-SHA256 签名鉴权

---

## 附录 A · 给 Claude Code 实施时的指引

在 FluxDesk 项目里实施 receiver 时，参考以下文件 / helper：

| 实施点 | 用什么 |
|---|---|
| 路由 endpoint | `src/app/api/internal-os/integrations/inbound/personal/route.ts` |
| Ping endpoint | `src/app/api/internal-os/integrations/inbound/personal/ping/route.ts` |
| Token 校验 | 新 helper `src/lib/internal-os/inbound/authenticate-token.ts` |
| Token CRUD | 新 server actions `src/lib/actions/personal-inbound-tokens.ts`（create / rotate / disable / revoke） |
| Adapter 抽象 | 新 `src/lib/internal-os/inbound/adapters/standard-v2.ts` |
| Schema 校验 | Zod schema，参考 `src/lib/internal-os/schemas/` 现有写法 |
| Dedup 查询 | `inbound_integration_deliveries` 表，UNIQUE(token_id, source_event_id) constraint |
| 通知 fanout | 复用现有 `notifyActivityPush({ users, type: "system_notice", ... })` |
| ~~严重度过滤~~ | v0.17.28 起下线 —— sender 按事件选 severity,FluxDesk 不再二次过滤(都进 inbox + push,受 daily_limit / 断路器约束) |
| 限频 | 复用 `audit_logs` 计数（同模式：`project.lead_note_broadcast` 30min cooldown） |
| 升级为需求 | 新 server action `elevateInboundDeliveryToTask`；写 `task.elevated_from_inbound` audit |
| Admin token 总览 | `/settings/integrations` 改造：只看元数据，不解密 token |
| 12 个月自动失效 cron | 新 cron job `cron/expire-stale-inbound-tokens.ts`，每日跑 |
| Offboarding 集成 | `src/lib/actions/offboarding.ts` 加 `revokeAllInboundTokensFor(userId)` |
| i18n keys | `inbound.token.label` / `inbound.token.create` / `inbound.token.revoke` / `inbound.notif.title` / `inbound.action.elevate` |
| Audit log action keys | `inbound.token.created` / `inbound.token.rotated` / `inbound.token.revoked` / `inbound.token.revoked_by_admin` / `inbound.token.auto_expired` / `inbound.event.received` / `inbound.event.deduped` / `inbound.event.rejected` |

### Schema migration（drizzle）

```sql
CREATE TABLE personal_inbound_tokens (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  label text NOT NULL,
  source_hint text,                  -- 「自研监控」自由文字
  token_hash text NOT NULL,           -- bcrypt
  token_prefix varchar(16) NOT NULL,  -- 用于快速查找
  daily_limit int NOT NULL DEFAULT 200,
  -- 注:severity_ceiling 字段在 v0.17.28 之后从写入路径下线 (列还在 DB 里向后兼容,新行不再赋值)
  enabled boolean NOT NULL DEFAULT true,
  last_used_at timestamp,
  use_count int NOT NULL DEFAULT 0,
  created_at timestamp NOT NULL DEFAULT now(),
  disabled_at timestamp,
  revoked_at timestamp,
  revoked_by_user_id uuid REFERENCES users(id),
  revoke_reason text
);

CREATE INDEX idx_personal_inbound_tokens_prefix ON personal_inbound_tokens(token_prefix);
CREATE INDEX idx_personal_inbound_tokens_owner ON personal_inbound_tokens(owner_user_id);

CREATE TABLE inbound_integration_deliveries (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  token_id uuid NOT NULL REFERENCES personal_inbound_tokens(id),
  source_event_id varchar(120) NOT NULL,
  notification_id uuid REFERENCES notifications(id),
  event_type varchar(60),
  severity varchar(10),
  external_status varchar(20),
  fire_count int NOT NULL DEFAULT 1,
  first_event_at timestamp NOT NULL DEFAULT now(),
  last_event_at timestamp NOT NULL DEFAULT now(),
  UNIQUE(token_id, source_event_id)
);

CREATE INDEX idx_inbound_deliveries_token ON inbound_integration_deliveries(token_id, last_event_at DESC);

ALTER TABLE tasks
  ADD COLUMN inbound_delivery_id uuid REFERENCES inbound_integration_deliveries(id);
```

**Charter 守底**：每个 PR commit message 末尾标注守护的 Article 编号。
