# Phase 0 PoC Runbook · internal-app-platform

> **目的**：把"找 1 名 HR 员工 + 1 个真实小工具，端到端跑通"这件事拆成可照做的步骤，
> 含 IT 准备项 + 员工操作项 + 故障排查。
>
> **日期**：2026-05-14 首版 · **维护人**：lijian
>
> 本文档**会改**——Entra middleware / DNS / 前端「我的 Apps」页落地后，相应步骤会
> 从"手动 SQL"升级到"FFOA 自助页"。每次落地一项，删对应 §X.X 标黄的临时步骤。

> ⚠️ **2026-05-15 拓扑更新**：本文示例的 IP `43.166.182.155` 是 PoC 期临时 dev 沙箱，
> **不是** Phase 1 上线后的目标。Phase 1 正式拓扑见 [01-prd.md](./01-prd.md) 头部表：
>
> - 测试服 → `170.106.161.71`（`*.apps.ffworkspace.test.faradayfuturecn.com`）
> - UAT    → `43.153.69.73`（`*.apps.ffworkspace.test.faradayfuture.com`）
> - 生产   → `43.130.6.44`（`*.apps.ffworkspace.faradayfuture.com`，**员工唯一感知**）
>
> 本 runbook 在 Phase 1 拓扑就绪后**整体废弃**或迁移到 `170.106.161.71`。当前在 `43.166.*` 上
> 跑通的所有步骤都按上面映射换 IP + 域名即可复用。详细 DNS/TLS 切换流程见
> [11-dns-tls-rollout.md](./11-dns-tls-rollout.md)，PoC infra 在新拓扑下的安装（nginx + Caddy 共存）见
> [12-nginx-caddy-coexist.md](./12-nginx-caddy-coexist.md)。

---

## 0. 当前能力边界（影响 runbook 的临时降级）

| 维度 | 真实 Phase 1 设计 | 当前 Phase 0 临时降级 |
|---|---|---|
| 员工身份接入 | Entra SSO → FFOA "我的 Apps" 页颁发 token | **手工 SQL 在 DB 写 binding + token** |
| 员工访问 URL | `https://<slug>.apps.ffworkspace.faradayfuture.com` (LE 通配) | **HTTP + Host header / `/etc/hosts`**（DNS 未配，见 [03-architecture §5.1](03-architecture.md)） |
| controller 鉴权 | Entra middleware 注入 `req.user` | **`INTERNAL_APP_ENABLE_SKELETON_AUTH=true` flag 短路** |
| 后端运行 | docker compose + systemd | **pm2 + 测试服 `43.166.182.155`** |

走 PoC 前**先确认上述降级在你愿意接受的范围内**，否则先解依赖再约员工。

---

## 1. IT 一次性准备（每位 PoC 员工 ≤ 10 分钟）

### 1.1 验后端 / Gitea / 测试服都健康

```bash
# 后端在跑
ssh lijian@43.166.182.155 'pm2 status ffoa-backend'
# 期望: status = online

# Gitea 反应
curl -s http://43.130.59.228/api/v1/version | jq -r .version
# 期望: 类似 "1.26.x"

# 测试服 Caddy 健康
curl -s http://43.166.182.155/healthz
# 期望: "ok"

# Webhook 已注册
TOKEN='bf8ee0a7...'  # service token，从 /srv/internal-apps/.env.platform 取
curl -s -H "Authorization: token $TOKEN" \
  http://43.130.59.228/api/v1/orgs/FFAIApps/hooks | jq '.[].config.url'
# 期望: "http://43.166.182.155:3000/api/v1/internal-apps/webhook/gitea"
```

任一条不过 → 看 §4 故障排查。

### 1.2 ⚠️ 给员工建 `employee_slug_binding`（临时手工 SQL）

> 此步骤 Entra middleware 上线后自动从 SSO session 注入，**Phase 0 手工跑**。

约定 employee_slug 的规范化规则：取 Entra `mailNickname`（邮箱 `@` 前部分），转小写，`.` 替成 `-`，超过 20 字符尾部加 SHA1 前 6 位（详见 [`slug.service.ts`](../../../backend/src/modules/internal-app-platform/services/slug.service.ts)）。

```bash
# 例：员工邮箱 li.lei@ff.com → slug = li-lei
ssh lijian@43.166.182.155 << 'SQL_BLOCK'
docker exec -i ffoa-testserver-postgres psql -U ffoa -d ffoa <<SQL
-- 1. 先确保 user 存在（实际项目里走 Entra 同步，PoC 期可临时 insert）
INSERT INTO platform_iam.users (id, username, email, password_hash, display_name, status, source, tenant_id, created_at, updated_at)
VALUES (gen_random_uuid(), 'li-lei', 'li.lei@ff.com', 'PLACEHOLDER', '李雷', 'ACTIVE', 'manual', 'default', now(), now())
ON CONFLICT (email) DO UPDATE SET updated_at = now()
RETURNING id;

-- 2. 拿 test org
SELECT id FROM corp_hr.organizations WHERE code = 'TEST_ORG';

-- 3. binding（拿上一步两个 id 填进去）
WITH u AS (SELECT id FROM platform_iam.users WHERE email = 'li.lei@ff.com'),
     org AS (SELECT id FROM corp_hr.organizations WHERE code = 'TEST_ORG')
INSERT INTO platform_internal_apps.employee_slug_bindings
  (id, user_id, employee_slug, source_mail_nickname, organization_id, created_by_id, created_at, updated_at)
SELECT gen_random_uuid(), u.id, 'li-lei', 'li.lei', org.id, u.id, now(), now()
FROM u, org
ON CONFLICT (employee_slug) DO NOTHING
RETURNING employee_slug;
SQL
SQL_BLOCK
```

### 1.3 ⚠️ 给员工颁发 token（临时通过 SQL；Phase 1 走 FFOA Web）

```bash
# 生成 32 字节随机 token + SHA256 哈希（明文给员工、哈希入库）
TOKEN_RAW="ffoa_$(openssl rand -base64 24 | tr '+/' '_-' | tr -d '=')"
TOKEN_HASH=$(echo -n "$TOKEN_RAW" | openssl dgst -sha256 | awk '{print $2}')
PREFIX=${TOKEN_RAW:0:8}   # 前 8 字符用于 token 识别（如 "ffoa_aBc")
echo "员工 token（只此一次明文）: $TOKEN_RAW"
echo "存库 hash: $TOKEN_HASH"

# 注意：tokens 表无 user_id 列，通过 employee_slug → binding 间接关联用户
ssh lijian@43.166.182.155 "docker exec -i ffoa-testserver-postgres psql -U ffoa -d ffoa <<SQL
WITH itadmin AS (SELECT id FROM platform_iam.users WHERE email = 'itadmin@ff.com'),
     org AS (SELECT id FROM corp_hr.organizations WHERE code = 'TEST_ORG')
INSERT INTO platform_internal_apps.internal_app_employee_tokens
  (id, employee_slug, token_hash, prefix, status, issued_at, expires_at, organization_id, created_by_id, created_at, updated_at)
SELECT gen_random_uuid(), 'li-lei', '$TOKEN_HASH', '$PREFIX', 'ACTIVE',
       now(), now() + interval '90 days',
       org.id, itadmin.id, now(), now()
FROM itadmin, org
RETURNING employee_slug, status, prefix, expires_at;
SQL"
```

把 `$TOKEN_RAW` 发给员工——**只此一次，明文存丢就要重新颁发**。

### 1.4 把 onboarding info 发员工

```text
你好 李雷，

internal-app-platform Phase 0 PoC 接入说明：

1. 你的员工标识（employee_slug）：li-lei
2. 你的 MCP token（请存到密码管理器，不要传截图/微信）：
   ffoa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
3. Claude Code 配置（在 shell 终端跑，不是 Claude Code 内）：
   claude mcp add --transport http internal-apps \
     http://43.166.182.155:3000/api/v1/internal-apps/mcp \
     --header "Authorization: Bearer ffoa_xxxxxxxx..."
4. 用法：在 Claude Code 里直接说"帮我部署 birthday-reminder 这个工具"
5. 访问 URL（演示用，DNS 未配）：
   - 终端：curl -H "Host: li-lei-birthday.apps.ffworkspace.faradayfuture.com" http://43.166.182.155/
   - 浏览器：把这行加到本机 hosts：
     43.166.182.155  li-lei-birthday.apps.ffworkspace.faradayfuture.com

有问题找 lijian。
```

---

## 2. 员工操作（Claude Code 对话即可，无需 git 操作）

### 2.1 装 Claude Code

下载 `https://claude.ai/code`（如已装跳过）。

### 2.2 注册 MCP server

在 **shell 终端**（不是 Claude Code 内）执行 IT 发的 `claude mcp add --transport http ...` 命令。

> **⚠️ 易踩**：在 Claude Code 会话内输入 `/mcp add ...` 会得到 "No MCP servers configured" 或类似错——`/mcp` slash 命令不接受这种参数形式。HTTP MCP **必须**在 shell 用 `claude mcp add --transport http`，**少了 `--transport http`** 会被当 stdio 子进程命令处理。

验证：

```bash
claude mcp list | grep internal-apps
# 期望: internal-apps (connected)
```

### 2.3 跟 Claude 说"帮我做一个生日提醒小工具"

让 Claude 生成代码（约 2-3 轮对话）。期望产物：

```
my-birthday-reminder/
├── package.json    (含 "scripts": { "start": "node index.js" })
└── index.js        (listen process.env.PORT 提供生日 API)
```

### 2.4 让 Claude 部署

```
"帮我把这个工具部署到内部 app 平台，叫 birthday"
```

Claude 会调 MCP `deploy_prepare`，拿到一个 push URL + 短期凭据，把代码 `git push` 到 Gitea。

> **⚠️ 先确认你在正确的 Claude 客户端**：
>
> `/mcp add` 仅在 **Claude Code CLI（终端 `claude`）** 或 **Claude Code VS Code 扩展** 里可用。如果你看到 `/mcp isn't available in this environment`，说明你在 Claude Desktop / claude.ai 网页 / 其他 API 客户端——它们调不到 ffoa-apps MCP。换到 Claude Code CLI 再试。
>
> **⚠️ Claude 走偏的两种典型表现**（都要立刻打断）：
>
> 1. **弹出"部署目标"选择框**（本机生产模式 / Docker 容器 / 远程服务器 / Vercel）——关掉。
> 2. **自动生成部署脚手架文件**：`Dockerfile`、`docker-compose.yml`、`deploy/*.service`（systemd）、`deploy/nginx-*.conf`、`deploy/deploy.sh`、`deploy/sync.sh`、`DEPLOY.md`、`next.config.js` 里加 `output: 'standalone'`——全部**不需要**，删掉。
>
> 本平台**固定** Docker 容器部署到 FFOA 服务器，容器化 / 反代 (Caddy) / TLS / 域名 / 日志 / 备份都由平台处理。员工的仓库里**只放业务代码 + `package.json`**（含 `"start"` 脚本，监听 `process.env.PORT`），其他什么都不写。
>
> ffoa-apps MCP 只暴露 5 个工具：`list_apps` / `deploy_prepare` / `logs` / `env` / `destroy`，**没有部署目标参数，也不会要求你生成任何配置文件**。
>
> **正确的提示句式**：
> > "用 ffoa-apps MCP 的 deploy_prepare 工具把这个项目部署到内部 app 平台，应用名叫 `xxx`。不要生成 Dockerfile / systemd / nginx 配置，平台会处理。"
>
> Claude 应直接调 `deploy_prepare`，拿到 push URL + 短期凭据，把代码 `git push` 到 Gitea，结束——员工仓库里不应该多出任何部署相关文件。

### 2.5 看部署结果 + 访问

```
"看一下我的 app 状态"
```

Claude 调 `list_apps`，返回 url。员工**测试期**走：

```bash
# 方法 1：本机 hosts
echo "43.166.182.155 li-lei-birthday.apps.ffworkspace.faradayfuture.com" | sudo tee -a /etc/hosts
# 然后浏览器打开 http://li-lei-birthday.apps.ffworkspace.faradayfuture.com/

# 方法 2：终端 curl
curl -H "Host: li-lei-birthday.apps.ffworkspace.faradayfuture.com" http://43.166.182.155/today
```

---

## 3. 完整 demo 脚本（约 15 分钟，照念可用）

> 给领导/HR 现场演示，每步都有对应预期输出。
> 用已有的 `lijian/hello` 仓库——员工注册流程可以另做一遍。

| 时间 | 动作 | 预期 |
|---|---|---|
| t+0 | 打开 http://43.166.182.155/（已 deploy 的 hello） | 看到 v-final HTML 页 |
| t+2min | 终端 `git clone http://oauth2:$TOKEN@43.130.59.228/FFAIApps/lijian-hello.git` + 改 `index.js` 一句话 + push | git push 成功 |
| t+3min | 后台日志 `ssh lijian@43.166.182.155 'pm2 logs ffoa-backend --lines 5 --nostream'` | 看到 `deploy script invoked` + ~5 秒后 `deploy ok` |
| t+4min | 刷新浏览器 http://43.166.182.155/ | 看到新内容（员工 push 触发的自动重新部署）|
| t+5min | Claude Code 里说"帮我看一下 hello 这个 app 的日志" | MCP `logs` 工具返回最近几行容器日志 |
| t+6min | Claude Code 里说"销毁 hello"，确认 `confirm: true` | `destroy` 工具撤容器 + Caddy 路由；浏览器再访问 → 404 |
| t+7min | Claude Code 里再说"重新部署 hello"（演示恢复路径） | 重新部署成功 |

---

## 4. 故障排查（按出现频率排）

### 4.1 员工 push 后没有自动部署

| 排查 | 命令 | 处理 |
|---|---|---|
| 1. Gitea 收到 push 没？ | `curl -s -H "Authorization: token $TOKEN" "http://43.130.59.228/api/v1/repos/FFAIApps/<slug>/commits?limit=1" \| jq .[0].sha` | 没收到 → 员工 push 没成功 |
| 2. Gitea webhook 推了没？ | Gitea 仓库 → Settings → Webhooks → Recent Deliveries | failed → 看返回 4xx 原因 |
| 3. backend 收到 webhook？ | `pm2 logs ffoa-backend --nostream \| grep webhook` | 没看到 → 检查 backend 在跑 + 端口 3000 开放 |
| 4. backend HMAC 校验过？ | grep `invalid_signature` | 失败 → secret 不一致，对齐 Gitea hook 配置和 backend `.env` |
| 5. backend 找到 app 行？ | grep `app_not_found` | 失败 → §1.2 没建 `internal_apps` 行 |
| 6. deploy script 跑成功？ | grep `deploy ok` / `deploy failed` | failed 看错误码（[详见 §4.2](#42-deploy-script-错误码)）|

### 4.2 deploy script 错误码

| 错误码 | 含义 | 处理 |
|---|---|---|
| `git_clone_failed` | 仓库 clone 不通 | 看 `details.stderr`；常见是 token scope 不够 |
| `no_package_json` / `no_start_script` | runtime=node 但缺文件 | 告诉员工补 package.json scripts.start |
| `health_check_timeout` | 容器 30s 内没监听 :3000 | 看 `details.logs` 找原因（npm install 失败 / SyntaxError 等） |
| `caddy_reload_failed` | Caddy reload 失败 | `ssh lijian@43.166.182.155 'docker logs ffoa-caddy --tail 30'` |
| `docker_run_failed` | 启动容器失败 | `details.stderr`；常见镜像 pull 网络问题 |

### 4.3 员工浏览器访问 URL 看不到东西

| 排查 | 处理 |
|---|---|
| 没改 hosts | 让员工加 `/etc/hosts` 一行 |
| 改了 hosts 还是空 | 缺 Caddy site 文件 → `ls /srv/internal-apps/repos/<emp>/<app>/` 看有没有；缺 → 重 deploy |
| HTTP 308 重定向到 HTTPS | Caddy auto-HTTPS 触发了——查 [`.learnings/2026-05-14-caddy-auto-https-308-without-dns.md`](../../../.learnings/2026-05-14-caddy-auto-https-308-without-dns.md) |

### 4.4 token 失效 / `invalid_token`

```bash
# 给该员工新发一个（撤旧 + 发新）
TOKEN_RAW="ffoa_$(openssl rand -base64 24 | tr '+/' '_-' | tr -d '=')"
TOKEN_HASH=$(echo -n "$TOKEN_RAW" | openssl dgst -sha256 | awk '{print $2}')
ssh lijian@43.166.182.155 "docker exec -i ffoa-testserver-postgres psql -U ffoa -d ffoa <<SQL
UPDATE platform_internal_apps.internal_app_employee_tokens
   SET status = 'REVOKED', revoked_at = now()
   WHERE employee_slug = 'li-lei' AND status = 'ACTIVE';
-- 然后跑 §1.3 的 INSERT 给新 token
SQL"
```

---

## 5. 销毁 / 清理 / 30 天内恢复

### 5.1 标准销毁（员工自助）

员工 Claude Code 里说 "销毁 hello"，自动触发 MCP `destroy`：
- 容器 + sidecar `docker rm -f`
- Caddy site file 删 + reload
- **Gitea 仓库 PATCH archived=true**（read-only，禁 push）
- DB 标 DESTROYED + retention_until = now+30d
- Litestream 备份**保留在 MinIO**（30 天 PURGE 前一直在）

### 5.2 30 天内 IT-Admin 恢复（实测 2026-05-14）

```bash
# 1. Gitea 解封（解锁 push）
TOKEN="<service token>"
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
  http://43.130.59.228/api/v1/repos/FFAIApps/<emp>-<app> \
  -d '{"archived": false}'

# 2. DB 状态回 HEALTHY
ssh lijian@43.166.182.155 'docker exec -i ffoa-testserver-postgres psql -U ffoa -d ffoa <<SQL
UPDATE platform_internal_apps.internal_apps
   SET status='\''HEALTHY'\'', destroyed_at=NULL, retention_until=NULL
   WHERE app_slug='\''<app>'\'' AND employee_slug='\''<emp>'\'';
SQL'

# 3. 触发 deploy：员工 push 任意 commit（webhook 自动触发）
# 或 IT 手动：ssh lijian@43.166.182.155 'ffoa-deploy --employee-slug <emp> --app-slug <app> --runtime node --repo-url <clone-url> --git-token $TOKEN'

# 4. 验证：
ssh lijian@43.166.182.155 'docker ps --filter name=ffoa-app-<emp>-<app>'
curl -H "Host: <emp>-<app>.apps.ffworkspace.faradayfuture.com" http://43.166.182.155/
```

**Litestream 自动恢复**：第 3 步部署时 ffoa-deploy 看到 `/srv/internal-apps/data/<emp>/<app>/app.db` 不存在 →
`litestream restore -config /etc/litestream.yml` 从 MinIO 拉回最新 snapshot + WAL。员工感觉"数据没丢"。

> ⚠️ 已知 limitation：30 天内若 IT 多次手工 `rm -rf` DATA_DIR 测试，会产生多个
> Litestream generation。0.3 版 `restore` 按 lex-order 挑第一个不是最新。
> 正常 PoC 流程不触发；Phase 1 升级 Litestream 0.5+ 修。详见
> `.learnings/2026-05-14-litestream-yaml-config-needed-for-minio.md`

### 5.3 30 天后彻底清理（systemd timer 自动跑）

setup-test-server.sh 已自动装好 `ffoa-sweep-expired-apps.timer`，每天 03:00 跑一次扫所有 `retention_until < now() AND status='DESTROYED'` 的 app，按以下顺序清：

1. **Gitea**：`DELETE /repos/FFAIApps/{emp}-{app}`
2. **MinIO**：`mc rm -r internal-apps-backups/{emp}/{app}/`
3. **本地**：`rm -rf /srv/internal-apps/{repos,data}/{emp}/{app}/`
4. **DB**：`DELETE FROM internal_apps`（FK CASCADE 自动删 env_vars / deployments）

**操作命令**：

```bash
# 看下一次自动跑的时间
ssh lijian@43.166.182.155 'systemctl list-timers ffoa-sweep-expired-apps'

# 查最近一次跑的日志（JSONL，过滤"sweep_completed"看汇总）
ssh lijian@43.166.182.155 'tail -20 /var/log/ffoa-sweep-expired-apps.jsonl | jq'

# 手动 dry-run（不真删）—— 看现在有哪些过期 app
ssh lijian@43.166.182.155 'ffoa-sweep-expired-apps'

# 手动 execute（cron 本来就会跑，这是诊断用）
ssh lijian@43.166.182.155 'sudo systemctl start ffoa-sweep-expired-apps.service'

# 暂停 cron（紧急情况，比如发现误判）
ssh lijian@43.166.182.155 'sudo systemctl stop ffoa-sweep-expired-apps.timer'
```

**安全设计**（详见 [`scripts/internal-app-platform/sweep-expired-apps.sh`](../../../scripts/internal-app-platform/sweep-expired-apps.sh) 顶部注释）：

- 严格 `retention_until < now() AND status='DESTROYED'` 过滤，**绝不删活的 app**
- Gitea DELETE 限定 `FFAIApps` org；MinIO 限定 `internal-apps-backups` bucket；本地限定 `/srv/internal-apps/{repos,data}` 下——任一条不命中则 NOOP
- `flock` 防并发（systemd timer 重叠触发只跑一份）
- 全程 JSONL 审计日志到 `/var/log/ffoa-sweep-expired-apps.jsonl`
- 部分失败时（如 Gitea 不可达）该 app 不会被 DB 删除，下次重跑继续

---

## 6. PoC 验收清单

PoC 通过判定（[01-prd.md §成功指标](01-prd.md) 同步源）：

- [ ] 1 名真实 HR 员工 ≤ 30 分钟完成接入 → 首次部署
- [ ] 同事拿到 URL 后能浏览器访问（用 hosts / DNS 任一种）
- [ ] 员工自己 push 第二个 commit，能在 ~30s 内看到生效（增量部署）
- [ ] 员工说"看日志"能拿到容器日志
- [ ] 员工说"销毁"能停掉
- [ ] 数据重启不丢（Litestream 落地后才能验，Phase 0 末期）
- [ ] 全程没人写 docker / nginx / 服务器命令

通过后 → 进入 Phase 1 MVP 路线（前端 / Entra middleware / DNS）。

---

## 附：联系人 / 相关资源

- 平台代码：`backend/src/modules/internal-app-platform/`
- 部署脚本：`scripts/internal-app-platform/`
- learnings（实施期踩坑）：`.learnings/2026-05-1{3,4}-*.md`
- PRD：[`01-prd.md`](01-prd.md)
- 架构：[`03-architecture.md`](03-architecture.md)
- API 契约：[`07-api.md`](07-api.md)
