独立复核报告:这些功能到底完成没有
复核日期: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 的被引用消息都在库中,却一条都显示不出引用了什么。
- 根因(三处叠加):
- 详情接口用
typeof message.replyToId === 'number'判断,而replyToId是 QQ 的原始字符串 id → 恒为 false,预览永远null; - 时间线接口从不返回
replyPreview,前端replyPreviewOf()的三个候选字段全都取不到; - 前端点击”点击加载”时把 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 节。









