---
date: 2026-05-19
type: error
tags: [promote, uat, runbook, env-drift, migration, multi-host, production-prep]
---

# 升 UAT 一次性踩 5 个坑 — promote runbook 跨「数据 / env / 多主机」维度有覆盖缺口

## 现象

2026-05-19 跑 `gitea promote uat`（develop → staging）升 61 commit / 12 migration / 删 9 next route 的大批次，**单一 deploy-uat workflow run 4773 被三次失败 + 三次救火** 才跑通：

| 失败 # | 真因 | 救火 |
|---|---|---|
| 1 | `prisma migrate deploy` 撞 23502：`column "created_by_id" of relation "robot_units" contains null values` | TRUNCATE robot_units CASCADE + 删 _prisma_migrations ghost row + rerun |
| 2 | FFAI backend NestJS 启动 throw `INTERNAL_APP_ENV_MASTER_KEY 未设置或仍是占位` → backend down → health check 失败 | `openssl rand -hex 32` 生成 key 加到 `.env.uat`；PM2 用 `ecosystem.uat.config.js --update-env` 重启（不是 `pm2 restart`，因为 PM2 backoff 已清进程） |
| 3 | AIxC UAT 前端 `next build` 撞 `.next/types/validator.ts:Cannot find module '../../src/app/(modules)/flow-diagram/[id]/page.js'` | `rm -rf /srv/apps/aixcworkspace/frontend/.next`；AIxC UAT 路径在 `/srv/apps/aixcworkspace`（不在 `/srv/apps/ffworkspace-test`），需 itadminaixc + sudo |

5 个真问题（含同一 deploy 内连环触发的）：

1. **migration `ALTER TABLE ADD COLUMN NOT NULL`** 未带 DEFAULT / 未带 backfill UPDATE — test 空表 OK，UAT 91 行业务数据炸（[failure #1](#failure-1-migration-add-not-null-on-existing-data)）
2. **`INTERNAL_APP_ENV_MASTER_KEY` 未同步到 UAT/PROD `.env`** — develop 上新增的 env 变量 develop merge 后没人同步 host env，NODE_ENV=production 时硬 throw（[failure #2](#failure-2-env-drift)）
3. **AIxC UAT 部署路径不在 runbook 步骤 1** — `docs/ops/11-major-refactor-deploy-checklist.md` 步骤 1 只列 FFAI 的 `/srv/apps/ffworkspace-test`，没列 AIxC 的 `/srv/apps/aixcworkspace`（[failure #3](#failure-3-aixc-host-missing-from-runbook)）
4. **品牌 NEXT_PUBLIC_* 7 个变量未同步** — 升级到 UAT 前需要 `.env.uat` 加 `NEXT_PUBLIC_BRAND_NAME` 等 7 个变量，构建期烧死必须 rebuild
5. **promote PR self-approve 422** — `gitea promote uat` CLI 用 PR 作者 token 调 `POST /reviews APPROVED`，Gitea 1.25 拒绝（作者不能批自己），fallback 用 AIBot token 才能 approve

## 直接原因

每条独立看都是 known pattern，但组合起来暴露 runbook 几个**覆盖缺口**：

### Failure #1: migration ADD NOT NULL on existing data

`backend/prisma/migrations/20260517043500_pr234_robot_v3_full_refactor/migration.sql:98-104`:

```sql
ALTER TABLE "robot_manager"."robot_units"
  DROP COLUMN "created_by",
  ADD COLUMN  "created_by_id" UUID NOT NULL,  -- ❌ no DEFAULT, no backfill
  ...;
```

PG 行为：ALTER ADD COLUMN NOT NULL 时已有行的新列填 NULL → NOT NULL check → 23502 拒。

**为什么 test 跑通**：test 上 robot_units **0 行**（test 环境覆盖式部署不积累业务数据），空表 ALTER 通过。
**为什么 UAT 炸**：UAT 91 行业务数据 → 91 行新列填 NULL → 撞 NOT NULL。
**为什么 production 也会炸**：production 上数据量更多，更必撞。

### Failure #2: env drift

`backend/src/modules/internal-app-platform/services/env-crypto.service.ts:37` 强制要 `INTERNAL_APP_ENV_MASTER_KEY`：

```typescript
const masterKey = this.config.get<string>('INTERNAL_APP_ENV_MASTER_KEY');
this.keyMissing = !masterKey || masterKey === '__GENERATE_RANDOM__';
if (this.keyMissing) {
  const msg = 'INTERNAL_APP_ENV_MASTER_KEY 未设置或仍是占位 — env 加密能力关闭';
  if (process.env.NODE_ENV === 'production') {
    throw new Error(msg);   // ← UAT NODE_ENV=production 触发
  }
  this.logger.warn(`${msg}（非生产环境，开发期容忍...）`);
}
```

`.env.example` 在 develop 上**有**这条变量 + 注释「缺失症状：env set 失败」，但 UAT host 的 `.env.uat` **从来没人补过**——因为没机制（`scripts/ops/check-env-coverage.sh` 只校验本地代码 vs `.env.example`，不校验 host）。

### Failure #3: AIxC host missing from runbook

`docs/ops/11-major-refactor-deploy-checklist.md` 步骤 1 写：

```bash
# UAT
ssh ubuntu@43.153.69.73 'rm -rf /srv/apps/ffworkspace-test/frontend/.next'
```

但 deploy-uat workflow 同时并发部署到 **两台 UAT host**：
- FFAI UAT: `ubuntu@43.153.69.73` `/srv/apps/ffworkspace-test`
- AIxC UAT: `itadminaixc@52.234.29.56` `/srv/apps/aixcworkspace`（root 拥有，需 sudo）

runbook **漏列 AIxC**，AIxC `.next` 没清就跑同样 stale stub 失败。

## 元根因

UAT promote 是「跨 N 维度协调」的复杂操作：

| 维度 | 今天遇到的失败 | 现有 runbook 覆盖 |
|---|---|---|
| 数据兼容性 | migration on 现存数据（91 行 / NOT NULL） | ✅ 步骤 2/3/4 有但**只查 UNIQUE 重复 + enum cast，没查 NOT NULL 列**|
| 环境变量同步 | `INTERNAL_APP_*` 等新 env 没补 UAT | ❌ **完全没覆盖** |
| 前端构建产物 | `.next` stale validator stub | ✅ 步骤 1 但**只列 FFAI host** |
| 多目标主机 | FFAI + AIxC 并发部署 | ❌ AIxC 完全没列 |
| Promote 流程本身 | self-approve 422、CLI fallback | ❌ 没文档化 |

runbook 之前的 5 个维度只覆盖了 1.5 个（数据部分 + 单主机前端），剩 3.5 个真实生产风险**完全没文档化**。

## 解法

### 应用层（救火，已做）

- failure #1: TRUNCATE + 删 ghost migration row
- failure #2: 两台 host 都加 master key + PM2 ecosystem restart
- failure #3: AIxC 用 sudo + 正确路径清 .next

### 工程化保险（本 PR 落地）

补 `docs/ops/11-major-refactor-deploy-checklist.md`，**5 个新增维度**：

1. **步骤 0：env 同步**（新增）—— 对比 `.env.example` 在本 PR 跨度内新增的 var 名，逐台 host 检查 `.env.uat` / `.env.pro` 是否有；对生成类（master key / secret）必须每个环境**独立** `openssl rand` 生成
2. **步骤 1 加 AIxC**：所有 FFAI 主机操作命令旁边平行列 AIxC 主机命令
3. **步骤 N+1：NOT NULL 列加约束反查**（新增）—— grep migration 找 `ADD COLUMN ... NOT NULL` + `ALTER COLUMN ... SET NOT NULL`，逐列查目标 host 数据是否有 NULL；有 NULL 必须**先**在 PR 里加 backfill migration（不能等 promote 救火）
4. **步骤 N+2：promote PR self-approve**（新增）—— 明示用 AIBot token approve（chentao token 撞 Gitea 1.25 422）
5. **步骤 N+3：PM2 ecosystem restart 用 `--update-env`**（新增）—— PM2 进程缓存 env，restart 不更新；必须 `pm2 start ecosystem.config.js --update-env` 才让进程读新 env

### 测试金字塔上的元教训

**test 上 migration 跑通 ≠ 生产可上**——核心原因：

- test 环境**覆盖式部署**（每次 `db push --accept-data-loss`）不积累业务数据
- UAT/PROD **累积式部署**，migration 在 N 行数据上 ALTER

这是结构性差异。建议 `docs/standards/05-development-workflow.md` 测试金字塔补一段「prod-likeness gap」：test 测语法、UAT 测数据、prod 测规模——`migration 在 test 跑通`是必要不充分。

## 教训

1. **`ALTER TABLE ADD COLUMN NOT NULL`** 是 production-unsafe pattern，正确三步：`ADD nullable → UPDATE backfill → ALTER SET NOT NULL`。这条已经在 PostgreSQL 圈是 well-known，但 prisma `migrate dev` 生成的默认 SQL **不知道**业务数据状态，会写直接 NOT NULL —— 工程师 review migration SQL 时必须自己加 backfill
2. **新增 env 变量必须有 sync 机制**：项目已有 `scripts/ops/check-env-coverage.sh` 校验本地代码 vs `.env.example`，但**host 上的 `.env.uat` / `.env.pro` 没在 CI 校验范围**。建议加一条 promote 前检查：「`.env.example` vs host `.env.<env>` diff」
3. **多主机部署 runbook 必须列全**：一个 workflow 部署到 N 台 host 时，runbook 步骤要 N 倍展开，不能只写主机 #1 假设 #2 类比
4. **PM2 `restart` 不读 env，必须 `start --update-env`**：被 PM2 backoff 清掉的进程更要用 ecosystem.config.js start 而非 restart
5. **Gitea 1.25 自批 422 是底线规则**：promote CLI 想绕但 Gitea API 不让；必须用第二账号（AIBot）approve。CLI fallback 应该写明这点

## 关联

- [ERR-20260517-002-nextjs-stale-route-type-validator.md](ERR-20260517-002-nextjs-stale-route-type-validator.md) — `.next/types/validator.ts` stale 机理（本次复用，多 host 触发）
- [ERR-20260519-006-prisma-db-push-p1014-on-major-schema-rewrite.md](ERR-20260519-006-prisma-db-push-p1014-on-major-schema-rewrite.md) — 同 robot-manager v3 重构在 test 触发的另一类问题
- [ERR-20260518-002-prisma-migrate-dev-drift-pollution.md](ERR-20260518-002-prisma-migrate-dev-drift-pollution.md) — prisma migrate 在不一致 dev DB 状态下生成污染 migration（跟本 ERR 是同主题的不同变体）
- `docs/ops/11-major-refactor-deploy-checklist.md` — 同 PR 补 5 个新增维度
- `docs/ops/01-server-infrastructure.md` §2 — UAT 双主机定义（FFAI + AIxC）
- `.env.example` line 26-33 品牌段 + `INTERNAL_APP_ENV_MASTER_KEY` 段（缺失 UAT 同步）
- promote PR：http://43.130.59.228/FFAIWorkspace/workspace/pulls/460
- deploy-uat run：http://43.130.59.228/FFAIWorkspace/workspace/actions/runs/4773
- dump 兜底：`testing/reports/uat-promote-20260519/robot_manager_pre_v3.sql`（341 行 SQL，91 行 robot_units + 102 行 status_change_logs + 其他 6 张表）
