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
This commit is contained in:
Neo
2026-05-04 07:38:28 +08:00
parent c6453829a6
commit 509cf43284
44 changed files with 10789 additions and 0 deletions

View File

@@ -0,0 +1,481 @@
# 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 人时。