# Gitea webhook：必须每仓库自带 hook，否则 org-level URL 漂移会静默杀光部署

**Date**: 2026-05-19
**Module**: internal-app-platform / Gitea integration
**Related**: [2026-05-19-logs-tool-needs-build-stage-fallback.md](2026-05-19-logs-tool-needs-build-stage-fallback.md) · PR #434

## 症状

员工部署 `itadmin-carousel` 后访问 `https://itadmin-carousel.apps.ffworkspace.test.faradayfuturecn.com/` 返：

```
HTTP/1.1 200 OK
Server: nginx/1.24.0
Connection: keep-alive

(empty body, no Content-Type)
```

跟工作的 `itadmin-album` 对比：

| | carousel | album |
|---|---|---|
| `Via` 头 | **缺** | `1.1 Caddy` |
| `Content-Type` | **缺** | `text/html` |
| `X-Powered-By` | **缺** | `Next.js` |
| body | **0 字节** | 完整 HTML |

→ carousel 根本没经过 Caddy 反代到容器；服务器上没容器、没 Caddy site config、没 repo dir、pm2 日志没任何 carousel 字样。

## 三层根因

| 层 | 原因 |
|---|---|
| **表层** | nginx 用 default vhost 兜底返 200 空 body（因为这个域名没有对应的 Caddy 反代规则） |
| **直接** | deploy-container.sh 从未在 test server 上跑过这个 app → webhook 没投递成功 |
| **元根因** | (a) `createRepo` 从不装 per-repo webhook（代码注释承诺过但从未实现）；(b) 唯一兜底 = org-level webhook URL，但当前指向 Phase 0 PoC 老 IP `43.166.182.155`（已死），所有"只有 org-level"的仓库 push 完静默丢消息 |

为什么 album 能用：album 的 Gitea repo 自己**有一条 per-repo webhook** 指向正确的 `https://ffworkspace.test.faradayfuturecn.com/...`——大概率是 IT 早期手工补的，新建 repo 没人会补。

## 关键检测命令

```bash
# 1. repo 有没有 per-repo webhook
curl -sk -H "Authorization: token $GITEA_API_TOKEN" \
  "http://43.130.59.228/api/v1/repos/FFAIApps/<slug>/hooks"
# [] = 没有

# 2. org-level webhook 指向哪
curl -sk -H "Authorization: token $TOKEN_WITH_READ_ORG" \
  "http://43.130.59.228/api/v1/orgs/FFAIApps/hooks"
# 看 config.url 是不是当前环境的活 backend

# 3. 服务器上有没有部署痕迹
ssh test 'ls /srv/caddy/sites/ | grep <slug>'         # 没文件 = 没部署
ssh test 'docker ps -a --filter name=<slug>'          # 没容器
ssh test 'ls -la /srv/internal-apps/<emp>/<app>/'     # 没 repo dir
```

## 修复（两层）

### 应用层

`gitea-client.service.ts` 新增 `ensureWebhook(repoFullName)`：

```ts
async ensureWebhook(repoFullName: string): Promise<{ ok: true; created: boolean } | { ok: false; error }> {
  const expectedUrl = `${WEBHOOK_BASE_URL}/api/v1/internal-apps/webhook/gitea`;
  const existing = await GET /repos/${repoFullName}/hooks;
  const match = existing.find(h => h.config.url === expectedUrl && h.active && h.events.includes('push'));
  if (match) return { ok: true, created: false };
  await POST /repos/${repoFullName}/hooks { type:'gitea', active:true, events:['push'], config:{ url:expectedUrl, secret, content_type:'json' } };
  return { ok: true, created: true };
}
```

`deployPrepare` 在 createRepo / getRepo 之后**无条件**调一次 `ensureWebhook`：

```ts
const hookResult = await this.giteaSvc.ensureWebhook(`FFAIApps/${slug}-${app}`);
if (!hookResult.ok) return this.translateGiteaError(hookResult.error);
```

幂等：已有匹配 hook → 跳过。这意味着**存量坏仓库**（如 carousel）下次再走 deploy_prepare 就自愈，无需写一次性 backfill 脚本。

### 工程化保险

1. **新 env `INTERNAL_APP_WEBHOOK_BASE_URL`**（fallback `FRONTEND_URL`），在 `.env.example` 显式列出 + 缺失症状描述。
2. **新错误码 `gitea_webhook_install_failed` / `gitea_webhook_base_url_missing`** 加到 `GiteaError` union，translateGiteaError 自动透传到 Claude，让 AI 立刻看到结构化失败原因（不靠 pm2 日志）。
3. **07-api.md §8.4 重写**：从"单一 secret，组织级 webhook"升级到"org-level + per-repo 双保险"，并解释为什么——防 IT 看到 per-repo hook 觉得"重复"就清掉。
4. **postDeployHint 不变** —— 修好后下次 push 完 5 分钟容器就起来了，原话术仍适用。

## 通用原则：依赖外部"单一注册点"的功能必须本地兜底

任何"靠外部系统的一个全局配置点（org webhook / DNS 解析 / 中心化路由表）让自己工作"的功能，**默认都要在本地有"per-instance 自动兜底"机制**，原因：

- 全局点的运维归属经常**模糊**（IT / DevOps / 谁配的谁不一定还在）
- 全局点改一次影响全部，运维人会**畏惧改动**，于是漂到死的全局点比"经常改但准的全局点"更常见
- 出问题时**症状离根因远**（push 成功 → 服务器无任何痕迹 → 员工看到 200 空 body），调试成本是 per-instance hook 的 10×

适用场景：

| 系统 | 全局点 | per-instance 兜底 |
|---|---|---|
| Gitea webhook (本案) | org-level webhook | per-repo webhook by deploy_prepare |
| 多租户 SaaS 域名 | 通配 DNS A 记录 | 每租户独立 CNAME（或 SNI 校验） |
| K8s ingress 路由 | wildcard ingress | 每 service 独立 ingress rule |
| OAuth callback | 全局 redirect_uri whitelist | 每 client_id 独立 URI list |

判别准则：**"全局点被改坏 / 漂移 / 删除后，单实例还能不能工作？"**——不能 → 必须有 per-instance 兜底。

## 反例（已修正）

PR #396 实现 deploy_prepare 时代码注释承诺 `write:organization` scope 是"建仓 + 装 webhook"用的，但 `installPreReceiveHook` 一直标⏳ TODO，从未实现。结果整个 Phase 1 期间所有新建 app 都靠 org-level webhook 维生。直到 carousel 案才暴露 org URL 已经漂死好几天。

教训：**代码注释承诺过的能力 = 已实现** 是错的假设。承诺要么落地，要么明确删除注释 + 加 follow-up issue。半实现状态在 P1 阶段会变成时间炸弹。

## 元规则

跟同日另两个 learning 同源：

- [postDeployHint](2026-05-19-tool-return-value-beats-description-for-ai-followup-script.md) — AI 行为指令走返回值
- [logs build fallback](2026-05-19-logs-tool-needs-build-stage-fallback.md) — 多阶段流水线诊断要跨阶段
- **本文** — 全局依赖必须本地兜底

三者都是同一种思路：**把对"外部世界稳定性"的隐式假设拆出来，要么本地复刻一份，要么显式失败**。隐式假设是最贵的——出问题时根因离症状远，调试成本指数级膨胀。
