# 内部小工具自助部署平台 - 架构

> **module**: internal-app-platform
> **doc_type**: Architecture
> **status**: Draft
> **owner**: lijian.dai
> **upstream_docs**: 01-prd.md
> **last_verified**: 2026-05-13
>
> **事实源**: 本文档定义 MVP 阶段的架构分层、组件职责与关键流程

---

## 1. 架构摘要

平台把"员工说一句话 → 同事拿到 URL"拆成几个组件，员工只感知 Claude Code 与 FFOA 接入页两点：

```text
                  ┌────────────────────────┐
   员工浏览器 ───▶│ FFOA "我的 Apps" 页    │── 颁发/撤销 token ──┐
                  │  (Entra ID 已登录态)   │                     │
                  └────────────────────────┘                     ▼
                                                          ┌─────────────┐
   员工对话 ──▶ Claude Code ──Streamable HTTP + Bearer──▶ 远程 MCP server ──▶│ OA HTTP API │
                                                                  └──────┬──────┘
                                                                         │
                                          ┌──────────────┬───────────────┼──────────────┐
                                          ▼              ▼               ▼              ▼
                                   ┌─────────┐    ┌────────────┐  ┌────────────┐  ┌──────────┐
                                   │ Gitea   │──▶│ 部署脚本    │─▶│ Docker     │  │ audit-   │
                                   │ FFAIApps│webhook│(bash+flock)│  │ 容器(N×app)│  │ system   │
                                   └─────────┘    └────┬───────┘  └─────┬──────┘  └──────────┘
                                                       │ reload         │ /data 卷
                                                       ▼                ▼
                                                  ┌─────────┐     ┌─────────────┐
   同事浏览器 ──*.apps.ffworkspace.faradayfuture.com──▶ Caddy ◀──┤ Caddy   │     │ Litestream  │──▶ 对象存储
                                  (SSO 拦截 +    │  config │     │ (持续复制)  │   (备份/恢复)
                                   注入 X-User-) └─────────┘     └─────────────┘
```

> 关键回路：① 颁发链路 = FFOA 页 → OA API → token 存哈希；② 部署链路 = Claude Code → MCP → OA API → Gitea → webhook → 部署脚本 → Docker；③ 访问链路 = 同事浏览器 → Caddy (SSO) → Docker；④ 备份链路 = Docker `/data` → Litestream → 对象存储；⑤ 审计回路 = OA API / 部署脚本 / Caddy 全程写 audit-system。详细组件交互图见 §4.1 mermaid。

**核心架构决策**（依据 01-prd.md §核心业务约束）：

- **远程 MCP server 是员工唯一入口**：Streamable HTTP transport，部署在 FFOA 侧；员工电脑端不安装 MCP 包，工具升级零运维
- **bearer token 即员工身份**：MCP 调用 header 携带 token，OA API 校验后解出 `employeeSlug`；token 由 FFOA Entra ID 已登录态颁发
- **Gitea 是代码事实源**：所有 app 代码必须落库到 Gitea，不存在"只在容器里"的代码
- **部署脚本工程化**：bash + `flock` + `set -euo pipefail` + `trap ERR`；超过 500 行触发硬退出条件
- **资源限额是硬约束**：Docker 容器参数固定，不接受按 app 豁免
- **数据约定路径**：员工 SQLite 必须写到 `/data/app.db`，其他位置不备份、随重启消失
- **TLS wildcard 一次签**：DNS-01 challenge，禁用 on-demand TLS（避开 CT log 反馈循环）
- **SQLite 备份用 Litestream**：禁用 `cp` / `rsync`（WAL 模式直接复制损坏率 100%）

---

## 2. 分层边界

| 层 | 职责 | 不做 |
|---|---|---|
| **远程 MCP server 层** | 暴露 Streamable HTTP 端点给 Claude Code；把 tool call 翻译成 OA API 调用；校验 bearer token；返回结构化结果给 AI | 不做业务逻辑、不持久化状态 |
| **OA HTTP API 层** | 业务编排：权限校验 → Gitea 操作 → 触发部署 → 返回 URL；token 颁发 / 撤销端点供 FFOA Web 调用 | 不直接操作 Docker / Caddy（交给部署脚本） |
| **Gitea 层** | 代码托管、webhook 触发 | 不做构建 / 部署（仅作为代码事实源 + 触发器） |
| **部署脚本层** | 拉代码 → docker build → docker run → 健康检查 → Caddy 路由更新 | 不和员工 / Claude Code 直接交互 |
| **Docker 运行时层** | 跑员工 app 进程，强制资源限额 + 非 root + 只读 fs + tmpfs | 不持久化（除 `/data` 卷） |
| **Caddy + SSO 层** | TLS 终止（wildcard `*.apps.ffworkspace.faradayfuture.com`）、SSO 拦截、**按 `Host` 子域名反代**到 Docker network 同名容器、注入 user header | 不感知员工 app 内部逻辑；不做路径前缀路由 |
| **备份层** | Litestream 持续把 `/data/app.db` 复制到对象存储 | 不做应用级备份（如导出 CSV） |

---

## 3. 组件结构（规划）

代码会落在以下位置（实际命名待状态 3 `06-data-model.md` / `07-api.md` 最终敲定）：

| 组件 | 路径 | 说明 |
|---|---|---|
| OA API 后端模块 | `backend/src/modules/internal-app-platform/` | NestJS module，按项目惯例 controller/service/repository 分层 |
| 远程 MCP 端点 | `backend/src/modules/internal-app-platform/mcp.controller.ts` | NestJS Controller，`POST /api/v1/internal-apps/mcp`，Streamable HTTP transport（见 [07-api §8.1](./07-api.md#81-mcp-transport--streamable-http非-sse)）；不单独分发 Node 包 |
| Prisma schema | `backend/prisma/schema/platform_internal_apps.prisma` | InternalApp / Deployment / EnvVar / EmployeeToken 等表 |
| FFOA Web 接入页 | `frontend/src/app/internal-apps/` | "我的 Apps" 页，承载 token 生成 / 撤销 + 复制 `claude mcp add` 命令 |
| 部署脚本 | `scripts/internal-app-platform/deploy.sh` 等 | bash，由 Gitea webhook handler 调用 |
| 服务器目录 | `/srv/internal-apps/<appId>/` | 每个 app 一个目录，含 `data/`、`current/`、`versions/` |
| Caddy 配置 | `/etc/caddy/internal-apps.caddy` | 由部署脚本 reload 生成 |
| Litestream 配置 | `/etc/litestream/<appId>.yml` | 每 app 一份，备份目标桶共享 |
| Gitea pre-receive hook | Gitea 仓库模板 `FFAIApps` org 级 hook | bash 脚本：文件名黑名单 + 单文件 > 100MB + `.git/` 嵌套 + 可执行 binary 拒绝；**不做内容扫描**（PRD §不做内容扫描）；返回结构化错误供 Claude Code 自动重试 |
| 推送凭据颁发服务 | `backend/src/modules/internal-app-platform/git-credential/` | OA API 子模块，调 Gitea API 颁发 5 分钟 TTL 的 fine-grained OAuth token（scope = 单仓库 write:repo） |

---

## 4. 关键流程

### 4.0 token 颁发与首次接入鉴权链路

```mermaid
sequenceDiagram
  autonumber
  actor E as 员工
  participant Br as 浏览器
  participant FFOA as FFOA "我的 Apps" 页
  participant API as OA HTTP API
  participant Entra as Azure Entra ID
  participant DB as token 哈希表
  participant Audit as audit-system
  participant CC as Claude Code
  participant MCP as 远程 MCP server

  Note over E,MCP: 阶段 ①：颁发 token

  E->>Br: 打开 https://ffworkspace.faradayfuture.com/internal-apps
  Br->>FFOA: GET /internal-apps
  alt 未登录
    FFOA->>Entra: 跳 SSO 登录
    Entra-->>Br: session cookie
    Br->>FFOA: 携 cookie 重发
  end
  FFOA->>FFOA: 渲染区块 A "尚未生成 token"
  E->>FFOA: 点 [生成新 token]
  FFOA->>API: POST /tokens (Entra session)
  API->>Entra: 校验 session + 取 mailNickname
  API->>API: 规范化 employeeSlug<br/>(首次入库后冻结)
  API->>API: 生成 opaque token<br/>+ SHA256 哈希
  API->>DB: 存 (employeeSlug, hash, expiresAt=+90d)
  API->>Audit: token.issued
  API-->>FFOA: { tokenPlaintext, mcpEndpoint, expiresAt }
  FFOA->>Br: navigator.clipboard.writeText(完整命令)<br/>明文不写入 DOM
  FFOA->>FFOA: 卡片切"有效·本次生成"<br/>+ toast "已复制"
  FFOA->>FFOA: 内存中明文 5 分钟后清空

  Note over E,MCP: 阶段 ②：员工接入 Claude Code

  E->>CC: shell 跑 claude mcp add --transport http ffoa-apps https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp --header "Authorization: Bearer <TOKEN>"
  CC->>CC: 持久化到 ~/.config/claude/mcp.json

  Note over E,MCP: 阶段 ③：首次工具调用鉴权

  E->>CC: "帮我列出我的 app"
  CC->>MCP: tools/call list_apps<br/>Authorization: Bearer <TOKEN>
  MCP->>API: GET /apps?owner=me<br/>Authorization 透传
  API->>API: SHA256(token) → 查 DB
  alt 哈希不匹配 / 已撤销
    API-->>MCP: 401 + structured { code: "invalid_token", onboardUrl }
    MCP-->>CC: 结构化错误
    CC-->>E: "token 无效，请去 https://ffworkspace.faradayfuture.com/internal-apps 重新生成"
  else 匹配 + 未过期
    API->>API: 解出 employeeSlug + 校验 expiresAt
    API->>API: N = expiresAt - now (天)
    alt N ≤ 7
      API-->>MCP: 200 + body + warning="还有 N 天过期，去 https://ffworkspace.faradayfuture.com/internal-apps 续期"
    else N > 7
      API-->>MCP: 200 + body
    end
    MCP-->>CC: 工具结果
    CC-->>E: 列出 app
  end
```

> 关键不变量：① 明文 token 仅在颁发响应里出现一次，不入库 / 不渲染 DOM；② 哈希 SHA256 存表，撤销 = 删表项；③ 7 天 warning 由 OA API 在每次调用时计算（员工时区无关）。

---

### 4.1 架构总览（组件 + 数据流）

```mermaid
flowchart LR
  subgraph EmployeeLaptop["员工电脑"]
    CC["Claude Code<br/>(对话 + 写代码)"]
    LocalDir["本地工作目录<br/>(Node 代码 + package.json)"]
    CC --> LocalDir
  end

  subgraph Platform["内部 App 平台（服务器）"]
    MCPSrv["远程 MCP server<br/>(Streamable HTTP, 校验 bearer)"]
    OAAPI["OA HTTP API"]
    FFOAWeb["FFOA Web<br/>我的 Apps 页 / token 发放"]
    Gitea[("Gitea<br/>FFAIApps/&lt;employeeSlug&gt;-&lt;app&gt;")]
    Builder["部署脚本<br/>(bash + flock)"]
    Docker["Docker 容器<br/>512M / 0.5 CPU<br/>--read-only --tmpfs"]
    Caddy["Caddy 反代<br/>+ Entra ID SSO 拦截"]
    Litestream["Litestream"]
    DataVol[("/data 持久化卷<br/>app.db")]
    EntraID[("Azure Entra ID<br/>(FFOA 已集成)")]
  end

  OS[("对象存储<br/>(SQLite 备份)")]

  Visitor["同事浏览器"]

  CC -->|Streamable HTTP + Bearer| MCPSrv
  MCPSrv -->|内部调用| OAAPI
  FFOAWeb -->|颁发 / 撤销 token| OAAPI
  FFOAWeb -->|登录态| EntraID
  OAAPI -->|建仓 + 颁发 5min<br/>OAuth push 凭据| Gitea
  CC -->|本地 git push<br/>(via Bash tool)| Gitea
  Gitea -->|webhook| Builder
  Builder -->|docker run| Docker
  Docker -.挂载.- DataVol
  DataVol <-->|持续复制| Litestream
  Litestream --> OS
  Builder -->|更新路由| Caddy

  Visitor -->|&lt;employeeSlug&gt;-&lt;app&gt;.apps.&lt;域名&gt;| Caddy
  Caddy -->|未登录跳 FFOA 登录| EntraID
  EntraID -->|已认证 session + header| Caddy
  Caddy -->|反代 :port| Docker
```

### 4.2 首次部署（员工说"部署一下"）

```mermaid
sequenceDiagram
  autonumber
  actor E as 员工
  participant CC as Claude Code
  participant MCP as 远程 MCP server
  participant API as OA HTTP API
  participant G as Gitea
  participant B as 部署脚本
  participant D as Docker
  participant C as Caddy

  E->>CC: "帮我把这个部署一下"
  CC->>CC: 根据业务上下文生成 app slug<br/>(birthday-reminder)
  CC->>MCP: deploy_prepare(app=birthday-reminder)<br/>Authorization: Bearer <TOKEN>
  MCP->>MCP: 校验 bearer<br/>解出 employeeSlug=zhang-san
  MCP->>API: POST /apps {employeeSlug, appSlug}
  API->>API: 校验 slug 规则 + 查重
  API->>G: 建仓 FFAIApps/zhang-san-birthday-reminder<br/>(初始 internal, 装 pre-receive hook)
  API->>API: 颁发 5min TTL OAuth token<br/>scope = 该仓库 write:repo
  API-->>MCP: { repoUrl, pushCredential, branch, suggestedCommitMessage }
  MCP-->>CC: 同上 (结构化 JSON, 不含 shell 命令字符串)

  Note over CC: Layer 1: 本地 .gitignore 自动注入<br/>Layer 2: 友好摘要 (告知不询问)

  CC->>CC: 本地探测 .gitignore<br/>缺失则注入默认模板<br/>追加 >50MB 数据文件
  CC->>E: "准备部署 N 个文件，已忽略<br/>node_modules / data/big.csv"
  CC->>CC: 通过 Bash tool 执行:<br/>git init / git add / git commit<br/>git remote add ffapp <repoUrl-with-token><br/>git push ffapp main

  Note over G: Layer 3: pre-receive hook 兜底

  alt hook 拒绝（文件名黑名单 / 大文件）
    G-->>CC: stderr 含结构化错误 + 违规文件
    CC->>CC: 把违规文件加进 .gitignore<br/>git commit --amend + push --force-with-lease
    G-->>CC: 重试通过
  end

  G-->>B: webhook 触发
  B->>B: flock 锁住同 app 部署
  B->>D: docker build (Node 镜像)
  alt 构建失败
    B-->>API: status=failed + 日志摘要
    API-->>MCP: {status: failed, logs}
    MCP-->>CC: 结构化错误
    CC-->>E: "构建失败了，是因为 X，我帮你改"
  else 构建成功
    B->>D: docker run --memory=512M --cpus=0.5 ...
    B->>D: 健康检查 :port/
    B->>C: Docker network 内部 DNS 自动生效<br/>(wildcard *.apps.ffworkspace.faradayfuture.com 已签证书，无需改 Caddy 配置)
    B-->>API: status=success
    API-->>MCP: {url, deployedAt}
    MCP-->>CC: URL
    CC-->>E: "好了，URL 是 zhang-san-birthday-reminder.apps.ffworkspace.faradayfuture.com<br/>发给同事就能用"
  end
```

### 4.3 增量部署（员工改完再次说"部署"）

> **复用说明**：MCP `env` 写操作（PRD §F2.2）走**完全相同的滚动重启流程**，唯一差别是跳过"覆盖式 push + docker build"两步——直接拿现有镜像 + 新 env 启 `Dnew` 进入下方"健康检查 → 切流量 → 停 Dold"分支。本图未单独画 env 流程，避免重复。

```mermaid
sequenceDiagram
  autonumber
  actor E as 员工
  participant CC as Claude Code
  participant MCP as 远程 MCP server
  participant API as OA HTTP API
  participant G as Gitea
  participant B as 部署脚本
  participant Dold as 旧容器
  participant Dnew as 新容器
  participant C as Caddy

  E->>CC: "改好了，再部署一下"
  CC->>MCP: deploy_prepare(app=birthday-reminder)
  MCP->>API: POST /apps/birthday-reminder/deploy
  API->>API: 校验 owner = 当前员工
  API->>G: 覆盖式 push 到原仓库 (+1 commit)
  G-->>B: webhook
  B->>Dnew: 新建容器（旧容器仍在跑、流量未切）
  B->>Dnew: 健康检查
  alt 健康检查失败
    B->>Dnew: 销毁
    B-->>API: failed，保留旧容器
    API-->>CC: 失败日志
  else 健康检查通过
    B->>C: 切流量到 Dnew
    B->>Dold: 停容器、清理
    Note over Dnew: /data 卷不变<br/>数据无感继承
    B-->>API: success
    API-->>CC: 新部署完成
  end
```

### 4.4 同事访问 app URL

```mermaid
sequenceDiagram
  autonumber
  actor V as 同事
  participant Br as 浏览器
  participant C as Caddy
  participant SSO as Azure Entra ID<br/>(经 FFOA iam)
  participant D as Docker 容器（员工的 app）

  V->>Br: 打开 zhang-san-birthday-reminder.apps.ffworkspace.faradayfuture.com/
  Br->>C: GET / (Host: zhang-san-birthday-reminder.apps.ffworkspace.faradayfuture.com)
  alt 未登录
    C->>SSO: 重定向到 FFOA 登录页
    V->>SSO: Entra ID 单点登录
    SSO-->>Br: 颁发 session cookie
    Br->>C: 携 cookie 重发请求
  end
  C->>C: 校验 session + 拼 X-User-Email header
  C->>D: 反代 :port/ + X-User-Email
  D->>D: 读 /data/app.db
  D-->>C: HTML / JSON
  C-->>Br: 响应
  Br-->>V: 看到生日列表
```

### 4.5 销毁 + 备份 + 恢复

```mermaid
sequenceDiagram
  autonumber
  actor E as 员工
  participant CC as Claude Code
  participant MCP as 远程 MCP server
  participant API as OA HTTP API
  participant D as Docker
  participant C as Caddy
  participant G as Gitea
  participant L as Litestream
  participant OS as 对象存储
  participant Sw as TTL Sweeper<br/>(cron / 定时任务)

  Note over L,OS: 在 app 存活期间，Litestream<br/>持续把 /data/app.db 复制到对象存储

  E->>CC: "删掉 birthday-reminder"
  CC->>MCP: destroy(app=birthday-reminder)
  MCP->>API: DELETE /apps/birthday-reminder
  API->>API: 二次确认 + 校验 owner
  API->>D: 停容器 + 删容器
  API->>C: 移除路由
  API->>G: 归档仓库（不删）
  API->>OS: 标记备份保留 30 天<br/>(写 destroyedAt + retentionUntil)
  API-->>CC: destroyed, 数据保留至 2026-06-11

  Note over Sw,OS: TTL Sweeper 每日扫描<br/>retentionUntil < now 的备份

  alt 员工后悔（30 天内）
    E->>CC: "把 birthday-reminder 恢复"
    CC-->>E: MVP 需提工单给 IT 管理员（F5.3）
    Note over E: V2 才支持员工自助 restore
  else 30 天到期
    Sw->>OS: 删除过期备份对象
    Sw->>API: 写 audit + 标记 app 状态为 purged
  end
```

### 4.6 runtime 自动判别决策树

```mermaid
flowchart TD
  Start([部署脚本拉到代码]) --> P{根目录有<br/>package.json?}
  P -- 是 --> S{有 'start' script?}
  S -- 是 --> Node[node runtime<br/>docker build + run<br/>注入 PORT env<br/>资源限额生效]
  S -- 否 --> H{根目录有<br/>index.html?}
  P -- 否 --> H
  H -- 是 --> Static[static runtime<br/>发布到 Caddy 静态目录<br/>不起容器]
  H -- 否 --> Reject[拒绝部署<br/>结构化错误回 Claude Code:<br/>"未识别 runtime,需 package.json+start 或 index.html"]

  Node --> HC[健康检查 :PORT/<br/>status&lt;500 通过<br/>3s 超时 / 30 次重试]
  Static --> SF[检查 index.html 可读<br/>+ Caddy reload 成功]

  HC -- 通过 --> Live[切流量上线]
  HC -- 失败 --> Keep[保留旧版本<br/>回写最后 3 次响应摘要]
  SF -- 通过 --> Live
  SF -- 失败 --> Keep

  Reject --> End([返回 CC])
  Live --> End
  Keep --> End
```

> 员工**不手工声明 runtime**——决策完全由根目录文件结构驱动，Claude Code 生成什么就跑什么。规则改动属于 PRD §核心约束，不是工程实现细节。

---

### 4.7 deploy staging 三层防护（对员工无感）

```mermaid
flowchart TD
  Start([员工说"部署"]) --> L1Start

  subgraph L1[Layer 1: Claude Code 本地 auto-ignore]
    L1Start[Claude Code 探测员工目录] --> GI{有 .gitignore?}
    GI -- 否 --> InjectDefault[注入默认模板:<br/>node_modules/ .env* *.pem *.key<br/>id_rsa* *.p12 *.pfx *.log<br/>.DS_Store dist/ .cache/]
    GI -- 是 --> ScanSize
    InjectDefault --> ScanSize[本地 find -size +50M<br/>追加 .csv .zip .iso 等大数据文件]
  end

  ScanSize --> L2Start

  subgraph L2[Layer 2: 友好摘要 - 告知不询问]
    L2Start[Claude Code 数 staging 文件] --> Tell["对员工说一句:<br/>'准备部署 N 个文件<br/>已自动忽略 node_modules / data/big.csv'"]
    Tell --> NoAsk[不弹确认 / 不等回车<br/>直接进入推送]
  end

  NoAsk --> L3Start

  subgraph L3[Layer 3: Gitea pre-receive hook 兜底]
    L3Start[git push ffapp main<br/>via Bash tool] --> Hook{pre-receive hook 检查}
    Hook -- 通过 --> Accept[push 落库<br/>webhook 触发]
    Hook -- 拒绝<br/>文件名黑名单<br/>大文件 / 嵌套 .git<br/>可执行 binary --> RetryLogic
    RetryLogic[Claude Code 自动捕获错误<br/>把违规文件加进 .gitignore<br/>git commit --amend + push] --> Retry{第二次 push}
    Retry -- 通过 --> Accept
    Retry -- 仍拒绝 --> OnlyNowSpeak[首次向员工出声:<br/>'部署失败: X 文件无法上传']
  end

  Accept --> Continue([进入 §4.2 webhook → 部署脚本])
  OnlyNowSpeak --> End([极少触发<br/>员工对话出错])

  style L1 fill:#e8f5e9
  style L2 fill:#fff9c4
  style L3 fill:#ffe0b2
```

> **设计原则**（PRD §非技术用户 UX 原则）：员工只看 Layer 2 那一句友好告知。Layer 1 / Layer 3 全程透明，**99% 部署员工感知到的全过程就是"说一声 → 拿到 URL"**。
>
> **不做内容扫描**：MVP 不集成 gitleaks / 高熵字符串检测；理由见 PRD §不做内容扫描（内网隔离 + 非技术员工读不懂建议 + 推到 V2 离线合规）。三层防护合力针对的是"文件大小 / 文件名 / 嵌套版本控制"等**客观可枚举**的硬伤。

---

### 4.8 token 生命周期状态机

```mermaid
stateDiagram-v2
  [*] --> NeverIssued: 员工首次访问<br/>"我的 Apps" 页

  NeverIssued --> Active: [生成新 token]<br/>(expiresAt = now+90d)

  Active --> Expiring: now ≥ expiresAt-7d<br/>(自动迁移)
  Active --> Active: [重新生成]<br/>(旧 token 失效, 新 expiresAt = now+90d)
  Active --> Revoked: [撤销]<br/>(强确认 + 输入 REVOKE)
  Active --> Disabled: Entra ID disable<br/>(见 §4.9)

  Expiring --> Active: [重新生成]<br/>(归零 90d)
  Expiring --> Expired: now > expiresAt
  Expiring --> Revoked: [撤销]
  Expiring --> Disabled: Entra ID disable

  Expired --> Active: [生成新 token]
  Revoked --> Active: [生成新 token]
  Disabled --> [*]: 60 天硬清理<br/>(30d 容器 + 30d 备份)

  note right of Active
    MCP 调用返回 200
    token 哈希校验通过
  end note

  note right of Expiring
    MCP 返回里附 warning
    Claude Code 主动提示员工
  end note

  note right of Expired
    MCP 返回 401 expired
    引导 https://ffworkspace.faradayfuture.com/internal-apps
  end note

  note left of Disabled
    Entra disable 触发
    所有 token 立即失效
  end note
```

> **不变量**：① 同一时间一个 employeeSlug 只有 0 或 1 个 active token（重新生成原子吊销旧的）；② Disabled 状态由 Entra ID 事件驱动，员工无法自助恢复；③ 状态转移**全程写 audit-system**。

---

### 4.9 离职（Entra ID disable）联动

```mermaid
sequenceDiagram
  autonumber
  participant Entra as Azure Entra ID
  participant API as OA HTTP API
  participant DB as token + app 表
  participant D as Docker 容器
  participant L as Litestream
  participant G as Gitea
  participant Sw as TTL Sweeper
  participant Audit as audit-system

  Note over Entra,Audit: T0: 员工被 disable

  Entra->>API: webhook / 周期同步任务<br/>employee.disabled
  API->>DB: 标记所有该 employeeSlug 的 token 为 disabled
  API->>Audit: token.disabled (cascade from entra)
  Note over D: 容器继续运行<br/>(同事还在用)
  Note over L: Litestream 继续备份

  alt T0+ ≤ 30 天: 员工 re-enable（误操作 / 复职）
    Entra->>API: employee.enabled
    API->>DB: 标记 token 为 active 不重发<br/>(员工需自助重新生成)
    API->>G: 仓库归属维持原 owner
    Note over D: 容器无中断
    API->>Audit: employee.reenabled
  else T0+30 天到期，员工未复职
    Sw->>API: scan disabled employees, age > 30d
    API->>D: 停容器 + 删容器实例
    API->>D: 标记 app 状态 = disabled_archived
    API->>L: 触发最终一次备份<br/>+ 备份保留期 retentionUntil = now+30d
    API->>G: transfer 仓库 → FFAIApps-Archive org<br/>原 owner read-only
    API->>Audit: app.disabled_archived (per app)

    Note over Sw,L: T0+60 天: 备份过期清理（走 §4.5 Sweeper）
  end
```

> **总恢复窗口 = 30 天容器 + 30 天备份 = 60 天**。复职超过 30 天的员工需手工重新部署（数据可能已被清理）。`FFAIApps-Archive` 是专用归档 org，所有人 read-only，不删——保留代码可追溯性。

---

### 4.10 IT-Admin 强制停用 / 销毁

```mermaid
sequenceDiagram
  autonumber
  actor IT as IT-Admin
  participant CLI as CLI 工具<br/>(curl / 内部脚本)
  participant API as OA HTTP API
  participant RBAC as RBAC 校验
  participant D as Docker 容器
  participant C as Caddy
  participant L as Litestream
  participant G as Gitea
  participant Audit as audit-system
  participant Notify as 邮件 / IM

  Note over IT,Notify: 触发场景：违规内容 / 资源失控 / 员工申诉

  IT->>CLI: ffapp-admin disable <employeeSlug>/<app>
  CLI->>API: POST /admin/apps/<id>/disable<br/>Authorization: Bearer <admin_token>
  API->>RBAC: 校验 internal-app:admin 权限
  alt 权限不通过
    RBAC-->>API: 403
    API-->>CLI: forbidden
  else 权限通过
    API->>D: 停容器（保留实例 + 数据）
    API->>C: 移除路由（访问返回 503 + 友好页）
    API->>Audit: app.force_disabled<br/>{ admin, target, reason }
    API->>Notify: 邮件 owner: "你的 app X 被 IT 停用，原因 Y, 申诉走工单 Z"
    API-->>CLI: { status: disabled, restorableBy: admin }

    opt 进一步强制销毁（F4.3，更严重）
      IT->>CLI: ffapp-admin destroy <id> --reason="violation"
      CLI->>API: DELETE /admin/apps/<id>?force=true
      API->>D: 删容器实例
      API->>L: 立即终止 Litestream
      API->>L: 标记备份 retentionUntil = now+30d
      API->>G: transfer 仓库 → FFAIApps-Archive
      API->>Audit: app.force_destroyed
      API->>Notify: 邮件 owner + IT 全员
    end
  end
```

> **关键约束**（PRD §Never Do）：IT-Admin 可停用 / 销毁，但**不能读 / 下载 SQLite 数据**——容器停掉就停掉，数据卷不暴露给管理员。员工申诉成功后，强制 disable 可一键恢复（30 天内）；强制 destroy 走 §4.5 同样的 30 天备份保留 + 工单恢复路径。

---

## 5. 实施阶段

| Phase | 范围 | 退出条件 |
|---|---|---|
| **Phase 0 PoC** | 1-2 周。找 1 名真实员工 + 1 个真实小工具，硬编码最小链路跑通"对话 → URL" | 员工 ≤ 30 分钟拿到 URL；同事能登录访问；数据重启不丢 |
| **Phase 1 MVP** | 3-4 周。把 PoC 硬编码替换为正式 API + MCP + 完整部署脚本 + Litestream | 01-prd §成功指标 + ≥ 3 个不同员工各自部署 |
| **Phase 2** | 见 01-prd §V2 路线图，本工单 scope 外 | — |

### 5.1 DNS / TLS 三档降级（测试 → 演示 → 真员工）

`*.apps.ffworkspace.faradayfuture.com` 通配域名 + LE 通配证书是 **Phase 0 真员工 PoC 的硬前置**，但内部开发期可降级跑通。三档：

| 档位 | 适用 | 配置 | 访问方式 |
|---|---|---|---|
| **A. 仅 IP + Host header** | 内部跑通 / CI 验证 / 自测 | 无需 DNS；Caddy 用 `http://{host} {}` 跳过 auto-HTTPS（见 [`.learnings/2026-05-14-caddy-auto-https-308-without-dns.md`](../../../.learnings/2026-05-14-caddy-auto-https-308-without-dns.md)） | `curl -H "Host: lijian-hello.apps.ffworkspace.faradayfuture.com" http://43.166.182.155/` |
| **B. /etc/hosts** | 单机演示 / 给领导看 | 改本机 hosts：`43.166.182.155 lijian-hello.apps.ffworkspace.faradayfuture.com` | 浏览器直接打开 URL（仅这台机器生效） |
| **C. 真 DNS + LE 通配** | 真员工 PoC（Phase 0 退出条件） | IT 在 faradayfuture.com 加 `*.apps A 43.166.182.155`；Caddy 切回 auto-HTTPS（去掉 deploy 脚本里的 `http://` 前缀，即 `INTERNAL_APP_CADDY_SCHEME=`） | `https://lijian-hello.apps.ffworkspace.faradayfuture.com/` 全网可达 |

**升级触发点**：找 HR 候选人之前必须升到 C 档——员工看到 `https://你的名字.apps.ffworkspace.faradayfuture.com` 才符合"零摩擦"体验承诺；让员工改 hosts 或带 `Host:` header 等于 onboarding 失败。

**升级动作**（≈ 5 分钟）：
1. IT 加 DNS 记录 `*.apps  A  43.166.182.155`
2. 测试服 ENV `INTERNAL_APP_CADDY_SCHEME=` 清空（或 unset）
3. 重跑 `ffoa-deploy ...`，Caddy 自动向 LE 申请通配证书（DNS-01 challenge，需要 DNS provider API token）
4. 验证 `https://test.apps.ffworkspace.faradayfuture.com/healthz` → `ok`

**降级返回 A/B**：如 DNS 故障/LE 限流，只需把 `INTERNAL_APP_CADDY_SCHEME=http://` 重新设上，所有 site 即刻回退到 HTTP，应用不中断。

---

## 6. 与现有模块的交互

| 模块 | 交互方式 | 调用方向 |
|---|---|---|
| iam | 复用 user 体系判断员工身份 + 权限码 | 本模块 → iam（读） |
| audit-system | `@Auditable()` 装饰器（来自 `@core/observability/audit/decorators/auditable.decorator`，与 `organization/roles` 等模块同源用法）记录 token 颁发/撤销 / deploy / destroy / env 修改 / IT-Admin 强制操作 | 本模块 → audit |
| Azure Entra ID（经 FFOA iam） | 复用 FFOA 已集成的 SSO；Caddy 层拦截 + 注入 `X-User-Email` 等 header 给员工 app；同时为 FFOA "我的 Apps" 页颁发 token 提供登录态 | Caddy → iam → Entra ID |
| Gitea | REST API 建仓 + push；webhook 反向触发部署 | 双向 |
| 对象存储 | Litestream 持续写入；销毁时标记保留窗口 | 本模块 → 对象存储 |

> 显式不依赖：FFOA 业务数据库、approval-engine、form-engine、organization 业务接口。员工 app 完全和 FFOA 业务数据隔离（01-prd §显式拒绝）。
