Files

160 lines
14 KiB
Markdown
Raw Permalink Normal View History

# MCN 数据面接入改造 · 阶段二 · **第一批按表切换(第 28 棒)**
> **执行会话**:插件投放与分库线-28棒(2026-09-25 14:43–15:0x)|**工作区**:`E:/ProgramData/AIProject/aliyun-dsh-server`(WS)
> **唯一执行单**:`DOC/05-交接单/插件投放与分库线-03MCN数据面接入改造-阶段二.md`(`DOC=D:/github/dsh_shenxian/dsh-server-docs`)
> **上一棒**:第 27 棒(`交付物/MCN数据面接入-阶段二-20260925.md`,S0/S1/S2/S3 完成)
> **本棒范围(入口 §2「🎯 本轮动作」)**:① 给 `store.js` 加**写源档**(`local` 默认 / `platform`),与读源档**同源同文件** + 单测;② 按**「表」**(⛔ 不是按文件)推进第一批全量切换:`account_persona` + `creative_log` 跑端到端样板(全部读写点 → 迁移 → `shadow` 真机读数 → 切档)。
---
## 一 结论
**两件都做完了,且拿到了真机读数。** 关键判定如下(逐条可复现,见 §二 判据表):
1. **写源档落地** —— 读写两档共用一个配置文件(`<DSH_HOME>/.dsh/data-plane.json`),
**单测 31/31 通过**(旧 24 + 新增 7)。默认档 = `local` ⇒ **零平台写请求**(可断言,非"应该不会")。
2. 🔴 **补了一个交接单没写、但不做就不成立的东西:按表档**。全局单档**表达不了逐表切换**
(一张表迁完、其余没迁时切全局 ⇒ 未迁的表读到空)。现支持
`{"mode":"local","tables":{"account_persona":{"mode":"platform","writeMode":"platform"}}}`;
解析顺序 = env(全局)> 该表覆盖 > 全局 file > `local`。
3. **第一批两张表的全部读写点已接线**(含删除),逐处落点见 §三。
4. **存量迁移真机完成**:本地 `account_persona` **4 行 → 平台 4 行**,逐账号集合
**onlyLocal=[] / onlyPlatform=[] / match=true**;幂等重跑 `ins=0 skip=4`;**旧库 sha256 迁移前后逐字一致**
(`b37f8aab…`)。`creative_log` 本地 **0 行** ⇒ 空迁移(无写请求)。
5. **`shadow` 档真机读数**:`shadowReads=4`、**`shadowMismatch=0`**、三账号全 `match=true`、
**`degraded` 一次都没出现**;平台调用 **27 次全 ok / 0 失败**。
6. **已切档(按表)**:`account_persona` + `creative_log` 两表 = read/write 双 `platform`;
**全局仍 `local`**、`hot_accounts` 仍 `local`(⛔ 没有"顺手全切")。回滚 = 删该文件或 `clearPlaneMode()`。
7. ⛔ **未出包 / 未投放 / 未重启任何服务** ⇒ **实例内(插件路由)端到端未取证**,本期**不宣称通过**
(实例仍载 `0.3.15`,即**尚无本棒代码**;生效需 47 实例重启 ⇒ 交接单 §四 4.3 ㈠ **待拍板**)。
---
## 二 判据表(命令 + 实际输出摘要 + 退出码)
| # | 判据 | 命令 | 实际输出 | 退出码 |
|---|---|---|---|---|
| **C1** | 写档单测(默认档零写请求 / 非法值折回 / `platform` 真发写请求 / 不回落 / 按表档 / 同源同文件) | `node --test tests/mcn-store.test.mjs` | `# tests 31 / pass 31 / fail 0`(旧 24 全过 + 新增 7 例 ⑱–㉔) | **0** |
| **C2** | 语法自检(改动文件) | `node --check <f>` × `store/index/imports/workshop/data` | 5/5 OK | **0** |
| **C3** | 包内 smoke 未回归 | `node tests/smoke.mjs` | `全部通过(16 组断言)` | **0** |
| **C4** | 迁移工具本机自证(假 fetch) | `node --experimental-sqlite tmp/p28/local-migrate-check.mjs` | dry-run **平台调用 0**;apply `ins=2/1`;**重跑 `skip=2/1`**;`coerced=["0:content_json","1:content_json"]` | **0** |
| **C5** | 静态改(本地库指纹不变) | `sha256sum $DB`(迁移前 / 迁移后) | 前后**逐字一致** `b37f8aab51a9…`(2,383,872 B,mtime 仍 `09-20 23:05:05`) | **0** |
| **A1** | 存量迁移一致性(**逐表 + 逐账号**) | 47 真机探针 `P28RESULT` 的 `per_account` | `1627: 1/1`、`1628: 1/1`、`1629: 2/2`,**`onlyLocal=[]` `onlyPlatform=[]` `match=true`** | **0** |
| **A1b** | 幂等(可重跑) | 同上 `rerun` | `account_persona: ins=0, skip=4, fail=0`|`creative_log: ins=0, skip=0` | **0** |
| **A2** | 全表读写 E2E(**实例内**) | — | ⛔ **未达成**(需 47 实例重启 ⇒ 待拍板 ㈠;本轮⛔ 未重启、⛔ 未投放) | — |
| **A3** | 红线归零(删 `db.js`) | — | ⛔ **未开工**(附属录 D 前置 = A1+A2 全绿;A2 未达成) | — |
| **A4** | 表名对齐彻底 | 第 27 棒已达成(包内字面 0 命中) | 本轮未回退:本轮改动未引入旧表名(改动面 6 件全文无命中) | **0** |
| **A5** | 旧库备份在位 + 零数据损失 | 47:`ls -la /opt/dsh/backups/plugin-data/dsh-plugin-mcn-suite/` | 备份 `cce6d1cd-…-20260925-141436.tar.gz`(809,732 B)**在位**;旧库**原样在盘**(sha256 不变) | **0** |
| **A6** | 移动端五条 | `08 §4-补 ④` | **未改 UI ⇒ 免跑**。文件清单依据:本轮改动面恰 **6 件** = `lib/host/mcn/{store,index,imports,workshop}.js`、`tests/mcn-store.test.mjs`、`tools/migrate-local-to-platform.mjs` —— **`lib/client.js` / `lib/host/mcn/data.js` 零改动** | — |
| **A7** | 卫生 | 见 §五 | ⛔ 未改 `src/**`|⛔ `im.ts` 零改动|⛔ 未手改用户 `home` 下 profile/`.pnpm`|⛔ 未重启 106|⛔ 未 commit·push|✅ 临时会话 `1 → 删 1 → 残留 0` | **0** |
| **A8** | 两机一致 | — | ⛔ **未做**(本轮不投放 ⇒ 与两机部署面无交集;共享层仍 `0.4.0`,实例仍 `0.3.15`) | — |
### §二-补 · `shadow` 档真机读数(交接单 §五 S6 的验证项)
```
[mcn-store] shadow what=account_persona.account=1627 platform=1 local=1 match=true
[mcn-store] shadow what=account_persona.account=1628 platform=1 local=1 match=true
[mcn-store] shadow what=account_persona.account=1629 platform=2 local=2 match=true
[mcn-store] shadow what=account_persona.count.shadow platform=4 local=4 match=true
stats: shadowReads=4 shadowMismatch=0 platformCalls=27 platformOk=27 platformFailed=0 writeCalls=0
```
- **无 `degraded`**(即一次都没有"平台不可用回落本地")
- 影子读一律 `source:"local"`(影子档**必须**返回本地 ⇒ 零回归),`mode:"shadow"` ✅
### §二-补 2 · 前置读数(迁移前后)
| 项 | 迁移前 | 迁移后 |
|---|---|---|
| 平台 `account_persona` 行数 | **0** | **4** |
| 平台 `creative_log` 行数 | 0 | 0(本地本来 0 行) |
| 本地 `account_persona` 行数 | 4(`account_id` = 1627/1628/1629) | 4(⛔ 未动) |
| 本地 `creative_log` 行数 | 0 | 0 |
---
## 三 逐处落点(两张表 · **全部**读写点)
| # | 文件:行(本棒现算) | 原形态 | 现形态 | 档位归属 |
|---|---|---|---|---|
| 1 | `lib/host/mcn/imports.js`(`importExternalDir` 账号设定.md) | `SELECT id…`去重 + `INSERT INTO account_persona` | `await store.insertPersona(row, { local })` —— `local` 回调里**原样那条去重 SQL** | 读写档 |
| 2 | `lib/host/mcn/imports.js`(`importExternalData` persona 分支) | 同上 | 同上 | 读写档 |
| 3 | `lib/host/mcn/workshop.js`(`batchDeleteCascade` 账号级) | `DELETE FROM account_persona WHERE account_id=?`(在本地事务里) | **平台侧先删**(`store.removePersonaByAccount`)+ 本地那一条**照旧保留** | 写档 |
| 4 | `lib/host/mcn/index.js`(`taskDone` 的 `dsh_persona-*`) | `SELECT COUNT(*) … account_persona` | `await store.readCount({table:"account_persona", …})` | 读档 |
| 5 | `lib/host/mcn/index.js`(视频列表 `has_content`) | `EXISTS(SELECT 1 FROM account_persona …)` 写在**同一条大 SQL** 里 | **platform 档**下该子查询换成 `0`,取回本页后**插件内补**(`store.accountIdsWithPersona`);`shadow`/`local` 档**仍走本地子查询**(零回归) | 读档 |
| 6 | `lib/host/mcn/index.js`(`creative_log` 计数) | `INSERT INTO creative_log (created_time, source)` | `await store.insertCreativeLog(row, { local })` | 写档 |
| 7–9 | `lib/host/mcn/index.js` ×3(`/mcn/api/account/persona`、脚本列表、账号详情) | 阶段一已接 `store.readRows`,但 `keyOf` 是 `persona_id ?? analysis_time` ⇒ **本地行算不出**(本地无 `persona_id`) | `keyOf = store.personaKeyOf`,并给三处本地 SELECT 补上 `account_id`(两侧共用同一键函数,影子读这才比得齐) | 读档(复核) |
**新增/改动的导出(`store.js`)**:`WRITE_MODES` · `dataPlaneWriteMode(table?)` · `setDataPlaneWriteMode(mode, table?)` ·
`setDataPlaneMode(mode, table?)`(**合并写**,修掉了原来"写一次覆盖整份文件"的隐患) · `clearPlaneMode(table?)` ·
`planeSummary()` · `readsPlatform/writesPlatform(table)` · `writeThrough({what,table,platform,local})` ·
`insertPersona` · `removePersonaByAccount` · `insertCreativeLog` · `personaKey/personaKeyOf` ·
`accountIdsWithPersona` · `JSON_COLUMNS` · `repairJsonText` · `canonicalJsonText`。
(`insertPersona` 原为"夹具口",改名 **`insertPersonaDirect`** 并注明"直连平台、不看档位"。)
---
## 四 本棒的三个技术判定(自决 · 可推翻)
1. **写路径 ⛔ 不回落本地**(与读路径的 `degraded` 回落**方向相反**)。读回落只是"看到旧数据";
写回落是"**写到了没人读的地方**"(调用方拿到成功、界面不报、平台侧永远是旧值)⇒ 宁可失败可见。
单测 ㉑ 钉住:"local 回调一次都不许跑"。
2. **`json` 列按附录 C 的 A 案(值清洗)落码**,且放在适配层**唯一咽喉**(`normalizeRow`):
字符串是合法 JSON ⇒ 原样(⛔ 不二次包装);非法(Markdown 正文)⇒ 包成 JSON 字符串;
非字符串 ⇒ 原样。**被清洗的列名具名进 `coerced[]`**(⛔ 不静默改数据)。
本棒真机:**4/4 行被清洗**,原文一字不丢(`personaKeyOf` 两侧算得出同一个键即反证)。
⚠️ 案 A 的已知代价(读侧两形态)如实保留;**案 B(改声明 `text`)仍⛔ 未执行**(须删库 ⇒ 待拍板 ㈢/㈣)。
3. **级联删除的顺序**:`account_persona` 的平台删除放在**本地事务之前**。理由:本地事务管不了平台写,
"先本地后平台"一旦平台失败 ⇒ "本地已删、平台还在"(平台档下读回来**看起来没删**);
反过来先删平台、本地再失败 ⇒ 重试一次即收敛(`removeWhere` 幂等)。逐账号**具名收集**进
`platform[]`(`ok/matched/removed/failed[]`),⛔ 不中断整批。
---
## 五 卫生(A7 逐条)
- ⛔ 未改 `D:/github/dsh_shenxian/src/**`;⛔ `src/web/routes/im.ts` 零改动(本棒未碰 `src/`)
- ⛔ 未手改用户 `home` 下的 profile / `.pnpm`(**唯一写入** = 该用户自己的插件数据目录
`<home>/.dsh/data-plane.json` —— 这是阶段一设计的**插件自有档位文件**,也是"一键回滚"的落点)
- ⛔ 未重启任何服务(47 实例 / 106 / nginx 全未动);⛔ 未 commit / push
- ⛔ **未投放**任何版本到候选池或共享层(共享层仍 `0.4.0`)
- ✅ 临时 admin 会话:建 **1** → 删 **1** → 残留 **0**(`SESS_DELETED=1 SESS_LEFT=0`)
- ✅ 47 旧库**零改动**(sha256 前后逐字一致);✅ 未删任何库(⛔ 全程无 `DROP`)
- ✅ 探针落 `/tmp/p28/**`(收口清理,见 §七)
---
## 六 未闭合项(逐条:现象 / 归属 / 下一步)
| # | 现象 | 归属 | 下一步 |
|---|---|---|---|
| 1 | **实例内端到端未取证**(插件路由 `/mcn/api/account/persona` 仍跑 `0.3.15` 旧码) | 部署面 | 需 **47 实例重启**(待拍板 ㈠);无窗口则 A2 只能取"取数口"读数 |
| 2 | 出包 `0.5.0` / 建库 / 投放 / 两机一致(S7–S11)**全未开工** | 部署面 | ⛔ 投放会让新版**对所有登录用户可见可启**(R5 扩面)⇒ 与 ㈠ 一起排 |
| 3 | `creative_log` 无任何读点 ⇒ 切档后**无法用"读数"证明**,只能证明"写请求真到了平台" | 插件侧 | 后续批次给该表补一个统计读口,或接受"写侧单边取证"(本棒如实声明) |
| 4 | 其余 9 张表(`hot_accounts` 拆表 + 12 处 JOIN + 4 处 OFFSET + 2 处聚合、`account_videos` 362 行)未切换 | 插件侧 | 排后续批次,按「表」推进(见入口 §2) |
| 5 | `json` 案 B(声明改 `text`)仍待拍板 ⇒ 读侧**两形态**长期存在 | 平台侧 / 决策 | 待拍板 ㈢㈣;⛔ 未拍板一律不执行删库 |
---
## 七 探针与证据落点
| 件 | 位置 |
|---|---|
| 本机:迁移工具自证(假 fetch) | `WS/tmp/p28/local-migrate-check.mjs` |
| 47:真机探针(fetch 垫片 + 迁移 + 影子读 + 切档) | `WS/tmp/p28/run-p28.mjs` → 47 `/tmp/p28/run-p28.mjs` |
| 47:编排脚本(指纹 / mksess / 清理) | `WS/tmp/p28/orchestrate.sh` |
| 47:探针上传的包副本(md5 已核对逐字一致) | 47 `/tmp/p28/pkg/**`(`store.js b6b48b9b…`、工具 `950e8a5f…`) |
| 47:只读取证 | `WS/tmp/p28/probe-a.sh`、`probe-b.sh` |
> 🔴 探针用的是 Phase 25 已验证的**合法通道**(会话 Cookie + `x-dsh-im-plugin-id`);
> node `fetch` 改不了 `Host` 头(会静默丢 ⇒ 假 404)⇒ 垫片改走 `http.request` 并**剥掉实例 token 头**。
> 本探针跑的是**包内真实代码**(`store.js` / `tools/migrate-local-to-platform.mjs`,md5 与本机逐字一致),
> 不是"另写一份等价逻辑"。
---
## 八 下一棒建议(唯一)
**第 29 棒 · 执行棒 = 第二批按表切换**:`account_analysis`(与 `account_persona` **同构** ⇒ 直接复制本轮口径:
2 写 + 1 删 + 计数读,可复用 `personaKey` 的写法)+ `video_analysis`(补写/删 + 迁移;**12/12 行非法 JSON**
⇒ 正好检验 A 案清洗的规模面)。链路同本棒四步:**全部读写点 → 迁移(幂等)→ `shadow` 真机读数 → 切档**。
⚠️ 仍⛔ 不出包、⛔ 不投放、⛔ 不动删库候选;`account_videos` / `hot_accounts`(JOIN/OFFSET 主战场)留第三批。