最后验证:2026-10-13T13:06:08Z
QQ 归档
  1. QQ 归档 00:项目总览
  2. QQ 归档 01:快速开始
  3. QQ 归档 02:完整使用手册
  4. QQ 归档 03:部署与运维手册
  5. QQ 归档 04:架构设计
  6. QQ 归档 05:数据模型与格式映射
  7. QQ 归档 06:验收测试报告
  8. QQ 归档 07:独立复核报告
  9. QQ 归档 08:项目报告
  10. QQ 归档 09:最终交付报告
  11. QQ 归档 10:同类导出项目调研
  12. QQ 归档 11:qq-chat-exporter 复用性分析
  13. QQ 归档 12:SnowLuma 调研与改进落地
  14. QQ 归档 13:Windows 部署指南
  15. QQ 归档 14:脱敏样例数据集

独立复核报告:这些功能到底完成没有

复核日期:2026-10-13 复核对象:/home/emo/code/QQ(v0.1.0)
复核方式:不看文档声称什么,只认我自己造出来的证据
结论一句话:核心功能是真的、可用的、能过测试的;但我在复核中又抓出 6 处「实现了却没接线 / 前端与后端字段对不上」的真缺陷,全部已修并补了回归测试。


0. 先回答”你确定完成了吗”

不完全确定 —— 所以我去查了。查下来的结论是:

维度 结论
之前声称的功能(导入/采集/媒体/网页/备份/部署) 属实,不是静态样板:网页连真库、媒体真落盘、采集真在跑
声称的测试全绿 属实:现在 347 个用例 0 失败(复核前是 263)
声称的”真机验证” 属实:库里有 282 条真实采集消息、25 个真实媒体文件,NapCat 此刻仍在采
但 23 个用例的测试套件测不出 6 个真缺陷 —— 因为它们全都出在”模块之间的接线”上,而单元测试只在模块内部打转

最后这条是关键。这个项目的测试写得相当扎实(真的起了子进程、真的开了 HTTP、真的算了 sha256),
但测试和缺陷有共同盲区:没人去验证「前端读的字段,后端到底有没有返回」。
下面 6 个缺陷里有 4 个属于这一类。


1. 我自己造的证据(不是复述文档)

1.1 媒体完整性:逐个文件重算哈希

不看数据库说什么,直接把 data/media/ 里每个文件重新算一遍 sha256:

media rows: 25  |  sha256+size verified OK: 25  mismatch: 0  missing: 0  sizebug: 0
thumb present: 21
--- disk files not referenced by DB ---  (none — every disk file is referenced)
--- message_media links --- links=28 dangling_message=0 dangling_media=0

25/25 全部对得上,磁盘上没有孤儿文件,关联表没有悬空外键。这一项是真的。

1.2 API 与数据库对账:逐条比对

自己写脚本,把 API 返回值与 SQL 直查结果逐条比对(不是跑项目的测试):

检查项 结果
会话列表条数 = DB 条数 ✅
每个会话的消息集合(limit=500) = DB 精确集合 ✅ 全 6 个非空会话一致
游标分页(limit=17 逐页翻)能无遗漏重建全集 ✅
中文搜索命中数与 ID 集合 = SQL LIKE 真值 ✅ 6 组关键词零漏报零误报
消息类型筛选(image/video/audio/forward/reply)计数 ✅ 全部相等(22/2/3/7/27)
媒体 raw 字节 sha256 = DB 里记的 sha256 ✅
JSONL 导出行数 = DB 消息行数 ✅ 83 = 83
路径穿越 /api/media/../../config.json(含编码变体) ✅ 被拦
未登录访问 5 个接口 ✅ 全部 401 且不泄露内容

注:我第一次跑这套对账时”失败”了 7 项,结果全是我自己的测试写错了
(分页参数名是 pageSize 不是 limit、消息列表是游标分页不是页码分页、
搜索返回的是 {message, snippet} 不是扁平对象)。改对之后全绿 —— 这类要先怀疑自己。

1.3 真实浏览器验收(不是 curl,是真页面)

用真实浏览器登录 http://127.0.0.1:8787 逐页操作:

页面 观察到的真实状态
总览 账号 1 / 会话 13 / 消息 282 / 媒体 25 / 52.5 MB / 磁盘 290.7 GB —— 与 DB 直查一致
聊天记录 时间线渲染真实消息;引用预览显示”↩ 白:…”真实被引用内容(修复后)
搜索 输入”微信”→ 命中 1 条并高亮 <mark>微信</mark>,与 API/DB 对账一致
媒体管理 21 张缩略图全部解码成功(naturalWidth>0);音频项直接显示转写文字
视频播放 点”预览”→ <video> readyState=4、duration=39.13s、720×1280、paused=false(真在播)

1.4 备份 → 恢复:真的走了一遍恢复

在 data/ 的副本上做(真实库全程未动),先破坏再恢复:

损坏后:messages 199 / media 22 / sessions 12
restore --apply  →  通过
恢复后:messages 282 / media 25 / sessions 13   (与破坏前完全一致)
按 msg_key 精确核对 3 条被删样本:3/3 FOUND
原库另存为 archive.db.bak-20261013-134248    ✅
恢复后引用关系仍完整:27/27 可解析            ✅

dry-run(默认)也实测过:报告清单 188 条 / 实际 199 条,且不改动任何数据。

1.5 持续采集:对着真实 NapCat 跑

已连接采集端:示例账号 (1000000001)
一轮采集完成:会话 13|拉取 27|新增 0|重复 27|失败 0
媒体:保存 0|缺失 0

重复 27 就是去重生效的直接证据(拉回 27 条,全被唯一约束挡下,一条没重复入库)。


2. 复核中抓出的 6 个真缺陷(全部已修 + 补回归测试)

这一节的每一条都是先复现、再定位、再修、再验证,不是”顺手改改”。

D1 引用(回复)内容端到端显示不出来 —— 三处同时断链

  • 现象:库里 27 条引用消息,27/27 的被引用消息都在库中,却一条都显示不出引用了什么。
  • 根因(三处叠加):
    1. 详情接口用 typeof message.replyToId === 'number' 判断,而 replyToId 是 QQ 的原始字符串 id → 恒为 false,预览永远 null;
    2. 时间线接口从不返回 replyPreview,前端 replyPreviewOf() 的三个候选字段全都取不到;
    3. 前端点击”点击加载”时把 QQ 原始 id 当本库 id 去请求 → 实测 404({"error":"消息不存在"})。
  • 修复:Db.getReplyPreviews() 批量解析(会话内 msg_key 优先、msg_id 兜底,两种形态都支持),时间线与详情都返回 replyPreview / replyToMessageId,前端改用内部 id。
  • 验证:真实库 27/27 解析成功、同会话隔离成立、文本逐条相等;浏览器里实拍到”↩ 示例账号:这是一条示例消息”;点击引用目标 GET /api/messages/2 → 200(原先 404)。
  • 回归测试:tests/reply-preview.test.ts(15 例)。

D2 磁盘上有备份,网页说”尚无备份记录”,且这些备份永远不会被清理

  • 现象:data/backups/ 里躺着 2 份带 manifest 的快照,backups 表 0 行 → 网页显示”尚无备份记录”,保留策略(keep)也永远数不到它们。
  • 根因:快照用 VACUUM INTO 生成,而它的登记行是在快照之后写进当前库的 —— 快照不可能包含自己那一行。一旦用这份快照做过恢复(恢复 = 快照替换当前库),那次备份的记录就消失了。pruneBackups() 只遍历表里的行 → 孤儿快照在磁盘上无限堆积。
  • 修复:新增 reconcileBackups() 以文件系统为准对账(元数据取同名 manifest,缺 manifest 时按文件名 + mtime 兜底),并接到 /api/backups 与 pruneBackups() 之前。
  • 验证:清空表后调接口 → 2 份快照带真实 kind/createdAt/sizeBytes/messageCount 全部登记回来;浏览器里备份面板从”尚无备份记录”变成”成功 · 2026-10-13 13:26:36 · 1.1 MB · 282 条 / 25 个”。
  • 回归测试:tests/backup-reconcile.test.ts(8 例,含用真实 createBackup 产出的 manifest 做输入)。

D3 数据目录一搬走,备份记录就会重复

  • 现象:我自己写的 D2 修复,在把 data/ 复制到别处后立刻暴露了:同一个快照被登记了两行(老行路径失效 → 新路径又被当新快照)。
  • 影响:这正是任务书里的”设备更换 / 部署到服务器”场景 —— 换机后备份列表会出现同名重复项,保留份数算错。
  • 修复:按文件名认领,老行改指到新位置(repointBackup),不再补新行。
  • 回归测试:tests/backup-reconcile.test.ts 的”数据目录整体搬迁后不产生重复记录”用例(8 例中的 1 例)。

D4 网页”运行日志与错误”永远是空的(dbSink 从没接上)

  • 现象:logs 表、/api/logs、网页日志页都在,但 SELECT COUNT(*) FROM logs 恒为 0。
  • 根因:Logger 的 dbSink 参数早就写好、logger.ts 里也真的会调用它,但全仓没有任何调用点传它(apps/cli/src/main.ts 里甚至留着一行注释”日志落库”,代码却没写)。
  • 修复:在 CLI 组装 logger 时接上 sink(scope 为 db* 的不落库,避免数据库自身出错时递归);并把 CLI 采集端不可用这条失败也记进日志 —— 原先它只 stderr.write,终端滚走就再也查不到”为什么某一轮没采到”。
  • 修复后失败记录也能被单独列出(tests/log-sink.test.ts 全部用例)。
  • 回归测试:tests/log-sink.test.ts(6 例,真实子进程验证 —— 因为缺陷恰好在”接线”上,只测 Logger 单测在修复前也会全绿)。

D5 数据库日志没有保留策略(pruneLogs 零调用)

  • 现象:Db.pruneLogs(keepDays) 定义了却没有任何调用点,数据库日志只增不减。
  • 修复:新增配置段 logs.dbKeepDays(默认 30,0 = 不清理,支持 QQ_ARCHIVE_LOG_KEEP_DAYS 覆盖),启动时执行一次清理;config.example.json 已同步说明。
  • 验证:插一条 40 天前 + 一条 3 天前的日志 → 跑任意命令 → 只剩 3 天前那条;keepDays=0 时 400 天前的也不动。
  • 回归测试:tests/log-sink.test.ts(含环境变量覆盖与非法值校验)。

D6 采集窗口重叠导致媒体每轮被重新下载

  • 现象:真实采集日志里每轮都是 mediaSaved=2 —— 同一张图/同一段视频每隔 60 秒被重新下载一次。
  • 根因:窗口重叠(lookbackSec=300)是常态,重复消息被唯一约束挡住,但媒体没有这道闸门。
  • 影响:长期挂机时白白消耗带宽与上游压力(不是正确性问题,但正是”长期运行”会咬人的地方)。
  • 修复:handleMedia() 先查”这条消息已关联的 present 媒体数是否 ≥ 本条引用数”,齐备则跳过。
  • 安全性质(重点):只数 present —— 缺失/损坏的媒体下一轮必须仍然重试,所以直链 rkey 过期后的补救路径不受影响;查询失败时按”未知”处理照常下载,绝不因一次读失败漏掉媒体。
  • 验证:真实 NapCat 连跑两轮,媒体:保存 0(原先每轮都是 2);以及回归测试里显式验证”媒体转成 missing 后必须重新尝试”。
  • 回归测试:tests/collect.test.ts 用例 7b。

D10 停机会被连接拖住(有界停机,含一次未能解释的实测)

  • 现象:一次重启操作里,正在运行的实例收到 SIGTERM 后 20 秒仍未退出(最后只能 kill -9)。
  • 诚实说明:我没能复现出那次挂起。隔离环境下试了四种情形 —— 空闲 keep-alive 连接、在途大媒体流(慢读者不读完)、只发一半请求体的请求、采集中每 5 秒一轮 —— 全部都在 1 秒内干净退出。
  • 处理方式:不去假装能修一个复现不了的根因,而是把停机改成有界的:SIGTERM 后给 10 秒优雅期(QQ_ARCHIVE_SHUTDOWN_GRACE_MS 可调),超时就强制断开剩余连接并在 0.5 秒后退出。这样无论进程里挂了什么,停机时间都可预期,systemd 不必靠 90 秒的 TimeoutStopSec 去 SIGKILL。
  • 回归测试:tests/cli.test.ts 的”收到 SIGTERM 后必须在宽限期内退出”用例 —— 守的是结果(有界),不是那个复现不了的根因。这一条是我没有完全查清的问题,如实列在这里。

另外 3 处前端/后端契约不一致(同一类问题,已修)

在系统排查”前端读的字段后端到底有没有”时又发现 3 处,一并修掉并用浏览器验证:

# 位置 症状 修复
D7 总览 · 备份面板 前端把 /api/backups 当数组用,实际返回 {items,total} → 永远显示”尚无备份记录” 改成读 items(浏览器实测:面板已显示真实备份)
D8 总览 · 采集面板 lastOkAt/consecutiveFailures/lastError 挂在每条会话状态上,前端读顶层 → 这三行永远是”—“ 前端改为从 collect.states[] 聚合(浏览器实测:已显示真实时间与失败次数)
D9 总览 · 健康表 collectors 是每会话一行 → 网页上出现 13 行一模一样的”onebot 正常” 服务端按 source 聚合(ok 取与、时间取最新、错误取第一条)→ 现在 1 行(浏览器实测)

3. 修复后的门禁状态

npm test      →  347 tests / 347 pass / 0 fail / 0 skipped   (26 个测试文件,约 28 s)
npm run typecheck →  0 错误

复核期间新增的回归测试:reply-preview 15 例、backup-reconcile 8 例、log-sink 6 例、filter 16 例、config-jsonc 14 例、backup-media 6 例、
collect 新增 1 例、backup-reconcile 搬迁 1 例、cli 停机 1 例 —— 合计 279 例。

复核结束时的线上状态(实测快照)

服务已用含全部修复的版本重启并跑着:

进程     308961  node ... serve --config config.json
健康     ok=true  schema=v5  collectors=[{onebot, ok:true, lastOkAt:05:54:28}]
备份     total=2  状态核查-真机 1.1MB exists=true / manual 794KB exists=true
日志     total=2  (修复前恒为 0)
采集     sessions=13 fetched=27 inserted=0 duplicate=27 failed=0 mediaSaved=0 ok=true
统计     会话=13 消息=282 媒体=25(present=25, missing=0)  lastBackupAt=2026-10-13T05:26:36Z

对照复核开始时:备份面板是”尚无备份记录”、采集器三行全是”—“、健康表 13 行重复、日志恒空、
每轮重复下载 2 个媒体 —— 现在这五项都是真实数据。


4. 关于”完成度”的诚实结论

4.1 已实现且我亲手验证过(不是跑项目自己的测试)

能力 我的证据
导入现有聊天记录 5 种格式有自动化用例;真实库 282 条来自真机采集,导入幂等(重复导入新增 0)
持续保存新消息 真机 NapCat 每 60 秒一轮,fetched=27 / duplicate=27 / failed=0;重启续传有测试
独立保存图片/视频 25 个媒体逐文件重算 sha256 全部一致、0 孤儿、0 悬空关联;视频在浏览器里真实播放
网页管理界面 真实浏览器逐页操作:13 会话 / 282 消息 / 中文搜索高亮 / 引用预览 / 视频播放 / 媒体缩略图
备份与恢复 破坏→恢复实测:199 → 282 条,3/3 样本按 msg_key 找回,原库另存 .bak,引用关系完好
部署到服务器 单进程 node:http、生产依赖 0 个、systemd unit 已提供;未在真实 systemd 上跑过(见 4.2)

4.2 仍未验证 / 做不到的(如实列出,不美化)

项 状态
手机端(华为,无 Root)采集 做不到,且不是工程问题 —— 未同步到服务端的记录不存在于任何可访问位置
群聊离线期间的消息 补不齐,上游 NapCat 明确表示这是设计行为(Issue #1838)
真实 systemd 部署 未验证(unit 文件已提供,但没在真 systemd 上启动过)
Windows 脚本真实执行 未验证(开发机是 Linux、无 pwsh,只做了 80 项静态自检)
真实腾讯服务端记录完整性 无法证明,官方工具自己标注 serverCompletenessProven: false
LLOneBot 等其它 OneBot 实现 未真机验证(只验证了 NapCat)
语音转写质量(CER/WER、方言、噪声) 未做基准(只验证真机 3 个 .amr 能转出可读中文)

4.3 复核暴露的系统性教训(比单个 bug 更值得记住)

6 个缺陷里有 5 个是同一个类型:功能实现了、测试过了、但从没被接进生产路径,
或者前后端的字段名对不上。它们全都能躲过单元测试,因为单元测试在模块内部打转。

测试全绿 ≈ 模块内部自洽,不等于系统能跑。
只有真的起服务、真的开浏览器、真的对着真采集端跑一遍,才会掉出来。
这也是为什么这次复核我没有停在 npm test 的绿灯上。


5. 复现方式(每条结论都能自己验)

# 1. 门禁
cd /home/emo/code/QQ && npm test && npm run typecheck

# 2. 媒体完整性(独立重算哈希,不信 DB 自述)
node /tmp/audit_media.mjs          # 脚本见本报告 1.1 节描述,或直接照抄那 20 行

# 3. 起服务 + 浏览器
node --experimental-strip-types apps/cli/src/main.ts serve --config config.json
#   浏览器打开 http://127.0.0.1:8787 ,用 `qq-archive token` 的令牌登录

# 4. 备份→恢复演练(务必在副本上做)
cp -a data /tmp/qqr2/data   # 然后照本报告 1.4 节走一遍

# 5. 真实采集一轮
node --experimental-strip-types apps/cli/src/main.ts collect --once --config config.json

6. 一句话结论

能导入、能持续采集、能独立保存图片视频、能部署并用网页查看 —— 这些我都亲手验过,是真的。
手机端未同步的历史消息、离线期间群消息、腾讯服务端完整性 —— 拿不到,这是上游的硬边界,不是实现偷懒。
而”确定完成了吗”的答案是:复核前不确定,复核中又抓出 6 个真缺陷(全部已修 + 补测),现在这一版是 347 用例全绿、真机在采、网页可用的状态。


7. 追加:采集过滤(filters)与”和 NapCat 功能对比”

7.1 先说清楚:本系统不是 NapCat 的替代品

NapCat 是完整的 QQ 机器人框架(OneBot 11/12 协议实现 + 几百个接口,能发消息、管群、踢人、改名片……)。
本系统刻意只用它的只读子集,这不是能力不足,是三条硬约束下的选择:

约束 说明
账号安全 写接口(发消息/删消息/踢人)意味着”程序可能替你在群里说话”。一个防丢失归档工具不该有此能力
许可证 NapCat 是自定义许可(禁商用、禁发布修改版)。只通过网络调用公开 HTTP 接口属于互操作,不分发、不修改、不打包
可替换性 只要实现 OneBot v11 的只读接口(NapCat / LLOneBot / …),采集端就能换

代码里这条边界是硬编码的最后一道闸:READ_ONLY_ACTIONS 白名单之外的动作在本地直接拒绝,
连 HTTP 请求都不会发出(packages/collect/src/onebot.ts)。白名单共 11 个:
get_login_info / get_status / get_version_info / get_group_list / get_friend_list /
get_group_msg_history / get_friend_msg_history / get_forward_msg / get_image / get_record / get_file。

7.2 登录方式:扫码 + 快捷登录有,账号密码没有(也不是本项目的选择)

实测当前实例(GET /api/napcat/status):

{"configured":true,"webuiUrl":"http://127.0.0.1:6099","connected":true,"isLogin":true}
GET /api/napcat/accounts → [{"uin":"1000000001","nickname":"…","isQuickLogin":true}]
POST /api/napcat/qrcode  → {"isLogin":true,"uin":null,"png":null}   # 已登录时不发二维码

已支持:扫码登录(二维码在网页显示 / 终端渲染,轮询登录态)、快捷登录(用本机已保存的登录态,不用扫码)。
账号密码登录(QQ 号 + 密码):不支持,且 NapCat 本身也没有这个接口 —— 这一点我不是靠猜,
而是查了本地那份最完整的 NapCat 驱动项目(qq-chat-exporter 的 Rust 驱动)实际调用的端点:

/api/auth/login                (WebUI 自身的令牌认证)
/api/QQLogin/GetQQLoginQrcode  (取二维码)
/api/QQLogin/CheckLoginStatus  (轮询登录态)
/api/QQLogin/GetQuickLoginListNew / GetQuickLoginList(已保存账号)
/api/QQLogin/SetQuickLogin     (快捷登录)

没有 password 登录端点。腾讯早已不支持第三方客户端用密码直登(设备锁/验证码/风控),
所以”支持账号密码登录”这条做不到,不是没实现。

7.3 新增:采集过滤(本次追加实现)

原来的系统没有任何过滤能力(只有”只采集哪些会话”的白名单)。现在补上了 filters 段:

规则 语义
excludeKeywords 子串匹配,大小写不敏感
excludeSenders 与 QQ 号 / uid / 昵称精确相等
excludeTypes 与消息类型精确相等(text/image/video/audio/file/forward/reply/system/json/other)

最关键的设计决定:默认只标记、不丢弃。 命中规则的消息照样入库,只是打上 filtered + 命中理由,
默认不在时间线/搜索里显示;勾选「包含已过滤消息」就能看到,导出永远带上它们。
真正跳过存储需要显式 drop: true。理由很直接:这是防丢失系统,过滤只该让人”少看点”,不该变成”少存点”。

实测证据(真实 NapCat,309 条消息一轮):

一轮采集完成:会话 13|拉取 309|新增 309|重复 0|失败 0     ← 全部入库,一条没丢
库内:messages 总数 = 309,其中被标记 = 1
标记样例:#262 「这是一条示例消息」 | 关键词命中「微信」

浏览器实测:勾选「包含已过滤消息」后,该消息出现并带橙色徽标
已过滤:关键词命中「微信」,时间线标题显示 含已过滤 1;不勾选时它不出现。

配套 qq-archive refilter:改规则后对已有消息重算标记(表结构加了 migration 6 的两列)。
实测:同规则重跑 = 0 改动(幂等);换规则 = 旧标记取消 + 新命中标记(可逆);
关掉过滤再跑 = 清空标记且消息一条不少(310 → 310)。

这一节也顺带暴露并修掉了我自己代码里的两个缺陷:过滤计数没进轮次日志、以及
refilter 命令漏登记进命令白名单(表现是打印整篇用法而不是执行)——
第二个是真实运行时才发现的,单测覆盖不到”命令名是否注册”。

7.4 部署到服务器:Windows / Linux

环境 支持情况 证据
Linux(Debian/Ubuntu/Arch) 支持且已实测 本机 CachyOS(Arch 系)上正在跑:node:sqlite + node:http,生产依赖 0 个;systemd unit 在 deploy/
Linux 服务器长期运行 支持,但未在真实 systemd 上验证过 unit 文件与文档齐备;采集端即使与服务器分离也能跑(先归档再同步)
Windows 源码可跑(Node 原生跨平台),但脚本未在真实 Windows 上执行过 deploy/windows/ 有启动/安装脚本,只做了 80 项静态自检;本机无 pwsh,无 Windows 环境
Docker 未提供 本机没装 docker,不交付未验证的东西

采集端与归档端可以分开部署:服务器上没有能稳定运行 QQ 客户端的环境时,
在个人电脑上跑采集端,再把已归档的数据同步到服务器(见 docs/DEPLOYMENT.md 第 5 节)。
但要明确:服务器上登录了 QQ ≠ 拿到了手机端的全部历史,界限见第 1 节。