# 内部小工具自助部署平台 - 数据模型

> **module**: internal-app-platform
> **doc_type**: DataModel
> **status**: Draft
> **owner**: lijian.dai
> **upstream_docs**: 01-prd.md, 03-architecture.md
> **last_verified**: 2026-05-13
>
> **事实源**: 本文档定义 MVP 阶段的 schema / 表 / 字段 / 约束 / 索引；与 PRD §核心业务约束、03 §4 流程图共同构成契约面。

---

## 1. Schema 与文件位置

- **Prisma schema 文件**: `backend/prisma/schema/platform_internal_apps.prisma`
- **PostgreSQL schema**: `platform_internal_apps`
- **核心表**: 5 个
  1. `employee_slug_bindings` — Entra ID `mailNickname` → `employeeSlug` 一次性冻结映射
  2. `internal_apps` — app 主表（运行时 / 状态 / URL / 生命周期）
  3. `deployments` — 每次部署记录（含构建日志摘要、健康检查结果）
  4. `app_env_vars` — 每 app 的环境变量（值加密）
  5. `employee_tokens` — bearer token 哈希存储（明文不入库）

### 设计原则

- **employeeSlug 终身冻结**：首次接入时入库后不跟随 Entra ID `mailNickname` 变化（PRD §核心业务约束）
- **token 只存哈希**：SHA256，明文仅在颁发响应里出现一次（03 §4.0 不变量）
- **env value 透明加密**：列加密 + KMS 密钥，应用层无感（避免 dump 数据库即泄漏）
- **app 软删除 + retentionUntil**：销毁不立刻删行，标记 `destroyedAt` + `retentionUntil = destroyedAt + 30d`，TTL Sweeper 到期再 purge
- **遵循 04-database-architecture §标准字段**：所有表带 `id` / `createdAt` / `updatedAt` / `createdById` / `organizationId`，命中 DataScope 零配置；`organizationId` 取自 owner User 的 organizationId 快照（owner 不可转让，故快照后不会漂移）
- **不引入 departmentId / regionId**：app 由员工个人持有，不按部门 / 区域分片（与 PRD §角色权限矩阵一致：MVP 不做应用级 ACL）

---

## 2. 表结构

### 2.1 `employee_slug_bindings` — slug 终身映射

#### 用途
首次接入时把 Entra ID `mailNickname` 规范化为 `employeeSlug`（≤ 20 字符），入库后**永不修改**。所有 app / token / Gitea 仓库名以此为锚，员工调岗、改名、跨部门均不漂移。

#### 表结构

```sql
CREATE TABLE platform_internal_apps.employee_slug_bindings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- 业务字段
  user_id UUID NOT NULL UNIQUE,                    -- 一员工一行
  employee_slug VARCHAR(20) NOT NULL UNIQUE,       -- 终身冻结
  source_mail_nickname VARCHAR(64) NOT NULL,       -- 入库时的原始值，仅作审计追溯

  -- 标准字段
  organization_id UUID NOT NULL,
  created_by_id UUID NOT NULL,                     -- 首次接入触发者（= user_id）
  created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),

  CONSTRAINT fk_slug_binding_user FOREIGN KEY (user_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_slug_binding_org FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id),
  CONSTRAINT check_employee_slug_format CHECK (employee_slug ~ '^[a-z0-9]([a-z0-9-]{1,18}[a-z0-9])?$')
);

CREATE INDEX idx_slug_bindings_user_id ON platform_internal_apps.employee_slug_bindings(user_id);
CREATE UNIQUE INDEX uq_slug_bindings_slug ON platform_internal_apps.employee_slug_bindings(employee_slug);
CREATE INDEX idx_slug_bindings_org ON platform_internal_apps.employee_slug_bindings(organization_id);
```

#### 业务约束

1. `employee_slug` 一旦写入禁止 UPDATE（应用层强制 + DB CHECK 在 trigger 层可选）
2. `source_mail_nickname` 仅作审计，不参与 URL / Gitea 仓库名生成
3. 撞 slug → 应用层走"数字后缀"流程后再 INSERT（PRD §核心业务约束）
4. CHECK 约束保证：纯 `[a-z0-9-]`、首尾非 `-`、长度 1-20

---

### 2.2 `internal_apps` — app 主表

#### 用途
每条 = 一个员工的一个 app，承载 URL / 运行时 / 状态 / 生命周期。owner 不可转让。

#### 表结构

```sql
CREATE TABLE platform_internal_apps.internal_apps (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- 业务字段
  employee_slug VARCHAR(20) NOT NULL,              -- 冗余 from binding, 便于直接索引
  app_slug VARCHAR(22) NOT NULL,                   -- PRD §核心业务约束 长度上限
  display_name VARCHAR(64),                        -- 可选：Claude Code 生成的人类可读名
  runtime VARCHAR(10) NOT NULL,                    -- 'node' | 'static'
  status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
  url VARCHAR(255) NOT NULL,                       -- <slug>-<app>.apps.ffworkspace.faradayfuture.com
  gitea_repo_full_name VARCHAR(100) NOT NULL UNIQUE, -- 'FFAIApps/<slug>-<app>'

  -- 生命周期
  last_deployed_at TIMESTAMPTZ(3),
  current_deployment_id UUID,                      -- 指向 deployments.id（活跃版本）
  destroyed_at TIMESTAMPTZ(3),
  retention_until TIMESTAMPTZ(3),                  -- 销毁后 30 天到期

  -- IT-Admin 强制操作（PRD §F4）
  force_disabled_at TIMESTAMPTZ(3),
  force_disabled_reason VARCHAR(500),
  force_disabled_by_id UUID,

  -- 标准字段
  organization_id UUID NOT NULL,
  created_by_id UUID NOT NULL,                     -- = owner，不可转让
  created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),

  CONSTRAINT fk_app_owner FOREIGN KEY (created_by_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_app_org FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id),
  CONSTRAINT fk_app_force_disabled_by FOREIGN KEY (force_disabled_by_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_app_slug_binding FOREIGN KEY (employee_slug) REFERENCES platform_internal_apps.employee_slug_bindings(employee_slug),
  CONSTRAINT check_app_runtime CHECK (runtime IN ('node', 'static')),
  CONSTRAINT check_app_status CHECK (status IN ('PENDING', 'BUILDING', 'HEALTHY', 'FAILED', 'DISABLED', 'DESTROYED', 'DISABLED_ARCHIVED', 'PURGED')),
  CONSTRAINT check_app_slug_format CHECK (app_slug ~ '^[a-z0-9]([a-z0-9-]{1,20}[a-z0-9])?$'),
  CONSTRAINT uq_app_per_employee UNIQUE (employee_slug, app_slug)
);

CREATE INDEX idx_apps_employee_slug ON platform_internal_apps.internal_apps(employee_slug);
CREATE INDEX idx_apps_status ON platform_internal_apps.internal_apps(status);
CREATE INDEX idx_apps_created_by_id ON platform_internal_apps.internal_apps(created_by_id);
CREATE INDEX idx_apps_organization_id ON platform_internal_apps.internal_apps(organization_id);
CREATE INDEX idx_apps_retention_until ON platform_internal_apps.internal_apps(retention_until)
  WHERE retention_until IS NOT NULL;                 -- 部分索引，加速 TTL Sweeper
```

#### 枚举：`status`

| 值 | 说明 | 进入条件 | 后续可达 |
|----|------|---------|---------|
| `PENDING` | 已建仓未首次部署 | `deploy_prepare` 建仓后 | BUILDING |
| `BUILDING` | 正在构建 | webhook 触发 | HEALTHY / FAILED |
| `HEALTHY` | 在线服务中 | 健康检查通过 | BUILDING（再次部署）/ FAILED（重启失败）/ DISABLED / DESTROYED |
| `FAILED` | 构建 / 启动失败 | 健康检查全部失败 | BUILDING（员工修复后重试）/ DESTROYED |
| `DISABLED` | IT-Admin 强制停用，可恢复 | F4.2 调用 | HEALTHY（解除）/ DESTROYED |
| `DESTROYED` | 员工 / IT-Admin 销毁 | F2.4 / F4.3 | DISABLED_ARCHIVED（不会，直接走 PURGED）/ PURGED |
| `DISABLED_ARCHIVED` | 员工离职 30 天到期归档 | §4.9 联动 | PURGED |
| `PURGED` | 备份过期清理后终态 | TTL Sweeper | — |

> 状态转移规则详见 `04-state-machine.md`（待补；MVP 此 8 态足以覆盖 §4 全部流程）。

#### 业务约束

1. `app_slug` 服务层校验**保留字白名单**（PRD §核心业务约束）；DB 仅校验字符集
2. `gitea_repo_full_name` 全局唯一（Gitea 物理唯一），数据库再加一道防御
3. `current_deployment_id` 指向最近一次健康部署，构建失败不更新（保留旧版本承接流量）
4. `destroyed_at` + `retention_until` 必须同时写入；TTL Sweeper 扫 `retention_until < NOW()` 把 status 推到 `PURGED`
5. `force_disabled_*` 三字段同生同灭：要么全 NULL，要么全有值

---

### 2.3 `deployments` — 部署记录

#### 用途
每次 `deploy_prepare` 后由 webhook handler 创建一行；记录构建日志摘要、健康检查结果、commit SHA。失败保留供 Claude Code 回写员工调试。

#### 表结构

```sql
CREATE TABLE platform_internal_apps.deployments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- 业务字段
  app_id UUID NOT NULL,
  commit_sha VARCHAR(40),                          -- nullable: 建仓但未首次 push 时为 NULL
  status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
  build_log_summary TEXT,                          -- ≤ 8KB AI 友好摘要
  health_check_log JSONB,                          -- {attempts: [{status, ts, body}], passed: bool}
  started_at TIMESTAMPTZ(3),
  finished_at TIMESTAMPTZ(3),

  -- 触发来源
  trigger VARCHAR(20) NOT NULL,                    -- 'deploy' | 'env_change' | 'admin_restart'

  -- 标准字段
  organization_id UUID NOT NULL,
  created_by_id UUID NOT NULL,                     -- 触发部署的员工 / IT-Admin
  created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),

  CONSTRAINT fk_deployment_app FOREIGN KEY (app_id) REFERENCES platform_internal_apps.internal_apps(id) ON DELETE CASCADE,
  CONSTRAINT fk_deployment_creator FOREIGN KEY (created_by_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_deployment_org FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id),
  CONSTRAINT check_deployment_status CHECK (status IN ('PENDING', 'BUILDING', 'HEALTH_CHECKING', 'SUCCESS', 'FAILED', 'ROLLED_BACK')),
  CONSTRAINT check_deployment_trigger CHECK (trigger IN ('deploy', 'env_change', 'admin_restart'))
);

CREATE INDEX idx_deployments_app_id ON platform_internal_apps.deployments(app_id);
CREATE INDEX idx_deployments_status ON platform_internal_apps.deployments(status);
CREATE INDEX idx_deployments_app_created ON platform_internal_apps.deployments(app_id, created_at DESC);
CREATE INDEX idx_deployments_org ON platform_internal_apps.deployments(organization_id);
```

#### 业务约束

1. 同一 app 同时只有一个非 final 状态的 deployment（应用层 `flock`，DB 不强制）
2. `build_log_summary` 上限 8KB（应用层截断），避免大日志撑爆表
3. `health_check_log` JSONB 结构示例：
   ```json
   { "attempts": [{ "ts": "2026-05-13T03:00:01Z", "status": 503, "body": "..." }],
     "passed": false, "totalAttempts": 30 }
   ```
4. 失败的 deployment 不更新 `internal_apps.current_deployment_id`

---

### 2.4 `app_env_vars` — env 键值（加密存储）

#### 用途
F2.2 MCP `env` 工具读写。值在 DB 层加密，应用层透明（生命周期与 app 同生同灭）。

#### 表结构

```sql
CREATE TABLE platform_internal_apps.app_env_vars (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- 业务字段
  app_id UUID NOT NULL,
  key VARCHAR(64) NOT NULL,                        -- env key，遵循 [A-Z_][A-Z0-9_]*
  value_encrypted BYTEA NOT NULL,                  -- AES-GCM 密文
  value_iv BYTEA NOT NULL,                         -- 初始向量
  kms_key_version INT NOT NULL,                    -- 支持密钥轮转

  -- 标准字段
  organization_id UUID NOT NULL,
  created_by_id UUID NOT NULL,
  created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),

  CONSTRAINT fk_env_app FOREIGN KEY (app_id) REFERENCES platform_internal_apps.internal_apps(id) ON DELETE CASCADE,
  CONSTRAINT fk_env_creator FOREIGN KEY (created_by_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_env_org FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id),
  CONSTRAINT check_env_key_format CHECK (key ~ '^[A-Z_][A-Z0-9_]{0,63}$'),
  CONSTRAINT uq_env_per_app UNIQUE (app_id, key)
);

CREATE INDEX idx_env_app_id ON platform_internal_apps.app_env_vars(app_id);
```

#### 业务约束

1. `key` 命中保留前缀 `FFOA_` / `PLATFORM_` 时应用层拒绝（避免员工 app 覆盖平台注入变量）
2. value 上限 4KB 明文（应用层校验）
3. KMS 密钥位置由 ops 决定，配置项 `INTERNAL_APP_ENV_KMS_KEY_ID`
4. 列出 env 不返回明文值，需调用方再次调 `decrypt` 端点（OA API）

---

### 2.5 `employee_tokens` — bearer token 哈希

#### 用途
FFOA 颁发的 MCP bearer token；明文仅响应里出现一次，DB 只存 SHA256 哈希。

#### 表结构

```sql
CREATE TABLE platform_internal_apps.employee_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- 业务字段
  employee_slug VARCHAR(20) NOT NULL,
  token_hash CHAR(64) NOT NULL UNIQUE,             -- SHA256 hex
  prefix VARCHAR(8) NOT NULL,                      -- token 前 8 字符，用于 UI "ffoa_xxxx****" 展示
  status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',    -- ACTIVE / REVOKED / DISABLED / EXPIRED
  issued_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMPTZ(3) NOT NULL,              -- = issued_at + 90d
  revoked_at TIMESTAMPTZ(3),
  revoked_reason VARCHAR(50),                      -- 'self' | 'admin' | 'entra_disabled' | 'rotated'
  last_used_at TIMESTAMPTZ(3),

  -- 标准字段
  organization_id UUID NOT NULL,
  created_by_id UUID NOT NULL,                     -- = owner user_id
  created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),

  CONSTRAINT fk_token_creator FOREIGN KEY (created_by_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_token_org FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id),
  CONSTRAINT fk_token_slug FOREIGN KEY (employee_slug) REFERENCES platform_internal_apps.employee_slug_bindings(employee_slug),
  CONSTRAINT check_token_status CHECK (status IN ('ACTIVE', 'REVOKED', 'DISABLED', 'EXPIRED')),
  CONSTRAINT check_token_revoked_reason CHECK (revoked_reason IN ('self', 'admin', 'entra_disabled', 'rotated') OR revoked_reason IS NULL)
);

CREATE UNIQUE INDEX uq_tokens_hash ON platform_internal_apps.employee_tokens(token_hash);
CREATE INDEX idx_tokens_employee_slug ON platform_internal_apps.employee_tokens(employee_slug);
CREATE INDEX idx_tokens_status ON platform_internal_apps.employee_tokens(status);
CREATE INDEX idx_tokens_expires_at ON platform_internal_apps.employee_tokens(expires_at)
  WHERE status = 'ACTIVE';                         -- 部分索引，加速 7d warning 扫描
CREATE UNIQUE INDEX uq_tokens_active_per_employee
  ON platform_internal_apps.employee_tokens(employee_slug)
  WHERE status = 'ACTIVE';                         -- 不变量：同时只能有一个 ACTIVE
```

#### 业务约束

1. **同一 employee_slug 同时只能有一个 ACTIVE token**（部分唯一索引强制）
2. "重新生成" = 先 `UPDATE ... SET status='REVOKED', revoked_reason='rotated'` 旧的，再 INSERT 新的；事务内完成
3. Entra ID disable 触发 → 该 user 所有 token `status='DISABLED', revoked_reason='entra_disabled'`
4. token 明文格式（应用层约定）：`ffoa_<base32 32 字符>`，total 37 字符；`prefix` = 前 8 字符
5. `last_used_at` 每次 MCP 调用更新（异步，不阻塞响应；可降级丢失，仅作弱审计参考）

### 2.6 `internal_app_events` — 全生命周期事件流（admin 视图 + 员工活动）

#### 用途

跨 `deployments` / `audit-system` / `pm2 logs` / `systemd` 的**统一业务事件流**，作为 admin UI 和员工"我的活动"的单一事实源。每个**状态变更**写一行，**读操作不写**（量大且无审计价值）。

设计上属于 "operational events"——非合规审计（合规级别仍走 `audit-system` 模块）。两层分工详见 §7。

#### 表结构

```sql
CREATE TABLE platform_internal_apps.internal_app_events (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

  -- 业务字段
  app_id UUID,                                          -- token 类事件无 app，置 NULL
  employee_slug VARCHAR(20),                            -- 冗余便于"该员工所有事件"过滤，不必 join app 表
  actor_id UUID,                                        -- SYSTEM/匿名事件可为 NULL
  actor_role VARCHAR(10) NOT NULL,                      -- OWNER | ADMIN | SYSTEM
  event_type VARCHAR(48) NOT NULL,                      -- 见下方枚举
  outcome VARCHAR(4) NOT NULL DEFAULT 'OK',             -- OK | FAIL
  error_code VARCHAR(64),                               -- 仅 outcome=FAIL 时填，对齐 §5 错误码总表
  duration_ms INT,                                      -- 操作耗时（可选）
  payload JSONB NOT NULL DEFAULT '{}',                  -- 事件类型相关上下文
  request_id VARCHAR(64),                               -- 串联同一请求/部署链路的多条事件
  ip_addr VARCHAR(64),                                  -- 仅写 OWNER/ADMIN 事件
  user_agent VARCHAR(256),                              -- 同上

  -- 标准字段（事件无 createdById 概念，actor_id 已覆盖；无 updatedAt 因事件不可变）
  organization_id UUID NOT NULL,
  created_at TIMESTAMPTZ(3) NOT NULL DEFAULT NOW(),

  CONSTRAINT fk_event_app FOREIGN KEY (app_id) REFERENCES platform_internal_apps.internal_apps(id) ON DELETE SET NULL,
  CONSTRAINT fk_event_actor FOREIGN KEY (actor_id) REFERENCES platform_iam.users(id),
  CONSTRAINT fk_event_org FOREIGN KEY (organization_id) REFERENCES corp_hr.organizations(id),
  CONSTRAINT check_event_actor_role CHECK (actor_role IN ('OWNER', 'ADMIN', 'SYSTEM')),
  CONSTRAINT check_event_outcome CHECK (outcome IN ('OK', 'FAIL'))
);

CREATE INDEX idx_events_app_created ON platform_internal_apps.internal_app_events(app_id, created_at DESC);
CREATE INDEX idx_events_employee_created ON platform_internal_apps.internal_app_events(employee_slug, created_at DESC);
CREATE INDEX idx_events_type_created ON platform_internal_apps.internal_app_events(event_type, created_at DESC);
CREATE INDEX idx_events_actor_role ON platform_internal_apps.internal_app_events(actor_role, created_at DESC);
CREATE INDEX idx_events_org_created ON platform_internal_apps.internal_app_events(organization_id, created_at DESC);
```

#### event_type 枚举（P1.5 范围）

| 分类 | event_type | 写入点 | actor_role | 关键 payload |
|------|------------|--------|------------|--------------|
| Token | `token.issued` | token.service `issue` 首次 | OWNER | `{ prefix, expiresAt }` |
| Token | `token.regenerated` | token.service `issue` 替换旧 | OWNER | `{ prefix, expiresAt, replacedTokenId }` |
| Token | `token.revoked` | token.service `revokeCurrent` | OWNER \| ADMIN | `{ reason }` |
| Token | `token.expired` | sweep cron | SYSTEM | `{ tokenId }` |
| Token | `token.disabled` | Entra ID disable webhook | SYSTEM | `{ tokenId, reason: 'entra_disabled' }` |
| App | `app.created` | mcp-tools `deployPrepare` 首次 | OWNER | `{ appSlug, runtime, giteaRepoFullName }` |
| App | `app.destroyed` | mcp-tools `destroy` | OWNER | `{ retentionUntil }` |
| App | `app.recovered` | admin 恢复（Phase 2） | ADMIN | `{ recoveredAt }` |
| App | `app.purged` | sweep cron | SYSTEM | `{ purgedAt }` |
| Deploy | `app.deploy_started` | webhook `handlePush` 接收 | SYSTEM | `{ commitSha, pusherId, deploymentId }` |
| Deploy | `app.deploy_succeeded` | deploy.sh 回调 success | SYSTEM | `{ deploymentId, durationMs }` |
| Deploy | `app.deploy_failed` | deploy.sh 回调 fail | SYSTEM | `{ deploymentId, errorCode, buildLogSummary }` |
| Env | `app.env_set` | mcp-tools `envSet` | OWNER | `{ key }`（不记 value） |
| Env | `app.env_unset` | mcp-tools `envUnset` | OWNER | `{ key }` |
| Admin | `app.disabled_by_admin` | admin `forceDisable` | ADMIN | `{ reason }`（必填） |
| Admin | `app.force_destroyed_by_admin` | admin `forceDestroy` | ADMIN | `{ reason, retentionUntil }` |

> 后续阶段（P2.5/P3）可加：`container.unhealthy` / `container.oom` / `quota.exceeded` / `token.used_from_new_ip`。**新增 event_type 不需要 schema migration**（VARCHAR 而非 PG enum，故意留扩展空间）。

#### 业务约束

1. **写失败不回滚业务操作**：emit 是 best-effort，try/catch 包住、写失败 `logger.warn`，不抛。事件丢失比 token 失效更可容忍。
2. **payload 不写 value**：env_set 只记 `key`，不记 value（哪怕加密版）；密钥/敏感数据走 audit-system 单独路径。
3. **payload 不写大字段**：buildLogSummary 只截前 500 字符；完整 log 仍在 `deployments.build_log_summary`，事件只摘要。
4. **app_id ON DELETE SET NULL**：app 被 purge 后 `app.purged` 事件保留，`app_id` 自动置 NULL，仍可按 `employee_slug` 查到。
5. **不可变**：事件无 `updatedAt`，写入后只读；纠错走"补一条新事件"，不更新历史。
6. **保留期**：90 天热（PG 主表）→ 1 年冷归档（cold-archive 表或对象存储），由后续运维任务实现，P1.5 不做。

#### 与 `deployments` 表的关系

- `deployments` 表：仍是 deploy 状态的**主表**（构建日志、当前 deployment 指针），事件流不取代它。
- `internal_app_events` 表：deploy 状态变化时**额外 emit 一条**事件，方便跨表 timeline 查询，避免 admin UI 把 deploy / env / token 全分开 join。
- 数据一致性：`deployment.status = SUCCESS` 与 `event(type=app.deploy_succeeded)` 由同一事务保证；如有偏离，以 `deployments` 表为准。

---

## 3. ER 图

```mermaid
erDiagram
  USERS ||--o| EMPLOYEE_SLUG_BINDINGS : "首次接入冻结"
  EMPLOYEE_SLUG_BINDINGS ||--o{ INTERNAL_APPS : "owns (via slug)"
  EMPLOYEE_SLUG_BINDINGS ||--o{ EMPLOYEE_TOKENS : "持有 (via slug)"
  INTERNAL_APPS ||--o{ DEPLOYMENTS : "history"
  INTERNAL_APPS ||--o{ APP_ENV_VARS : "config"
  INTERNAL_APPS ||--o{ INTERNAL_APP_EVENTS : "lifecycle events"
  INTERNAL_APPS }o--|| DEPLOYMENTS : "current_deployment_id"
  USERS ||--o{ INTERNAL_APP_EVENTS : "actor"

  USERS {
    UUID id PK
    string email
    UUID organization_id
  }
  EMPLOYEE_SLUG_BINDINGS {
    UUID id PK
    UUID user_id FK
    string employee_slug UK
    string source_mail_nickname
  }
  INTERNAL_APPS {
    UUID id PK
    string employee_slug FK
    string app_slug
    string runtime
    string status
    string url
    string gitea_repo_full_name UK
    UUID current_deployment_id FK
    timestamp destroyed_at
    timestamp retention_until
  }
  DEPLOYMENTS {
    UUID id PK
    UUID app_id FK
    string commit_sha
    string status
    text build_log_summary
    jsonb health_check_log
  }
  APP_ENV_VARS {
    UUID id PK
    UUID app_id FK
    string key
    bytes value_encrypted
  }
  EMPLOYEE_TOKENS {
    UUID id PK
    string employee_slug FK
    string token_hash UK
    string status
    timestamp expires_at
  }
  INTERNAL_APP_EVENTS {
    UUID id PK
    UUID app_id FK "nullable: token/system events"
    string employee_slug
    UUID actor_id FK "nullable: SYSTEM events"
    string actor_role "OWNER|ADMIN|SYSTEM"
    string event_type
    string outcome "OK|FAIL"
    jsonb payload
    timestamp created_at
  }
```

---

## 4. Prisma Schema 草稿

```prisma
// backend/prisma/schema/platform_internal_apps.prisma

model EmployeeSlugBinding {
  id                  String   @id @default(uuid()) @db.Uuid
  userId              String   @unique @map("user_id") @db.Uuid
  employeeSlug        String   @unique @map("employee_slug") @db.VarChar(20)
  sourceMailNickname  String   @map("source_mail_nickname") @db.VarChar(64)

  organizationId      String   @map("organization_id") @db.Uuid
  createdById         String   @map("created_by_id") @db.Uuid
  createdAt           DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt           DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)

  user                User     @relation(fields: [userId], references: [id])
  apps                InternalApp[]
  tokens              EmployeeToken[]

  @@index([organizationId])
  @@map("employee_slug_bindings")
  @@schema("platform_internal_apps")
}

model InternalApp {
  id                     String              @id @default(uuid()) @db.Uuid
  employeeSlug           String              @map("employee_slug") @db.VarChar(20)
  appSlug                String              @map("app_slug") @db.VarChar(22)
  displayName            String?             @map("display_name") @db.VarChar(64)
  runtime                AppRuntime
  status                 AppStatus           @default(PENDING)
  url                    String              @db.VarChar(255)
  giteaRepoFullName      String              @unique @map("gitea_repo_full_name") @db.VarChar(100)

  lastDeployedAt         DateTime?           @map("last_deployed_at") @db.Timestamptz(3)
  currentDeploymentId    String?             @map("current_deployment_id") @db.Uuid
  destroyedAt            DateTime?           @map("destroyed_at") @db.Timestamptz(3)
  retentionUntil         DateTime?           @map("retention_until") @db.Timestamptz(3)

  forceDisabledAt        DateTime?           @map("force_disabled_at") @db.Timestamptz(3)
  forceDisabledReason    String?             @map("force_disabled_reason") @db.VarChar(500)
  forceDisabledById      String?             @map("force_disabled_by_id") @db.Uuid

  organizationId         String              @map("organization_id") @db.Uuid
  createdById            String              @map("created_by_id") @db.Uuid
  createdAt              DateTime            @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt              DateTime            @updatedAt @map("updated_at") @db.Timestamptz(3)

  ownerBinding           EmployeeSlugBinding @relation(fields: [employeeSlug], references: [employeeSlug])
  deployments            Deployment[]
  envVars                AppEnvVar[]

  @@unique([employeeSlug, appSlug], name: "uq_app_per_employee")
  @@index([status])
  @@index([createdById])
  @@index([organizationId])
  @@index([retentionUntil])
  @@map("internal_apps")
  @@schema("platform_internal_apps")
}

model Deployment {
  id                String           @id @default(uuid()) @db.Uuid
  appId             String           @map("app_id") @db.Uuid
  commitSha         String?          @map("commit_sha") @db.VarChar(40)
  status            DeploymentStatus @default(PENDING)
  buildLogSummary   String?          @map("build_log_summary") @db.Text
  healthCheckLog    Json?            @map("health_check_log") @db.JsonB
  startedAt         DateTime?        @map("started_at") @db.Timestamptz(3)
  finishedAt        DateTime?        @map("finished_at") @db.Timestamptz(3)
  trigger           DeploymentTrigger

  organizationId    String           @map("organization_id") @db.Uuid
  createdById       String           @map("created_by_id") @db.Uuid
  createdAt         DateTime         @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime         @updatedAt @map("updated_at") @db.Timestamptz(3)

  app               InternalApp      @relation(fields: [appId], references: [id], onDelete: Cascade)

  @@index([appId, createdAt(sort: Desc)])
  @@index([status])
  @@index([organizationId])
  @@map("deployments")
  @@schema("platform_internal_apps")
}

model AppEnvVar {
  id                String      @id @default(uuid()) @db.Uuid
  appId             String      @map("app_id") @db.Uuid
  key               String      @db.VarChar(64)
  valueEncrypted    Bytes       @map("value_encrypted")
  valueIv           Bytes       @map("value_iv")
  kmsKeyVersion     Int         @map("kms_key_version")

  organizationId    String      @map("organization_id") @db.Uuid
  createdById       String      @map("created_by_id") @db.Uuid
  createdAt         DateTime    @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime    @updatedAt @map("updated_at") @db.Timestamptz(3)

  app               InternalApp @relation(fields: [appId], references: [id], onDelete: Cascade)

  @@unique([appId, key], name: "uq_env_per_app")
  @@index([appId])
  @@map("app_env_vars")
  @@schema("platform_internal_apps")
}

model EmployeeToken {
  id                String              @id @default(uuid()) @db.Uuid
  employeeSlug      String              @map("employee_slug") @db.VarChar(20)
  tokenHash         String              @unique @map("token_hash") @db.Char(64)
  prefix            String              @db.VarChar(8)
  status            TokenStatus         @default(ACTIVE)
  issuedAt          DateTime            @default(now()) @map("issued_at") @db.Timestamptz(3)
  expiresAt         DateTime            @map("expires_at") @db.Timestamptz(3)
  revokedAt         DateTime?           @map("revoked_at") @db.Timestamptz(3)
  revokedReason     TokenRevokeReason?  @map("revoked_reason")
  lastUsedAt        DateTime?           @map("last_used_at") @db.Timestamptz(3)

  organizationId    String              @map("organization_id") @db.Uuid
  createdById       String              @map("created_by_id") @db.Uuid
  createdAt         DateTime            @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt         DateTime            @updatedAt @map("updated_at") @db.Timestamptz(3)

  binding           EmployeeSlugBinding @relation(fields: [employeeSlug], references: [employeeSlug])

  @@index([employeeSlug])
  @@index([status])
  @@index([expiresAt])
  @@map("employee_tokens")
  @@schema("platform_internal_apps")
}

enum AppRuntime {
  node
  static

  @@schema("platform_internal_apps")
}

enum AppStatus {
  PENDING
  BUILDING
  HEALTHY
  FAILED
  DISABLED
  DESTROYED
  DISABLED_ARCHIVED
  PURGED

  @@schema("platform_internal_apps")
}

enum DeploymentStatus {
  PENDING
  BUILDING
  HEALTH_CHECKING
  SUCCESS
  FAILED
  ROLLED_BACK

  @@schema("platform_internal_apps")
}

enum DeploymentTrigger {
  deploy
  env_change
  admin_restart

  @@schema("platform_internal_apps")
}

enum TokenStatus {
  ACTIVE
  REVOKED
  DISABLED
  EXPIRED

  @@schema("platform_internal_apps")
}

enum TokenRevokeReason {
  self
  admin
  entra_disabled
  rotated

  @@schema("platform_internal_apps")
}

model InternalAppEvent {
  id            String  @id @default(uuid()) @db.Uuid
  appId         String? @map("app_id") @db.Uuid
  employeeSlug  String? @map("employee_slug") @db.VarChar(20)
  actorId       String? @map("actor_id") @db.Uuid
  actorRole     InternalAppActorRole @map("actor_role")
  eventType     String  @map("event_type") @db.VarChar(48)
  outcome       InternalAppEventOutcome @default(OK)
  errorCode     String? @map("error_code") @db.VarChar(64)
  durationMs    Int?    @map("duration_ms")
  payload       Json    @default("{}")
  requestId     String? @map("request_id") @db.VarChar(64)
  ipAddr        String? @map("ip_addr") @db.VarChar(64)
  userAgent     String? @map("user_agent") @db.VarChar(256)

  organizationId String   @map("organization_id") @db.Uuid
  createdAt      DateTime @default(now()) @map("created_at") @db.Timestamptz(3)

  app   InternalApp? @relation(fields: [appId], references: [id], onDelete: SetNull)
  actor User?        @relation("InternalAppEventActor", fields: [actorId], references: [id])

  @@index([appId, createdAt(sort: Desc)])
  @@index([employeeSlug, createdAt(sort: Desc)])
  @@index([eventType, createdAt(sort: Desc)])
  @@index([actorRole, createdAt(sort: Desc)])
  @@index([organizationId, createdAt(sort: Desc)])
  @@map("internal_app_events")
  @@schema("platform_internal_apps")
}

enum InternalAppActorRole {
  OWNER
  ADMIN
  SYSTEM

  @@schema("platform_internal_apps")
}

enum InternalAppEventOutcome {
  OK
  FAIL

  @@schema("platform_internal_apps")
}
```

---

## 5. 数据量预估

按 PRD §假设（MVP 25 app + 10-30 员工）：

| 表 | 1 个月 | 1 年 | 3 年 | 增长动力 |
|----|------|------|------|---------|
| `employee_slug_bindings` | 10-30 | 100 | 300 | 一员工一行，与人员数线性 |
| `internal_apps` | 25 | 300 | 800 | 销毁后保留行（不物理删） |
| `deployments` | 200-500 | 5,000 | 20,000 | 每 app 平均每周 1-2 次部署 |
| `app_env_vars` | 50-100 | 500 | 1,500 | 每 app 平均 2-3 个 env |
| `employee_tokens` | 10-30 | 200 | 600 | 90 天到期 + 偶尔重新生成 |
| `internal_app_events` | 1,000-3,000 | 30,000-50,000 | 100,000-150,000 | 每 app 每周 ~10 条（deploy + env + token），P2.5 加 container 类后翻倍 |

总量级别都很小，单机 PostgreSQL 完全覆盖。事件表 1 年内 5w 行 / 多索引，单表查询 ms 级；超 50w 行后启动 P1.5+ 阶段的 90d 热 / 1y 冷归档策略（§2.6 业务约束 6）。

---

## 6. 迁移策略

- **首次迁移**：单一 migration 文件 `migrations/<timestamp>_init_internal_app_platform/`，包含全部 5 张表 + 索引 + CHECK 约束
- **遵守"每次提交最多一个迁移文件"**（CLAUDE.md §数据库）
- **种子数据**：本模块不需要 seed（无字典表 / 无角色预置；权限码走 `platform_iam` 现有 seed 流程，在 07-api 定义后补到 `seed.ts`）
- **回滚预案**：MVP 阶段保留 schema 演化空间，避免一次定死 enum / NOT NULL；新增列优先 nullable + 应用层兜底，再补 NOT NULL（PRD §零技术债倾向 ≠ 第一版完美，是允许演化）

---

## 7. 安全与合规

| 关注点 | 处理 |
|-------|------|
| token 明文泄漏 | 仅 SHA256 哈希入库；明文 5 分钟后从前端内存清空（05 §2.1） |
| env 值泄漏 | AES-GCM 列加密 + KMS 轮转；DB dump 无法直接读 |
| SQLite 数据隐私 | 数据卷在容器内，IT-Admin 不可读（PRD §Never Do） |
| 审计追溯 | 全部写操作 `@Auditable()`（03 §6） |
| owner 越权 | 所有 read/write API 强制校验 `app.createdById === currentUser.id` 或 `internal-app:admin` 权限码 |
| Gitea 仓库 leaks | repo 默认 `internal` 可见性，组织级 hook 兜底；离职走 §4.9 transfer 至 `FFAIApps-Archive`（不删） |
| 事件流敏感字段 | `payload` 禁写 env value / token 明文 / 密钥；仅记 key/prefix/eventId 等标识符。完整密文走加密列存储，访问走 audit-system 单独路径 |

### 7.1 事件流 vs audit-system 的分工

| 维度 | `internal_app_events`（本模块新增） | `audit-system`（平台现成） |
|------|---------------------------------|---------------------------|
| 定位 | **运营**事件流，admin UI / 员工自助查询 | **合规**审计，对接外审 / 法务 |
| 保留 | 90d 热 + 1y 冷 | 法务要求（180d+，按合规策略） |
| 写策略 | best-effort（失败不回滚业务） | 强一致（与业务同事务） |
| 范围 | 全部 lifecycle 状态变更 | 仅安全敏感子集（token 颁发/撤销、admin 强制操作、跨 owner env） |
| 查询面 | admin 全表过滤 + 员工"我的活动" | 合规专用 query，admin/员工不直接读 |

**双写关系**：admin force_destroy / token revoke / 跨 owner env_set 这类事件**两边都写**——`audit-system` 走 `@Auditable()` 装饰器、`internal_app_events` 走本模块 emit。两者独立，互不依赖。

---

## 8. 已敲定的关键决策（2026-05-13 锁定）

### 8.1 `DISABLED_ARCHIVED` = 保留 `internal_apps` 行（不拆归档表）

- **理由**：
  - 查询体验更好——"显示该员工历史所有 app" 单表 query
  - 跨表 join 简单，避免 union 归档表
  - 3 年增长约 800 行，PostgreSQL 单表完全无压力
- **不变量**：`PURGED` 状态的行**不**物理删除，标记终态保留作合规追溯；如未来真需要硬删除，单独跑归档脚本到冷存储

### 8.2 `last_used_at` 更新策略 = 应用层 **60 秒去抖**

- **逻辑**：MCP 鉴权中间件解出 token id 后，若 `now - last_used_at > 60s` 才发 UPDATE；否则跳过
- **理由**：避免热点 token 行被高频 UPDATE 拖慢；60 秒精度对"最近活跃"审计够用
- **降级**：UPDATE 失败 / 超时不阻塞 MCP 主流程，写日志即可（弱审计参考）

---

## 9. 仍待定项（依赖外部输入）

> 以下需 ops / iam 维护方共同决定，不在本文档技术决策范围。

- [ ] KMS 密钥管理具体集成方式（沿用 `platform_iam` 现有 KMS 客户端？还是新接？）—— 与 ops + iam 维护方对齐
- [ ] `corp_hr.organizations` 外键约束在未来多租户演进时的兼容性 —— 与 iam 维护方对齐

---

## 10. 相关文档

- [01-prd.md](./01-prd.md) — 功能边界 / 业务约束
- [03-architecture.md](./03-architecture.md) — 架构分层 / 流程图
- [07-api.md](./07-api.md) — HTTP API + MCP 工具签名（下一步）
- [09-test-scenarios.md](./09-test-scenarios.md) — 集成测试场景（下下步）
- [docs/standards/04-database-architecture.md](../../standards/04-database-architecture.md) — 标准字段与命名规范
