QQ 聊天记录防丢失与自动归档系统 — 项目报告
📌 修订说明:初稿写于独立复核之前(当时 263 个用例)。复核又抓出 6 个真缺陷并修复
(引用内容端到端显示、备份对账与保留策略、数据目录搬迁、日志落库与保留、媒体重复下载、
3 处前后端字段不一致),用例由 263 增至 347。§0 速览、§7 测试表格与全文用例数均已按
当前实测刷新为 347 / 0 失败,复核过程见 INDEPENDENT-VERIFICATION.md。
本文的架构、实现细节与边界分析仍然有效。
位置:
/home/emo/code/QQ
报告日期:2026-10-13
口径说明:本文所有数字都是本机实测,不是文档抄写。与 README 中过时的计数冲突时,以本文为准;
与更细的验收条目冲突时,以 acceptance.md 为准。
0. 一页速览
| 项 | 值 |
|---|---|
| 一句话 | 独立于 QQ 客户端的个人聊天记录归档系统:把已有导出文件导进来,把在线消息持续采下来,把图片/语音/视频/文件内容寻址落盘,用中文网页检索浏览,可备份可恢复可部署。 |
| 运行时 | Node.js ≥ 22.5(实测 24.x),直接用类型剥离跑 TS,无构建步骤 |
| 生产依赖 | 0 个(只用 node:sqlite / node:http / node:crypto 等内置模块);唯一外部程序依赖是可选的 ffmpeg |
| 代码规模 | 47 个源文件(.ts,不含测试),16391 行 |
| 结构 | 7 个包 core/db/import/collect/stt/qr/server + 1 个 CLI + 1 个无构建前端 web/ |
| 数据库 | SQLite(node:sqlite),15 张表,5 个迁移版本 |
| 测试门禁 | npm test → 347 tests / 347 pass / 0 fail / 0 skipped(26 个测试文件,实测约 66 s)npm run typecheck → 0 错误 |
| 真机数据 | 13 会话 / 282 消息 / 25 个媒体(全部 present)/ 28 条消息↔媒体关联 / 51 联系人 / 2 份备份 |
| 真机采集 | 最新轮次 sessions=13 fetched=27 duplicate=27 failed=0 mediaSaved=2 ok=true |
| 对接方式 | OneBot v11 只读 HTTP(不注入、不 hook、不改客户端) |
1. 目标与真实边界
1.1 它解决什么
QQ 记录的”丢失”有好几个不同来源,混在一起谈会得出错误方案。本系统对每个来源的立场:
| 丢失来源 | 能否解决 | 怎么解决 |
|---|---|---|
| 换机 / 重装 / 清缓存导致本地记录消失 | 能 | 提前把已获取的记录导入归档,独立于 QQ 的存储目录 |
| 群消息超过约 5000 条被 NapCat LRU 清理 | 能 | 长期挂采集端 + 断点续传;进了库的永久保留 |
| 已撤回的消息 | 能(前提:撤回前已采到) | 采集时落 recalled 标记与原文;撤回后无法再取回 |
| 媒体(图片/视频/语音/文件)链接失效 | 能 | 内容寻址落盘 + SHA-256 校验,不依赖 QQ 的缓存路径 |
| 腾讯服务端删除云端记录 | 不能 | 超出任何第三方工具能力 |
| 手机端有、但从未同步到采集端的消息 | 不能 | 没有任何 API;需设备端解密导出(root/Frida),本项目有意不提供 |
| 已解散 / 已退出的群 | 不能 | OneBot 接口明确不支持 |
1.2 为什么这些边界是真的(不是”懒得做”)
- NapCat 维护者在 Issue #1838 明确回复:机器人离线期间的消息不会补齐,且这是设计行为。
- NapCat 没有历史数据库,消息走 LRU 缓存。
- 私聊历史走”漫游”接口,QCE 官方文档标注
serverCompletenessProven: false,并写明任何响应都不能证明服务端记录完整性。 - 因此本系统的正确用法是:采集端长期挂着(systemd 自动重启 + 开机自启),把离线窗口压到最短。
系统保证的是「进了库的消息,永久保留」,而不是「阻止 QQ 删消息」。
2. 总体架构
2.1 分层
┌──────────────────────────────────────────┐
数据来源 ────────►│ packages/import (5 种格式,流式解析) │
(导出文件) └───────────────────┬──────────────────────┘
│
┌───────────────────▼──────────────────────┐
QQ 客户端 ──► NapCat ──► OneBot v11 HTTP(只读)
│ packages/collect (发现会话 / 拉增量 / │
│ 断点续传 / 转发展开 / │
│ 媒体归档 / 重试告警) │
└───────────────────┬──────────────────────┘
│ NormalizedMessage
┌───────────────────▼──────────────────────┐
│ packages/db SQLite:迁移 / 仓储 / │
│ FTS5 / 统计 / 日志 / 备份记录│
└───────────────────┬──────────────────────┘
┌───────────────────────────┼───────────────────────────┐
│ │ │
┌────────▼────────┐ ┌─────────▼─────────┐ ┌─────────▼────────┐
│ packages/server │ │ packages/stt │ │ packages/qr │
│ HTTP API+认证+ │ │ whisper.cpp 适配 │ │ 零依赖 QR 编码器 │
│ 媒体服务+SSE │ │ (可选后处理) │ │ → PNG/SVG/终端 │
└────────┬────────┘ └───────────────────┘ └─────────┬────────┘
│ │
┌────────▼────────┐ 扫码登录(NapCat WebUI)
│ web/ 中文界面 │
│ 原生 ES 模块 │
└─────────────────┘
2.2 模块职责
| 包 | 职责 | 关键文件 |
|---|---|---|
packages/core |
领域类型、配置加载与校验、结构化日志(含脱敏与文件轮转)、稳定键生成(msg_key/session_key)、CJK 二元切分、错误类型 |
config.ts logger.ts ids.ts types.ts errors.ts |
packages/db |
SQLite 存储层:schema/迁移、仓储方法、FTS 检索、统计、采集游标、日志、备份记录;预编译语句 LRU 缓存 | schema.sql migrations.ts index.ts |
packages/import |
5 个导入器 + 格式自动识别 + 流式 JSON 解析 + 媒体归档器(内容寻址、Range 续传、缩略图) | importers/* detect.ts media.ts run.ts |
packages/collect |
OneBot v11 客户端、采集调度(CollectRunner)、合并转发展开、告警、NapCat WebUI 客户端(扫码登录) |
onebot.ts runner.ts forward.ts napcat-webui.ts cq.ts |
packages/stt |
可选语音转文字:探测 whisper-cli/ffmpeg/模型 → ffmpeg 转码 → whisper 转写 → 落库(幂等) |
whisper.ts pipeline.ts |
packages/qr |
从零实现的 QR 编码器(ISO/IEC 18004 byte 模式:RS 纠错、掩码、格式信息)+ PNG/SVG/终端渲染 | encode.ts render.ts |
packages/server |
node:http 手写路由;认证(HMAC access token);媒体服务(raw/thumb);导出;备份/恢复;SSE 事件流;静态托管 |
index.ts auth.ts router.ts backup.ts media.ts export.ts static.ts |
apps/cli |
12 个命令:serve import collect transcribe login backup restore verify doctor stats logs token |
main.ts(965 行) |
web/ |
中文管理界面,原生 ES 模块 + 手写 CSS,无构建、无 CDN | index.html app.js lib.js style.css |
关键接口(模块间全部签名与 HTTP 端点)冻结在 ARCHITECTURE.md。
2.3 一次采集轮次的数据流(CollectRunner.runRound)
packages/collect/src/runner.ts:481-653:
- 建
import_jobs行(kind='collect'),失败只告警不中断采集。 discoverSessions()拿会话列表(可配置为固定清单或自动发现)。- 逐会话(一个会话失败不影响其他会话):
- 读
collect_state游标 →sinceTs = max(cursorTs - lookbackSec, now - initialBackfillDays); fetchWithRetry()拉增量消息(带退避重试);expandForwards()先展开合并转发再入库:子消息正文落messages.extra.forward,子消息媒体平铺进父消息resources,与普通媒体走同一条归档通路(runner.ts:420-467);ensureSession();- 按
pageSize分块insertMessages(),每块成功立刻持久化游标(崩溃后不重复拉取); handleMedia()串行下载媒体:MediaArchiver.archive()落盘 →persistMedia()写media/message_media;- 失败则写
lastError+consecutiveFailures。
- 读
- 轮次结束:整轮失败(fatal 或全部会话失败)才累加连续失败;达到
alert.collectFailureThreshold时写日志 + POST webhook。判定为成功的轮次会清零连续失败计数。 - 写结构化日志
采集轮次结束 {sessions, fetched, inserted, duplicate, failed, mediaSaved, mediaMissing, forwardMessages, forwardFailed, ok}。
3. 技术选型与理由
| 关注点 | 选择 | 为什么不是别的 |
|---|---|---|
| 运行时 | Node ≥ 22.5 | 原生 node:sqlite(免 better-sqlite3 原生编译)+ 原生 TS 类型剥离(免 tsc/tsx/esbuild) |
| 数据库 | SQLite | 单文件、VACUUM INTO 原子快照、FTS5;实测 3.53.4 支持 FTS5 + JSON1 + WAL |
| 中文检索 | FTS5 unicode61 + 应用层 CJK 二元切分 |
老版本 SQLite 无 trigram 分词器;二元切分不依赖版本且能同时命中「演唱会」与「看了演」 |
| HTTP | node:http 手写路由 |
0 依赖 = 可审计、无供应链风险、无升级破坏 |
| 前端 | 原生 ES 模块 + 手写 CSS | 无构建,改完刷新即生效;纯函数可被 node:test 直接测 |
| 采集来源 | OneBot v11 HTTP(只读) | 不注入、不 hook、不改客户端;接口稳定且被多个实现支持 |
| 测试 | node:test + node:assert/strict |
内置,零依赖 |
4. 数据模型
完整 DDL:packages/db/src/schema.sql;迁移:packages/db/src/migrations.ts(当前 5 个版本)。
| 表 | 作用 |
|---|---|
accounts |
归档的账号(多账号隔离的顶层键) |
sessions |
会话(private/group/temp/other),含消息数、首末时间 |
contacts |
联系人/群成员昵称、备注 |
messages |
消息主表 |
media |
媒体内容对象 |
message_media |
消息↔媒体多对多(序号、原文件名、声明大小) |
import_jobs |
导入/采集/备份任务与进度、计数、错误 |
collect_state |
每会话采集游标(断点续传核心) |
logs |
结构化日志(供网页查看 — 见 §9 缺陷 G1) |
backups |
备份记录与校验结果 |
messages_fts / messages_fts_bigram |
全文索引(原文分词 / CJK 二元) |
settings |
运行期设置(含 refresh token 的 sha256) |
media_transcripts |
语音转写结果(media_id 主键 ⇒ 天然幂等) |
schema_migrations |
迁移版本台账 |
四个关键设计:
msg_key是幂等的核心。源数据有真实消息 ID 时用id:<msgId>;没有时用sha256(ts|sender|text|seq|资源指纹)内容哈希。同一文件重复导入新增 0 条,不同时间导出的重叠部分自动归并(唯一约束UNIQUE(account_uid, session_key, msg_key),schema.sql:90)。- 重复导入不覆盖已有行。导出文件可能是较早的快照,覆盖会丢数据。
- 媒体内容寻址。
media.sha256唯一(schema.sql:102);路径由 sha256 决定 ⇒ 同内容只存一份,不同内容天然不覆盖。 - 迁移只做向后兼容新增(加表/加索引/加可空列),每版本在事务内执行;删列/改语义必须新开版本并写数据迁移(
migrations.ts:7)。
5. 各子系统的实现
5.1 导入(5 种格式,流式)
| 格式 | 识别方式 | 来源 |
|---|---|---|
qce-json |
顶层有 chatInfo + messages |
qq-chat-exporter 单文件 JSON |
qce-jsonl |
manifest.json + chunks/*.jsonl |
同一工具的分块导出(大文件推荐) |
qce-txt |
文本行模式 | 同一工具的 TXT 导出(尽力解析) |
generic-jsonl |
每行一个对象 | 自带规范,供其他工具接入 |
html |
DOM 结构保守探测 | QCE viewer / QCE DOM / 通用聊天页 |
- 流式解析(
json-stream.ts)→ GB 级文件不占内存。 - 不静默丢字段:无法识别的字段整条存进
messages.raw;无法解析的行计入import_jobs.stats.errors并在网页日志可见。 - 格式探测是保守的:不像聊天页必须判
unknown,绝不硬猜(tests/html-import.test.ts有专门护栏)。 - 字段映射细则:format-mapping.md。
5.2 采集(OneBot v11 只读)
只读白名单(packages/collect/src/onebot.ts:25-38 的 READ_ONLY_ACTIONS):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。不使用任何写消息接口、不注入客户端。
- 增量:
sinceTs由collect_state.cursorTs减去lookbackSec回看窗口决定;首次回填由initialBackfillDays控制。 - 断点续传:每插入一块就落游标;
cursor_ts+cursor_seq双游标,位点持久化早于轮次结束。 - 退避重试:
fetchWithRetry按尝试次数退避;单个会话异常被捕获,不影响其他会话。 - 零历史会话不误判:NapCat 对”会话无消息”和”游标到历史尽头”都返回
{"status":"failed","retcode":200,"message":"消息<N>不存在"},旧代码把它当会话失败(真机上产生 7 条假告警)。现在这被识别为”没有更多历史”。 - 合并转发展开(
forward.ts):真实调get_forward_msg,递归展开嵌套转发(默认 3 层 / 200 节点),子消息正文入extra.forward,子消息附件平铺入父消息resources一并归档;去重与预算触顶会置truncated标记,不静默少存。单条转发拉取失败只记forwardFailed,不影响该会话其余消息。 - 媒体回落重取(
runner.ts→retryViaOneBot):媒体直链带 rkey 且有 TTL,过期后直链必失败。归档结论为missing/corrupt时回落一次 OneBot 取字节接口(get_image/get_record/get_file),拿到字节交给ArchiveMediaBytesFn(types.ts:125)走同一条内容寻址通路;没有字节入口就不发无谓请求,异常不冒泡。 - 告警:连续失败达阈值 → 写日志 + POST
alert.webhookUrl(runner.ts:390-408),webhook 失败不影响采集。
5.3 媒体归档
目录布局(packages/import/src/media.ts:6-9):
<root>/<kind>/<yyyy>/<mm>/<sha256前2位>/<sha256>.<ext> 正式内容(内容寻址)
<root>/.incoming/<sha256(url+目标身份)>.part 下载/拷贝中转
<root>/.incoming/<file>.part.meta.json 断点续传状态 sidecar
<root>/thumbs/<kind>/<sha2>/<sha256>.jpg 缩略图
- 原子落地:先写
.part再rename(同目录同文件系统,rename 原子),杜绝半截文件被当成正常内容。 - SHA-256 强校验:签名覆盖完整重组后的文件(
media.ts:196),不是只覆盖续传那一段。 - HTTP Range 断点续传(
media.ts:454-539,默认开启):.part路径按 (url, 目标身份) 确定性生成 ⇒ 同一候选重试能找到同一残片;续传起点以.part的实际磁盘大小为准;用Range+If-Range(ETag)请求;206追加、200表示服务端不支持续传则截断重下、416/不可信Content-Range则丢弃残片重头下载;最多两轮。超maxMediaBytes(默认 512 MB)清掉残片并显式失败。 - 状态显式标记:
present/missing/corrupt/pending,绝不伪造文件(缺 ffmpeg 时视频缩略图降级为占位图,不影响归档与播放)。 - 关联:归档字节 → 写
media(按 sha256 去重)→ 写message_media(runner.ts的persistMedia)。
5.4 中文全文检索
难点:unicode61 按空格/标点切词,而中文没有空格 ——「今天去看了演唱会」会被当成一个 token,搜「演唱会」永远 0 命中。
做法(packages/core/src/ids.ts 的 buildSearchText,ids.ts:113):
- 入库时把中文连续段切成二元组:
今天去看了演唱会→今天 天去 去看 看了 了演 演唱 唱会; - 英文/数字/空格保持原样(整词);
- 查询侧做同样切分,用
AND连接各 token(escapeFtsQuery,ids.ts:190); - 单个汉字(如「唱」)没有对应二元组,自动退化为字面
LIKE匹配(hasSingleCjkRun,ids.ts:169)。
结果:演唱会 命中、看了演 命中、唱 命中(走 LIKE)、hello 整词命中而 ell 不命中。
命中位置由服务端生成 <mark> 片段,前端先转义再插入标记,不会 XSS(web/ 有 XSS 回归护栏测试)。
5.5 扫码登录 QQ
- 走 NapCat WebUI HTTP 接口(与采集用的 OneBot 是两套接口、两个 token):
sha256(token + ".napcat")作password_hash换Credential,之后每请求带Authorization: Bearer <Credential>;端口自增探测 6099..6120。 - 二维码由本仓库自研编码器生成(
packages/qr,零依赖、ISO/IEC 18004 byte 模式),终端可渲染,网页下发 PNG。 - 扫码是两段式:扫到 ≠ 登录。状态会停在
qrcode_scanned+isLogin:false,必须在手机上点「确认登录」。 - 凭据只在本机内存中流转;
Credential失效会自动重认证(tests/napcat.test.ts/napcat-login-e2e.test.ts覆盖”恰好一次”)。
5.6 Web 管理界面
原生 ES 模块 + 手写 CSS,无构建、无 CDN,全部连接真实 API 与数据库(tests/web.test.ts 断言 API 调用全部走真实 /api/* 路径、index.html 自包含)。
页面覆盖:登录认证、会话列表、消息时间线(关键词高亮)、媒体预览/播放/下载、转写结果、归档统计、任务进度、日志、备份管理、QQ 登录。多账号隔离由 account_uid 顶层键保证。
服务端 REST 面覆盖:认证(login/refresh/session/logout)、统计、会话、会话消息、单条消息、搜索、媒体(列表/raw/thumb/verify/transcript/transcribe)、STT(transcribe/probe/status)、任务、导入、采集(run/health)、NapCat(status/qrcode/accounts/quick-login)、备份(创建/列表/恢复)、日志、会话导出、SSE 事件流。
5.7 认证与安全
packages/server/src/auth.ts:
- 常量时间比较校验
server.token:先各自 sha256 再timingSafeEqual,长度不同也不抛异常(auth.ts:26)。 accessToken = base64url(payload) + "." + base64url(hmac-sha256(payload)),签名密钥由server.token派生(auth.ts:33-34);直接把server.token当 Bearer 打接口会 401(认证是两段式)。- refreshToken 随机生成,DB 只保存其 sha256(
auth.ts:116),支持撤销(jti 黑名单)。 - 登录限流(
LoginThrottle)。 - 日志脱敏(
packages/core/src/logger.ts:36的redact、:44的redactValue):正则替换sk-…、Bearer xxx、token|password|…的值、data:image/*;base64,长串;redactValue()递归处理对象且对命中 key 名直接<redacted>。 - 默认只监听
127.0.0.1;server.allowDownload、exportMaxMessages、trustProxy可控。 - Token 首次运行自动生成并写回 config.json(权限建议
chmod 600)。
5.8 备份 / 恢复 / 校验
packages/server/src/backup.ts:
VACUUM INTO生成一致性快照(先 WAL checkpoint,失败则继续用 VACUUM INTO);参数化失败回退字面量 SQL(含转义)。- 快照写 manifest(含计数、sha256)+ 媒体清单 + 恢复说明。
verifySnapshot()校验 sha256 并比对计数。- 自动备份:
packages/server/src/index.ts:1380-1407起真定时器,读backup.intervalHours(默认 24,0 关闭),
与 API 路由共用唯一入口runBackupJob()(server/index.ts:1198,快照与入库收敛在一处),跑完后按backup.keep(默认 7)裁剪。 pruneBackups()(backup.ts:353+)额外清理孤儿快照:快照由VACUUM INTO生成,而登记行是在快照之后写进当前库的,所以崩溃会留下表里没有的快照文件,只按表裁剪会让它们无限堆积。qq-archive backupCLI 会写backups表(否则/api/stats的lastBackupAt恒为 null)。- 恢复演练可执行(
restore+ dry-run)。
5.9 语音转文字(可选)
packages/stt:不绑定任何 npm 依赖,直接调 whisper.cpp 官方 CLI。
流程:probe() 探测 whisper-cli / ffmpeg / ggml 模型是否真存在 → ffmpeg 把任意可解音频(QQ 语音是 amr,部分版本 silk)转 16 kHz wav → whisper-cli 拿纯文本 → 落 media_transcripts。
- 默认关闭(
stt.enabled=false),没装 whisper 的用户不付任何代价。 - 幂等:
media_transcripts用media_id作主键;空白转写(静音)也落一行,避免每次重跑。 - 中文实测可用(
tests/stt.test.ts25 例,含英文模型识别、探测缓存、命令组装、批量流水线与级联删除)。
5.10 部署
| 形态 | 适用 | 组成 |
|---|---|---|
| A. 单机全量 | 个人电脑 / 已登录 QQ 的机器 | 一个进程:归档 + 采集 |
| B. 服务器 + 采集端分离 | 有 Linux 服务器 | 服务器只存归档跑网页;采集端在个人电脑,定期把备份推到服务器 |
| C. 纯离线导入 | 不装采集端 | 只用 import 导入导出文件,网页查看 |
- Linux:
deploy/qq-archive.service(归档服务)+deploy/qq-archive-collect.service(独立采集端);StartLimitIntervalSec/StartLimitBurst正确位于[Unit]段(deploy/*.service:8-9)。 - Windows:
deploy/windows/提供计划任务安装/卸载/状态脚本,不需要管理员、不依赖 NSSM。 - ⚠️ 服务器与采集端之间只能单向同步:SQLite 不能双向同步或多写。
- 细节见 DEPLOYMENT.md。
5.11 CLI
12 个命令:serve(HTTP 服务)、import(导入)、collect(--once 或常驻)、transcribe、login(终端二维码/快捷登录)、backup、restore、verify、doctor(环境自检)、stats、logs、token。
包装脚本 bin/qq-archive.mjs 自带运行 flag,可 npm link 成全局命令。
5.12 存储层性能:预编译语句缓存
packages/db/src/index.ts 有 stmtCache + stmt(),但热路径曾绕过它直接 raw.prepare() —— 每插一条消息重新 prepare 5 条语句。
现在 6 处静态 SQL 已收敛到 stmt(),并加有界 LRU(Db.STATEMENT_CACHE_MAX = 256,packages/db/src/index.ts:288,淘汰在 :314)防止动态 IN(...) 撑爆缓存。实测(tests/db-prepare-cache.test.ts 计数 prepare 调用):
| 批量插入 | 修复前 prepare 次数 | 修复后 |
|---|---|---|
| n = 10 | 32 | 5 |
| n = 50 | 152 | 5 |
保留 raw.prepare 的 4 处是应该保留的:stmt() 自身、health() 的 SELECT 1 冷路径探针、以及两处运行时拼 WHERE 的动态 SQL。
6. 真机端到端验证
环境:NapCat 跑在 podman 容器,真实 QQ 号已登录,配置 config.json 指向本机 OneBot HTTP 通道。
6.1 采集链路
最新轮次日志(data/logs/archive.log):
2026-10-13T05:28:23.853Z sessions=13 fetched=27 inserted=0 duplicate=27 failed=0 mediaSaved=2 mediaMissing=0 ok=true
2026-10-13T05:29:36.727Z sessions=13 fetched=27 inserted=0 duplicate=27 failed=0 mediaSaved=2 mediaMissing=0 ok=true
duplicate=27 表示幂等生效(同一批消息重复拉取 0 新增);failed=0 表示”零历史会话误判”已修复。
6.2 归档库实测内容
直接查 SQLite(只读):
counts: sessions=13 messages=282 media=25 message_media=28 contacts=51 backups=2
messages by session: 会话A(私聊)=83 会话B(群)=51 会话C(群)=50 会话D(私聊)=37 会话E(群)=31 会话F(群)=30
media by kind: image=19 (5.65 MB) video=2 (19.08 MB) file=1 (30.25 MB) audio=3 (31 KB)
media by status: present=25 ← 无 missing / corrupt
migrations: 1..5 全部已应用
磁盘:archive.db 1.1 MB、data/media 53 MB、data/backups 1.9 MB。
6.3 备份 / 恢复 / 导出 / 检索
- 备份:0.76 MB 一致性快照 + manifest,dry-run 恢复通过。
- 导出:
json/txt正常。 - 检索:FTS 中文检索正常(含单汉字 LIKE 退化路径)。
- 扫码登录:自研编码器渲染的二维码与 NapCat 下发的
qrcodeurl用zbarimg反解后逐字节一致。
6.4 运行现状(含一次真实故障)
采集容器曾被主机 podman 用户服务重启(podman.service 于 13:26:24 CST 重启)带崩:容器被 SIGKILL、无 stop 事件,导致采集停摆。重启容器后:
- 登录态存活(
get_login_info→ 真实账号仍在线),无需重新扫码; - 采集轮次自动恢复,13:28 起恢复
ok=true、failed=0。
这验证了采集端的容错设计(单轮失败不清游标、容器恢复后接着跑),但也说明一个运维事实:NapCat 容器被强杀有丢登录态的风险,改动 OneBot 配置应优先写盘 + 由用户自行重启,避免 restart 已登录的容器。
7. 测试与门禁
npm run typecheck # tsc -p tsconfig.json --noEmit
npm test # node --test --experimental-strip-types "tests/**/*.test.ts"
实测结果:347 tests / 347 pass / 0 fail / 0 skipped / 0 todo,duration ≈ 66 s;typecheck 0 错误。
| 测试文件 | 用例 | 覆盖 |
|---|---|---|
tests/acceptance.test.ts |
15 | 任务书第六阶段的 15 项验收 |
tests/backup-media.test.ts |
6 | 带媒体备份往返:backup --with-media 真带媒体、删媒体后 restore --restore-media 逐个 sha256 还原;请求恢复却无媒体可恢复时必须告警而非静默通过 |
tests/backup-reconcile.test.ts |
8 | 备份对账:快照丢了登记行也能按 manifest 找回(幂等、可被裁剪、数据目录搬迁不重复登记);manifest 损坏时按文件名 + mtime 兜底——“宁可信息少,不可漏账” |
tests/cli.test.ts |
22 | 参数解析 + 真实子进程端到端(--config/导入/备份/恢复/令牌写回/网页托管/--version/transcribe/包装脚本) |
tests/collector.test.ts |
11 | collector 子命令:命令注册/帮助/参数校验;从 NapCat 挂载目录读两个 token(OneBot 取自 onebot11_<QQ号>.json 的 httpServers,WebUI 取自 webui.json);全新容器读不到也不报错 |
tests/collect.test.ts |
23 | OneBot 协议、只读白名单、退避重试、断点续传、媒体入库护栏、零历史会话不当失败(回归) |
tests/config-jsonc.test.ts |
14 | JSONC 容错:去注释/去尾逗号各司其职,字符串里的 // 绝不动;真坏的 JSON 仍报错且带文件路径;写令牌时注释不被抹掉 |
tests/db-prepare-cache.test.ts |
3 | 预编译语句缓存:prepare 次数不随批量线性增长 |
tests/db.test.ts |
7 | 迁移、FTS 中文检索、游标 |
tests/filter.test.ts |
16 | 消息过滤:关键词/发送者/类型三条规则;命中照样入库只打 filtered 标记;默认隐藏、includeFiltered=1 可全取;不启用时与加功能前行为完全一致(老配置升级回归);refilter 真实子进程重算且绝不删消息 |
tests/forward.test.ts |
15 | 合并转发展开:纯函数(嵌套/去重/截断冒泡/预算)、get_forward_msg 协议(含真机 node.sender 嵌套形状回归)、CollectRunner 真库端到端 |
tests/hardening.test.ts |
2 | 快速开始自检回归:systemd StartLimit* 必须在 [Unit]、generate.ts --help 不得创建 --help/ 目录 |
tests/html-import.test.ts |
9 | HTML 导入:三种 fixture + 保守探测 + 换行/方向/媒体/时间解析 |
tests/import.test.ts |
14 | 5 种格式导入、格式自动识别、错误分类、导入器注册数 |
tests/integration.test.ts |
5 | 不注入任何桩的真实联调 |
tests/log-sink.test.ts |
6 | 日志落库:error 级真进 logs 表(修复前恒为 0 行)、info 不落库以免淹没;启动时按 dbKeepDays 裁剪、0 表示不裁剪、环境变量可覆盖保留天数 |
tests/media.test.ts |
23 | 内容寻址、SHA-256、缩略图(含归档时必产出 thumbPath 回归)、缺失/损坏标记、Range 续传 9 例(206 追加后 sha256 一致、200 截断重下、If-Range、416、超限清残片) |
tests/napcat.test.ts |
18 | NapCat WebUI 客户端:密码哈希、端口探测、认证、凭据失效重认证、快捷登录错误语义 |
tests/napcat-login-e2e.test.ts |
7 | 真子进程 CLI + 真 HTTP + 假 NapCat WebUI:退出码、终端二维码逐模块等于编码器输出、重认证恰好一次 |
tests/napcat-routes.test.ts |
9 | /api/napcat/* 四条路由的 HTTP 契约:401/503/400 语义、二维码只下发 PNG、响应体不含 URL 明文(逐像素解开 PNG 与编码器矩阵比对) |
tests/purge.test.ts |
8 | 删除与撤回语义:默认只演练、--yes 才真删、删会话要连 FTS 虚拟表一起清(它不会级联);--media 只删不再被引用的文件;--all 不动备份 |
tests/qr.test.ts |
13 | QR 编码器:容量表、RS 数学、格式信息、几何、放置不变式、PNG 结构、与 qrencode 逐模块黄金对比、zbarimg 解码回环 |
tests/reply-preview.test.ts |
15 | 引用解析:getReplyPreviews 支持 reply_to_id(OneBot 真实形态)与 reply_to_msg_key 两种引用、按会话隔离不跨会话误配、目标不在库内返回空不抛错、已撤回目标仍可解析;时间线与详情两条路由都返回 replyPreview/replyToMessageId(含”详情原实现恒为 null”的回归);前端只认本库 id 而非 QQ 原始 id |
tests/server.test.ts |
24 | 路由、认证、导出、备份、SSE、定时自动备份写 backups 表(回归)、/api/stt/* 与 /api/media/:id/transcript |
tests/stt.test.ts |
25 | 语音转文字:文本归一化、英文模型识别、探测缓存、ffmpeg+whisper 命令组装、批量流水线、DB 契约与级联删除 |
tests/web.test.ts |
29 | 纯函数、XSS 护栏(无字符串拼接 innerHTML)、API 路径契约、无 CDN 自包含、路由表/侧边栏/视图注册三张表一致、自动刷新控制器 |
逐文件用例数由
node --test单独运行每个文件汇总而来,合计 347,与整仓一次跑批的 347 完全一致。
测试数据全部由 tests/fixtures/generate.ts 合成生成(确定性 PRNG + 手写字节生成的合法 PNG/GIF/MP4),不含任何真实聊天记录。
已修复的历史缺陷(都有回归护栏)
| ID | 缺陷 | 修复位置 |
|---|---|---|
| BUG#1 | handleMedia() 只把字节落盘,从不写 media/message_media 表 → 真机 media 表 0 行、网页媒体页空白、/api/media/:id/raw 404、备份清单媒体数 0。测试之所以没抓到,是因为 tests/acceptance.test.ts 自己注入回调把活干了 |
packages/collect/src/runner.ts persistMedia()(:327) |
| BUG#2 | 把 NapCat 的”消息undefined不存在”当会话失败 → 真机 7 条假告警 |
packages/collect/src/onebot.ts 历史分页判定 |
| D1 | systemd StartLimitIntervalSec/StartLimitBurst 放在 [Service] 段(无效) |
deploy/qq-archive.service:8-9、deploy/qq-archive-collect.service:8-9 |
| D2 | ensureThumb 定义了但归档时不调用 → 缩略图缺失 |
packages/import/src/media.ts:743(定义在 :841) |
| D3 | fixture 生成器把 --help 当目录名创建 |
tests/fixtures/generate.ts:580 |
| — | 自动备份根本不存在(backup.intervalHours/keep 零消费者) |
packages/server/src/index.ts:1380-1407 + runBackupJob()(:1198)+ packages/server/src/backup.ts pruneBackups()(:353+) |
| — | CLI backup 不写 backups 表 → lastBackupAt 恒为 null |
apps/cli/src/main.ts cmdBackup |
| — | 媒体热路径绕过预编译语句缓存 | packages/db/src/index.ts LRU + 6 处改走 stmt() |
8. 交付物
| 交付物 | 位置 |
|---|---|
| 可运行系统 | packages/* + apps/cli + web/ |
| 安装/部署手册 | DEPLOYMENT.md(Linux + Windows + 三种形态) |
| 架构契约(冻结的模块签名与 HTTP 端点) | ARCHITECTURE.md |
| 格式映射细则 | format-mapping.md |
| 15 项验收逐条结果 | acceptance.md |
| 最终交付报告 | FINAL-REPORT.md |
| 开源项目调研 | qq-export-projects-research.md、qce-reuse-analysis.md、snowluma-analysis.md |
| systemd / Windows 部署脚本 | deploy/ |
| 配置模板(含逐字段注释) | config.example.json |
| 合成测试数据生成器 | tests/fixtures/generate.ts |
9. 已知缺口与未实现(诚实清单)
9.1 本轮复核新发现的未接线功能(与 BUG#1 同类)
| ID | 事实 | 证据 |
|---|---|---|
| G1 | logs 表、/api/logs 路由与网页日志页都在,但日志从未写入数据库:Logger 支持 dbSink,全仓没有任何调用点传它。真机实测 SELECT COUNT(*) FROM logs = 0 |
packages/core/src/logger.ts:16,30,146;apps/cli/src/main.ts:157 构造时只传 level/dir |
| G2 | Database.pruneLogs(keepDays) 定义但无任何调用点 → 数据库日志保留策略是死代码 |
packages/db/src/index.ts:1444 |
影响:网页的日志视图(GET /api/logs → db.listLogs() → logs 表)恒为空;这不影响文件日志 data/logs/archive.log(它正常写入并按大小轮转,packages/core/src/logger.ts:109)。
这两项都是”实现存在、生产路径未接线”,与 BUG#1 是同一类问题;修法是构造 logger 时传 dbSink: (e) => db.appendLog(e) 并在启动时调用一次 pruneLogs。
9.2 已实现但有局限
- 视频缩略图依赖系统
ffmpeg,缺失时降级为占位图(不影响归档与播放)。 - TXT 导入格式信息量低,只能尽力解析;无法解析的行保留原文并计数。
- 中文单字搜索走
LIKE全表扫描,超大数据集下比 FTS 慢(二元组路径不受影响)。 - 合并转发展开默认 3 层 / 200 节点,触顶置
truncated而非静默截断。 - 媒体直链过期:已有 OneBot 回落重取,但没有 rkey 主动刷新(不做预判过期,只在失败后补救)。
- 性能证据口径:
prepare次数是硬计数,但没有 wall-clock 吞吐量测量;DB 测试用内存库。
9.3 有意不做
- 设备端解密导出(root/Frida)——不合法不稳定,也不符合”不注入、不改客户端”的定位。
- 从腾讯服务端删除记录中恢复、读取已解散群、获取手机端未同步历史——见 §1.2。
10. 第三方代码与许可证边界
本项目没有复制任何第三方项目的代码,所有代码为自研。 架构选择是刻意的:通过稳定的 HTTP 接口解耦,而不是链接/打包 GPL 或非开源代码。
| 项目 | 许可证(已核实 LICENSE) | 使用方式 |
|---|---|---|
| shuakami/qq-chat-exporter | GPL-3.0 | 只借鉴导出格式(读取其产物),未复制代码;扫码登录只参考其接口契约,客户端从零重写 |
| Tealina28/QQNT_Export | GPL-3.0 | 未使用(需已解密本地 DB,与手机端场景不符) |
| NapNeko/NapCatQQ | 自定义:禁商用、禁发布修改版(非 OSI 开源) | 仅通过 HTTP 调用其 OneBot 与 WebUI 接口,不链接、不修改、不打包、不分发其字节 |
| LagrangeDev/Lagrange.Core | 无 LICENSE(默认保留所有权利) | 未使用(无许可证 = 不可复用) |
| LLOneBot/LuckyLilliaBot | GPL-2.0 | 未使用(但兼容其 OneBot 接口) |
| QQBackup/QQ-History-Backup | MIT | 未使用(需 root/Frida) |
| SnowLuma/SnowLuma | Source-Available 非商业许可 v1.0(限制商业使用与修改版公开发布,且禁止在其之上另建项目) | 未使用任何代码;仅阅读源码提取机制与思路(预编译语句缓存、媒体 URL 失效处理),由本项目独立实现 |
本项目的许可:PolyForm Noncommercial License 1.0.0(源码可见,非 OSI 认可的开放源代码许可)——任何非商业目的可自由使用、修改与分发;商业使用须事先取得著作权人书面授权。公开仓库:https://github.com/emoeem/qq-archive。
11. 复现步骤
cd /home/emo/code/QQ
npm run typecheck # 期望 0 错误
npm test # 期望 347 pass / 0 fail
cp config.example.json config.json && chmod 600 config.json
node --experimental-strip-types apps/cli/src/main.ts doctor # 环境自检(首次会生成令牌)
node --experimental-strip-types apps/cli/src/main.ts import <导出文件>
node --experimental-strip-types apps/cli/src/main.ts stats
node --experimental-strip-types apps/cli/src/main.ts collect --once # 需要旁边有 OneBot 实现
node --experimental-strip-types apps/cli/src/main.ts token
node --experimental-strip-types apps/cli/src/main.ts serve # 浏览器打开 http://127.0.0.1:8787
12. 结论
系统是真实实现并经过真机验证的,不是静态页面或演示桩:
- 采集、导入、媒体归档、中文检索、网页管理、扫码登录、备份恢复、部署脚本全部连真实数据通路;
- 347 个自动化用例 + 0 类型错误,且测试数据全部合成、不含真实聊天记录;
- 真机跑出了 13 会话 / 282 消息 / 25 媒体(全部
present)/ 28 条关联,采集轮次持续ok=true; - 历史上一批”文档宣称已实现、代码里没接线”的缺陷(BUG#1、自动备份、CLI 备份入库、缩略图、systemd 段位)已修复并有回归护栏;
- 仍存在 §9.1 的两处未接线(日志入库、DB 日志裁剪)和 §9.2 的若干局限,本文如实标注,不宣称已完成;
- §1.2 的边界是 QQ/OneBot 侧的硬限制,本系统不宣称能突破。







