# WeChat EXP 开发手册

> 版本：1.5 | 更新日期：2026-06-01 | 已测试 WeChat 4.1.9 / 4.1.10

---

## 目录

1. [项目概述](#1-项目概述)
2. [系统架构](#2-系统架构)
3. [目录结构](#3-目录结构)
4. [核心模块详解](#4-核心模块详解)
   - [4.1 引擎层 (engine/)](#41-引擎层-engine)
   - [4.2 备份管线 (backup/)](#42-备份管线-backup)
   - [4.3 Web 应用 (web/)](#43-web-应用-web)
   - [4.4 CLI 入口 (main.py)](#44-cli-入口-mainpy)
5. [数据流与关键流程](#5-数据流与关键流程)
   - [5.1 备份流程](#51-备份流程)
   - [5.2 消息查询流程](#52-消息查询流程)
   - [5.3 媒体解析流程](#53-媒体解析流程)
   - [5.4 V2 密钥收割流程](#54-v2-密钥收割流程)
6. [V2 图片解密系统](#6-v2-图片解密系统)
7. [配置系统](#7-配置系统)
8. [API 参考](#8-api-参考)
9. [扩展指南](#9-扩展指南)
10. [构建与部署](#10-构建与部署)
   - [附录 A：消息类型码](#附录-a消息类型码)
   - [附录 B：文件命名约定](#附录-b文件命名约定)
   - [附录 C：微信 4.x 数据库文件与表结构](#附录-c微信-4x-数据库文件与表结构)

---

## 1. 项目概述

WeChat EXP 是一款 Windows 平台下的微信 4.x 聊天记录提取、解密与分析工具。核心功能包括：

- **内存密钥提取**：从微信进程内存中提取 SQLCipher 4 数据库密钥
- **批量解密**：AES-256-CBC 解密微信消息数据库
- **Web 聊天查看器**：基于 Flask 的 SPA 聊天记录浏览器，支持头像、群组、消息搜索
- **V2 图片解密**：支持 WeChat 4.x 的 V2 格式 .dat 加密图片本地解码
- **年度报告**：8 张 Spotify-Wrapped 风格的数据卡片
- **多种导出**：词云、HTML 综合报告、员工报表、TXT/HTML 聊天导出

**技术栈**：Python 3.8+ | Flask 3.x | SQLite (SQLCipher 4) | Vanilla JS (SPA) | ECharts | pycryptodome

**兼容性**：Windows 10/11, WeChat 4.x（已测试 4.1.9 / 4.1.10，其他版本未经测试）

---

## 2. 系统架构

```
┌──────────────────────────────────────────────────────────────┐
│                        CLI (main.py)                          │
│   backup │ serve │ export │ harvest-keys                     │
├──────────────────────────────────────────────────────────────┤
│                    Web Application (web/)                     │
│   Flask SPA │ SSE Progress │ REST API │ Blueprints           │
├──────────────────────────────────────────────────────────────┤
│                    Engine Layer (engine/)                     │
│   decrypt │ services (chat/message/media/v2_key) │ parsers   │
├──────────────────────────────────────────────────────────────┤
│                   Backup Pipeline (backup/)                   │
│   scanner → decryptor → migrator → indexer → pipeline        │
├──────────────────────────────────────────────────────────────┤
│                   External Dependencies                       │
│   SQLCipher 4 │ zstandard │ pycryptodome │ Whisper │ ECharts  │
└──────────────────────────────────────────────────────────────┘
```

**分层设计原则**：

1. **Engine 层**是核心，被 CLI 和 Web 共享，不依赖 Flask
2. **Backup 管线**编排备份任务，依赖 Engine 层进行解密
3. **Web 层**仅做展示与路由，业务逻辑全部在 Engine 层
4. **CLI** 是最薄的入口层，解析参数后调用对应模块

---

## 3. 目录结构

```
wechat-exp/
├── .wechat_exp_config.json     # 持久化配置（备份路径、DB密钥、wxid）
├── requirements.txt            # 开发依赖
├── requirements-build.txt      # PyInstaller 打包依赖
├── backup/                     # 备份输出（按日期分目录）
├── docs/
│   ├── analysis/               # 分析日志与修复追踪
│   └── superpowers/            # 设计规格与实现计划
├── scripts/
│   ├── build_readme_html.py    # README → HTML 手册构建
│   ├── bump_version.py         # 版本号自动递增
│   ├── capture_screenshots.py  # Playwright 截图脚本
│   └── gen_version_file.py     # 版本文件生成
├── screenshots/                # README 截图素材
├── src/
│   ├── main.py                 # CLI 入口
│   ├── wechat_exp.spec         # PyInstaller 构建规格
│   ├── chat_export.py          # 聊天导出（TXT/HTML）
│   ├── chat_list.py            # 扫描列出聊天会话
│   ├── employee_match.py       # 员工报表批量导出
│   ├── key_scan.py             # 内存密钥扫描
│   ├── report_gen.py           # HTML 综合报告生成
│   ├── stopwords.py            # 中文停用词系统
│   ├── wordcloud_gen.py        # 词云生成
│   │
│   ├── engine/                 # 核心引擎
│   │   ├── config_file.py      # 配置文件读写
│   │   ├── constants.py        # 消息类型码、颜色常量
│   │   ├── decrypt.py          # SQLCipher 4 解密
│   │   ├── utils.py            # 微信目录自动检测
│   │   ├── parsers/
│   │   │   └── types.py        # 消息类型 XML 解析器
│   │   └── services/
│   │       ├── chat.py         # 联系人/群组查询
│   │       ├── emoji_map.py    # 表情代码映射
│   │       ├── media.py        # 媒体解析与提供 (V1/V2 .dat 解密)
│   │       ├── name_resolver.py# 统一名称解析
│   │       ├── v2_key_extract.py # V2 AES 密钥内存提取
│   │       └── message/
│   │           ├── __init__.py  # 消息查询 API
│   │           ├── decode.py    # zstd 解压与发送者提取
│   │           └── media_resolve.py # 媒体路径解析
│   │
│   ├── backup/                 # 备份管线
│   │   ├── scanner.py          # 发现账户/会话
│   │   ├── decryptor.py        # 数据库解密
│   │   ├── migrator.py         # 硬链接媒体迁移
│   │   ├── indexer.py          # 构建统一索引
│   │   └── pipeline.py         # 管线编排
│   │
│   └── web/                    # Web 应用
│       ├── app.py              # Flask 工厂
│       ├── sse.py              # SSE 进度推送
│       ├── static_builder.py   # 离线静态 HTML
│       ├── static/             # JS/CSS/模型
│       │   ├── js/             # api.js, app.js, store.js, components/
│       │   └── models/         # Whisper 离线模型
│       ├── templates/          # Jinja2 模板
│       ├── routes/             # API Blueprints
│       │   ├── api.py          # 主 REST 端点
│       │   ├── avatar_api.py   # 头像服务
│       │   ├── backup_api.py   # 备份 SSE 端点
│       │   ├── cleanup_api.py  # 垃圾清理 API
│       │   └── export_api.py   # 导出 SSE 端点
│       └── reports/            # 报告子系统
│           ├── wordcloud.py    # 词云路由
│           └── wrapped/        # 年度报告（7张卡片）
```

---

## 4. 核心模块详解

### 4.1 引擎层 (engine/)

#### 4.1.1 decrypt.py — 数据库解密

```python
from engine.decrypt import decrypt_database

# SQLCipher 4 使用 AES-256-CBC，512,000 次 PBKDF2 迭代
# 密钥为 64 字符 hex 字符串（32 字节）
success = decrypt_database(src_path, dst_path, key_bytes)
```

微信 4.x 使用 SQLCipher 4 加密所有消息数据库。解密模块负责：
- 使用 64 位 hex 密钥进行 AES-256-CBC 解密
- 支持批量解密（message_*.db, contact.db, session.db 等）
- 自动密钥匹配（按文件名前缀 → 任意可用密钥的降级策略）

#### 4.1.2 services/chat.py — 联系人查询

```python
from engine.services.chat import get_contacts, get_group_info

# 获取所有联系人（包含头像、备注名、群组信息）
contacts = get_contacts(decrypted_dir, wxid)

# 获取群组详细信息（成员列表、群名等）
info = get_group_info(decrypted_dir, chat_id)
```

5 级名称解析优先级：备注名 → 群昵称 → 微信号 → 通讯录名 → 原始 wxid

#### 4.1.3 services/message/ — 消息查询

```python
from engine.services.message import query_messages, query_message_detail

# 分页查询消息（支持日期范围、类型、发送者、关键词过滤）
result = query_messages(
    decrypted_dir, chat_id, wxid=wxid,
    page=1, per_page=50,
    start_date='2026-05-01', end_date='2026-05-27',
    msg_types='3,43',  # 图片+视频
    keyword='搜索词'
)

# 获取单条消息详情（含所有字段）
detail = query_message_detail(decrypted_dir, msg_id, chat_id='xxx')
```

消息处理管线：
1. **zstd 解压**：`message/decode.py` — 检测 `\x28\xb5\x2f\xfd` 魔数并解压
2. **类型解析**：`parsers/types.py` — 用正则提取 XML 中的媒体信息
3. **名称解析**：`services/name_resolver.py` — 统一 wxid → 显示名
4. **媒体路径**：`message/media_resolve.py` — 多级路径解析

#### 4.1.4 services/media.py — 媒体提供服务

```python
from engine.services.media import (
    serve_hardlink_media,  # HardLink DB 路径解析 + 解密
    serve_media,           # 传统路径解析
    serve_voice,           # 语音 SILK → WAV
    transcribe_voice,      # Whisper 语音转文字
    _silk_to_wav,          # SILK V3 解码（调用 silk_decoder.exe）
)
```

**语音解码流程**（`serve_voice` → `_silk_to_wav`）：
1. 搜索缓存目录 `{decrypted_dir}/media/voice/`
2. 未找到则从 `VoiceInfo` 表提取（`_extract_voice_from_db`）
3. 调用 `silk_decoder.exe` 将 SILK V3 → PCM
4. `_write_wav()` 将 PCM 封装为 WAV（24000Hz, 16-bit, mono）
5. 失败时输出 `[WARN]` 日志（含具体原因），fallback 返回 raw SILK

**调试语音播放问题**：查看 Flask 控制台的 `[WARN]` 输出，常见原因：
- `silk_decoder.exe not found` → 源路径检查 `tools/`，PyInstaller 检查 `_MEIPASS/tools/`
- `silk_decoder.exe failed (exit=X)` → SILK 文件头无效或损坏
- 无 WARN 且返回 206 → 文件已找到但转换失败（可能是子进程权限问题）

**PyInstaller 打包注意**：`silk_decoder.exe` 需在 `build.bat` 中通过 `--add-data` 和 `.spec` 文件中通过 `datas` 列表纳入打包。

支持三种 .dat 加密版本：

| 版本 | 签名 | 加密方式 | 解密能力 |
|------|------|----------|----------|
| V0 | 无签名 | 纯 XOR | 自动检测密钥（0xC9 等） |
| V1 | `\x07\x08V1\x08\x07` | AES-128-ECB (固定) + XOR | 内置密钥，可解密 |
| V2 | `\x07\x08V2\x08\x07` | AES-128-ECB (动态) + XOR | 需内存收割 |

#### 4.1.5 services/v2_key_extract.py — V2 密钥提取

独立模块，详见 [第 6 节](#6-v2-图片解密系统)。

### 4.2 备份管线 (backup/)

#### 管线编排

```
scanner.py          → 发现微信账户、会话列表
    ↓
decryptor.py        → 解密 SQLCipher 数据库（调用 engine.decrypt）
    ↓
migrator.py         → 硬链接/复制媒体文件到备份目录
    ↓
indexer.py          → 构建统一 chats.db 索引
    ↓
pipeline.py         → 编排上述四阶段，提供进度回调
```

#### pipeline.py 接口

```python
from backup.pipeline import run_backup

result = run_backup(
    db_path,            # 微信 db_storage 目录
    output_dir,         # 备份输出目录
    key_file=None,      # 密钥文件（None=自动检测）
    start_date=None,    # 起始日期 "YYYY-MM-DD"
    end_date=None,      # 截止日期
    on_progress=None,   # 进度回调 fn(stage, detail, progress)
)
# → {'success': bool, 'stats': {...}, 'errors': [...]}
```

### 4.3 Web 应用 (web/)

#### 导出 API 智能匹配 (`routes/export_api.py`)

导出和词云的 `/chat` 和 `/wordcloud` 端点都支持联系人智能匹配：

1. **精确匹配**：username 或 display_name 相等 → 直接使用
2. **模糊匹配**：在 display_name 和 username 中搜索子串
   - 单个匹配 → 自动选中
   - 多个匹配 → 通过 SSE `push.select(matches)` 发送匹配列表给前端
   - 零匹配 → `push.error()` 返回错误
3. **前端选择**：用户从匹配列表中点击选择后，WizardPage 将 username 回填到输入字段，自动重发请求（此时精确匹配命中）

`push.select()` 发送的 SSE 事件被 `sse_progress.js` 解析，触发 WizardPage 的 `onSelect` → `handleSelect()` 回调。

#### Flask 应用工厂

```python
from web.app import create_app, run_server

app = create_app(decrypted_dir, wxid='...', db_dir='...')
# 自动注册 7 个 Blueprint
```

#### Blueprint 列表

| Blueprint | 前缀 | 文件 | 功能 |
|-----------|------|------|------|
| `api_bp` | `/api` | `routes/api.py` | 联系人、消息、媒体、语音、表情 |
| `avatar_bp` | `/avatar` | `routes/avatar_api.py` | 3 级头像降级 |
| `backup_bp` | — | `routes/backup_api.py` | SSE 备份执行 |
| `export_bp` | — | `routes/export_api.py` | SSE 导出执行 |
| `cleanup_bp` | `/api` | `routes/cleanup_api.py` | 垃圾清理扫描与删除 |
| `reports_bp` | — | `reports/` | 报告/词云 |
| `wrapped_bp` | `/wrapped` | `reports/wrapped/` | 年度报告 |

#### 前端架构（Vanilla JS SPA）

```
static/js/
├── api.js              # REST API 封装（fetch + AbortController）
├── app.js              # 主应用路由器
├── store.js            # 全局状态管理
├── utils.js            # 工具函数
├── wizard.js           # 通用向导/SSE 进度页面
├── sse_progress.js     # SSE 事件流解析
└── components/         # UI 组件
    ├── chat-header.js      # 聊天头部
    ├── chat-viewer.js      # 消息列表容器
    ├── contact-list.js     # 联系人列表
    ├── message-bubble.js   # 消息气泡渲染
    ├── date-picker.js      # 日期选择器
    ├── emoji-picker.js     # 表情选择器
    ├── filter-panel.js     # 筛选面板
    ├── group-info.js       # 群组信息面板
    └── ...
```

**状态管理**：所有状态集中在 `store.js`（无框架依赖）：
- `store.state.chats` — 联系人列表
- `store.state.messages` — 当前消息列表
- `store.state.activeChat` — 当前选中联系人
- `store.state.filters` — 筛选条件

**WizardPage 通用向导组件** (`wizard.js`)：

`WizardPage` 封装了"配置 → SSE 进度 → 结果展示"的标准流程，被导出、词云、备份等页面共用。关键配置项：

```javascript
new WizardPage({
  apiUrl: '/api/export/chat',  // SSE 端点
  chatFieldId: 'cfg-contact',  // 联系人字段 ID（默认 'cfg-chat'）
  getBody: () => ({...}),      // 构建请求体
  steps: ['export'],           // 阶段标识列表
});
```

当后端返回 `select` 事件时，`handleSelect()` 渲染选择列表到 `#select-container`，用户选择后将 username 回填到 `chatFieldId` 指定的字段，自动重新发起请求。`chatFieldId` 默认值为 `cfg-chat`（兼容词云页面），导出页面设置为 `cfg-contact`。

### 4.4 CLI 入口 (main.py)

```bash
python main.py <command> [options]

命令：
  backup          备份微信聊天记录（4 阶段管线）
  serve           启动 Web 聊天记录查看器
  export          导出聊天记录（7 种子模式）
  harvest-keys    收割 V2 图片 AES 密钥

全局选项（无需子命令）：
  -q, --quick     快速测试：跳过备份，直接打开聊天查看器（使用已有解密数据）
  -c, --contact   配合 --quick，自动打开指定联系人的聊天（名称模糊匹配）

导出子模式（-m）：
  chat            导出为 TXT/HTML
  wordcloud       生成词云 HTML
  report          生成综合 HTML 报告
  employee        员工 Excel 驱动批量导出
  list            列出所有聊天会话
  keys            扫描内存提取数据库密钥
  decrypt         独立数据库解密
```

**快速测试模式实现**：`_cmd_quick()` 函数自动解析 `decrypted_dir`（从配置 → 最新备份 → 回退），调用 `run_server()` 时传入 `open_url='/chat?contact=NAME'` 参数，跳过仪表盘直接进入聊天查看器。前端 `app.js` 的 `initApp()` 通过 `URLSearchParams` 解析 `contact` 参数，自动搜索并选中匹配的联系人。

---

## 5. 数据流与关键流程

### 5.1 备份流程

```
1. scanner.py
   扫描 db_storage/ 目录
   ├── 发现所有微信账户（wxid_xxx）
   ├── 枚举每个账户的数据库（message_0.db, contact.db...）
   └── 识别会话列表

2. decryptor.py
   对每个加密数据库：
   ├── 查找对应密钥（config → 自动扫描）
   ├── SQLCipher 4 AES-256-CBC 解密
   └── 输出到 output/message/*.db

3. migrator.py
   扫描解密后的消息数据库：
   ├── 查询所有媒体引用（图片/视频/文件/语音）
   ├── 通过 HardLink DB 解析路径
   ├── 硬链接（同分区）或复制（跨分区）到 output/media/
   └── 支持日期范围过滤

4. indexer.py
   构建统一索引：
   ├── 扫描所有 message_*.db 中的 Msg_* 表
   ├── 生成聚合统计（消息数、日期范围、类型分布）
   └── 写入 output/chats.db
```

### 5.2 消息查询流程

```
GET /api/messages?chat_id=xxx&page=1&per_page=50

1. Flask route → engine/services/message/__init__.py
2. 确定消息表：根据 chat_id 找到对应 Msg_* 表
3. SQL 查询：
   ├── WHERE 子句组合（日期、类型、发送者、关键词）
   ├── ORDER BY create_time DESC
   └── LIMIT/OFFSET 分页
4. 每条消息：
   ├── message/decode.py        → zstd 解压
   ├── parsers/types.py         → XML 解析（提取媒体信息）
   ├── services/name_resolver.py → wxid → 显示名
   └── message/media_resolve.py → 媒体路径（用于 API URL 构建）
5. 返回 JSON：{messages: [...], pagination: {...}}
```

### 5.3 媒体解析流程

```
GET /api/hardlink-media?md5=xxx&path=xxx&type=3

1. _resolve_hardlink_path()
   ├── 使用提供的 local_path（如果有）
   ├── HardLink DB 查询（md5 → dir1/dir2 → 路径）
   └── 多存储根目录尝试（D:\xwechat_files, C:\xwechat_files）

2. 版本检测
   ├── 读取文件头 6 字节
   ├── V2 签名 → 走 V2 解密管线
   ├── V1 签名 → AES-128-ECB（固定密钥 cfcd208495d565ef）+ XOR
   └── V0（无签名） → XOR 自动检测密钥

3. V2 解密尝试顺序
   ├── ① 缓存键映射（_media_keys.json）
   ├── ② 消息 DB 逐个搜索（_collect_image_aeskey）
   ├── ③ 微信进程内存提取（find_keys_for_files）
   └── ④ 缩略图缓存降级（本地缓存目录）

4. 成功后缓存到 decrypted_media/ 子目录
```

### 5.4 V2 密钥收割流程

```
python main.py harvest-keys

1. _load_v2_ciphertexts()
   ├── 扫描 output/media/images/*.dat
   ├── 读取每个 V2 文件的 AES 密文块
   └── 构建 {md5: (file_path, ciphertext_16b)}

2. 收割循环（每 N 秒）
   ├── 检查微信进程是否存在
   ├── 策略 1：扫描 V2 头部附近内存
   │   ├── 找到所有 \x07\x08V2\x08\x07 出现位置
   │   ├── 读取 ±256 字节窗口
   │   └── 16 字节滑动窗口测试 → 所有密文
   ├── 策略 2：扫描所有 RW 内存
   │   ├── 正则匹配 32 字符 hex 字符串
   │   └── hex 解码 → 测试所有密文
   └── 找到的密钥 → 立即缓存到 _media_keys.json

3. 自动停止条件
   ├── 所有 V2 密钥已找到
   ├── 微信退出
   └── 60 轮无新密钥
```

---

## 6. V2 图片解密系统

### 6.1 V2 文件格式

```
偏移    大小    内容
0       6       签名 \x07\x08\x56\x32\x08\x07
6       4       AES 数据大小（LE uint32）
10      4       XOR 数据大小（LE uint32）
14      1       填充字节
15      N       AES-128-ECB 加密数据（PKCS7 填充）
15+N    16      常量尾部（56fbf486095aa2eed54a41405dffe35d）
15+N+16 M       XOR 加密尾部（密钥 0xC9）
```

### 6.2 密钥来源

| 来源 | 可靠性 | 说明 |
|------|--------|------|
| 消息 XML `aeskey` 属性 | 0%（已验证） | CDN aeskey ≠ 本地文件密钥 |
| 微信进程内存 | 100%（浏览时） | 仅在图片查看期间存在 |
| `_media_keys.json` 缓存 | 100%（收割后） | 收割器缓存结果 |

### 6.3 收割器三种策略

**策略 1：V2 头部邻近扫描**
- 搜索所有内存中的 V2 魔数
- 读取每个出现位置 ±256 字节
- 16 字节滑动窗口测试原始二进制密钥
- 32 字节 hex 解码窗口测试

**策略 2：wx_key 模式定位**
- 检测微信版本号
- 在 Weixin.dll 中搜索版本特定的字节模式
- 定位图片解密函数地址
- 读取函数附近内存寻找密钥

**策略 3：全内存正则扫描**
- 枚举所有 RW 内存区域
- 正则匹配 32 字符 hex 字符串
- hex 解码后测试 AES 解密

### 6.4 扩展收割器

```python
from engine.services.v2_key_extract import harvest_v2_keys, is_wechat_running

if is_wechat_running():
    # 无限循环，2 秒间隔
    found = harvest_v2_keys(
        decrypted_dir='/path/to/output',
        wxid='wxid_xxx_xxx',
        interval=2.0,
        max_rounds=None,  # 无限
        print_fn=print,
    )

# 单轮扫描（用于 API）
found = harvest_v2_keys(
    decrypted_dir='/path/to/output',
    wxid=None,          # 自动检测
    interval=0,
    max_rounds=1,
)
```

---

## 7. 配置系统

### .wechat_exp_config.json

```json
{
  "last_backup_data_dir": "D:\\perl_wrk\\PC_Wechat\\wechat-exp\\backup\\2026-05-27",
  "last_backup_wxid": "wxid_xxxxxxxxxxxx",
  "db_keys": {
    "message_0.db": "a1b2c3d4e5f6...64位hex...",
    "contact.db": "f6e5d4c3b2a1...64位hex...",
    "_db_dir": "D:\\xwechat_files\\wxid_xxx\\db_storage"
  }
}
```

**关键函数**：

```python
from engine.config_file import (
    get_backup_data_dir,    # → str | None
    set_backup_data_dir,    # (dir, wxid=None)
    get_backup_wxid,        # → str | None
    get_latest_backup_dir,  # (base_dir) → str | None
)
```

### 数据库密钥自动发现

备份时如果 `db_keys` 为空或缺失：
1. 检查 `--key-file` CLI 参数
2. 检查 `.wechat_exp_config.json` 中的 `db_keys`
3. 自动扫描微信进程内存（`key_scan.py`，支持 **WeChat.exe / Weixin.exe** 双进程名）
4. 按文件名前缀匹配密钥到数据库
5. 解密失败时自动重新扫描并重试（`pipeline.py` 内置 auto-retry 机制）

---

## 8. API 参考

### 8.1 REST API（Web 查看器）

| 端点 | 方法 | 参数 | 返回 |
|------|------|------|------|
| `/api/contacts` | GET | `?q=` (搜索) | `{contacts: [...], total: N}` |
| `/api/messages` | GET | `?chat_id=&page=&per_page=&start_date=&end_date=&type=&sender=&keyword=` | `{messages: [...], pagination: {...}}` |
| `/api/messages/<id>` | GET | `?chat_id=` | 消息详情 |
| `/api/chat/<id>/stats` | GET | — | 统计数据 |
| `/api/chat/<id>/dates` | GET | — | 日期范围 |
| `/api/chat/<id>/group-info` | GET | — | 群组信息 |
| `/api/media` | GET | `?path=` | 媒体文件 |
| `/api/hardlink-media` | GET | `?md5=&path=&type=&file_name=&local_id=` | HardLink 媒体文件 |
| `/api/voice` | GET | `?path=&create_time=&local_id=` | 语音文件 |
| `/api/voice/transcribe` | GET | `?path=` | `{text: "..."}` |
| `/api/emoji` | GET | `?md5=&account=&emoji_url=` | 表情图片 |
| `/api/harvest-keys/status` | GET | — | `{cached, v2_total, pending, wechat_running}` |
| `/api/harvest-keys/run` | POST | — | `{found, keys: {...}}` |

### 8.2 Engine API（供模块调用）

```python
# 联系人
from engine.services.chat import get_contacts, get_group_info

# 消息
from engine.services.message import (
    query_messages, query_message_detail,
    get_chat_stats, get_chat_dates,
)

# 媒体
from engine.services.media import (
    serve_media, serve_hardlink_media,
    serve_voice, transcribe_voice,
)

# V2 密钥
from engine.services.v2_key_extract import (
    find_keys_for_files,   # 为特定文件查找密钥
    harvest_v2_keys,        # 持续收割（阻塞）
    is_wechat_running,      # 检查微信是否运行
)

# 配置
from engine.config_file import (
    get_backup_data_dir, set_backup_data_dir,
    get_backup_wxid, get_latest_backup_dir,
)

# 解密
from engine.decrypt import decrypt_database

# 实用工具
from engine.utils import find_all_wechat_data_dirs, is_wechat_running
```

---

## 9. 扩展指南

### 9.1 添加新的导出模式

1. 在 `src/` 下创建新模块（如 `calendar_export.py`）
2. 向 `main.py` 的 `cmd_export()` 添加新分支
3. 更新 `ep.add_argument('--mode', choices=[...])` 的 choices 列表

```python
# src/calendar_export.py
def export_calendar(decrypted_dir, output_path):
    """导出日历视图的聊天记录"""
    pass

# src/main.py cmd_export() 添加：
elif mode == 'calendar':
    from calendar_export import export_calendar
    export_calendar(decrypted, args.output)
```

### 9.2 添加新的年度报告卡片

```python
# src/web/reports/wrapped/cards/card_08_new_card.py

def generate(decrypted_dir: str, wxid: str) -> dict:
    """生成新卡片数据。返回 {title, data, chart_config}。"""
    from engine.services.message import query_messages
    # ... 计算逻辑
    return {
        'title': '新卡片标题',
        'type': 'bar',  # ECharts 图表类型
        'data': {...},
    }

# 然后在 service.py 的 CARD_REGISTRY 中注册
```

### 9.3 添加新的 V2 密钥收割策略

```python
# src/engine/services/v2_key_extract.py

def _scan_new_strategy(h_process, tasks, print_fn=None):
    """新策略：扫描特定内存区域。"""
    for md5, (file_path, ciphertext) in tasks.items():
        # ... 策略实现
        # 使用 _try_key(candidate, ciphertext) 测试
        pass

# 在 harvest_v2_keys() 的收割循环中调用
```

### 9.4 添加新的前端组件

```javascript
// src/web/static/js/components/new-component.js

// 组件必须导出 init(container) 和 destroy()
const NewComponent = {
  init(container) {
    this.el = document.createElement('div');
    this.el.className = 'new-component';
    container.appendChild(this.el);
    this.render();
  },

  render() {
    // ... 渲染逻辑
  },

  destroy() {
    if (this.el) this.el.remove();
  }
};

export { NewComponent };
```

然后在 `app.js` 中导入并在适当的路由中初始化。

---

## 10. 构建与部署

### 开发环境

```bash
# 安装依赖
pip install -r requirements.txt

# 运行 Web 查看器
cd src
python main.py serve

# 快速测试模式（跳过备份）
python main.py -q               # 打开聊天查看器
python main.py -q -c 张三       # 直接打开指定聊天

# 运行完整备份
python main.py backup --days 30

# 密钥收割（需微信运行）
python main.py harvest-keys
```

### PyInstaller 打包

```bash
# 1. 生成手册 HTML（在 PyInstaller 之前）
python scripts/build_readme_html.py

# 2. 打包
cd src
pyinstaller wechat_exp.spec
# 输出: src/dist/wechat_exp.exe
```

`wechat_exp.spec` 配置要点：
- `datas` 包含 `src` 源码、`tools/silk_decoder.exe` 解码器，以及 Web 模板/静态文件
- `manual.html` 由 `scripts/build_readme_html.py` 生成到 `src/web/templates/`，PyInstaller 通过 `--add-data "src;src"` 一并打包
- 排除不必要的依赖以减小体积
- `upx=False` 以避免反病毒误报

**重要**：`silk_decoder.exe` 必须在打包时通过 `--add-data "tools\silk_decoder.exe;tools"`（build.bat）和 spec 的 `datas` 列表纳入。运行时 `_silk_to_wav()` 优先从 `sys._MEIPASS/tools/` 查找，其次从源码树 `{project_root}/tools/` 查找。

### 目录约定

| 变量 | 开发模式 | 打包后 |
|------|----------|--------|
| `BASE` | `src/` | `sys._MEIPASS/src/` |
| 模板/静态 | `web/templates/`, `web/static/` | `_MEIPASS/src/web/` |
| 配置 | 项目根 `.wechat_exp_config.json` | 可执行文件旁 |
| 备份输出 | `../backup/` (相对于 src/) | `backup/` (相对于 exe) |

---

## 附录 A：消息类型码

| 代码 | 类型 | local_type 掩码 |
|------|------|----------------|
| 1 | 文本 | `type & 0xFFFF == 1` |
| 3 | 图片 | `type & 0xFFFF == 3` |
| 34 | 语音 | `type & 0xFFFF == 34` |
| 43 | 视频 | `type & 0xFFFF == 43` |
| 47 | 表情 | `type & 0xFFFF == 47` |
| 49 | 小程序/链接 | `type & 0xFFFF == 49` |
| 10002 | 系统消息 | `type & 0xFFFF == 10002` |
| 436207665 | 引用回复 | 特殊位掩码 |
| 1090519089 | @提及 | 特殊位掩码 |

## 附录 B：文件命名约定

| 文件 | 格式 | 说明 |
|------|------|------|
| `message_N.db` | SQLite (解密后) | 分片消息数据库 |
| `contact.db` | SQLite (解密后) | 联系人数据库 |
| `hardlink.db` | SQLite (解密后) | HardLink 路径映射 |
| `media/images/*.dat` | V0/V1/V2 加密 | 图片文件 |
| `chats.db` | SQLite | 统一索引（备份产物） |
| `_media_keys.json` | JSON | V2 密钥缓存 |
| `.wechat_exp_config.json` | JSON | 持久化配置 |

---

---

## 附录 C：微信 4.x 数据库文件与表结构

微信 4.x 将用户数据存储在 `xwechat_files/<wxid>/db_storage/` 目录下，所有数据库使用 **SQLCipher 4**（AES-256-CBC）加密。本工具在备份流程中解密以下数据库并重新组织到 `output/decrypted/` 目录。

### C.1 数据库文件清单

| # | 数据库文件 | 微信原始路径 | 解密后路径 | 用途 |
|---|-----------|-------------|-----------|------|
| 1 | `message_0.db` ~ `message_N.db` | `db_storage/message/` | `decrypted/message/` | 消息分片存储（核心） |
| 2 | `contact.db` | `db_storage/contact/` | `decrypted/contact/` | 联系人资料与头像 |
| 3 | `session.db` | `db_storage/session/` | —（直接读取） | 会话摘要与最近消息预览 |
| 4 | `hardlink.db` | `db_storage/hardlink/` | `decrypted/hardlink/` | 媒体文件 HardLink 路径映射 |
| 5 | `media_0.db` | `db_storage/message/` | `decrypted/message/` | 语音消息原始数据 |
| 6 | `message_resource.db` | `db_storage/message/` | `decrypted/message/` | 消息媒体文件引用索引 |
| 7 | `chats.db` | —（本工具生成） | `decrypted/data/` | 统一聊天索引（备份产物） |

> **注**：`session.db` 可能未加密，工具直接从原始路径或解密目录读取。备份管线当前不解密 `session.db`。

### C.2 message_N.db — 消息分片数据库

微信按时间/容量将消息拆分到多个分片文件（`message_0.db`, `message_1.db`, ...）。每个分片包含多个 `Msg_<hash>` 表，每个表对应一个聊天会话。

#### 表：`Msg_<md5hash>`

表名 = `Msg_` + `md5(chat_id)` 的 32 位十六进制哈希。

| 列名 | 类型 | 说明 |
|------|------|------|
| `local_id` | INTEGER | 消息在聊天内的本地 ID（主键） |
| `local_type` | INTEGER | 消息类型码（低 16 位为实际类型，见附录 A） |
| `origin_source` | INTEGER | 1 = 自己发送（群聊可靠，单聊不可靠） |
| `create_time` | INTEGER | Unix 时间戳（秒；部分旧数据为毫秒 > 1000000000000） |
| `status` | INTEGER | 消息状态（已发送/已读等） |
| `message_content` | BLOB/TEXT | 消息内容：zstd 压缩 XML（魔数 `\x28\xb5\x2f\xfd`）、纯文本（UTF-8/GBK）、或 protobuf 二进制 |
| `real_sender_id` | INTEGER | 发送者在聊天内的成员索引（**不是** contact.db 的 rowid） |
| `packed_info_data` | BLOB | protobuf 编码的媒体元数据（图片/视频/文件的 md5、尺寸等） |
| `compress_content` | BLOB | 备用压缩内容列（部分消息使用） |
| `server_id` | INTEGER | 服务端消息 ID |
| `sort_seq` | INTEGER | 排序序列号 |

**zstd 压缩说明**：微信 4.x 对类型 3/6/42/43/47/48/49/50 的消息内容使用 zstd 压缩。压缩数据以 `\x28\xb5\x2f\xfd`（zstd 帧头）开头，解压后得到 XML 文本。单聊中接收的消息在 XML 前有 `wxid_xxx:\n` 发送者前缀。

#### 表：`Name2Id`

| 列名 | 说明 |
|------|------|
| `user_name` | 微信用户名（wxid 或微信号） |

用于将 `Msg_<hash>` 表的哈希值反向映射回 `chat_id`：遍历所有 `user_name`，计算 `md5(user_name)` 匹配哈希。

#### 表：`ChatName2Id`

| 列名 | 说明 |
|------|------|
| `rowid` | 整数 ID |
| `user_name` | 微信用户名 |

将 `chat_id` 映射为整数，供 `MessageResourceInfo` 表引用。

### C.3 contact.db — 联系人数据库

#### 表：`contact`

| 列名 | 类型 | 说明 |
|------|------|------|
| `id` | INTEGER | 内部联系人 ID（非 wxid） |
| `username` | TEXT | 微信用户名（`wxid_xxx` 或 `xxx@chatroom`） |
| `remark` | TEXT | 用户设置的备注名（**最高优先级**显示名） |
| `nick_name` | TEXT | 微信昵称 |
| `alias` | TEXT | 微信号/别名（如 `lucifer_sk`） |
| `small_head_url` | TEXT | 小头像 URL |
| `big_head_url` | TEXT | 大头像 URL |
| `description` | TEXT | 联系人简介/签名 |

**显示名优先级**：`remark` → `nick_name` → `alias` → `username`

#### 表：`chat_room`

| 列名 | 说明 |
|------|------|
| `username` | 群聊 ID（`xxx@chatroom`） |
| `owner` | 群主 wxid |

#### 表：`stranger`

| 列名 | 说明 |
|------|------|
| `username` | 陌生人 wxid |
| `remark` | 备注 |
| `nick_name` | 昵称 |
| `alias` | 微信号 |
| `big_head_url` | 大头像 URL |
| `small_head_url` | 小头像 URL |

存储非好友联系人资料（如在群聊中出现但未添加好友的人）。

### C.4 session.db — 会话数据库

#### 表：`SessionTable`

| 列名 | 说明 |
|------|------|
| `username` | 聊天 ID |
| `summary` | 会话摘要，格式 `群名:最新消息预览`（群聊）或 `最新消息内容`（单聊） |

用于群聊名称解析：从 `summary` 中提取冒号前的群名部分。单聊最近消息不在此表存储。

> **注**：部分旧版本微信使用 `Session` 表（仅 `username` 列），本工具兼容处理。

### C.5 hardlink.db — HardLink 路径数据库

微信使用 HardLink 引用机制管理媒体文件，实际文件存储在 `xwechat_files/<wxid>/msg/` 子目录下。

#### 表：`db_info`

| 列名 | 说明 |
|------|------|
| `Key` | 键名（`'uuid'` 行存储存储根路径） |
| `ValueStdStr` | 值 |

`uuid` 行的 `ValueStdStr` 格式：`{prefix}_{wxid}_{storage_root_path}`，用于定位文件存储根目录。

#### 表：`dir2id`

| 列名 | 说明 |
|------|------|
| `rowid` | 目录 ID |
| `username` | 目录名（哈希字符串） |

将 `hardlink_info` 表中的 `dir1`/`dir2` 整数 ID 映射为实际目录名。

#### 表：`image_hardlink_info_v4`

| 列名 | 说明 |
|------|------|
| `md5` | 文件 MD5（32 位十六进制） |
| `file_name` | 文件名（含 `_h.dat` / `_t.dat` 缩略图后缀） |
| `file_size` | 文件大小（字节） |
| `dir1` | 一级目录 ID（→ `dir2id.rowid`） |
| `dir2` | 二级目录 ID（→ `dir2id.rowid`） |

文件路径构造：`msg/attach/{dir1_name}/{dir2_name}/Img/{file_name}`

#### 表：`video_hardlink_info_v4`

| 列名 | 说明 |
|------|------|
| `md5` | 文件 MD5 |
| `file_name` | 视频文件名（`.mp4` 等） |
| `file_size` | 文件大小 |
| `dir1` | 目录 ID（→ `dir2id.rowid`） |
| `dir2` | 目录 ID |

文件路径构造：`msg/video/{dir1_name}/{file_name}`

#### 表：`file_hardlink_info_v4`

| 列名 | 说明 |
|------|------|
| `md5` | 文件 MD5 |
| `file_name` | 原始文件名（如 `.docx`, `.pdf` 等） |
| `file_size` | 文件大小 |
| `dir1` | 目录 ID（→ `dir2id.rowid`） |
| `dir2` | 目录 ID |

文件路径构造：`msg/file/{dir1_name}/{file_name}`

### C.6 media_0.db — 媒体数据库

#### 表：`VoiceInfo`

| 列名 | 说明 |
|------|------|
| `create_time` | 消息创建时间戳 |
| `local_id` | 消息本地 ID |
| `voice_data` | SILK V3 格式音频原始数据（BLOB） |

语音播放流程：从 `VoiceInfo` 提取 `voice_data` → 缓存为 `.silk` 文件 → `silk_decoder.exe` 解码为 PCM → 封装 WAV 返回前端。

### C.7 message_resource.db — 消息资源索引

#### 表：`MessageResourceInfo`

| 列名 | 说明 |
|------|------|
| `chat_id` | 聊天 ID（引用 `ChatName2Id.rowid`） |
| `message_svr_id` | 服务端消息 ID |
| `message_local_id` | 本地消息 ID |
| `message_local_type` | 消息类型 |
| `message_create_time` | 消息创建时间 |
| `packed_info` | protobuf 编码的文件引用（`{'2': {'1': b'file_name_hash'}}`） |

当 `packed_info_data` 中无 md5 或 HardLink DB 查不到时，通过此表获取 `file_name` 哈希，再在 HardLink DB 中模糊匹配文件路径。

#### 表：`ChatName2Id`

同 message_N.db 中的 `ChatName2Id`，用于 `chat_id` → 整数 ID 映射。

### C.8 chats.db — 统一聊天索引（本工具生成）

备份管线第四阶段（indexer）生成，位于 `decrypted/data/chats.db`。

```sql
CREATE TABLE chats (
    chat_id TEXT PRIMARY KEY,        -- 聊天 ID
    display_name TEXT,               -- 预解析的显示名
    message_count INTEGER DEFAULT 0, -- 消息总数
    first_msg_time INTEGER,          -- 最早消息时间戳
    last_msg_time INTEGER,           -- 最新消息时间戳
    is_group INTEGER DEFAULT 0,      -- 是否群聊
    source_db TEXT                   -- 来源 message_N.db 文件名
);

CREATE TABLE messages (
    chat_id TEXT,                    -- 聊天 ID
    local_id INTEGER,                -- 消息本地 ID
    local_type INTEGER,              -- 消息类型
    create_time INTEGER,             -- 消息时间戳
    PRIMARY KEY (chat_id, local_id)
);
```

Web 查看器优先从 `chats.db` 读取联系人列表，缺失时才回退到全量扫描 `message_N.db`。

### C.9 消息内容编码说明

微信 4.x 的 `message_content` 列存在多种编码格式：

| 格式 | 判定方式 | 解码 |
|------|---------|------|
| zstd 压缩 XML | 前 4 字节 = `\x28\xb5\x2f\xfd` | zstd 解压 → UTF-8/GBK |
| 纯文本 UTF-8 | 字节内容可解码为 UTF-8 | 直接 decode |
| 纯文本 GBK | UTF-8 解码失败 | GBK decode（含 CDATA 修复） |
| protobuf 二进制 | UTF-8 解码后大量 `�` 替换字符 | 使用 `blackboxprotobuf` 解码 |
| protobuf + XML 混合 | 类型 10000/10002/49 | 从二进制中提取 XML 片段 |

### C.10 单聊发送者识别机制

微信 4.x 的单聊消息不直接标注发送者，需通过以下策略推断：

1. **发送者前缀扫描**（Strategy 1 & 2）：文本消息中 `wxid_xxx:\n` 前缀标识对方发送
2. **origin_source 统计**（Strategy 3）：`origin_source=1` 最多的 `real_sender_id` 为本人
3. **zstd XML 中的 fromusername**（Strategy 4）：媒体消息（类型 47/49）的 XML 中含发送者信息
4. **逐消息启发式**（Fallback）：纯文本无前缀 → 自发送；有前缀 → 对方发送

> **注意**：`real_sender_id` 是**每个消息分片内**的成员索引，不同 `message_N.db` 之间
> 同一联系人的 rsid 值可能不同，必须为每个分片独立构建 sender_map。
> 旧版 `chat_export.py` 曾使用 `Name2Id` 表的 rowid 进行 wxid 查找，但该表存储的
> wxid 不含账号后缀（如 `wxid_xxx` vs `wxid_xxx_10e8`），导致字符串完全匹配失败。
> 现已统一使用 `_build_sender_map()` 函数。

群聊消息在内容前统一携带 `发送者wxid:\n` 前缀，识别相对简单。

---

> **维护者**：sunanhaix  
> **仓库**：内部项目  
> **最后更新**：2026-05-31（v1.4）
