# MCN 数据面接入 · 阶段二 · **第二批按表切换**(`account_analysis` + `video_analysis`) > **执行会话**:插件投放与分库线 · 第 29 棒(2026-09-25 15:1x–15:3x) > **唯一执行单**:`D:/github/dsh_shenxian/dsh-server-docs/05-交接单/插件投放与分库线-03MCN数据面接入改造-阶段二.md`(§五 S4–S6 / §六 A1–A8) > **变更集**(7 件,全在插件包内):`lib/host/mcn/{store,index,imports,workshop,data}.js` + `tools/migrate-local-to-platform.mjs` + `tests/mcn-store.test.mjs` > **§0 前提**:第一批(`account_persona` + `creative_log`)已于第 28 棒切换完毕并在位;本棒**只做第二批**,⛔ 未顺手切第三批。 --- ## 一 结论 **第二批按表切换完成,四步链路走完,真机读数全绿。** | 步 | 结果 | |---|---| | ① 该表**全部**读写点接线 | ✅ `account_analysis` 6 处 + `video_analysis` 10 处,逐处落点见 §三;剩余命中**全部**是「`local` 回调 / schema 维护 / 具名降级」,见 §四 | | ② 迁移(幂等) | ✅ 本地 `account_analysis` 1 → 平台 1;本地 `video_analysis` 12 → 平台 12;重跑 `ins=0 / skip=1`、`ins=0 / skip=12` | | ③ `shadow` 真机读数 | ✅ 4 次影子读**全部 `match=true`**、`shadowMismatch=0`、`degraded` 一次未出现、平台 44 次调用**全 ok / 0 失败** | | ④ 按表切档 | ✅ 只切这两张(+保留第一批),`hot_accounts` / `account_videos` **仍 `local`** | **离线判据**:单测 `40/40 通过`(31 → 40,新增 9 例);`node --check` 7 文件 `rc=0`;旧表名字面量 `0`(A4)。 🔴 **未闭合(⛔ 不宣称通过)**:实例内端到端(**需 47 实例重启** ⇒ 待拍板 ㈠)、出包/投放/两机一致(S7–S11)、红线归零(S10,属第三批收口)。逐条见 §六。 --- ## 二 判据表(A1–A8 · 命令 + 输出原文 + 退出码) | # | 判据 | 命令(在 47 上跑) | 输出原文(关键行) | rc | |---|---|---|---|---| | **A1** | **存量迁移一致性(逐表)** | `P29_SID=… P29_HOME=… node --experimental-sqlite /tmp/p29/run-p29.mjs`(§四 口径) | `compare.account_analysis=[{account_id:"1627", local_n:1, platform_n:1, onlyLocal:[], onlyPlatform:[], match:true}]`;`compare.video_analysis={local_n:12, platform_n:12, truncated:false, onlyLocal:[], onlyPlatform:[], match:true, local_rows_without_aweme:0}` | 0 | | **A1′** | **内容往返**(本棒加严:A1 只判计数+主键集合,内容不判会漏掉 `json` 清洗问题) | 同上 | `roundtrip_va={n:12, present:12, equal:12, mismatches:[], plat_types:["string"], plat_unwrapped_is_markdown:12}`;`roundtrip_aa={present:true, equal:true, plat_type:"string"}` | 0 | | **A2** | 全表读写 E2E(**实例内**) | 需实例重启 | ⛔ **未达成**(见 §六 ㈠)—— 本棒只取到「平台取数口」读数 | — | | **A3** | 红线归零 | `cd $PKG && grep -rEn "CREATE TABLE\|ALTER TABLE\|DROP TABLE\|better-sqlite3\|from 'pg'\|require\('pg'\)" . \| grep -v /node_modules/ \| wc -l` | **23**(= 阶段一 22 + 第 27 棒表名对齐 `ALTER` 1);**本棒零新增**(本棒未加任何 DDL / 驱动依赖) | 0 | | **A4** | 表名对齐彻底 | `grep -rE "account_video_analysis" $PKG \| grep -v /node_modules/ \| wc -l` | **0**(旧名仅以**拆写常量**存在于 `db.js` 与迁移工具,用于**读本地存量旧表名**) | 0 | | **A5** | 旧库备份在位 + 零数据损失 | 47:`sha256sum "$DB"`(迁移前后各一次)+ `ls /opt/dsh/backups/plugin-data/dsh-plugin-mcn-suite/` | 迁移前 `b37f8aab51a90f7481eb321e50456ca347635bcf2bfce567ea4200d88c18277a`;迁移后 **同值**;`size=2383872 mtime=2026-09-20 23:05:05` 未变;备份 `cce6d1cd-…-mcn-plugin-db-20260925-141436.tar.gz`(809,732 B)仍在位 | 0 | | **A6** | 移动端五条 | `08-规范 §4-补 ④` | **免跑** —— 依据 = 变更集 7 件中 **无 `lib/client.js`**(`grep` 改动清单可验),即**未改 UI 代码** | — | | **A7** | 卫生 | `git -C D:/github/dsh_shenxian status --short -- src/` + `find src -newermt "2026-09-25 15:15"` | `src/` 变更 **0 件**(最新 mtime `12:20`,早于本棒 15:15 开工)|`src/web/routes/im.ts` 零改动|未重启任何服务|未 commit·push|临时会话 `1 → 删 1 → 残留 0`|47 `/tmp/p29*` 残留 **0** | 0 | | **A8** | 两机一致(代码面 / 运行面**分开记**) | — | **均未开工**:本棒**未出包、未投放**(插件包仍 `version 0.4.0`;共享层仍 0.4.0)⇒ 代码面/运行面**都还是旧的**,⛔ 不得写"一致" | — | ⚠️ **⛔ 不许只报"全部通过"**:A2 / A8 明确**未达成**,理由与归属已逐条写明(§六)。 --- ## 三 逐处落点表(本棒接线面) ### 3.1 `account_analysis`(6 处 · 与 `account_persona` 同构) | # | 位置 | 原形态 | 改后 | |---|---|---|---| | 1 | `imports.js` `importExternalDir` 的 `else`(账号数据分析.md) | `SELECT id … ` + `INSERT …` | `store.insertAnalysis(row, { local })`——`local` 档回调里是**原样两条 SQL** | | 2 | `imports.js` `importExternalData` 的 `an.type === "account"` | 同上 | 同上 | | 3 | `imports.js` `previewExternalDir` 的 `checkMd(…, "account_analysis")` | `SELECT id … content_json=?` | `store.analysisExists({table, accountId, content})`(平台档按内容比对;读失败**具名**回落本地) | | 4 | `index.js` `taskDone` 的 `dsh_acctana-` 分支 | `SELECT COUNT(*) … account_id=?` | `store.readCount({ table:"account_analysis", platform: countAnalysis, local: 原 SQL })` | | 5 | `index.js` `/mcn/api/account/analysis` | `SELECT content_json … ORDER BY id DESC LIMIT 1` | `store.readRows` + `store.latestAnalysis()`(平台拉一页后**插件内按 `analysis_time` 归约**);`content` 过 `store.jsonTextToRaw()`(案 A 读侧兼容) | | 6 | `workshop.js` `batchDeleteCascade`(账号连坐) | `DELETE FROM account_analysis WHERE account_id=?` | 平台侧 `store.removeAnalysisByAccount()`(D9 两步)**先**执行,本地事务里那条 SQL **保留**(本地保持一致、保回滚面) | ### 3.2 `video_analysis`(10 处 · 主键 = 天然键 `aweme_id`) | # | 位置 | 原形态 | 改后 | |---|---|---|---| | 1 | `imports.js` `importExternalDir`(拆解分析.md) | `SELECT dup` + `INSERT` | `store.writeVideoAnalysis(row, { local })`,语义 = **去重** | | 2 | `imports.js` `importExternalData` 的 `an.type === "video"` | `SELECT dup` + `DELETE` + `INSERT` | 同上但 `{ replace: true }`,语义 = **删旧保新** | | 3 | `imports.js` `previewExternalDir`(拆解分析.md) | `SELECT id … content_json=?` | `store.analysisExists({table:"video_analysis", awemeId, videoId, content})` | | 4 | `index.js` `taskDone` 的 `dsh_video-analyze-` 分支 | `COUNT(account_video_source) + COUNT(video_analysis)` 并列 | `account_video_source` 那半**照旧本地**(第三批);`video_analysis` 那半走 `store.countVideoAnalysis()` | | 5 | `index.js` 视频总表的 **`parsed` 过滤条件**(进 `WHERE`) | `v.id IN (SELECT video_id FROM video_analysis)` | 平台档:`loadVideoAnalysisIndex()` 拉全量 ⇒ 拼 `v.id IN (<字面 int 列表>)`;索引不可用 ⇒ **具名降级回本地子查询** | | 6 | `index.js` 视频总表的 **`has_analysis` 输出列** | 同一条 SQL 里的子查询 | 平台档下该列**不进 SQL**(`0`),取回本页后由索引在**插件内补**(与第 28 棒 `has_content` 同法) | | 7 | `index.js` `/mcn/api/video/detail` | `SELECT … WHERE aweme_id=? OR video_id=? ORDER BY id DESC LIMIT 1` | `store.readRows` + `store.latestVideoAnalysis()`(先按 `aweme_id` 直取,再退 `video_id` 归约) | | 8 | `index.js` 桌面统计(`analyses`) | `SELECT COUNT(*) FROM video_analysis` | `store.readCount({ table:"video_analysis", platform: countRows, local: 原 SQL })` | | 9 | `workshop.js` `deepVideos` | `DELETE … WHERE aweme_id=?` + `WHERE video_id=?`(本地事务内) | **平台侧在 `BEGIN` 之前**先按两 scope `store.removeVideoAnalysis()`;本地两条 SQL 保留 | | 10 | `data.js` 账号视频列表的平台档补列 | 平台档下 `has_analysis` 走**本地** `COUNT` | 平台档改用 `loadVideoAnalysisIndex()`+`videoAnalysisCountOf()`(`has_source` 仍本地,第三批) | ### 3.3 顺带闭合的两处(**⛔ 不是扩大范围,是不修就留坏态**) 1. 🔴 **`videoAnalysisCountOf` 的 OR 语义**(本棒自证时抓到):初版写成 `byAweme.get(a) + byVideo.get(v)`(两路相加)——而本地语义是 `WHERE video_id=? OR aweme_id=?` ⇒ **同一行两键都命中时被计两次**(本地 1、这里 2)。已改为**逐行 OR 判定**,并以单测 ㉚ 钉住(`{awemeId, videoId}` 同视频 ⇒ **1**,两个不同视频 ⇒ 2)。 2. 🔴 **`previewExternalDir` 的三处「是否已存在」判据**:其中 `account_persona` 那处是**第 28 棒的遗留读点**(该表已切,但预览判据仍读本地 SQL ⇒ "平台已写、预览还说不重复")。本棒把三处统一走 `store.analysisExists()` 并让该函数 **async**。 ### 3.4 一处**按表档连贯性**修复 `data.js` 的视频列表读(`account_videos`)**阶段一没接按表档**(当时还没有按表档)⇒ 该表的档位覆盖对它不生效。本棒补上 `table: "account_videos"`(全局档仍是 `local` ⇒ **行为不变**)。 --- ## 四 具名豁免(剩余命中为什么不算"没切") | 类别 | 位置 | 为什么豁免 | |---|---|---| | **`local` 回调** | `imports.js` 4 处、`index.js` 2 处、`workshop.js` 3 处的 `d.prepare(...)` | 它们就是**写源档 `local`(默认)那一半**:按设计保留原 SQL 逐字不变 ⇒ 默认档行为与 0.3.15 完全一致(可单测) | | **schema 维护** | `db.js` 的 `sqlite_master` 查询 / `ALTER TABLE RENAME` / `PRAGMA` / 一次性 `UPDATE` 回填 | 属**本地 SQLite 生命周期**,不是"按表的数据面读写点";`db.js` 的删除是阶段二 **S10**(须 A1+A2 全绿后)⇒ 届时该文件整体退场 | | **具名降级** | `imports.js` 预览的本地回退、`index.js` 索引不可用时的本地子查询、`data.js` 的本地补列 | 只在**平台读失败 / 索引 `truncated`** 时触发,且每次都 `console.warn` **具名**(⛔ 不静默)—— 这是"不拿半份数据冒充全量"的口径,不是遗漏 | | **第三批** | `account_video_source` / `hot_accounts` / `account_videos` / `rewrite_log` / `script_review` / `storyboard_log` / `hot_account_ext` 的全部落点 | ⛔ 本棒按接续入口**只做第二批**;第三批 = `hot_accounts` 拆表 + 12 处 JOIN + 4 处 OFFSET + 2 处聚合(主战场) | ### 4.1 一个**必须记档**的实测事实 🔴 **本地库的表名还是旧名**:`sqlite_master` 现算 = `…,account_video_analysis,…`(22 字符旧名),**没有** `video_analysis`。 原因:第 27 棒在 `db.js` 的 `initDb()` 里加的是**幂等改名**,而它只在**插件新代码跑起来之后**才生效 —— 实例仍载 `0.3.15` ⇒ 改名尚未发生。 影响:**迁移工具必须两种名字都能读** ⇒ 已加 `resolveLocalTable()`(按 `sqlite_master` 现算,平台名不存在才回退旧名;旧名按**拆写常量**书写以守住 A4 字面判据)。 ⚠️ 后棒注意:实例一旦跑起新版,该表会被改名;`resolveLocalTable` 两种形态都能读,⛔ 不要写死。 --- ## 五 迁移读数(逐表) | 表 | 本地 n | 计划 n | 首跑插入 | 重跑 | 坏行 | 被剥列 | 清洗列 | 平台 n(前 → 后) | |---|---|---|---|---|---|---|---|---| | `account_analysis` | 1 | 1 | **1** | `ins=0, skip=1` | 0 | `0:id` | 1(`content_json`) | 0 → **1** | | `video_analysis` | 12 | 12 | **12** | `ins=0, skip=12` | 0 | `0..11:id` | 12(`content_json`) | 0 → **12** | - 稳定键:`account_analysis` = `analysisKey(账号, 正文)`(UUIDv5,可复算;实测 `e5b7d9a1-826f-55c0-9de6-9eb429070b0b`);`video_analysis` = **天然键 `aweme_id`**(⛔ 不生成)。 - **`video_analysis` 的平台主键不可为空**:`aweme_id` 为空的行平台侧落不了 ⇒ 迁移会**具名拒收**(`badRows`),⛔ 不静默丢。**本地实测 0 行**(`local_rows_without_aweme=0`)⇒ 本次无人被拒。 - `json` 列按**附录 C 的 A 案**清洗(13 行全部被包成 JSON 字符串,**具名**进 `coerced[]`);⛔ **未执行任何删库**。 - 迁移守「先备份 + 先出清单」(第 27 棒已在位,本棒**未重做**);本地库 sha256 迁移前后**逐字一致**。 --- ## 六 未闭合项(逐条:现象 / 归属 / 下一步) | # | 现象 | 归属 | 下一步 | |---|---|---|---| | ㈠ | **实例内端到端未取证**:实例仍载 `0.3.15`(实例启动早于本棒改动);本棒 host 代码虽 `lib/client.js` 零改动,但 `lib/host/mcn/**` 已是新版 —— 实例里跑的还是旧 host 码 | 部署面 | **需 47 实例重启**(影响 1 个在线用户 `cce6d1cd-…`,约 1–3 分钟)⇒ 待拍板 ㈠ | | ㈡ | **出包 `0.5.0` / 投放 / 两机一致(S7–S11)全未开工**(包仍 `version 0.4.0`;共享层仍 0.4.0) | 部署面 | 投放会让新版对**所有登录用户可见可启**(扩面)⇒ 与 ㈠ 一起排 | | ㈢ | **红线仍 23**(未归零) | 插件侧 | 属阶段二 **S10**(删 `db.js`),前置 = A1+A2 全绿 | | ㈣ | **第三批未动**:`account_videos`(362 行)+ `hot_accounts`(**拆表 + 12 处 JOIN + 4 处 OFFSET + 2 处聚合**=主战场)+ `account_video_source`/`rewrite_log`/`script_review`/`storyboard_log`/`hot_account_ext` | 插件侧 | 排第三棒(见入口 §2「⏭️ 本线后续」) | | ㈤ | `json` 列**案 B**(改声明 `text`,须**删库重建**)仍待拍板 | 待用户拍板 | 越晚做代价越高(迁完再删 = 丢真实数据) | | ㈥ | 平台侧观察项(沿用第 25 棒):非法 `json` 值返回 `reason:"disabled"`,语义误导(应为 `bad-args`/`invalid-value`) | **平台侧**(⛔ 本棒不改 `src/**`) | 另线立项 | --- ## 七 卫生声明(A7 逐条) - ⛔ **未改 `D:/github/dsh_shenxian/src/**`** —— `find src -newermt "2026-09-25 15:15"` = **0 件**(最新 mtime `12:20`);`src/web/routes/im.ts` 零改动。 - ⛔ **未改用户 `home` 下的 profile / `.pnpm`** —— 唯一写入 = 该用户**自己的插件档位文件** `/.dsh/data-plane.json`(即阶段一设计的**回滚落点**);本地库 `mcn-plugin.db` **只读打开**(`readOnly: true`)。 - ⛔ **未重启任何服务**(47 实例、106、dshs 均未动);⛔ **未 commit / push**;⛔ **未投放任何版本**(共享层仍 0.4.0,包仍 0.4.0)。 - ⛔ **未删任何库**;⛔ 未删旧库 `mcn-plugin.db`。 - ✅ **临时 admin 会话** `1 → 删 1 → 残留 0`(`user_agent='poc-curl2'` 清理)。 - ✅ **47 `/tmp/p29*` 残留 0**(探针目录已在收口时 `rm -rf`)。 - ✅ 改动面**恰 7 件**(见头部变更集);`lib/client.js` 零改动 ⇒ A6 免跑。 --- ## 八 下一个棒(唯一) **第三批按表切换**(`account_video_source` + `hot_accounts` 拆表读写 + `account_videos`)—— ⚠️ 这批是**主战场**(12 处 JOIN / 4 处 OFFSET / 2 处聚合),且 `hot_accounts` 必须**拆表**(本地 25 列 > 平台 `MAX_COLUMNS=20`)⇒ 建议**先出一份规划棒**把「读改写(两次单表查 + 插件内合)+ 写改写(两表分写、无事务、具名 partial)」的口径定死再动手。 **本棒落地件**:本文件|探针 = `tmp/p29/{pre-a.sh,pre-b.sh,run-p29.mjs,orchestrate.sh,local-migrate-check.mjs}`(本机副本;`run-p29.mjs` 亦曾落 47 `/tmp/p29/`,已清)。 --- **变更记录**:2026-09-25 15:1x 立(第 29 棒 · 执行棒)。判据命令与输出原文均取自本轮真机执行,未转述。