Files
dsh_ai1net_server/交付物/MCN数据面接入-阶段二-第一批按表切换-20260925.md
admin 318430c9e9 chore(工作区): 插件投放与分库线收口入库(第 35 棒 + 22:44 拍板落盘)
范围 = 本线(插件投放与分库线)产物 + 记忆类,共 27 件:
- 接续入口_插件投放与分库线(1 件):§0 新增 22:44 拍板行;§2 第 36 棒范围改 5 步、条件步转无条件
- 交付物(22 件):MCN 数据面接入阶段一/阶段二系列(含 B 案落地与 shadow 读数)、
  P0 修通与移动端真机验收、pnpm-EPERM、两机 lib 差异、共享层台账语义、
  基础插件身份与回滚、插件接入验收、插件数据面取数口、移动端迁包与字号扩面、跨机错误消息
- 记忆(4 件):2026-09-24 / 2026-09-25 日志、MEMORY.md、本棒 automation 执行记录

⛔ 未含他线在途改动(只报告、不代提交):机制层 CODEBUDDY.md / state.py /
.codebuddy/rules/server-ops.md、接续入口_IM线、接续入口_StoryForge验收线、
其余 29 个 automation 目录、docs/规则与载体/、归档/、接续包_*。
2026-09-25 23:06:29 +08:00

161 lines
14 KiB
Markdown
Raw Permalink 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.
# 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 主战场)留第三批。