范围 = 本线(插件投放与分库线)产物 + 记忆类,共 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/规则与载体/、归档/、接续包_*。
16 KiB
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 顺带闭合的两处(⛔ 不是扩大范围,是不修就留坏态)
- 🔴
videoAnalysisCountOf的 OR 语义(本棒自证时抓到):初版写成byAweme.get(a) + byVideo.get(v)(两路相加)——而本地语义是WHERE video_id=? OR aweme_id=?⇒ 同一行两键都命中时被计两次(本地 1、这里 2)。已改为逐行 OR 判定,并以单测 ㉚ 钉住({awemeId, videoId}同视频 ⇒ 1,两个不同视频 ⇒ 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 件(最新 mtime12: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 棒 · 执行棒)。判据命令与输出原文均取自本轮真机执行,未转述。