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

151 lines
16 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 数据面接入 · 阶段二 · **第二批按表切换**(`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`** —— 唯一写入 = 该用户**自己的插件档位文件** `<home>/.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 棒 · 执行棒)。判据命令与输出原文均取自本轮真机执行,未转述。