# webhook 部署事件 ≠ 数据库状态推进：事件只是"发生过"的记录，状态必须显式写

**Date**: 2026-05-19
**Module**: internal-app-platform / webhook + state machine
**Related**: PR #434 系列（同日第 5 个 bug）

## 症状

PR #434 修复部署到 test 后，员工在 `https://ffworkspace.test.faradayfuturecn.com/internal-apps`
页面看到**所有已部署 app 都显示"准备中"**（PENDING），包括早就 healthy 的 album / birthday-reminder
（curl URL 返 200 + 正常 HTML，容器确实在跑）。

## 根因

`webhook.service.ts` 在 deploy 成功/失败时**只 emit 审计事件**（`APP_DEPLOY_SUCCEEDED` /
`APP_DEPLOY_FAILED`），**从不 update `internal_apps.status`**。

`deploy_prepare` 把行写成 status=PENDING（先决条件，让 webhook handler 能找到 app），
然后整条状态机就停在 PENDING 不动了——容器跑起来、健康检查通过、Caddy 配上反代——
全部完成，DB 状态字段纹丝不动。

前端按 `status` 字段渲染 → "准备中"。

## 三层根因

| 层 | 原因 |
|---|---|
| **表层** | 前端 My Apps 页全员显示"准备中" |
| **直接** | webhook 处理完 runDeployScript 后只 emit 事件，不 update DB status |
| **元根因** | "事件"和"状态"的语义混淆：事件是不可变的发生记录，状态是可变的当前真相。后者必须从前者**派生 + 持久化**，单靠事件流前端看不到当前状态（除非每次查询都重新跑状态机投影）|

## 修复

`webhook.service.ts` `runDeployScript` 的 `.then()` / `.catch()` 三个分支都加 `prisma.internalApp.update`：

- 成功 → `status='HEALTHY' + lastDeployedAt=now()`
- 失败（result.ok=false）→ `status='FAILED'`
- 异常 throw → `status='FAILED'`（套 .catch 防止 update 自身挂了把整链路打散）

事件 emit 依然保留（审计 + 时序分析），只是不再是状态的唯一记录。

## 通用原则：事件流 ≠ 状态机 ≠ 物化视图

CQRS 风格里这三者必须分清：

| 层 | 数据 | 写时机 | 读路径 |
|---|---|---|---|
| **事件流** (append-only) | 发生了什么 | 任何状态转换时 | 审计 / 重放 / 时序分析 |
| **状态机** (current state) | 此刻是什么 | 事件 emit 同事务内更新 | 业务查询 / 前端渲染 |
| **物化视图** (cached projection) | 衍生聚合 | 异步 reduce | 报表 / dashboard |

**新写 webhook / event handler 时强制自检**：emit event 后**有没有同步 update 一张"当前
状态" 表**？没有 → 状态机断了 → 前端永远看到旧值。

### 反模式：状态从事件流即时投影

"前端按 event 流跑投影"看着优雅（DDD 教科书），但：
- 每次查询 N 个事件 reduce → 高并发下 DB CPU 爆表
- 投影逻辑漂移 → 不同消费者看到不同状态
- 历史事件迁移困难（schema 变了重放变得复杂）

**实战默认**：状态字段强 typed 落 prisma schema（CLAUDE.md #13 "默认不立 L4 元数据驱动"），
事件流只做审计。两者**同事务**更新。

## 适用范围（不止 internal-app-platform）

| 场景 | 事件 | 必须同步推进的状态字段 |
|---|---|---|
| 本案 webhook deploy | APP_DEPLOY_SUCCEEDED | InternalApp.status |
| 订单状态机 | OrderPaid / OrderShipped | Order.status |
| 工作流引擎 | StepCompleted | Workflow.currentStep |
| 异步任务 | JobFailed | Job.status + Job.failureReason |

判别准则：**"前端 / API 消费者直接查 status 字段时，能拿到最新真相吗？"**——不能 → 状态机断了。

## 反例（已修正）

PR #396（deploy_prepare 落地）+ PR #434（同 PR 多轮加 ensureWebhook / postDeployHint /
logs fallback）整套都在围绕 deploy 链路修，**没人想起来 status 字段从来没被推进过**。
直到 user 部署到 test 后看 UI 才暴露——典型的"测试覆盖不到 UI 实际渲染状态"盲区。

教训：**测试要覆盖"前端读到什么"，不是只覆盖"事件 emit 没"**。
应增加一条 E2E：deploy 成功后，列表接口返回的 status='HEALTHY'。

## 元规则

跟同日另 4 篇 learning 同源：

- [postDeployHint](2026-05-19-tool-return-value-beats-description-for-ai-followup-script.md)
- [logs build fallback](2026-05-19-logs-tool-needs-build-stage-fallback.md)
- [per-repo webhook 兜底](2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md)
- [spy env-gated 短路](2026-05-19-spy-mocks-must-cover-all-env-gated-short-circuits.md)
- **本文**

5 个 learning 都暴露**"测试 / 监控盖不到的隐式契约"**，每条都是"功能从某层看似 work，但
真实用户路径上断了一截"。这一系列的元教训：**修 bug 时连同它的"邻居"也检查一遍**——deploy
链路涉及 push → webhook → build → caddy → DB status → 前端渲染 6 个交接点，任何一个
断都不会被同链路其他点检出。
