26 KiB
Weixin 群聊记录同步到 Neta 本地存档(weixin-archive sync)
Status: Draft 2026-05-13 Owner: 与 lixin 共识 关联 spec:
2026-05-08-weixin-group-channel-design.md(架构 C) 关联 plan:2026-05-12-weixin-db-channel.md(本 spec 的前置)
1. 背景与目标
架构 C 的 weixin-db 渠道已经把"实时监听 + 路由到 Agent"打通(WeixinDbService.bindChannel + WalWatcher + IncrementalReader + routeInboundMessage)。但当前体验有两个问题:
- 历史消息丢失:bindChannel 只读 wal mtime 触发后的新行(
WHERE create_time > MAX(create_time) at bind),群里之前已有的几千条历史完全读不到。 - 依赖 Weixin 在线:任何"查看群聊天记录"都要 Weixin 跑着、key 还有效、IncrementalReader 在线。Weixin 退出 / 关机 / key 过期 → 全部不可用。
本 spec 引入独立的 archive 同步管线:用户在群聊管理页点"同步聊天记录"按钮 → 后端把该群所有历史消息从 WCDB 解出来,落地到 neta 自己的 SQLite 文件。
核心原则:
- archive 与监听是两条独立链路,共用底层 wcdb_codec / room_resolver / message_repo,但不互相阻塞、不共享状态。
- archive 文件是永久存档;Weixin 自己清理旧消息后,neta 这边仍可查。
- archive 只做"读 → 解 → 落地",不做查看 UI、不解附件文件内容、不混排到 Agent session。这些是后续独立 spec。
显式 out-of-scope(避免 scope creep):
- ❌ 查看/展示 UI(按钮、抽屉、聊天回放页)
- ❌ 附件文件解密(图片 .dat / 语音 SILK / 视频 / 收藏)
- ❌ 把存档消息回放到
/agent/chatsession - ❌ 跨 channel 合库 / 全文搜索
- ❌ 实时监听管线的任何改动(walWatcher / IncrementalReader / agent_channel.routeInboundMessage 不动)
2. 用户视角
2.1 触发
群聊管理面板每个群卡片上,在原"查看对话记录"按钮旁边新增一个按钮 "同步聊天记录"。
注:原"查看对话记录"按钮当前会跳到一个并不存在的 chat session(报
Session not found)。本 spec 不动这个按钮(它的修复属于后续"查看"spec)。本 spec 只新增"同步"按钮。
点击后:
- 按钮变 loading + 文案"同步中..."
- 整个群卡片其它操作禁用,避免重复点
- 后端阻塞执行,5-30s 后返回结果
- toast 提示:"已同步 N 条新消息(总计 M 条)"
2.2 同步范围
| 触发时机 | 行为 |
|---|---|
| 首次 (sync_state.last_sync_ts === 0) | 全量:WHERE create_time > 0,把该群在 WCDB 里能看到的所有历史消息都拉一遍 |
| 后续 | 增量:WHERE create_time > sync_state.last_sync_ts,只拉自上次同步后的新消息 |
一万条群首次同步预计 5-15 秒(本地 SQLite + zstd 解压 + 写入)。增量同步通常秒级。
2.3 错误反馈
| 失败场景 | 提示 |
|---|---|
| 频道未连接 (loginStatus ≠ connected) | "频道未连接,请检查 PC 微信是否登录" |
| 群不在 WCDB(对方退群 / 群被解散) | "未找到该群消息,可能群已不存在" |
| key 已失效(Weixin 升级 / 重登) | "登录态失效,请重新触发频道连接" |
| 解密失败 | "解密失败,请联系管理员"(error 写日志) |
| 非 Windows 平台 | "本功能需要 Windows + PC 微信" |
3. 整体架构
[群聊管理 frontend]
│ 点击"同步聊天记录"按钮
▼
POST /admin/netaclaw/agent_channel_group/sync { groupId }
│
▼
[NetaClawAgentChannelGroupController.sync]
│
▼
[WeixinArchiveSyncService.syncGroup(groupId)]
│
├─ 加 channel-level mutex(同 channel 串行,避免双写 sqlite)
│
├─ 取 group + channel; 校验 channel.type === 'weixin-db' && loginStatus === 'connected'
│
├─ 取 WeixinDbService.runtimes.get(channelId) 拿到已 bind 的 reader/key/paths
│ ↑ archive 不重新 spawn ps1, 复用 bindChannel 已抽好的 key
│
├─ ArchiveDb.openOrCreate(channelId) → <dataDir>/weixin-archive/cid-<id>.db
│ └─ CREATE TABLE IF NOT EXISTS message / sync_state
│
├─ 解密 message_0.db → 临时明文 (★ 独立 work 目录, 见 §3.1 工作目录隔离)
│
├─ 用 IncrementalReader.getRoomResolver().findRoomsByName(group.roomName)
│ → 拿到 Msg_<sha> 表名 + roomInfo
│ ↑ 复用 bindChannel 时已加载好的 session.db + contact.db 映射;
│ archive 自身不再解 session.db/contact.db (只解 message_0.db),
│ 减少一半 IO 且避免与监听链路争抢 RoomResolver 重建。
│
├─ lastSyncTs = ArchiveDb.getSyncState(roomId).lastSyncTs (首次=0)
│
├─ 分批 SELECT * FROM Msg_<sha> WHERE create_time > ? ORDER BY create_time ASC LIMIT 500
│ └─ 流式分页,避免一次加载万条
│
├─ 对每行: zstd 解 message_content → 解析 sender prefix → 投影成 ArchiveMessage
│
├─ INSERT OR IGNORE INTO message (...) [batch transaction]
│ └─ UNIQUE(room_id, local_id) 保证幂等
│
├─ UPDATE sync_state SET last_sync_ts = MAX(create_time), last_sync_count = N, last_error = NULL
│
├─ 任意失败 → catch 块尝试 best-effort 写 sync_state.last_error 后重新 throw
│
├─ 释放锁 (try/finally 保证 archive db + repo 都 close)
│
└─ 返回 { newCount, totalCount, durationMs, lastSyncTs }
关键复用:
wcdb_codec.decryptDatabase—— 解密 WCDB(Phase C-2 Task 7)RoomResolver.findRoomsByName(新增,本 spec)—— roomName → 群信息(支持同名歧义)MessageRepo.listSince—— 流式 SELECT + zstd 解(Phase C-2 Task 11)- 公共
decryptDbToWorkDir(从 IncrementalReader 抽出)—— 拷贝 + 解密整 db WeixinDbService.getRuntime(cid)(新增,本 spec)—— 已 bind 的 reader / paths / messageKey
archive 不重新 spawn ps1 抽 key —— 必须先 bindChannel 成功。这意味着同步按钮的前置条件是"频道已连接"。
3.1 工作目录隔离 (★ 关键)
IncrementalReader 监听过程每 ~500ms 把 message_0.db 解密覆盖写到 <dataDir>/weixin-db-work/cid-<id>/decrypted/message_0.db。如果 archive sync 复用同一个 workDir:
监听线程: fs.writeFileSync(workDir/decrypted/message_0.db, ...) ← 持续覆盖
archive 线程: better-sqlite3 readonly open(workDir/decrypted/message_0.db) ← 同一文件
会读到撕裂的文件(写到一半被读)→ 可能 SIGBUS / corrupt page / wrong key 误报。
强制约束:archive 必须用独立子目录,本 spec 固定为:
<dataDir>/weixin-db-work/cid-<id>/ ← 监听 (IncrementalReader)
<dataDir>/weixin-db-work/cid-<id>/archive/ ← archive sync (本 spec)
实现:WeixinArchiveSyncService.doSyncGroup 计算 workDir = path.join(dataDir, 'weixin-db-work', \cid-${cid}`, 'archive'),传给 decryptDbToWorkDir`。
监听链路代码不动(它已经在用 cid-<id>/ 根目录)。仅 archive 进入子目录。
3.2 资源释放与失败恢复
- archive sqlite handle 和 MessageRepo 都用
try/finally保证close(),不能只 close 一个。 - 任意异常 → 在解出
room.username之后的失败,best-effort 写sync_state.last_error(再失败也吞掉,不掩盖原异常)。解出 room 之前的失败只 log,不写 sync_state(无 room_id 主键)。 - channel-level mutex 的释放必须放
finally,即使 doSyncGroup 抛错也要释放,否则下次同 channel 永远等待。
4. 数据模型
4.1 文件位置
<dataDir>/weixin-archive/
├── cid-6.db ← channel 6 (各群消息合存一文件)
├── cid-6.db-wal ← SQLite WAL (better-sqlite3 默认 WAL mode)
└── cid-7.db ← channel 7
每个 channel 一个独立 db 文件。理由:
- 频道删除时 cascade rm 整个文件,不留垃圾
- 单 channel 同步只锁自己的文件,跨 channel 并发互不影响
- 备份/迁移单 channel 简单
4.2 表结构
-- 消息主表
CREATE TABLE IF NOT EXISTS message (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL, -- 冗余,便于以后跨库合并
room_id TEXT NOT NULL, -- 群 username,如 xxx@chatroom
room_name TEXT, -- 同步时刻的显示名 (可能变,仅参考)
msg_table TEXT NOT NULL, -- 来源 'Msg_<md5>',方便回查源
local_id INTEGER NOT NULL, -- WCDB local_id
server_id TEXT, -- WCDB server_id (BigInt → TEXT 避免精度丢失)
local_type INTEGER NOT NULL, -- 1 文本 / 3 图片 / 34 语音 / 43 视频 / 49 应用消息(含引用) ...
sender_wxid TEXT, -- 解析自 message_content "wxid_xxx:\n" 前缀
create_time INTEGER NOT NULL, -- Unix 秒
content TEXT, -- zstd 解压后的 UTF-8 (文本消息=正文; 非文本=完整 XML)
raw_hex TEXT, -- 当 message_content 非 UTF-8 / zstd 解压失败时的兜底 hex (诊断用)
synced_at INTEGER NOT NULL, -- 入档时间戳 (Unix 秒)
UNIQUE(room_id, local_id)
);
CREATE INDEX IF NOT EXISTS idx_msg_room_time ON message(room_id, create_time DESC);
CREATE INDEX IF NOT EXISTS idx_msg_create_time ON message(create_time DESC);
-- 每群的同步水位
CREATE TABLE IF NOT EXISTS sync_state (
room_id TEXT PRIMARY KEY,
room_name TEXT,
last_sync_ts INTEGER NOT NULL DEFAULT 0, -- create_time 高水位
last_sync_count INTEGER NOT NULL DEFAULT 0, -- 上次同步入档数 (新行)
total_count INTEGER NOT NULL DEFAULT 0, -- 该 room 累计存档总数 (本表内 COUNT(*))
last_sync_at INTEGER NOT NULL, -- 上次完成时间戳 (Unix 秒)
last_error TEXT -- 上次失败原因 (成功时清空)
);
字段决策说明:
local_type不做枚举映射,留 raw 数字。WCDB type 体系复杂(49 下还有 sub-type),应用层有需要再解析,数据库层就只是数字。content对于文本消息(type=1)就是消息正文;对于 type 3/34/43/49 等,是完整的 message_content XML(zstd 解后)。这样未来要做附件、引用、卡片解析,不用回头重新读 WCDB —— 数据已经在 archive 里了。raw_hex是兜底 —— 极少数情况 zstd 解失败 / 数据损坏 / 二进制内容,把原始字节存 hex,避免数据丢失。正常 99%+ 的消息只填content不填raw_hex。UNIQUE(room_id, local_id)保证重复点同步幂等:同一条消息再次 INSERT OR IGNORE 不会写重。
5. 后端代码结构
5.1 新增文件
| 文件 | 责任 |
|---|---|
runtime/weixin_db/archive_db.ts |
better-sqlite3 包装:openOrCreate / migrate / insertMessages(batch) / getSyncState / updateSyncState / countMessages |
runtime/weixin_db/archive_decryptor.ts |
抽出 IncrementalReader.decryptToWork 的公共逻辑(拷贝 + 解密整 db → 临时文件)。Refactor: IncrementalReader 改用此模块,不再内联实现。 |
service/weixin_archive_sync.ts |
主服务:syncGroup(groupId): Promise<SyncResult> + channel-level mutex |
test/.../archive_db.test.ts |
表创建 / 重复插入幂等 / sync_state 读写 |
test/.../weixin_archive_sync.test.ts |
mock IncrementalReader + WeixinDbService,跑同步流程 |
5.2 修改文件
| 文件 | 改动 |
|---|---|
service/agent_channel_group.ts |
新增 sync(groupId) 委托给 WeixinArchiveSyncService.syncGroup |
controller/admin/agent_channel_group.ts |
新增 @Post('/sync') endpoint |
service/agent_channel.ts |
delete(ids) cascade 删 <dataDir>/weixin-archive/cid-{id}.db(避免泄漏) |
runtime/weixin_db/incremental_reader.ts |
decryptToWork 提取到 archive_decryptor.ts,本文件 import 复用 |
frontend/.../channel-group-panel.vue |
群卡片新增"同步聊天记录"按钮 + handler + loading 态 |
5.3 接口契约
5.3.1 后端 API
POST /admin/netaclaw/agent_channel_group/sync
Body: { groupId: number }
Response success:
{
code: 1000,
data: {
newCount: number, // 本次新入档消息数
totalCount: number, // 该群累计入档总数
lastSyncTs: number, // 新水位 (Unix 秒)
durationMs: number
}
}
Response error:
{ code: 1003, message: string }
错误码使用现有体系(channel-not-connected / room-not-found / decrypt-failed / unsupported-platform)。
5.3.2 Service 接口
// service/weixin_archive_sync.ts
@Provide()
@Scope(ScopeEnum.Singleton)
export class WeixinArchiveSyncService {
async syncGroup(groupId: number): Promise<SyncResult>;
async deleteChannelArchive(channelId: number): Promise<void>; // delete cascade 用
}
export interface SyncResult {
newCount: number;
totalCount: number;
lastSyncTs: number;
durationMs: number;
}
// runtime/weixin_db/archive_db.ts
export class ArchiveDb {
static openOrCreate(channelId: number, dataDir: string): ArchiveDb;
insertMessages(rows: ArchiveMessage[]): number; // 返回 INSERT OR IGNORE 真插入数
getSyncState(roomId: string): SyncState | null;
updateSyncState(roomId: string, patch: Partial<SyncState>): void;
countMessages(roomId: string): number;
close(): void;
static archivePath(channelId: number, dataDir: string): string; // for cascade delete
}
export interface ArchiveMessage {
channelId: number;
roomId: string;
roomName: string | null;
msgTable: string;
localId: number;
serverId: string | null;
localType: number;
senderWxid: string | null;
createTime: number;
content: string | null;
rawHex: string | null;
}
export interface SyncState {
roomId: string;
roomName: string | null;
lastSyncTs: number;
lastSyncCount: number;
totalCount: number;
lastSyncAt: number;
lastError: string | null;
}
// runtime/weixin_db/archive_decryptor.ts
export function decryptDbToWorkDir(opts: {
srcDb: string;
rawKey: Buffer;
workDir: string;
}): string; // returns plaintext db path
5.4 并发控制
Channel-level mutex,不是 group-level:
private readonly channelLocks = new Map<number, Promise<void>>();
async syncGroup(groupId: number): Promise<SyncResult> {
const group = ...;
const channelId = group.channelId;
const prev = this.channelLocks.get(channelId) ?? Promise.resolve();
let release: () => void;
const next = new Promise<void>(r => (release = r));
this.channelLocks.set(channelId, prev.then(() => next));
await prev;
try {
return await this.doSyncGroup(group);
} finally {
release!();
if (this.channelLocks.get(channelId) === next) this.channelLocks.delete(channelId);
}
}
理由:同一个 cid-X.db 文件 SQLite better-sqlite3 是同步 single-writer,跨群同时写同一 db 会抢锁;channel-level 串行最简单稳妥。跨 channel 并发不互相阻塞。
5.5 同步循环细节
async doSyncGroup(group: GroupEntity): Promise<SyncResult> {
// 1. 前置校验
const channel = await this.channelRepo.findOne({ where: { id: group.channelId } });
if (channel?.type !== 'weixin-db') throw new Error('only weixin-db supported');
if (channel.loginStatus !== 'connected') throw new Error('channel-not-connected');
if (process.platform !== 'win32') throw new Error('unsupported-platform');
const runtime = this.weixinDbService.getRuntime(channel.id); // 新增 getter
if (!runtime) throw new Error('channel-not-bound');
// 2. 解整库到 work
const plainMsgDb = decryptDbToWorkDir({
srcDb: runtime.paths.messageDb,
rawKey: runtime.messageKey,
workDir: path.join(resolveDataDir(), 'weixin-db-work', `cid-${channel.id}`),
});
// 3. 解析 roomName → tableName (用 runtime 已加载好的 RoomResolver)
const matches = runtime.reader.findRoomsByName(group.roomName); // 新增方法
if (matches.length === 0) throw new Error('room-not-found');
if (matches.length > 1) {
// 同名群,警告;按 lastSeenAt 取最近的那个
this.logger.warn('[archive-sync] %d rooms with same name=%s, using most recent',
matches.length, group.roomName);
}
const room = matches[0];
// 4. 打开 archive db
const archive = ArchiveDb.openOrCreate(channel.id, resolveDataDir());
// 5. 取水位 + 分批拉
const t0 = Date.now();
const state = archive.getSyncState(room.username);
const sinceTs = state?.lastSyncTs ?? 0;
const repo = new MessageRepo(plainMsgDb);
let newCount = 0;
let cursorTs = sinceTs;
try {
while (true) {
const rows = await repo.listSince(room.tableName, cursorTs, 500);
if (rows.length === 0) break;
const archiveRows = rows.map(r => projectToArchive(r, channel.id, room));
newCount += archive.insertMessages(archiveRows);
cursorTs = rows[rows.length - 1].createTime;
if (rows.length < 500) break;
}
} finally {
repo.close();
}
// 6. 更新水位
const totalCount = archive.countMessages(room.username);
archive.updateSyncState(room.username, {
roomName: room.roomName,
lastSyncTs: cursorTs,
lastSyncCount: newCount,
totalCount,
lastSyncAt: Math.floor(Date.now() / 1000),
lastError: null,
});
archive.close();
return { newCount, totalCount, lastSyncTs: cursorTs, durationMs: Date.now() - t0 };
}
projectToArchive 是纯函数,把 MessageRow 投影成 ArchiveMessage。与 build_pseudo.ts 解析 sender prefix 的逻辑共用一份(抽出 parseSenderPrefix 到 weixin_db/types 或类似公共位置,避免双份实现漂移)。
6. 前端
6.1 改动文件
packages/frontend/src/modules/agent/components/channel-group-panel.vue
6.2 UI 改动
群卡片操作区,在现有按钮旁新增:
<el-button
size="small"
:loading="group._syncing"
@click="handleSync(group)"
>
{{ group._syncing ? '同步中...' : '同步聊天记录' }}
</el-button>
群卡片元数据区可显示:
- 上次同步时间(读自 group.lastSyncAt,后端 list 返回时附加,见 §6.4)
- 上次同步条数 / 累计存档数
6.3 handler
async function handleSync(group: GroupItem) {
group._syncing = true;
try {
const resp = await apiPost('/admin/netaclaw/agent_channel_group/sync', { groupId: group.id });
if (resp.code !== 1000) {
ElMessage.error(resp.message || '同步失败');
return;
}
const { newCount, totalCount, durationMs } = resp.data;
ElMessage.success(`已同步 ${newCount} 条新消息(总计 ${totalCount} 条,${(durationMs/1000).toFixed(1)}s)`);
await loadList(); // 拉最新元数据回填
} catch (err: any) {
ElMessage.error(err?.message || '同步失败');
} finally {
group._syncing = false;
}
}
6.4 list 接口元数据扩展(★ 不在本 spec,留给查看 spec)
群卡片显示"上次同步时间 / 累计存档数"是个不错的体验加分,但需要 list 接口对每个 weixin-db 群打开 ArchiveDb 读 sync_state,本 spec 不做。
理由:
- 本 spec 焦点是同步本身,UI 元数据回显属于"展示"范畴
- 同步完成后 toast 已经告诉用户 newCount / totalCount,够用
- 未来"查看聊天记录"功能(独立 spec)会重做 list 接口或新增 archive list 接口,届时再统一加
7. 错误处理
| 场景 | 后端处理 | 前端提示 |
|---|---|---|
channel.type !== 'weixin-db' |
throw unsupported-channel-type |
"本频道不支持同步" |
loginStatus !== 'connected' |
throw channel-not-connected |
"频道未连接,请检查 PC 微信是否登录" |
WeixinDbService.runtimes 不含该 channel |
throw channel-not-bound |
"频道未连接,请检查 PC 微信是否登录" |
| RoomResolver 找不到 roomName | throw room-not-found |
"未找到该群消息,可能群已不存在" |
| 同名群多个 → warn,取 lastSeenAt 最新的 | log warn | (用户无感) |
decryptDbToWorkDir 失败(key 失效) |
throw decrypt-failed |
"登录态失效,请重新触发频道连接" |
MessageRepo SQL 失败 |
throw,日志 + lastError 写 sync_state | "同步失败:<原因>" |
| 跨 channel 并发同步 | channel-level mutex 串行,不报错 | (用户无感,只是排队) |
| 同 channel 同时同步两个群 | mutex 串行 | (用户无感) |
| 同 group 重复点 | mutex 自然排队;前端 _syncing flag 也阻止 | 按钮 loading 中无法再点 |
| sqlite WAL 损坏 / 磁盘满 | throw,日志,sync_state.last_error 留痕 | "同步失败:<原因>" |
| 非 Windows | throw unsupported-platform |
"本功能需要 Windows + PC 微信" |
lastError 写到 sync_state.last_error 后,下次同步成功会被清空,便于排查。
8. 测试策略
8.1 单元测试(可在 Linux/Mac 跑)
| 测试文件 | 覆盖 |
|---|---|
archive_db.test.ts |
openOrCreate 创建表 / 重复 INSERT OR IGNORE 幂等 / sync_state CRUD / archivePath 计算 |
weixin_archive_sync.test.ts |
mock WeixinDbService.runtimes + IncrementalReader, 跑 syncGroup 流程,断言 newCount/totalCount/sqlite 内容 |
weixin_archive_sync.concurrency.test.ts |
mock 慢速 syncGroup,并发调两次同 channel → 串行;并发不同 channel → 并行 |
parseSenderPrefix.test.ts(若抽公共) |
文本消息 / 引用消息 / 系统消息 sender 解析 |
8.2 集成测试(条件 skip,Windows-only)
| 测试 | 前置 | 内容 |
|---|---|---|
archive_e2e.test.ts |
Weixin 登录 + 至少一个测试群且已 bindChannel | 触发同步 → 断言 archive db 有行 → lastSyncTs 单调递增 |
8.3 手工冒烟(E2E checklist)
放在 plan 的 Phase 末尾。
9. 里程碑
本 spec 较小,单 plan 单 milestone,不分 M1/M2:
| 阶段 | 任务 | 产出 |
|---|---|---|
| A. ArchiveDb 模块(TDD) | 写 archive_db.ts + 单测 | 表创建 / 幂等插入 / sync_state CRUD |
| B. archive_decryptor 抽出 | 重构 IncrementalReader 用同款逻辑 | 不破坏现有监听链路(回归测试) |
| C. WeixinArchiveSyncService 主服务(TDD) | 写 service + 单测(mock) | syncGroup 流程闭环 |
| D. WeixinDbService 暴露 runtime getter + RoomResolver 多名匹配 | 改动最小 | reader.findRoomsByName |
| E. Controller endpoint | POST /sync |
API 通 |
| F. agent_channel.delete cascade rm archive 文件 | 防止泄漏 | rm cid-X.db 在 delete 时 |
| G. 前端按钮 + handler | UI + apiPost | 用户能点能看 toast |
| H. list 接口附加 archive 元数据(可选) | 群卡片显示上次同步时间 | 用户体验加分 |
| I. E2E 手工冒烟 | Windows + 真群 | 通过即合并 |
每阶段一个 commit。
10. 已对齐的关键决策(避免实施时反悔)
| 决策 | 结论 | 理由 |
|---|---|---|
| 文件粒度 | per-channel (cid-X.db) |
cascade 删除简单,跨 channel 无锁竞争 |
| 同步策略 | 首次全量 + 后续增量(基于 last_sync_ts 水位) |
自然语义,重复点幂等 |
| 同步反馈 | 后端阻塞,前端 loading | 首次最长 ~30s,简单可接受 |
| 附件 | 只存 raw XML 到 content,不解附件文件 |
数据不丢,未来再做附件解析 spec |
| 与监听的关系 | 两条独立链路,共享底层模块 | archive 不依赖 walWatcher,监听不依赖 archive |
| 工作目录 | archive 用 cid-<id>/archive/ 子目录,与监听隔离 |
防止与监听 500ms 覆盖写 message_0.db 撕裂 |
| 前置条件 | 频道必须已 bindChannel 成功 (loginStatus=connected) |
复用已抽好的 key,archive 不重新 spawn ps1 |
| 并发 | channel-level mutex | better-sqlite3 是 single-writer,channel 串行最稳 |
| 复用 RoomResolver | archive 只用 runtime.reader.getRoomResolver(),不重建 |
避免 archive 也解 session.db/contact.db,IO 减半 + 状态一致 |
| 失败时 sync_state | best-effort 写 last_error,不掩盖原异常 |
便于排查,同步链路对一致性容忍度高 |
| 查看 UI | 不在本 spec | 后续独立 spec |
| 重复点行为 | 幂等(INSERT OR IGNORE) |
用户无心理负担 |
| 群名匹配 | trim 后精确等于 | addByName 已 trim 入库,RoomResolver 输入也 trim 兜底 |
11. 风险与兜底
| 风险 | 概率 | 兜底 |
|---|---|---|
| WCDB schema 在 Weixin 新版本变化 | 中 | listMsgTables / listSince 已有,沿用现有抽象;若变,改 message_repo 即可 |
| 同名群歧义 | 低 | warn log + 取首条;未来支持用 username 直接选(下一个 spec) |
| sqlite 文件损坏 | 低 | better-sqlite3 WAL mode + 单写;损坏时手动 rm 文件,下次同步全量重建 |
| 万条群同步超时 | 中 | 分批 500 条 LIMIT 流式;若仍超 30s,改用后台 job 模式(非本 spec) |
| 用户在同步中删除频道 | 低 | mutex 持有 archive db 文件句柄;syncGroup 抛错 → 前端提示;cascade delete 在 mutex 释放后执行 |
| ★ archive 与监听双写 workDir 解密文件撕裂 | 高(若不隔离) | 必须用 cid-<id>/archive/ 子目录,见 §3.1 |
| ★ catch 路径漏 close archive db handle | 中 | try/finally 保证 archive + repo 都 close(spec §3.2) |
| 异常未写 sync_state.last_error 难排查 | 中 | best-effort 写 last_error,见 §3.2 |
| 群名首尾空格 / 不可见字符 | 低 | addByName 已 trim;findRoomsByName 内部也 trim 比较 |
| 漏释放 mutex 导致 channel 永远阻塞 | 低 | release 放 finally,doSyncGroup 抛错也释放 |
12. Self-Review(spec 写完检查)
- Placeholder 扫描:无 TBD/TODO,所有字段已定。
- 内部一致性:数据模型 §4.2 字段与 service §5.3 ArchiveMessage interface 对齐;接口契约与 controller / service 调用一致。
- scope 检查:已显式列出 out-of-scope(查看 UI / 附件解密 / 实时监听不动);本 spec 焦点收敛。
- 歧义检查:
- "全量"明确为
WHERE create_time > 0(不是"重置 + 全部覆盖")。 - "上次同步条数" =
INSERT OR IGNORE实际入库数,不是 SELECT 出来的数。 - "totalCount" = 当前 archive 表
COUNT(*) WHERE room_id = ?,不是 WCDB 源数。
- "全量"明确为
- 决策点都有理由:每个决策都附了"理由"列。