# P1-1 维客线索 schema 迁移（public → biz）风险评估

> 日期：2026-05-04
> 触发：Neo 在 `04b-conflicts-P1-detail.md` § P1-1 反馈选 A（迁移到 biz），保证项目工程的规范性，要求单独起任务调研风险评估
> 调研范围：测试库 `test_zqyy_app` + 仓库内全部代码 / DDL / FDW / SPEC / 后端 / 小程序
> 产出形态：调研 + 实施方案推荐，不动任何代码或 DB

---

## 一、依赖盘点结果

### 1.1 总览

按 grep 结果（`member_retention_clue` / `retention_clue`）统计涉及面：仓库内 85 个文件命中。其中**会因为 schema 迁移真正受影响的"代码 + DDL + FDW"清单如下**（文档 SPEC 仅需更新文字，不阻塞迁移）。

### 1.2 业务库 DDL（`db/zqyy_app/`）

| 文件 | 关键内容 | 改动类型 |
|------|----------|---------|
| `db/zqyy_app/schemas/public.sql` | 序列 `public.member_retention_clue_id_seq`、表 `public.member_retention_clue`、3 个索引、1 个 PK | 删除 |
| `db/zqyy_app/schemas/biz.sql` | 当前不含此表 | 新增表 + 序列 + 索引 |
| `db/zqyy_app/migrations/` | 当前空（v1 已归档） | 新增迁移脚本 |
| `db/_archived/migrations_v1_merged/zqyy_app/2026-03-20__ns4_member_clue_is_hidden.sql` | 历史迁移（已归档），不动 | 不改（仅文档说明） |

### 1.3 FDW 反向映射（`db/fdw/`）

| 文件 | 关键内容 | 改动类型 |
|------|----------|---------|
| `db/fdw/setup_fdw_reverse.sql` | `CREATE FOREIGN TABLE fdw_app.member_retention_clue ... OPTIONS (schema_name 'public', table_name 'member_retention_clue')` | 改 `schema_name 'biz'` |
| `db/fdw/setup_fdw_reverse_test.sql` | 同上（指向 `test_zqyy_app`） | 改 `schema_name 'biz'` |
| `docs/database/ddl/fdw_reverse.sql` | 自动生成的 DDL 镜像 | 重新生成 |
| `docs/database/BD_Manual_fdw_reverse_retention_clue.md` | FDW 文档 | 全文更新 schema 引用 |

注：FDW 外部表名 `fdw_app.member_retention_clue` 不需改名，只需把 `OPTIONS.schema_name` 由 `'public'` 改成 `'biz'`，对 ETL 侧调用无破坏（外部表名不变）。

### 1.4 后端代码（`apps/backend/app/`）— 共 6 处 SQL 直引

| 文件 | 行 | SQL 引用形态 | 改动 |
|------|-----|------------|------|
| `routers/member_retention_clue.py` | 29 / 68 / 87 | 无 schema 前缀（依赖 search_path） | 加 `biz.` 显式前缀 |
| `routers/tenant_clues.py` | 67 / 197 / 235 / 267 / 298 | `public.member_retention_clue` 硬编码 | 全改 `biz.member_retention_clue` |
| `services/customer_service.py` | 271 / 278 | `public.member_retention_clue` 硬编码 | 改 `biz.` |
| `services/task_manager.py` | 1125 | `public.member_retention_clue` 硬编码 | 改 `biz.` |
| `ai/data_fetchers/page_context.py` | 241 | 无 schema 前缀，且**字段名错为 `created_at`**（应为 `recorded_at`） | 加 `biz.` 前缀，**附带修 `created_at → recorded_at` Bug** |
| `ai/dispatcher.py` | 574 / 588 | App8 写入：无 schema 前缀（DELETE + INSERT） | 加 `biz.` 前缀 |

**总计后端硬编码点：6 文件 / 11 处 SQL**（与冲突卡估计的 5-8 处一致，略多）。

### 1.5 后端 Pydantic / Schema 层

| 文件 | 关注点 |
|------|--------|
| `apps/backend/app/schemas/member_retention_clue.py` | 无 schema 名硬编码；但**枚举 `ClueCategory` 仍使用旧值 `客户基础信息`**，与 BD 手册 2026-03-08 对齐后的 `客户基础` 不一致（独立 Bug，登记不在本任务） |
| `apps/backend/app/schemas/tenant_clues.py` | grep 命中文档字符串，无 schema 引用 |
| `apps/backend/app/schemas/xcx_tasks.py` / `xcx_customers.py` | grep 命中文档字符串，无 schema 引用 |

### 1.6 小程序前端（`apps/miniprogram/`）

仅命中 `retentionClues` / `retention_clues` 字段名，**与 schema 无关**：

- `typings/api.d.ts`（L112 / L209）：API 响应类型
- `pages/customer-detail/customer-detail.ts:122`、`pages/task-detail/task-detail.{ts,wxml}`：页面渲染
- `utils/mock-data.ts:57`：MOCK 数据

**结论：小程序零改动。**

### 1.7 租户管理后台前端（`apps/tenant-admin/`）

grep 命中 0 处。**零改动。**

### 1.8 ETL Connector（`apps/etl/connectors/feiqiu/`）

grep 命中 0 处。**ETL 侧零改动**（FDW 外部表名 `fdw_app.member_retention_clue` 由 FDW 文件维护）。

### 1.9 文档 / SPEC（仅文字同步，不阻塞迁移）

| 文件 | 内容 |
|------|------|
| `docs/database/BD_Manual_member_retention_clue.md` | 9 处 `public` → `biz` |
| `docs/database/BD_Manual_fdw_reverse_retention_clue.md` | 5 处 schema 引用 + 数据流向图 |
| `docs/database/BD_Manual_biz_tables.md` | `public.member_retention_clue` → `biz.member_retention_clue` |
| `docs/database/ddl/zqyy_app__public.sql` / `..__biz.sql` | 重新生成（`tools/db/gen_consolidated_ddl.py`） |
| `docs/database/README.md` | schema 索引 |
| `docs/specs/tenant-admin-web/{requirements,design,tasks}.md` | 7 处 |
| `docs/specs/rns1-task-performance-api/*.md` | 4 处 |
| `docs/specs/rns1-customer-coach-api/*.md` | 4 处 |
| `docs/specs/05-miniapp-ai-integration/design.md` | 1 处 |
| `docs/prd/specs/00-数据依赖矩阵.md` | 3 处 |
| `docs/prd/specs/P10-tenant-admin-web.md` | 1 处 |
| `docs/prd/specs/P14-ai-dashscope-migration.md` | 1 处 |
| `docs/prd/specs/board-detail-gap-analysis.md` | 1 处 |
| `docs/prd/Neo_Specs/RNS1-split-plan.md`、`NS1`、`NS2`、`NS4`、`review-audit/` | 多处 |
| `docs/_overview/01-product-overview.md` | § 八冲突登记 |
| `docs/_overview/04b-conflicts-P1-detail.md` | 冲突卡 |
| `docs/contracts/openapi/backend-api.json` | 注释字段 |
| `docs/architecture/backend-architecture.md` L118 | 1 处 |
| `docs/ai/ai_apps_feature_acceptance_spec.md` | 4 处（含 G20 RLS 缺失提醒） |

---

## 二、表结构 / 索引 / RLS 现状

### 2.1 列定义（11 列）

| 列名 | 类型 | 默认值 | NOT NULL | 备注 |
|------|------|--------|---------|------|
| id | bigint | `nextval('public.member_retention_clue_id_seq'::regclass)` | YES | PK |
| member_id | bigint | — | YES | 会员 ID |
| category | varchar(20) | — | YES | CHECK 6 值 |
| summary | varchar(200) | — | YES | 摘要 |
| detail | text | — | NO | 详情 |
| recorded_by_assistant_id | bigint | — | NO | 助教 ID |
| recorded_by_name | varchar(50) | — | NO | 助教姓名 |
| recorded_at | timestamptz | `now()` | YES | 录入时间 |
| site_id | bigint | — | YES | 多门店隔离 |
| source | varchar(20) | `'manual'` | YES | manual / ai_consumption / ai_note |
| is_hidden | boolean | `false` | YES | 隐藏控制（NS4） |

### 2.2 约束

- PK：`member_retention_clue_pkey`（id）
- CHECK：`chk_retention_clue_category`（6 值枚举）
- NOT NULL：8 列
- **FK：无**（既不引用其他表，也无表 FK 引用本表 — 经 `information_schema.constraint_column_usage` 查证）

### 2.3 索引（4 个，含 PK）

```
member_retention_clue_pkey         UNIQUE (id)
idx_retention_clue_member          (member_id)
idx_retention_clue_site            (site_id)
idx_retention_clue_category        (member_id, category)
```

### 2.4 RLS 策略

- `relrowsecurity = false` / `relforcerowsecurity = false`
- `pg_policies` 查询返回 0 行
- **结论：当前未启用 RLS**

> 注：`ai_apps_feature_acceptance_spec.md` G20 项已登记"全部 AI 表未启用 RLS"为已知差异，本次迁移**不引入新 RLS**，保持现状（与现行后端隔离方式一致：tenant_clues 路由通过 `site_filter_clause` 强制 site_id 条件，retention_clue 路由通过参数 `site_id` 限制）。

### 2.5 触发器

无非内部触发器。

### 2.6 序列

`public.member_retention_clue_id_seq`（bigint），需迁移到 `biz`。

---

## 三、数据量评估

测试库 `test_zqyy_app`（2026-05-04 实查）：

```
总行数:        44
门店分布:      site_id=2790685415443269 → 44 行（单店）
来源分布:      ai_consumption → 44 行（全部 AI 写入，无 manual）
分类分布:      消费习惯 23 / 玩法偏好 12 / 客户基础 9
时间范围:      2026-04-21 00:20 ~ 2026-05-01 01:53
biz schema:    存在
biz.member_retention_clue: 不存在
```

**生产库未直连查询**，估算依据：本表为 AI 写入主导（App8 强幂等替换），单门店 10 天 44 行，按 4 门店、180 天线性外推约 3000-5000 行。即使生产库 10 倍于测试库，**整表数据量在万行量级**，迁移过程一次性 INSERT 即可完成（< 1 秒）。

---

## 四、风险等级矩阵

| # | 风险维度 | 等级 | 简述 | 缓解措施 |
|---|---------|------|------|---------|
| R1 | 数据丢失 | 低 | 行数小（< 万），无并发写压力，可在事务内 INSERT INTO biz... SELECT FROM public... | 事务 + 完成后 `COUNT(*)` 一致性校验；保留 public 表 7 天后再 DROP |
| R2 | FK 破坏 | 无 | 无任何 FK 入/出 | — |
| R3 | FDW 外部表破坏 | 中 | `fdw_app.member_retention_clue` OPTIONS 写死 schema='public'，不改会读到旧表（DROP 后报错）| 迁移完成后立即 `ALTER FOREIGN TABLE ... OPTIONS (SET schema_name 'biz')` |
| R4 | RLS policy 重建 | 无 | 当前未启用 RLS，迁移不引入 | — |
| R5 | 后端代码硬编码 schema | **高** | 11 处 SQL 直引，遗漏一处即在生产报"relation does not exist" | 全量替换 + grep 自动校验 + 后端单元测试 + staging 全链路验证 |
| R6 | 后端 search_path 隐式依赖 | 中 | `routers/member_retention_clue.py`、`dispatcher.py`、`page_context.py` 共 5 处不带 schema 前缀，依赖 `search_path` 默认含 `public` | 迁移后这 5 处必须显式加 `biz.` 前缀，**否则会找到 public 旧表（如未删）或报错（已删）** |
| R7 | 前端 mock / 类型 | 无 | 小程序仅引用字段名 `retentionClues`，与 schema 无关 | — |
| R8 | App8 写入路径 | **高** | dispatcher.py L574/L588 是核心幂等写入，DELETE + INSERT 在事务内运行，写错 schema 会让幂等失效 | 改 schema 同时保持 SQL 结构不变；测试库 dry-run 跑一次 App8 |
| R9 | AI prompt 硬编码 SQL | 低 | App8 prompt 仅做 JSON 拼接，不含 SQL；只在注释提及表名 | 注释同步改即可 |
| R10 | 回滚成本 | 低 | 备份 public 表（保留 7 天）+ 代码 git revert + FDW OPTIONS 回退 | 完整 rollback runbook 见 § 五-A 第 7 步 |
| R11 | 文档 / SPEC 不一致 | 中 | 25+ 个文档需要同步替换 `public.member_retention_clue` → `biz.member_retention_clue` | 批量 grep + sed 风格替换；先做 DDL/代码，文档收尾 |
| R12 | 序列权限 | 低 | `public.member_retention_clue_id_seq` 需迁到 biz，应用 INSERT 时使用 `nextval()` | DDL 层 `ALTER SEQUENCE ... SET SCHEMA biz` 一并搬迁 |
| R13 | 测试数据库与生产环境差异 | 中 | 测试库已观测无 RLS、无 FK；生产库可能有手动加的索引或 grant | 迁移前对生产库执行同样的 prescan SQL（见 § 七验证锚点） |
| R14 | 多门店数据混入 | 低 | 测试库仅 1 site，生产库未知；INSERT INTO ... SELECT 不会造成混入 | site_id 列原样复制 |

**风险最高的 3 项**：R5（11 处 SQL 直引漏改）、R8（App8 幂等链路）、R3（FDW OPTIONS）。

---

## 五、推荐方案（三套对比）

### 方案 A — 一次性迁移（短停机 bigbang）

**适用条件**：本任务 100% 满足
- 数据量小（< 万行）
- 无 FK 出入
- 无 RLS 不需重建
- 无生产小程序高并发写入（写入只来自 tenant-admin 手动 + App8 离线触发）
- 单一 monorepo，可同时部署后端代码 + DB DDL + FDW

#### 步骤清单

1. **预检（5 min）**：在生产库执行 § 七 验证 SQL，确认结构与测试库一致
2. **公告 / 短停机（10 min 内）**：通知所有 tenant-admin 用户，暂停 ai_trigger_jobs 调度
3. **DDL 迁移（事务内执行，1 min）**：
   ```sql
   BEGIN;

   -- 5.1 先把 schema 从 public 搬到 biz（含序列）
   ALTER TABLE public.member_retention_clue SET SCHEMA biz;
   ALTER SEQUENCE public.member_retention_clue_id_seq SET SCHEMA biz;

   -- 5.2 PostgreSQL 12+ 会自动改 default 表达式中的序列引用，但稳妥起见显式重置：
   ALTER TABLE biz.member_retention_clue
     ALTER COLUMN id SET DEFAULT nextval('biz.member_retention_clue_id_seq'::regclass);

   -- 5.3 重命名约束（保持 chk_retention_clue_category 命名）— ALTER SCHEMA 已自动处理

   -- 5.4 索引随表迁移（PostgreSQL 自动）

   COMMIT;
   ```
4. **FDW OPTIONS 切换（在 etl_feiqiu 中执行，10 sec）**：
   ```sql
   ALTER FOREIGN TABLE fdw_app.member_retention_clue
     OPTIONS (SET schema_name 'biz');
   ```
   测试库同样在 test_etl_feiqiu 执行。
5. **后端代码部署（已经过 staging 全链路通过）**：
   - 6 文件 11 处 SQL 全改为 `biz.member_retention_clue`
   - 顺手修 `page_context.py:243` `created_at → recorded_at` Bug
6. **冒烟测试（10 min）**：
   - retention_clue CRUD 路由（POST/GET/DELETE）
   - tenant_clues 5 个端点
   - customer_service 客户详情接口（CUST-1 retentionClues 块）
   - task_manager 任务详情接口
   - 触发一次 App8（dispatch app8_consolidation），确认幂等 DELETE + INSERT 落入 biz
   - page_context.build_page_text 维客线索拼接
7. **回滚预案**：
   - DDL 层：`ALTER TABLE biz.member_retention_clue SET SCHEMA public; ALTER SEQUENCE biz.member_retention_clue_id_seq SET SCHEMA public;`
   - FDW：`ALTER FOREIGN TABLE fdw_app.member_retention_clue OPTIONS (SET schema_name 'public');`
   - 后端：`git revert` 单个 commit
   - 数据：原表整表搬迁未发生数据复制，零丢失
8. **观察期（7 天）**：保留 7 天观察告警，无异常后归档迁移脚本到 `db/zqyy_app/migrations/`

#### 工作量预估

| 阶段 | 人时 |
|------|------|
| 代码改动（6 文件 + 顺修 created_at Bug） | 2.0 |
| DDL + FDW 脚本编写 + 测试库验证 | 1.5 |
| 测试库全链路冒烟（含 App8 dry-run） | 1.5 |
| 文档 / SPEC 批量同步（25+ 文件） | 2.0 |
| 审计记录 + DB 文档同步 | 1.0 |
| 生产部署 + 观察 | 1.0 |
| **合计** | **9 人时** |

#### 风险等级 — **低-中**

主要风险落在 R5（漏改 SQL）和 R8（App8 链路），通过 staging 全链路验证可消除。

---

### 方案 B — 渐进迁移（双写 / 视图过渡）

**思路**：建 `biz.member_retention_clue` 实表 + 数据复制 → 在 `public.member_retention_clue` 替换为 `biz` 同名视图 → 后端代码逐文件切到 `biz.` 前缀 → 全部切完后删 public 视图。

#### 步骤清单

1. 在 biz 中 `CREATE TABLE biz.member_retention_clue (LIKE public.member_retention_clue INCLUDING ALL)` + 序列 + 索引
2. `INSERT INTO biz.member_retention_clue SELECT * FROM public.member_retention_clue`
3. 重置 `biz.member_retention_clue_id_seq` 到 max(id) + 1
4. 事务内：`DROP TABLE public.member_retention_clue` → `CREATE VIEW public.member_retention_clue AS SELECT * FROM biz.member_retention_clue` + 创建对应 INSTEAD OF 触发器（处理 INSERT/UPDATE/DELETE）
5. 后端代码逐文件 PR 切到 biz.（11 处分多个 PR）
6. 全部切完后：删 `public.member_retention_clue` 视图 + 触发器
7. FDW OPTIONS 切到 biz

#### 工作量预估

约 **20-25 人时**（视图 + INSTEAD OF 触发器 + 多次部署）。

#### 风险等级 — **中**

- 优点：后端代码可灰度切换，无停机
- 缺点：双写期间复杂度高，触发器路径出 Bug 排查难；本任务无并发写压力，**收益不匹配成本**

---

### 方案 C — 暂缓 + 文档说明

**思路**：保留 `public.member_retention_clue`，在 `db/CLAUDE.md` 和 `01-product-overview.md` § 八明确登记"维客线索为 public 例外，因 [理由]"。

#### 何时选择

- 当迁移工作量 > 收益时
- Neo 已**否决此选项**（反馈选 A，明确要"保证项目工程的规范性"）

故方案 C 不再展开。

---

## 六、关键风险点详细分析

### R5 — 后端代码 11 处 SQL 直引（高）

`tenant_clues.py` 是受影响最大的文件（5 处显式 `public.` 前缀），其中 `_get_clue_with_site_check` 内嵌 SQL 同时带 `site_filter_clause` 拼装，改动需保持参数顺序和 f-string 拼接的占位符一致。

具体替换清单（文件:行 → 旧 → 新）：

```
routers/member_retention_clue.py:29   "INSERT INTO member_retention_clue"           → "INSERT INTO biz.member_retention_clue"
routers/member_retention_clue.py:68   "FROM member_retention_clue"                  → "FROM biz.member_retention_clue"
routers/member_retention_clue.py:87   "DELETE FROM member_retention_clue WHERE..."  → "DELETE FROM biz.member_retention_clue WHERE..."
routers/tenant_clues.py:67            "FROM public.member_retention_clue"           → "FROM biz.member_retention_clue"
routers/tenant_clues.py:197           "FROM public.member_retention_clue"           → "FROM biz.member_retention_clue"
routers/tenant_clues.py:235           "UPDATE public.member_retention_clue"         → "UPDATE biz.member_retention_clue"
routers/tenant_clues.py:267           "DELETE FROM public.member_retention_clue"    → "DELETE FROM biz.member_retention_clue"
routers/tenant_clues.py:298           "UPDATE public.member_retention_clue"         → "UPDATE biz.member_retention_clue"
services/customer_service.py:278      "FROM public.member_retention_clue"           → "FROM biz.member_retention_clue"
services/task_manager.py:1125         "FROM public.member_retention_clue"           → "FROM biz.member_retention_clue"
ai/data_fetchers/page_context.py:241  "SELECT summary FROM member_retention_clue"   → "SELECT summary FROM biz.member_retention_clue"  ⚠ 同时修复 created_at → recorded_at
ai/dispatcher.py:574                  "DELETE FROM member_retention_clue"           → "DELETE FROM biz.member_retention_clue"
ai/dispatcher.py:588                  "INSERT INTO member_retention_clue"           → "INSERT INTO biz.member_retention_clue"
```

**校验做法**：迁移完成后，跑一次 `Grep "FROM member_retention_clue|INTO member_retention_clue|UPDATE member_retention_clue|DELETE.+member_retention_clue|public\.member_retention_clue" --path apps/backend`，应当 0 命中。

### R8 — App8 幂等写入链路（高）

`dispatcher.py:_write_retention_clue` 是 App8 的强幂等核心：DELETE 同源旧记录 + INSERT 新批，事务级别保证当天替换原子性。迁移后必须验证：
1. 触发一次 App8（target_id = 测试 member），确认 `biz.member_retention_clue` 中该 (member_id, site_id, source='ai_consumption') 的记录被 DELETE 后 INSERT
2. 检查事务回滚路径（人为制造 INSERT 失败）：DELETE 也要回滚

### R3 — FDW OPTIONS 切换（中）

FDW 外部表 `fdw_app.member_retention_clue` 是独立对象，名字不变；只需把 OPTIONS 中 `schema_name` 从 `'public'` 改成 `'biz'`。**遗漏后果**：ETL 库读到"对端 public 表已不存在"，但当前没有 ETL 任务消费此外部表（BD_Manual_fdw_reverse_retention_clue.md § 4 已注明 "当前无 DWS 任务直接消费"），故影响面其实更小，**但仍需更新以避免未来踩坑**。

### R6 — search_path 隐式依赖（中）

5 处不带 schema 前缀的 SQL（member_retention_clue.py 3 处、page_context.py 1 处、dispatcher.py 2 处）依赖 `search_path` 默认包含 `public`。迁移后 public 表被删，这些 SQL 会立刻报错；**反向风险**：如果 public 表保留过渡（如方案 B），SQL 会写到 public 的视图（间接到 biz），但 grep 看不出来，调试更难。**结论**：方案 A 在删 public 表的瞬间即暴露所有遗漏，更"硬"也更"快"。

### R11 — 文档 / SPEC 不一致（中）

25+ 文档需同步。建议批量执行 `sed -i 's/public\.member_retention_clue/biz.member_retention_clue/g'`（PowerShell 等价 `(Get-Content ... ) -replace ...`），**但要排除 `_archived/` 目录和审计历史记录**（保留历史原文）。

### R13 — 测试库 vs 生产库结构差异（中）

测试库观测：无 RLS、无 FK、无触发器。但生产库可能存在：
- 手动加的额外索引（DBA 可能为查询优化加）
- 表级 GRANT（`app_reader` / `etl_user` / `app_user`）
- COMMENT

**生产部署前必须执行**：
```sql
-- 在生产 zqyy_app 中
\d+ public.member_retention_clue
SELECT grantee, privilege_type FROM information_schema.role_table_grants
 WHERE table_schema = 'public' AND table_name = 'member_retention_clue';
SELECT obj_description('public.member_retention_clue'::regclass);
```
所有发现的额外属性（GRANT / COMMENT / 额外索引），需在 ALTER SCHEMA 后逐项确认是否随表带过去（PostgreSQL 12+：表级权限和注释会保留，索引随表迁移）。

---

## 七、推荐实施顺序（最小风险路径，方案 A）

### 阶段 1 — 预检（30 min）

```sql
-- 在生产 zqyy_app 中执行
-- A1. 当前结构快照
\d+ public.member_retention_clue
-- A2. 数据量
SELECT COUNT(*) AS rows, COUNT(DISTINCT site_id) AS sites,
       MIN(recorded_at), MAX(recorded_at)
  FROM public.member_retention_clue;
-- A3. GRANT 现状
SELECT grantee, privilege_type FROM information_schema.role_table_grants
 WHERE table_schema = 'public' AND table_name = 'member_retention_clue';
-- A4. 是否有意外 RLS / 触发器（理论应为 0）
SELECT relrowsecurity FROM pg_class WHERE oid = 'public.member_retention_clue'::regclass;
SELECT tgname FROM pg_trigger WHERE tgrelid = 'public.member_retention_clue'::regclass AND NOT tgisinternal;
```

### 阶段 2 — 测试库迁移演练（2 h）

1. 在 test_zqyy_app 执行 `ALTER TABLE ... SET SCHEMA biz` + 序列同步
2. 在 test_etl_feiqiu 执行 `ALTER FOREIGN TABLE ... OPTIONS (SET schema_name 'biz')`
3. 部署后端代码改动（11 处 SQL + page_context.py 顺修 Bug）到本地或 staging
4. 跑一遍后端单元测试 + 集成测试
5. 触发一次 App8 dispatch，确认 biz 表 DELETE + INSERT 正常
6. 对照测试库写一份"产出报告"：行数前后一致 / 序列衔接正常 / FDW 可读

### 阶段 3 — 文档批量同步（1 h）

按 § 一-1.9 文档清单，批量替换 `public.member_retention_clue` → `biz.member_retention_clue`，**排除 `_archived/` 和 `docs/audit/changes/` 历史审计**。

### 阶段 4 — 生产部署（30 min 窗口）

按方案 A 步骤 1-7 执行；持续 7 天观察告警。

### 阶段 5 — 收尾审计（30 min）

- 写 `docs/audit/changes/2026-05-XX__schema-migrate-retention-clue-public-to-biz.md`
- 跑 `python scripts/audit/gen_audit_dashboard.py`
- 把迁移 DDL 脚本归档到 `db/zqyy_app/migrations/2026-05-XX__retention_clue_to_biz.sql`

**总工作量：~9 人时**（测试库演练 4h + 生产部署 1h + 文档 + 审计 4h）

---

## 八、与其他 Wave 的关系

| Wave | 关系 | 建议 |
|------|------|------|
| Wave 0（已完成） | 04b-conflicts-P1-detail.md 第 P1-1 项即本任务的源 | 本评估即对 P1-1 反馈的响应 |
| Wave 1（小程序对齐） | 小程序零改动（仅引用字段名 retentionClues） | 不阻塞 |
| Wave 2（后端 API 对齐） | 后端 6 文件 11 处 SQL 改动属于 Wave 2 范围 | **建议合入 Wave 2 一起做**，避免后端分两次部署 |
| Wave 3（数据库 / Schema 治理） | 本迁移属于"业务表归 biz"治理大方向 | **建议作为 Wave 3 的首发任务**，先于其他迁移做（数据量最小、依赖最少，风险最低，可作为模板） |
| Wave 4（AI / ETL） | App8 dispatcher 受影响，但本任务自带验证 | 在 Wave 4 启动前完成本迁移可以让 App8 路径在新 schema 下稳定 1-2 周 |
| Wave 5（tenant-admin 完整化） | tenant_clues 路由 5 处改动属本任务范围 | 同 Wave 2 |

**合并建议**：把本任务和 Wave 2 后端 API 对齐合并为一个工作流（同一个分支同一次 PR），避免后端 SQL 改动分两次。Wave 3 治理的其他业务表迁移（如果有）以本任务为模板。

---

## 九、给 Neo 的决策清单

请逐项确认（每项打 √ 或 ✗ + 备注）：

| # | 决策项 | 默认 | 备注 |
|---|--------|------|------|
| D1 | 选择方案 A（一次性迁移）？ | √ | 数据量小、无 FK、无 RLS、无小程序写入压力，方案 A 性价比最高 |
| D2 | 接受 9 人时工作量预估？ | √ | 含测试库演练 + 生产部署 + 文档 + 审计 |
| D3 | 把本任务与 Wave 2 后端对齐合并到同一 PR？ | √ | 避免后端代码分两次部署 |
| D4 | 顺手修复 `page_context.py:243` 的 `created_at → recorded_at` Bug？ | √ | 同文件同 SQL，零额外成本 |
| D5 | 顺手修复 `schemas/member_retention_clue.py` 的 `ClueCategory.BASIC_INFO = "客户基础信息"` 与 BD 手册 2026-03-08 对齐到 `客户基础` 不一致？ | ⚠ | **登记但不在本任务范围**，建议另起 task。若一并修需评估对历史数据的兼容（44 行测试数据是否有"客户基础信息"值需要迁移） |
| D6 | 接受迁移后 `public.member_retention_clue` 立即删除（非保留视图过渡）？ | √ | 与方案 A 的"硬切"一致，所有遗漏 SQL 立即暴露 |
| D7 | 迁移后保留旧表数据备份多久？ | 7 天 | `pg_dump -t public.member_retention_clue` 单表备份，存到生产备份目录 |
| D8 | 是否在迁移同步引入 RLS（site_id 过滤）？ | ✗ | 本任务**不引入新 RLS**，保持 G20 已知差异；RLS 治理另起任务（涉及全部 AI 表统一规划） |
| D9 | 是否需要先做"预检"小任务确认生产库无意外结构？ | √ | 提交执行 § 七 阶段 1 SQL 后再决定动迁移 |
| D10 | 生产部署窗口选择？ | 工作日 09:00 前 / 22:00 后 | App8 调度、tenant-admin 用户访问最少时段 |

---

## 十、附录：grep 校验脚本

迁移完成后用以下命令自检遗漏：

```bash
# 后端代码：应为 0 命中
grep -rEn "(public\.member_retention_clue|FROM member_retention_clue|INTO member_retention_clue|UPDATE member_retention_clue|DELETE.+member_retention_clue)" \
  apps/backend/app

# 文档（排除归档和审计历史）：应为 0 命中
grep -rEn "public\.member_retention_clue" docs/ \
  --exclude-dir=_archived --exclude-dir=audit

# DDL（应仅出现在 biz.sql 中）
grep -rEn "member_retention_clue" db/zqyy_app/schemas/

# FDW（应仅 schema_name 'biz'）
grep -En "schema_name" db/fdw/setup_fdw_reverse*.sql
```

---

## 十一、附：本评估自身的限制说明

1. **未访问生产库**：仅基于测试库 `test_zqyy_app` 推断；生产库结构差异通过 § 七 阶段 1 预检 SQL 排查
2. **未跑 staging 全链路**：本评估为只读调研，方案 A 阶段 2 必须在 staging 跑通后才能进入生产
3. **未量化 App8 触发频率**：dispatcher.py 写入路径风险等级标"高"是基于"幂等性是核心特性"的保守判断；如果 App8 实际触发频率 < 1 次/天，迁移期间踩坑概率极低
4. **未触及 RLS 引入**：本评估遵循 Neo 反馈"保证规范性"的范围，仅做 schema 迁移，不并入 RLS 治理（RLS 治理建议另起任务统筹 G20）

---

**结论**：迁移 `public.member_retention_clue` → `biz.member_retention_clue` 工程上完全可行，**推荐方案 A（一次性迁移）+ 与 Wave 2 后端对齐合并 PR**。最高风险落在后端 11 处 SQL 直引，通过 grep 自检 + staging 全链路验证可控。预估 9 人时。
