Files
Neo-ZQYY/docs/_overview/04b-feedback/P1-1-schema-migration-risk.md
Neo 509cf43284 chore(docs): Wave 0 调研产出 + P0/P1/P2 反馈调研
建立项目级标杆文档 docs/_overview/ 作为产品全景索引,
解决"PRD 零碎、文档膨胀、跨子系统调研无入口"的问题。

主要内容:
- 00-index 总索引 + 维护协议 + 与 CLAUDE.md 关系
- 01-product-overview 产品全景脑图(6 角色 / 6 子系统 / 数据流 /
  7 业务概念 / 8+1 AI 矩阵 / 22 术语)
- 02a-miniprogram-page-matrix 小程序 21 页业务指纹
- 02b-adminweb-page-matrix admin-web 19 路由业务指纹
- 03-test-spec 测试规范 (L1-L5 分层 + 走查模板 + 75-95 case 估算)
- 04-doc-conflicts 39 条冲突索引(P0×8 / P1×13 / P2×13 + 5 子项)
- 04a/b/c-conflicts-*-detail 业务故事卡(7 字段:关联/逻辑/影响/选项/判定)
- 05-orphan-pages-cleanup admin-web 6 孤儿页面处置(1 归档 + 4 保留)
- WAVES-MASTER-PLAN.md 全 Wave 主计划(0-5,共 22-32 工作日)
- WAVE-1-KICKOFF.md Wave 1 实施 kickoff
- GLOBAL-DECISION-DASHBOARD.md 全局决策仪表板

反馈调研产物:
- 04a-feedback/ P0 两轮反馈(8+8 项决策 + D-1/2/3 + F-1/2 子代理产出)
- 04b-feedback/ P1 两轮反馈(13+1+5 项 + E-1/2/3/4 + G-1/2 子代理产出)
- 04c-feedback/ P2 反馈(13 项 + 5 子项 + H-1/2/3 子代理产出)
- NEO-DECISIONS-LOG 累积决策记录

关键追加发现 8 处 D Bug(原蓝本 0):
- P0-3 看板沙箱接入(Wave 1 W1-T1)
- P0-5 致命 1 (4 处 fdw_etl 残留, 已修 commit 17f045a)
- P0-5 致命 2 (JWT aud 缺失, 已修 commit 17f045a)
- P0-6 clearAllTasks 守卫 (Wave 3)
- P0-8 DBViewer 黑名单漏 (已修 commit 17f045a)
- P1-3 task-detail 跳转传 task_id 而非 customer_id
- P2-7 board-finance 隐式 null
- 2 个独立 Bug (page_context.created_at + ClueCategory 字典)

参考: docs/_overview/00-index.md
2026-05-04 07:38:28 +08:00

482 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 / L209API 响应类型
- `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 NULL8 列
- **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 项**R511 处 SQL 直引漏改、R8App8 幂等链路、R3FDW 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 任务详情接口
- 触发一次 App8dispatch 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和 R8App8 链路),通过 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. 触发一次 App8target_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 前缀的 SQLmember_retention_clue.py 3 处、page_context.py 1 处、dispatcher.py 2 处)依赖 `search_path` 默认包含 `public`。迁移后 public 表被删,这些 SQL 会立刻报错;**反向风险**:如果 public 表保留过渡(如方案 BSQL 会写到 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 4AI / ETL | App8 dispatcher 受影响,但本任务自带验证 | 在 Wave 4 启动前完成本迁移可以让 App8 路径在新 schema 下稳定 1-2 周 |
| Wave 5tenant-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 | 是否在迁移同步引入 RLSsite_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 人时。