# beancount-mobile 开发方案

> **已整合 BeeCount 分层处理架构 + AutoAccounting 无障碍实现**
> **v3 — 基于 BeeCount / AutoAccounting 参考项目全面审查修订**
> **采用 MVP + 演进路线图模式，明确各版本边界**

## 项目概述

**beancount-mobile** 是一个面向 Beancount 用户的离线移动端应用，支持 iOS 和 Android。核心原则：

- 账本文件（`.bean`）是唯一权威来源
- 应用只写入 `mobile.bean`，不改写桌面维护的源文件
- 复式记账原生支持，完全兼容 Beancount 生态

### 版本策略

| 版本 | 目标 | 核心功能 |
|------|------|----------|
| **v1.0 MVP** | 可用的移动端记账 | 基础架构 + UI + 交易管理 + CSV 导入 + 规则引擎 + 去重 + OCR（Android） |
| **v1.1 增强** | 完善的记账体验 | 通知/短信监听 + 信用卡管理 + 日历视图 + 年度报告 + AI 聊天 |
| **v2.0 进阶** | 高级功能 | 多币种增强 + 同步 + 首页小组件 + 语音记账 + 深度链接 |
| **v2.x 演进** | 生态完善 | MCP 支持 + 共享账本 + 海报生成 + 更多同步后端 |

## 技术栈

| 组件           | 技术选型                         | 版本             |
| -------------- | -------------------------------- | ---------------- |
| 框架           | React Native + Expo CNG          | 0.81.0 / ~54.0.0 |
| 语言           | TypeScript                       | ~5.9.0           |
| 状态管理       | Zustand                          | 5.x              |
| 本地存储       | expo-sqlite (SQLCipher)          | ~16.0.0          |
| 解析器         | 自定义正则 + Tree-sitter（可选） | -                |
| OCR（Android） | Google ML Kit + PP-OCRv5         | 16.0.0           |
| 图表           | react-native-chart-kit / Victory | -                |
| 国际化         | expo-localization + i18n-js      | -                |
| 规则引擎       | QuickJS（轻量 JS 执行器）        | -                |

---

## 设计决策与订正记录（v3.1 审查修订）

> 本章节是对参考项目（BeeCount / AutoAccounting）深度审查后的订正记录，集中说明若干影响全局的架构决策。后续各 Phase 中凡与本章冲突的旧描述，以本章节为准。

### 决策 1：分类/标签/预算采用「双轨制 + 映射表」

**问题背景**：BeeCount 的分类/标签/预算/信用卡都是数据库一等表，与账户无关。但 beancount-mobile 的权威数据源是 `.bean` 文件，其中"分类"本质是 `Expenses:Food` 这样的账户命名空间，标签是 `#tag` 语法，Beancount 原生没有预算概念。若直接照搬 BeeCount 的表结构，会出现"应用表里有分类，但 `.bean` 文件里没有对应账户"的不一致。

**双轨制方案**：

| 数据 | 应用本地表（UI 增强） | 是否写回 `.bean` | 映射方式 |
|------|----------------------|------------------|----------|
| 分类 | `categories`（name/keywords/icon/color/**linkedAccount**） | 否 | 记账时把 `category.linkedAccount`（如 `Expenses:Food`）作为 posting account |
| 标签 | `tags`（name/color） | 是 | 记账时把 `#tagName` 写入 narration（Beancount 原生 tag 语法） |
| 预算 | `budgets`（amount/period/categoryId） | 否 | 纯本地虚拟概念，仅用于统计/提醒，不影响余额 |
| 信用卡 | `credit_cards`（billingDay/paymentDay/creditLimit/linkedAccount） | 否（UI 字段） | 账户本身（如 `Liabilities:CreditCard:CMB`）在 `.bean`，UI 字段仅存本地 |

**关键接口修正**（`Category` 必须含 `linkedAccount`，否则双轨制无法落地）：

```typescript
// src/domain/categories.ts
export interface Category {
  id: string;
  name: string;
  parentId?: string;          // 支持层级（UI 展示用，不映射到 .bean）
  type: 'income' | 'expense';
  linkedAccount: string;      // ★ 映射到 Beancount 账户（如 'Expenses:Food'），记账时作为 posting account
  icon?: string;              // UI 增强（仅本地）
  color?: string;             // UI 增强（仅本地）
  keywords: string[];         // 自动匹配关键词（仅本地匹配逻辑用）
}
```

**映射失效处理**：

```typescript
// 记账前校验 linkedAccount 是否仍存在于账本（桌面端可能删除/重命名账户）
export function resolveCategoryAccount(category: Category, ledger: LedgerIndex): {
  account: string;
  valid: boolean;
  fallbackReason?: string;
} {
  const exists = ledger.accounts.has(category.linkedAccount);
  if (exists) return { account: category.linkedAccount, valid: true };
  // 优雅降级：账户被删除时降级到 Uncategorized，并提示用户修复映射
  const fallback = category.type === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized';
  return { account: fallback, valid: false, fallbackReason: `分类「${category.name}」映射的账户 ${category.linkedAccount} 已不存在，已降级` };
}
```

**与原 plan 描述的差异**：原 2.3「参考 BeeCount 的二级分类 + CategoryMatcher」的措辞误导（BeeCount 无 `.bean`，分类是独立表）。本方案改为：分类匹配算法参考 BeeCount 的 `CategoryMatcher`，但分类与账户的**关系模型**采用双轨制映射，而非 BeeCount 的独立表。

### 决策 2：参考项目归属修正

经对两个参考项目源码的深度审查，明确各自的**可参考范围**与**不可参考范围**：

#### BeeCount（`./reference_project/BeeCount`）

| 方面 | 可参考 | 说明 |
|------|--------|------|
| UI/UX、设计令牌、主题系统 | ✅ | `lib/styles/tokens.dart` 设计令牌体系可直接借鉴 |
| 同步架构（5 种云同步） | ✅ | SyncEngine / 冲突解决 / in-flight 单飞行锁 |
| 平台特性（Widget/Deep Link/Share Extension） | ✅ | iOS AppIntent 后台执行、Android ContentObserver |
| 截图自动记账通道 | ✅ | `ScreenshotObserver.kt` + `AutoBillingAppIntent.swift` |
| AI 三层架构 + 五通道统一 | ✅ | AiExtractionEngine / AiBookkeeper / BillCreationService |
| 应用锁 + 隐私模糊 | ✅ | `AppLockService` + BackdropFilter |
| 海报生成、孤儿文件 GC、日志中心 | ✅ | |

| 方面 | **不可参考** | 原因 |
|------|-------------|------|
| Beancount 数据建模 | ❌ | **BeeCount 不解析 `.bean`**，是自有 Drift 数据模型（`beecount.sqlite`，schemaVersion 30）。其分类/标签/预算/信用卡表与 Beancount account 语义无关 |
| 数据库 schema 设计 | ❌ | 同上，其表结构围绕自有模型，不能照搬到"`.bean` 为权威源"的架构 |

#### AutoAccounting（`./reference_project/AutoAccounting`）

| 方面 | 可参考 | 说明 |
|------|--------|------|
| 账单处理责任链 | ✅ | `BillService.analyze` 的完整流程（规则→AI→资产映射→分类→备注→去重→转账） |
| 多通道联合去重 | ✅ | `DuplicateDetector`（时间窗口 + 金额 + 渠道规则） |
| 转账智能识别 | ✅ | `TransferRecognizer`（Income+Expend→Transfer，先于去重） |
| 字段合并 | ✅ | `BillMerger`（已知资产优先、名称长度偏好） |
| 备注模板引擎 | ✅ | 16 个占位符 + 相邻重复归一化 |
| 规则引擎（QuickJS） | ✅ | 系统/用户规则分离、禁用规则可阻止 AI |
| 浮窗账单（Channel 队列 + 节流） | ✅ | `BillWindowManager`（串行 + 300ms 节流 + MD5 去重） |
| OCR 性能优化 | ✅ | 短边 720px 压缩、JPEG q60、CPU 模式 |
| AI Provider 抽象 | ✅ | 8 个 Provider 走 OpenAI 兼容协议 + Gemini 独立实现 |
| Bill Flag System | ✅ | 位运算标志位（不计收支/不计预算） |

| 方面 | **不可直接照搬** | 原因 |
|------|-----------------|------|
| 嵌入式 HTTP 服务器（localhost:52045） | ❌ | 那是为 Xposed 跨进程设计的；RN 架构下原生 Module 桥更原生（见决策 6） |
| Xposed Hooker（微信/支付宝/钱迹） | ❌ | 项目不走 Xposed 路线 |
| 无障碍服务伪装（SelectToSpeakService） | ⚠️ | 仅侧载版本保留（见决策 3） |
| OCR 降级到 ML Kit | ❌ | **AutoAccounting 根本不用 ML Kit**，只用 PP-OCRv5+NCNN。原 plan 的"PP-OCR 失败降级 ML Kit"是虚构降级链，已删除。注：本项目改用 ONNX Runtime 替代 NCNN（见决策 7） |

### 决策 3：上架策略 —— 纯开源侧载

**决策**：本项目**不申请应用商店上架**，采用 GitHub Release + 侧载分发。

| 项目 | 说明 |
|------|------|
| 分发渠道 | GitHub Release（Android APK 自签名；iOS 需自签证书或 TestFlight 替代） |
| 功能范围 | 保留无障碍伪装、通知监听、短信读取、浮窗等全功能（无商店审核约束） |
| 安装引导 | Onboarding 增加侧载安装说明 + 权限手动授予引导（无障碍/通知监听需用户到系统设置开启） |
| 签名密钥 | Android APK 签名密钥由项目维护；iOS 用户自签 |

**仍需注意的合规点**（侧载不等于无约束）：

- **隐私脱敏**：账单/截图发往第三方 AI API 前，应做脱敏（参考 BeeCount `lib/ai/privacy/`），并在 UI 明确告知用户数据会上传
- **无障碍服务伪装的风险告知**：`SelectToSpeakService` 伪装可能被部分安全软件标记，Onboarding 需说明
- **开源协议**：AutoAccounting 为 GPL-3.0，若复用其代码需注意传染性（本项目倾向自研 + MIT/Apache）

**对 plan 的影响**：原各 Phase 中"上架审核"相关担忧（如 Google Play READ_MEDIA_IMAGES 政策）不再适用，可保留全功能。

### 决策 4：Expo Config Plugin 方案（最被低估的工程障碍）

**问题**：项目使用 Expo CNG（`expo prebuild` 生成原生工程）。plan.md 文件清单里画的 `android/app/src/main/java/com/beancount/mobile/...` 裸 Kotlin 目录树，**会被 `expo prebuild` 清空**——因为 prebuild 每次根据 app.json/config 插件重新生成原生工程。

**方案**：所有原生功能必须封装为 **Expo Config Plugin + React Native Module**，而非裸 Kotlin 文件。

| 原生功能 | Config Plugin 职责 | RN Module 职责 |
|---------|-------------------|---------------|
| 无障碍服务 | 注册 manifest `<service>` + `accessibility_service_config.xml` | `OcrAccessibilityService` Kotlin 实现 + 桥接事件 |
| 通知监听 | 注册 manifest `<service>` + `BIND_NOTIFICATION_LISTENER_SERVICE` 权限 | `NotificationListenerService` Kotlin + 事件推送 |
| 短信监听 | 注册 manifest `<receiver>` + `RECEIVE_SMS` 权限 | `SmsReceiver` Kotlin + 事件推送 |
| 浮窗（TYPE_APPLICATION_OVERLAY） | 注册 `SYSTEM_ALERT_WINDOW` 权限 | `FloatingBillView` Kotlin + show/dismiss 桥接 |
| 快速设置磁贴 | 注册 manifest `<service>` + `BIND_QUICK_SETTINGS_TILE` | `OcrTileService` Kotlin |
| PP-OCRv5 (ONNX Runtime) | 打包 `assets/` ONNX 模型文件 + onnxruntime-android 依赖 | OCR 原生模块 + `recognizeText` 方法（见决策 7） |
| 截图监听（Android） | 注册 ContentObserver 相关配置 | `ScreenshotObserver` Kotlin + 事件推送 |

**目录结构修正**（替代原文件清单里的裸 Kotlin 树）：

```
plugins/
├── accessibility/           # 无障碍服务 Config Plugin
│   ├── app.plugin.js        # Expo Config Plugin 入口
│   └── android/             # Kotlin 实现（prebuild 时复制进原生工程）
├── notification-listener/   # 通知监听
├── sms-receiver/            # 短信监听
├── floating-window/         # 浮窗
├── ocr-tile/                # 快速设置磁贴
├── ppocr/                   # PP-OCRv5 原生模块
└── screenshot-monitor/      # 截图监听

app.json 中注册：
{
  "plugins": [
    "./plugins/accessibility",
    "./plugins/notification-listener",
    "./plugins/sms-receiver",
    "./plugins/ppocr"
  ]
}
```

**对 plan 的影响**：原 Phase 3.2「原生模块架构」的目录树需替换为上述 Plugin 结构；各 Phase 涉及原生的章节均需补 Config Plugin 说明。

### 决策 5：SQLCipher 密钥管理

**问题**：技术栈表写"expo-sqlite (SQLCipher)"，但全文未讨论密钥从哪来、存哪、丢失怎么办。

**方案**：

```
首次启动：
  随机生成 32 字节密钥 → 存入 expo-secure-store（iOS Keychain / Android Keystore）
  → openAsync('bean-mobile.db', { key: 密钥 }) 启用加密

后续启动：
  从 SecureStore 读密钥 → openAsync 传密钥

密钥丢失（用户清空 SecureStore / 重装）：
  DB 打不开 → 检测到后丢弃旧 DB → 从 .bean 文件重建（与"缓存层可重建"原则一致）
  → 重新生成密钥 → 新 DB
```

**加密意义说明**：`.bean` 文件本身在应用沙盒明文（应用自己的数据），DB 加密主要防御 **DB 文件被直接拷贝出沙盒后分析**（如备份导出、设备被取证）。若需更强保护，可后续加密 `.bean` 文件本身（但会影响桌面端兼容，v1 不做）。

**Config Plugin 配置 SQLCipher build**（expo-sqlite 需特定 build flag）：

```javascript
// plugins/sqlcipher/app.plugin.js
module.exports = (config) => {
  // 配置 expo-sqlite 的 SQLCipher build 选项
  config.extra = { ...config.extra, expoSQLite: { enableFTS: true, useSQLCipher: true } };
  return config;
};
```

**对 plan 的影响**：Phase 0.2（数据库 Schema）补加密启动流程；Phase 0.4（应用锁与安全）补密钥管理。

### 决策 6：跨进程通信（RN JS 进程 ↔ 原生服务进程）

**问题**：AutoAccounting 用嵌入式 HTTP 服务器（localhost:52045）让 App 端和 Xposed 注入代码共享同一份业务逻辑。但 RN 架构下，原生服务（无障碍/通知/短信）和 JS 主进程是不同进程，数据如何流通？

**方案**：**不学 AutoAccounting 的 HTTP 服务器**（那是为 Xposed 跨进程设计的）。RN 架构下用**原生 Module 事件桥**更原生：

```
原生服务进程（无障碍/通知/短信）
    ↓ 触发事件（如 OCR 识别到账单、收到通知）
原生 Module（Kotlin）
    ↓ NativeEventEmitter.emit('billDetected', payload)
RN JS 主进程
    ↓ 监听事件
领域层（统一入库入口）
    ↓ 走 billPipeline（转账识别 → 去重 → 资产映射 → 分类 → 入库）
SQLite 缓存 + mobile.bean
```

**关键点**：

- 原生服务**不直接写库**，只通过事件桥把原始数据推给 JS 层
- JS 层的 `billPipeline` 是唯一入库入口，保证去重/转账/分类逻辑统一（避免 AutoAccounting 那样各通道各自处理）
- 并发控制：JS 层用 Promise 队列串行化（参考 AutoAccounting 的 `deduplicationMutex`），见 Phase 2 的 `billPipeline`

**对 plan 的影响**：Phase 3（OCR）、Phase 4（通知/短信）的"原生服务直接 sendToReactNative"描述改为"通过 NativeEventEmitter 事件桥推送给 JS 层 billPipeline"。

### 决策 7：OCR 引擎改用 ONNX Runtime（替代 NCNN）

**问题**：原方案（与参考项目 AutoAccounting 一致）用 PP-OCRv5 + NCNN。但 NCNN 的模型获取链路在 Windows 开发机上极其折腾：需 `Paddle → ONNX → NCNN` 两步转换，`onnx2ncnn` 在 Windows 上通常要自编 NCNN 源码或下预编译包，对贡献者不友好。

**方案**：**改用 ONNX Runtime Android**（`com.microsoft.onnxruntime:onnxruntime-android`）。

| 维度 | NCNN（原方案） | ONNX Runtime（新方案） |
|------|---------------|----------------------|
| 模型获取 | `Paddle → ONNX → NCNN` 两步转换，Windows 地狱 | `Paddle → ONNX` 一步转换，或直接下社区现成 ONNX |
| 移动端体积 | 最小（~3MB） | 中（~10MB） |
| 移动端速度 | 最快（ARM NEON/Vulkan 深度优化） | 略逊但 OCR 场景足够 |
| 跨平台 | 主推 Android/iOS | 全平台（含桌面） |
| 维护 | 腾讯小众 | 微软官方，文档好 |

**已落地**：

- 模型：从 [ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 直接下载 `ppocrv5_det.onnx` / `ppocrv5_rec.onnx`（社区维护，基于官方 Paddle 权重转换，无质量损失）
- 字典：`ppocr_keys_v1.txt`（PaddleOCR 标准 CJK 字典）
- `plugins/ppocr/android/OcrModule.kt`：完全重写，含 det DB 后处理 + rec CTC 贪心解码
- `plugins/ppocr/android/OcrPackage.kt`：新增，注册到 `MainApplication.getPackages()`
- `plugins/ppocr/app.plugin.js`：gradle 依赖改为 `onnxruntime-android:1.20.1`

**模型 I/O 规格**（实测 `onnx` 库解析）：

- det 输入 `x`: `[N, 3, H, W]` 动态尺寸，输出 `[N, 1, H, W]` 概率图
- rec 输入 `x`: `[N, 3, 48, W]` 固定高 48，输出 `[N, T, 18385]`（18385 = 字典 + blank 的 CTC 类数）

**对 plan 的影响**：本节以下所有 NCNN 描述均订正为 ONNX Runtime。模型文件名从 `.ncnn.{bin,param}` 改为 `.onnx`。

---

## 已实现的核心功能（原型验证通过）

> **范围说明**：以下领域逻辑真实可用、有单测覆盖（`tests/accounting.test.ts`），但属于**原型验证级**，语法覆盖率和工程化程度有限。各文件当前规模与后续增强计划见下。

```
src/domain/
├── types.ts          ✓ 完整的类型定义（26 行）
├── ledger.ts         ✓ Beancount 解析器 + 验证器（49 行，正则 demo）
├── decimal.ts        ✓ 精度计算（避免浮点，26 行）
├── rules.ts          ✓ 规则引擎 + 分类（30 行）
├── statements.ts     ✓ CSV 账单导入 + 去重（45 行）
├── mobile.ts         ✓ mobile.bean 序列化（11 行）
└── index.ts          ✓ 领域接口导出

src/storage/
└── ledgerRepository.ts  ✓ SQLite 缓存层（33 行）
```

### 当前解析器覆盖率与增强计划

`ledger.ts` 当前为正则 demo，覆盖的 Beancount 语法：

| 语法 | 当前支持 | 后续（Step 1 增强） |
|------|---------|-------------------|
| `open`/`close` 账户 | ✅ | - |
| 交易（`*`/`!` flag + payee + narration） | ✅ | 补 metadata、多行 posting |
| posting（account + amount + currency） | ✅ | 补 cost `{...}`、price `@` |
| `#tag` / `^link` | ✅（仅 header 行） | 补 posting 行的 tag |
| `option`/`plugin`/`commodity`/`balance`/`pad`/`event`/`note`/`document`/`query`/`custom` | ⚠️ 只读保留（记入 `unsupported`） | 补 `balance` assertion 解析 |
| 注释（`;`） | ⚠️ 部分支持 | 补全 |
| `include` | ✅（文件级） | - |

**Tree-sitter 决策**：MVP 阶段继续用**增强版正则**（Step 1），覆盖常见语法。当真实用户 `.bean` 的复杂语法（如嵌套 metadata、custom 指令）导致正则方案维护成本过高时，再引入 `tree-sitter-beancount` 原生模块（通过 Config Plugin 打包）。决策依据：先用正则方案积累真实用户账本的兼容性数据，再决定是否上 tree-sitter。

### mobile.bean 写入的当前局限与增强计划

`mobile.ts` 当前的 `commitMobileTransaction` 是**纯函数追加**，未处理：
- 并发写入（多通道同时到达）
- 原子性（写入中途崩溃导致文件损坏）
- 大文件性能（万条交易后每次全量解析）

这些在 **Step 2（mobile.bean 工程化）** 解决，见 `src/domain/mobileStore.ts`。

---

## 数据架构：数据库作为读缓存

### 核心原则

`.bean` 文件是唯一权威数据源，SQLite 仅作为**读缓存**，不存储业务状态。

```
.bean 文件  ──(解析)──→  SQLite 缓存  ──(查询)──→  UI
     ↑                        ↓
     └────(写入 mobile.bean)───┘
```

- **写入**：只写 `mobile.bean`，不直接写数据库
- **读取**：从数据库读（快），数据库由解析 `.bean` 文件填充
- **同步**：文件变更时重建缓存
- **恢复**：数据库可从文件随时重建，丢失无影响

### 为什么需要数据库

| 问题 | 纯文件方案 | 数据库缓存方案 |
|------|-----------|---------------|
| UI 渲染 | 每次滚动都重新解析，卡顿 | 从索引读取，毫秒级 |
| 交易搜索 | 全量解析 + 内存过滤，O(n) | SQL WHERE + 索引，O(log n) |
| 去重查询 | 每笔遍历全量交易 | 按时间窗口 + 金额索引查询 |
| 导入 1000 笔 | 解析 1000 次文件 | 批量 INSERT，一次解析 |

### 数据库表设计（最小化）

仅保留缓存必要的表，不存储任何"可从文件重建"以外的数据：

```sql
-- 缓存解析后的 .bean 文件内容
CREATE TABLE ledger_files (
  path TEXT PRIMARY KEY,
  content TEXT NOT NULL,
  checksum TEXT NOT NULL
);

-- 缓存已导入的事件（用于去重查询）
CREATE TABLE imported_events (
  id TEXT PRIMARY KEY,
  payload TEXT NOT NULL,
  committed_transaction TEXT
);

-- 缓存用户规则（规则本身由用户管理，非 .bean 数据）
CREATE TABLE rules (
  id TEXT PRIMARY KEY,
  priority INTEGER NOT NULL,
  payload TEXT NOT NULL
);

-- 缓存未确认的草稿（临时状态）
CREATE TABLE drafts (
  id TEXT PRIMARY KEY,
  payload TEXT NOT NULL,
  created_at TEXT NOT NULL
);
```

**不建的表**（按需在对应 Phase 中添加）：

| 表 | 何时需要 | 说明 |
|----|---------|------|
| `categories` | Phase 2 分类系统 | 用户自定义分类，非 .bean 数据 |
| `tags` | Phase 2 标签系统 | 用户自定义标签 |
| `budgets` | Phase 5 预算管理 | 用户预算配置 |
| `recurring` | Phase 5 周期记账 | 定期交易模板 |
| `attachments` | Phase 5 附件 | 交易附件元数据 |
| `exchange_rates` | Phase 5 多币种 | 汇率缓存 |
| `credit_cards` | Phase 2 信用卡 | 信用卡账单日/还款日/额度 |
| `sync_errors` | Phase 6 同步 | 同步失败记录 |
| `logs` | Phase 8 日志 | 应用日志 |

### 缓存失效策略

> **订正**：原方案用 `content.length` 作 checksum 不可靠（内容改了长度可能不变）。改为 FNV-1a hash（与 `ledger.ts` 现有的 `hash()` 一致），轻量且碰撞率足够低。

```typescript
// 当 .bean 文件变更时，重建缓存
import { hash as fnvHash } from '../domain/ledger';  // 复用现有 FNV-1a

async function rebuildCache(ledger: LedgerIndex): Promise<void> {
  // 1. 对比 checksum，仅重算变更文件
  const cached = await db.getAllAsync<{ path: string; checksum: string }>(
    'SELECT path, checksum FROM ledger_files'
  );
  const cachedMap = new Map(cached.map(r => [r.path, r.checksum]));
  
  for (const file of ledger.files) {
    const checksum = fnvHash(file.content);  // 真实 hash，非 length
    if (cachedMap.get(file.path) === checksum) continue;
    // 文件变更，重新解析并更新缓存
    await db.runAsync(
      'INSERT OR REPLACE INTO ledger_files (path, content, checksum) VALUES (?, ?, ?)',
      file.path, file.content, checksum
    );
  }
}
```

---

## Phase 0: 基础架构（1-2 周）[P0]

> 奠定后续所有阶段的基础，后期改动成本极高，必须优先完成。

### 0.1 状态管理（Zustand）

参考 BeeCount 的 Riverpod 架构（29 个 provider 文件），采用 Zustand 作为轻量状态管理：

```typescript
// src/store/ledgerStore.ts
interface LedgerState {
  ledger: LedgerIndex | null;
  transactions: Transaction[];
  accounts: Map<string, Account>;
  isLoading: boolean;
  error: string | null;
  // actions
  loadLedger: (path: string) => Promise<void>;
  addTransaction: (draft: TransactionDraft) => Promise<void>;
  refresh: () => Promise<void>;
}

// src/store/importStore.ts
interface ImportState {
  events: ImportedEvent[];
  pendingConfirmations: TransactionDraft[];
  dedupResult: DedupResult | null;
  // actions
  importCsv: (content: string, adapter: StatementAdapter) => Promise<void>;
  confirmDraft: (draft: TransactionDraft) => Promise<void>;
  rejectDraft: (id: string) => void;
}

// src/store/settingsStore.ts
interface SettingsState {
  dedupConfig: DedupConfig;
  aiVisionConfig: AiVisionConfig;
  themeName: string;          // 'light' | 'dark' | 'system' | 自定义主题名
  locale: string;
  // actions
  updateDedupConfig: (config: Partial<DedupConfig>) => void;
  setTheme: (name: string) => void;
}
```

### 0.2 数据库 Schema 版本与迁移

数据库是缓存层，表结构简单。采用版本号 + 迁移函数，但不预建未来表——按 Phase 需要逐步添加：

```typescript
// src/storage/migrations.ts
const SCHEMA_VERSION = 1;

const MIGRATIONS: Record<number, string[]> = {
  1: [
    `CREATE TABLE IF NOT EXISTS ledger_files (path TEXT PRIMARY KEY, content TEXT NOT NULL, checksum TEXT NOT NULL)`,
    `CREATE TABLE IF NOT EXISTS imported_events (id TEXT PRIMARY KEY, payload TEXT NOT NULL, committed_transaction TEXT)`,
    `CREATE TABLE IF NOT EXISTS rules (id TEXT PRIMARY KEY, priority INTEGER NOT NULL, payload TEXT NOT NULL)`,
    `CREATE TABLE IF NOT EXISTS drafts (id TEXT PRIMARY KEY, payload TEXT NOT NULL, created_at TEXT NOT NULL)`,
  ],
  // 后续版本按 Phase 需要追加：
  // 2: [`CREATE TABLE IF NOT EXISTS categories (...)`, ...],
  // 3: [`CREATE TABLE IF NOT EXISTS tags (...)`, ...],
};

export async function migrateDb(db: SQLite.SQLiteDatabase): Promise<void> {
  const current = await getSchemaVersion(db);
  for (let v = current + 1; v <= SCHEMA_VERSION; v++) {
    for (const sql of MIGRATIONS[v]) await db.execAsync(sql);
  }
  await setSchemaVersion(db, SCHEMA_VERSION);
}
```

> 缓存层丢失无影响——从 `.bean` 文件重新解析即可重建。

### 0.3 错误处理策略

参考 AutoAccounting 的错误恢复，采用轻量方案——缓存层可重建，不需要持久化错误日志：

```typescript
// src/utils/errorHandler.ts
enum ErrorCategory {
  NETWORK = 'network',
  DATABASE = 'database',
  PARSING = 'parsing',
  OCR = 'ocr',
  FILE_SYSTEM = 'file_system',
}

interface AppError {
  category: ErrorCategory;
  message: string;
  recoverable: boolean;
  retryable: boolean;
  context?: Record<string, unknown>;
}

// 全局错误边界 + 重试策略
function withRetry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
  return fn().catch((err) => {
    if (retries <= 0) throw err;
    return new Promise(resolve => setTimeout(resolve, delay)).then(() =>
      withRetry(fn, retries - 1, delay * 2)
    );
  });
}
```

错误处理原则：
- **可恢复错误**（网络超时、文件读取失败）：自动重试，UI 显示提示
- **不可恢复错误**（解析失败、格式错误）：显示错误详情，引导用户修复
- **缓存层错误**：丢弃缓存，从 `.bean` 文件重建
- 开发阶段用 `console.error`，生产环境可选接入 Sentry（Phase 8）

### 0.4 应用锁与安全

参考 BeeCount 的 AppLockService（生物识别/PIN）+ 隐私模糊：

```typescript
// src/services/security.ts
import * as LocalAuthentication from 'expo-local-authentication';

export async function authenticate(): Promise<boolean> {
  const hasHardware = await LocalAuthentication.hasHardwareAsync();
  if (!hasHardware) return true; // 无生物识别硬件，跳过
  
  const isEnrolled = await LocalAuthentication.isEnrolledAsync();
  if (!isEnrolled) return true;
  
  return await LocalAuthentication.authenticateAsync({
    promptMessage: '解锁记账应用',
    disableDeviceFallback: false,
    cancelLabel: '取消',
  }).then(result => result.success);
}
```

在 `_layout.tsx` 中添加认证检查：

```tsx
function RootLayout() {
  const [authenticated, setAuthenticated] = useState(false);
  
  useEffect(() => {
    authenticate().then(setAuthenticated);
  }, []);
  
  if (!authenticated) return <LockScreen onRetry={() => authenticate().then(setAuthenticated)} />;
  return <Stack />;
}
```

#### 隐私模糊（Privacy Blur）

参考 BeeCount 的隐私模糊模式——切换应用时全屏模糊，不仅是锁屏：

```typescript
// src/services/privacyBlur.ts
import { AppState } from 'react-native';
import { BlurView } from '@react-native-blur/blur';

export function usePrivacyBlur() {
  const [isBlurred, setIsBlurred] = useState(false);
  
  useEffect(() => {
    const subscription = AppState.addEventListener('change', (state) => {
      if (state === 'background') {
        setIsBlurred(true);  // 进入后台时模糊
      } else if (state === 'active') {
        // 延迟解除模糊，等待认证完成
        setTimeout(() => setIsBlurred(false), 300);
      }
    });
    return () => subscription.remove();
  }, []);
  
  return isBlurred;
}
```

### 0.5 本地备份与恢复

备份的核心是 `.bean` 文件（数据库可重建）：

```typescript
// src/services/backup.ts
import * as FileSystem from 'expo-file-system';
import * as DocumentPicker from 'expo-document-picker';

// 备份 = 导出 .bean 文件 + mobile.bean + 用户规则
export async function createBackup(ledgerPath: string): Promise<string> {
  const backupDir = `${FileSystem.documentDirectory}backups/`;
  await FileSystem.makeDirectoryAsync(backupDir, { intermediates: true });
  
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const backupPath = `${backupDir}backup-${timestamp}.zip`;
  
  // 只备份源文件，数据库可从这些文件重建
  const files = [ledgerPath, `${FileSystem.documentDirectory}mobile.bean`];
  await createZip(files, backupPath);
  return backupPath;
}

// 恢复 = 导入 .bean 文件 → 自动重建缓存
export async function restoreBackup(): Promise<boolean> {
  const result = await DocumentPicker.getDocumentAsync({
    type: 'application/zip',
    copyToCacheDirectory: true,
  });
  if (result.canceled) return false;
  
  await extractZip(result.assets[0].uri);
  // 恢复后自动从 .bean 文件重建数据库缓存
  await rebuildCacheFromFiles();
  return true;
}
```

#### mobile.bean 写入的并发安全与原子性

> **新增（审查订正）**：`mobile.bean` 是唯一写入目标，多通道（手动、CSV、OCR、通知、短信）可能并发写入，必须保证并发安全与崩溃恢复。领域层的实现在 **Step 2（`src/domain/mobileStore.ts`）**，此处描述设计。

**并发写入锁（Promise 队列）**：

```typescript
// src/domain/mobileStore.ts（Step 2 实现）
// 所有写入串行化，避免多通道并发导致的内容交错/丢失
class MobileBeanStore {
  private writeQueue: Promise<string> = Promise.resolve('');
  
  async append(draft: TransactionDraft, ledger: LedgerIndex): Promise<MobileCommit> {
    // 串行化：每个 append 等前一个完成
    this.writeQueue = this.writeQueue.then(current => 
      commitMobileTransaction(draft, ledger, current).content
    );
    const content = await this.writeQueue;
    await this.persist(content);  // 原子写入
    return { content, diff: serializeTransaction(draft) };
  }
}
```

**原子写入（临时文件 + rename）**：

```typescript
// 写入中途崩溃不会损坏原文件
async function persist(content: string): Promise<void> {
  const path = `${FileSystem.documentDirectory}mobile.bean`;
  const tmpPath = `${path}.tmp`;
  // 1. 写入临时文件
  await FileSystem.writeAsStringAsync(tmpPath, content);
  // 2. rename 原子替换（expo-file-system 的 moveAsync 在同分区是原子操作）
  await FileSystem.deleteAsync(path, { idempotent: true });
  await FileSystem.moveAsync({ from: tmpPath, to: path });
}
```

**崩溃恢复（WAL journal）**：

```typescript
// 启动时检查：若有 .tmp 残留，说明上次写入未完成，丢弃 tmp 保留原文件
async function recoverIfNeeded(): Promise<void> {
  const tmpPath = `${FileSystem.documentDirectory}mobile.bean.tmp`;
  const info = await FileSystem.getInfoAsync(tmpPath);
  if (info.exists) {
    // tmp 残留 = 上次写入未完成，安全删除（原文件未被替换）
    await FileSystem.deleteAsync(tmpPath, { idempotent: true });
    logger.warn('mobileStore', '检测到未完成的写入 tmp 文件，已清理');
  }
}
```

**大文件性能（万条交易后）**：

- mobile.bean 增长到一定规模（如 >5000 条）后，每次全量读写变慢
- 缓解：SQLite 缓存承担读（UI 不直接读文件），写入只做 append（不重写整个文件）
- 进一步优化（v2）：mobile.bean 按月分文件（`mobile-2026-07.bean`），主 `mobile.bean` 仅 `include` 各月文件

### 0.6 日志系统

参考 AutoAccounting 的 Logger + BeeCount 的 Log Center：

```typescript
// src/utils/logger.ts
enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3,
}

interface LogEntry {
  timestamp: string;
  level: LogLevel;
  tag: string;
  message: string;
  data?: unknown;
}

class Logger {
  private buffer: LogEntry[] = [];
  private maxBuffer = 1000;
  
  debug(tag: string, message: string, data?: unknown) {
    this.log(LogLevel.DEBUG, tag, message, data);
  }
  
  info(tag: string, message: string, data?: unknown) {
    this.log(LogLevel.INFO, tag, message, data);
  }
  
  warn(tag: string, message: string, data?: unknown) {
    this.log(LogLevel.WARN, tag, message, data);
  }
  
  error(tag: string, message: string, data?: unknown) {
    this.log(LogLevel.ERROR, tag, message, data);
  }
  
  private log(level: LogLevel, tag: string, message: string, data?: unknown) {
    const entry: LogEntry = {
      timestamp: new Date().toISOString(),
      level,
      tag,
      message,
      data,
    };
    this.buffer.push(entry);
    if (this.buffer.length > this.maxBuffer) {
      this.buffer.shift();
    }
    // 开发环境输出到 console
    if (__DEV__) {
      const prefix = `[${tag}]`;
      switch (level) {
        case LogLevel.DEBUG: console.debug(prefix, message, data); break;
        case LogLevel.INFO: console.info(prefix, message, data); break;
        case LogLevel.WARN: console.warn(prefix, message, data); break;
        case LogLevel.ERROR: console.error(prefix, message, data); break;
      }
    }
  }
  
  getLogs(): LogEntry[] { return [...this.buffer]; }
  clearLogs() { this.buffer = []; }
}

export const logger = new Logger();
```

### 0.7 引导流程（Onboarding）

参考 BeeCount 的 Introduction 流程，新用户首次使用时引导完成基础配置：

```tsx
// src/app/_onboarding.tsx
function OnboardingScreen() {
  const [step, setStep] = useState(0);
  
  const steps = [
    { title: '欢迎使用 beancount-mobile', description: '基于 Beancount 的移动端记账应用' },
    { title: '选择语言', component: <LanguagePicker /> },
    { title: '选择主题', component: <ThemePicker /> },
    { title: '导入账本', component: <LedgerImporter /> },
    { title: '启用功能', component: <FeatureToggle /> },  // OCR、通知监听等
    { title: '设置安全', component: <SecuritySetup /> },   // 应用锁
  ];
  
  return (
    <View>
      <OnboardingStep step={steps[step]} />
      <View style={styles.buttons}>
        {step > 0 && <Button onPress={() => setStep(step - 1)}>上一步</Button>}
        {step < steps.length - 1 
          ? <Button onPress={() => setStep(step + 1)}>下一步</Button>
          : <Button onPress={completeOnboarding}>开始使用</Button>
        }
      </View>
      <DotsIndicator total={steps.length} current={step} />
    </View>
  );
}
```

引导流程内容：
1. 欢迎页 + 应用介绍
2. 语言选择（中/英）
3. 主题选择（浅色/深色/跟随系统）
4. 账本导入（选择 .bean 文件或新建）
5. 功能启用（OCR、通知监听、短信监听的权限说明）
6. 安全设置（应用锁、隐私模糊）

---

## Phase 1: UI 框架搭建（2 周）[P0]

### 1.1 项目结构重组

```
src/
├── app/                    # Expo Router 页面
│   ├── (tabs)/             # 底部导航
│   │   ├── index.tsx       # 首页（账本概览）
│   │   ├── transactions.tsx # 交易列表（搜索/筛选）
│   │   ├── import.tsx      # 导入账单
│   │   ├── rules.tsx       # 规则管理
│   │   └── settings.tsx    # 设置
│   ├── transaction/
│   │   ├── new.tsx         # 新增交易
│   │   └── [id].tsx        # 交易详情/编辑
│   ├── category/
│   │   └── index.tsx       # 分类管理
│   ├── tag/
│   │   └── index.tsx       # 标签管理
│   ├── budget/
│   │   └── index.tsx       # 预算管理
│   ├── calendar/
│   │   └── index.tsx       # 日历视图
│   ├── credit-card/
│   │   └── index.tsx       # 信用卡管理
│   └── _layout.tsx         # 根布局
├── components/             # 可复用组件
│   ├── TransactionCard.tsx
│   ├── AccountTree.tsx
│   ├── PostingEditor.tsx
│   ├── BalanceIndicator.tsx
│   ├── DedupBanner.tsx     # 去重提示组件
│   ├── SearchBar.tsx       # 搜索栏
│   ├── CategoryPicker.tsx  # 分类选择器
│   ├── TagPicker.tsx       # 标签选择器
│   ├── CalendarView.tsx    # 日历视图组件
│   ├── SpeedDial.tsx       # 快速操作按钮（长按展开）
│   └── charts/             # 图表组件
│       ├── MonthlyReport.tsx
│       ├── CategoryPie.tsx
│       ├── TrendLine.tsx
│       ├── AnnualReport.tsx   # 年度报告
│       ├── NetWorthChart.tsx  # 净资产趋势
│       └── CalendarHeatmap.tsx # 日历热力图
├── domain/                 # 已有核心逻辑（保持不变）
├── storage/                # SQLite 缓存层（可从 .bean 重建）
│   ├── ledgerRepository.ts
│   └── migrations.ts
├── store/                  # Zustand 状态管理
│   ├── ledgerStore.ts
│   ├── importStore.ts
│   └── settingsStore.ts
├── hooks/                  # 自定义 Hooks
│   ├── useLedger.ts
│   ├── useImport.ts
│   ├── useRules.ts
│   ├── useSearch.ts        # 交易搜索
│   ├── useCategory.ts      # 分类管理
│   └── usePrivacyBlur.ts   # 隐私模糊
├── services/               # 业务逻辑服务
│   ├── security.ts         # 应用锁
│   ├── backup.ts           # 备份恢复（备份 .bean 文件，非数据库）
│   ├── privacyBlur.ts      # 隐私模糊
│   ├── deepLink.ts         # 深度链接处理
│   └── crash.ts            # 崩溃上报
├── i18n/                   # 国际化
│   ├── index.ts
│   ├── zh.ts
│   └── en.ts
├── theme/                  # 主题系统（Token 化，可扩展）
│   ├── index.ts            # ThemeProvider + useTheme
│   ├── tokens.ts           # ThemeTokens 接口定义
│   ├── presets.ts           # light/dark 预置主题
│   ├── createTheme.ts       # 自定义主题工厂
│   └── storage.ts           # 主题持久化 + 导入导出
├── ocr/                    # OCR 模块（Android）
│   ├── OcrBridge.ts
│   ├── OcrProcessor.ts
│   └── AiVisionProcessor.ts
└── utils/                  # 工具函数
    ├── errorHandler.ts
    └── logger.ts
```

### 1.2 主题系统（Token 化，可扩展）

参考 BeeCount 的设计令牌系统，采用 **Token 化架构**——组件不直接引用颜色值，而是引用语义化令牌，主题通过令牌映射实现。

#### 设计令牌定义

```typescript
// src/theme/tokens.ts

// 语义化令牌：组件引用这些名称，不关心具体颜色
export interface ThemeTokens {
  // 色彩
  colors: {
    // 背景层级
    bgPrimary: string;      // 页面背景
    bgSecondary: string;    // 卡片/容器背景
    bgTertiary: string;     // 输入框/次级容器
    
    // 前景色
    fgPrimary: string;      // 主要文字
    fgSecondary: string;    // 次要文字
    fgInverse: string;      // 反色文字（用于深色背景上的文字）
    
    // 强调色（主题色）
    accent: string;         // 主题色（按钮、链接、选中态）
    accentLight: string;    // 主题色浅色变体（背景高亮）
    accentDark: string;     // 主题色深色变体
    
    // 语义色
    success: string;
    warning: string;
    error: string;
    info: string;
    
    // 财务语义色
    income: string;         // 收入色（默认绿色）
    expense: string;        // 支出色（默认红色）
    transfer: string;       // 转账色（默认蓝色）
    
    // 边框与分割
    border: string;
    divider: string;
    
    // 特殊场景
    overlay: string;        // 遮罩层
    skeleton: string;       // 骨架屏
    blur: string;           // 隐私模糊背景
  };
  
  // 间距
  spacing: {
    xs: number;
    sm: number;
    md: number;
    lg: number;
    xl: number;
  };
  
  // 圆角
  radii: {
    sm: number;
    md: number;
    lg: number;
    full: number;
  };
  
  // 字体
  typography: {
    h1: { fontSize: number; fontWeight: string; lineHeight: number };
    h2: { fontSize: number; fontWeight: string; lineHeight: number };
    h3: { fontSize: number; fontWeight: string; lineHeight: number };
    body: { fontSize: number; fontWeight: string; lineHeight: number };
    bodySmall: { fontSize: number; fontWeight: string; lineHeight: number };
    caption: { fontSize: number; fontWeight: string; lineHeight: number };
  };
  
  // 阴影
  shadows: {
    sm: { shadowColor: string; shadowOffset: object; shadowOpacity: number; shadowRadius: number; elevation: number };
    md: { shadowColor: string; shadowOffset: object; shadowOpacity: number; shadowRadius: number; elevation: number };
    lg: { shadowColor: string; shadowOffset: object; shadowOpacity: number; shadowRadius: number; elevation: number };
  };
}
```

#### 预置主题

```typescript
// src/theme/presets.ts
import type { ThemeTokens } from './tokens';

// 浅色主题
export const lightTheme: ThemeTokens = {
  colors: {
    bgPrimary: '#FFFFFF',
    bgSecondary: '#F5F5F5',
    bgTertiary: '#EEEEEE',
    fgPrimary: '#212121',
    fgSecondary: '#757575',
    fgInverse: '#FFFFFF',
    accent: '#2196F3',
    accentLight: '#E3F2FD',
    accentDark: '#1565C0',
    success: '#4CAF50',
    warning: '#FF9800',
    error: '#F44336',
    info: '#2196F3',
    income: '#4CAF50',
    expense: '#F44336',
    transfer: '#2196F3',
    border: '#E0E0E0',
    divider: '#EEEEEE',
    overlay: 'rgba(0,0,0,0.5)',
    skeleton: '#E0E0E0',
    blur: 'rgba(255,255,255,0.8)',
  },
  spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
  radii: { sm: 4, md: 8, lg: 16, full: 9999 },
  typography: {
    h1: { fontSize: 24, fontWeight: '700', lineHeight: 32 },
    h2: { fontSize: 20, fontWeight: '600', lineHeight: 28 },
    h3: { fontSize: 16, fontWeight: '600', lineHeight: 24 },
    body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
    bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
    caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
  },
  shadows: {
    sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 1 },
    md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 3 },
    lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 5 },
  },
};

// 暗色主题（OLED 纯黑）
export const darkTheme: ThemeTokens = {
  ...lightTheme,
  colors: {
    bgPrimary: '#000000',
    bgSecondary: '#121212',
    bgTertiary: '#1E1E1E',
    fgPrimary: '#FFFFFF',
    fgSecondary: '#B0B0B0',
    fgInverse: '#000000',
    accent: '#64B5F6',
    accentLight: '#1A237E',
    accentDark: '#90CAF9',
    success: '#66BB6A',
    warning: '#FFB74D',
    error: '#EF5350',
    info: '#64B5F6',
    income: '#66BB6A',
    expense: '#EF5350',
    transfer: '#64B5F6',
    border: '#2C2C2C',
    divider: '#1E1E1E',
    overlay: 'rgba(0,0,0,0.7)',
    skeleton: '#2C2C2C',
    blur: 'rgba(0,0,0,0.8)',
  },
  shadows: {
    sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.3, shadowRadius: 2, elevation: 1 },
    md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.4, shadowRadius: 4, elevation: 3 },
    lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.5, shadowRadius: 8, elevation: 5 },
  },
};

// 预置主题注册表
export const presetThemes: Record<string, ThemeTokens> = {
  light: lightTheme,
  dark: darkTheme,
};
```

#### 自定义主题工厂

```typescript
// src/theme/createTheme.ts
import type { ThemeTokens } from './tokens';
import { lightTheme } from './presets';

// 用户只需提供覆盖项，其余从基础主题继承
export function createTheme(overrides: Partial<ThemeTokens> & { name?: string }): ThemeTokens {
  return {
    ...lightTheme,
    ...overrides,
    colors: { ...lightTheme.colors, ...overrides.colors },
    spacing: { ...lightTheme.spacing, ...overrides.spacing },
    radii: { ...lightTheme.radii, ...overrides.radii },
    typography: { ...lightTheme.typography, ...overrides.typography },
    shadows: { ...lightTheme.shadows, ...overrides.shadows },
  };
}

// 示例：用户创建"森林绿"主题
const forestTheme = createTheme({
  name: 'forest',
  colors: {
    ...lightTheme.colors,
    accent: '#2E7D32',
    accentLight: '#E8F5E9',
    accentDark: '#1B5E20',
  },
});
```

#### ThemeProvider

```typescript
// src/theme/index.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useColorScheme } from 'react-native';
import type { ThemeTokens } from './tokens';
import { presetThemes } from './presets';
import { loadTheme, saveTheme } from './storage';

interface ThemeContextValue {
  theme: ThemeTokens;
  themeName: string;
  isDark: boolean;
  setTheme: (name: string) => void;
  registerTheme: (name: string, tokens: ThemeTokens) => void;
}

const ThemeContext = createContext<ThemeContextValue>(null!);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const systemColorScheme = useColorScheme();
  const [themeName, setThemeName] = useState('system');
  const [customThemes, setCustomThemes] = useState<Record<string, ThemeTokens>>({});
  
  // 加载持久化的主题偏好
  useEffect(() => {
    loadTheme().then(saved => {
      if (saved) setThemeName(saved);
    });
  }, []);
  
  // 解析实际主题
  const resolvedName = themeName === 'system' 
    ? (systemColorScheme === 'dark' ? 'dark' : 'light')
    : themeName;
  
  const theme = customThemes[resolvedName] 
    ?? presetThemes[resolvedName] 
    ?? lightTheme;
  
  const isDark = resolvedName === 'dark' || theme.colors.bgPrimary === '#000000';
  
  const setTheme = (name: string) => {
    setThemeName(name);
    saveTheme(name);
  };
  
  const registerTheme = (name: string, tokens: ThemeTokens) => {
    setCustomThemes(prev => ({ ...prev, [name]: tokens }));
  };
  
  return (
    <ThemeContext.Provider value={{ theme, themeName, isDark, setTheme, registerTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}
```

#### 组件使用方式

```tsx
// 组件通过 useTheme() 获取令牌，不硬编码颜色
function TransactionCard({ transaction }: { transaction: Transaction }) {
  const { theme } = useTheme();
  
  return (
    <View style={{
      backgroundColor: theme.colors.bgSecondary,
      borderRadius: theme.radii.md,
      padding: theme.spacing.md,
      ...theme.shadows.sm,
    }}>
      <Text style={{
        color: theme.colors.fgPrimary,
        ...theme.typography.body,
      }}>
        {transaction.narration}
      </Text>
      <Text style={{
        color: theme.colors.fgSecondary,
        ...theme.typography.caption,
      }}>
        {transaction.date}
      </Text>
    </View>
  );
}
```

#### 主题持久化与导入/导出

```typescript
// src/theme/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';

const THEME_KEY = '@theme_preference';
const CUSTOM_THEMES_KEY = '@custom_themes';

export async function loadTheme(): Promise<string | null> {
  return await AsyncStorage.getItem(THEME_KEY);
}

export async function saveTheme(name: string): Promise<void> {
  await AsyncStorage.setItem(THEME_KEY, name);
}

// 自定义主题持久化
export async function loadCustomThemes(): Promise<Record<string, ThemeTokens>> {
  const json = await AsyncStorage.getItem(CUSTOM_THEMES_KEY);
  return json ? JSON.parse(json) : {};
}

export async function saveCustomTheme(name: string, tokens: ThemeTokens): Promise<void> {
  const themes = await loadCustomThemes();
  themes[name] = tokens;
  await AsyncStorage.setItem(CUSTOM_THEMES_KEY, JSON.stringify(themes));
}

// 主题导出为 JSON（用户可分享主题）
export function exportTheme(name: string, tokens: ThemeTokens): string {
  return JSON.stringify({ name, tokens }, null, 2);
}

// 主题导入
export function importTheme(json: string): { name: string; tokens: ThemeTokens } {
  const data = JSON.parse(json);
  if (!data.name || !data.tokens) throw new Error('Invalid theme file');
  return data;
}
```

#### 设置页面主题选择

```tsx
function ThemeSettings() {
  const { themeName, setTheme, registerTheme } = useTheme();
  
  return (
    <View>
      <Text>主题模式</Text>
      <SegmentedControl
        values={['浅色', '深色', '跟随系统']}
        selectedIndex={themeName === 'dark' ? 1 : themeName === 'system' ? 2 : 0}
        onChange={(index) => setTheme(['light', 'dark', 'system'][index])}
      />
      
      <Text>自定义主题</Text>
      <Button onPress={() => importAndRegisterTheme()}>导入主题</Button>
      <Button onPress={() => exportCurrentTheme()}>导出当前主题</Button>
    </View>
  );
}
```

#### 主题扩展点

| 扩展方式 | 说明 | 示例 |
|---------|------|------|
| 修改令牌值 | 覆盖预置主题的颜色/间距 | `createTheme({ colors: { accent: '#FF5722' } })` |
| 创建新主题 | 基于模板完全自定义 | 注册"森林"/"海洋"/"日落"等主题 |
| 动态主题 | 运行时切换 | 根据时间/天气自动切换 |
| 主题分享 | JSON 导入导出 | 用户间分享自定义主题 |
| 第三方主题 | 通过插件系统 | 社区贡献主题包 |

### 1.3 国际化

```typescript
// src/i18n/index.ts
import { I18n } from 'i18n-js';
import zh from './zh';
import en from './en';

const i18n = new I18n({ zh, en });
i18n.defaultLocale = 'zh';
i18n.enableFallback = true;

export default i18n;

// src/i18n/zh.ts
export default {
  home: { title: '首页', balance: '余额' },
  transaction: { new: '记一笔', edit: '编辑交易', search: '搜索交易' },
  import: { title: '导入账单', csv: 'CSV 导入', ocr: 'OCR 识别' },
  category: { title: '分类管理', add: '添加分类' },
  tag: { title: '标签管理', add: '添加标签' },
  budget: { title: '预算管理', set: '设置预算' },
  settings: { title: '设置', theme: '主题', language: '语言', backup: '备份' },
  calendar: { title: '日历', today: '今天', thisMonth: '本月' },
  creditCard: { title: '信用卡', billingDay: '账单日', paymentDay: '还款日' },
};
```

### 1.4 核心页面实现

#### 首页（账本概览）

```tsx
function HomeScreen() {
  const { ledger } = useLedgerStore();
  return (
    <View>
      <AccountSummary accounts={ledger?.accounts} />
      <RecentTransactions transactions={ledger?.transactions.slice(0, 10)} />
      <MonthlyReport transactions={ledger?.transactions} />
    </View>
  );
}
```

#### Speed Dial 快速操作

参考 BeeCount 的 Speed Dial FAB——长按添加按钮弹出快捷操作：

```tsx
function SpeedDial() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <View style={styles.fabContainer}>
      {isOpen && (
        <View style={styles.actions}>
          <FAB icon="edit" onPress={addManualTransaction} label="手动记账" />
          <FAB icon="camera" onPress={takePhotoOcr} label="拍照识别" />
          <FAB icon="image" onPress={pickImageOcr} label="相册识别" />
          <FAB icon="mic" onPress={startVoiceInput} label="语音记账" />
        </View>
      )}
      <FAB
        icon={isOpen ? 'close' : 'add'}
        onPress={() => setIsOpen(!isOpen)}
        onLongPress={() => setIsOpen(true)}
        size="large"
      />
    </View>
  );
}
```

#### 记账页面（复式分录）

```tsx
function TransactionEditor() {
  const [postings, setPostings] = useState<Posting[]>([
    { account: '', amount: '', currency: 'CNY' },
    { account: '', amount: '', currency: 'CNY' },
  ]);
  const balance = calculateBalance(postings);
  
  return (
    <View>
      <PostingsList postings={postings} onChange={setPostings} />
      <BalanceIndicator balance={balance} />
      <CategoryPicker onSelect={handleCategorySelect} />
      <TagPicker selected={tags} onToggle={handleTagToggle} />
      <Button onPress={saveTransaction}>保存</Button>
    </View>
  );
}
```

#### 交易搜索页面

参考 BeeCount 的多维筛选（关键字/分类/账户/日期/标签）：

```tsx
function TransactionSearchScreen() {
  const [query, setQuery] = useState('');
  const [filters, setFilters] = useState<SearchFilters>({
    category: undefined,
    account: undefined,
    dateRange: undefined,
    tags: [],
    direction: undefined,
  });
  
  const results = useSearch(query, filters);
  
  return (
    <View>
      <SearchBar value={query} onChangeText={setQuery} />
      <FilterChips filters={filters} onFilterChange={setFilters} />
      <FlatList data={results} renderItem={TransactionCard} />
    </View>
  );
}
```

### 1.5 深度链接（Deep Link）

参考 BeeCount 的 `beecount://` URL scheme：

```typescript
// src/services/deepLink.ts
import * as Linking from 'expo-linking';

const PREFIX = 'beancount://';

type DeepLinkAction = 
  | { type: 'add-transaction'; draft?: Partial<TransactionDraft> }
  | { type: 'ocr-camera' }
  | { type: 'ocr-image'; imageUri?: string }
  | { type: 'voice-input' }
  | { type: 'import-csv' }
  | { type: 'open-tab'; tab: 'home' | 'transactions' | 'import' | 'rules' | 'settings' };

export function parseDeepLink(url: string): DeepLinkAction | null {
  const path = url.replace(PREFIX, '');
  const [action, ...params] = path.split('/');
  
  switch (action) {
    case 'add':
      return { type: 'add-transaction', draft: parseDraftParams(params) };
    case 'ocr':
      if (params[0] === 'camera') return { type: 'ocr-camera' };
      if (params[0] === 'image') return { type: 'ocr-image', imageUri: params[1] };
      return null;
    case 'voice':
      return { type: 'voice-input' };
    case 'import':
      return { type: 'import-csv' };
    case 'tab':
      return { type: 'open-tab', tab: (params[0] as any) ?? 'home' };
    default:
      return null;
  }
}

// 在 App 入口注册深度链接处理
export function setupDeepLinking() {
  Linking.addEventListener('url', ({ url }) => {
    const action = parseDeepLink(url);
    if (action) handleDeepLink(action);
  });
  
  // 处理冷启动时的深度链接
  Linking.getInitialURL().then(url => {
    if (url) {
      const action = parseDeepLink(url);
      if (action) handleDeepLink(action);
    }
  });
}
```

### 1.6 依赖添加

```json
{
  "dependencies": {
    "expo-router": "~4.0.0",
    "expo-document-picker": "~13.0.0",
    "expo-file-system": "~19.0.0",
    "expo-local-authentication": "~14.0.0",
    "expo-localization": "~15.0.0",
    "expo-linking": "~7.0.0",
    "@expo/vector-icons": "^14.0.0",
    "react-native-reanimated": "~3.17.0",
    "react-native-gesture-handler": "~2.24.0",
    "zustand": "^5.0.0",
    "i18n-js": "^4.0.0",
    "react-native-chart-kit": "^6.12.0",
    "@react-native-async-storage/async-storage": "^2.0.0",
    "quickjs-emscripten": "^0.23.0"
  }
}
```

---

## Phase 2: 核心功能完善（3-4 周）[P0]

### 2.1 账本导入流程

```
用户选择 .bean 文件
    ↓
解析主文件 + include 引用
    ↓
建立 LedgerIndex（账户、交易、诊断）
    ↓
缓存到 SQLite（带 schema 迁移检查）
    ↓
显示账本概览
```

### 2.2 交易管理

- **创建交易**：支持支出、收入、转账、自定义分录
- **编辑交易**：仅限 `mobile.bean` 内的交易
- **删除交易**：从 `mobile.bean` 中移除
- **交易验证**：实时校验借贷平衡、账户状态
- **交易搜索**：多维筛选（关键字/分类/账户/日期/标签/方向）
- **交易标志**：支持 `excludeFromStats`（不计入统计）和 `excludeFromBudget`（不计入预算）

#### 交易标志系统

参考 AutoAccounting 的 Bill Flag + BeeCount 的 transaction flags：

```typescript
// src/domain/transactionFlags.ts
export enum TransactionFlag {
  NONE = 0,
  EXCLUDE_FROM_STATS = 1 << 0,   // 不计入统计图表（但影响余额）
  EXCLUDE_FROM_BUDGET = 1 << 1,  // 不计入预算使用
  TRANSFER = 1 << 2,             // 标记为转账（自动识别）
  RECURRING = 1 << 3,            // 周期性交易模板
}

export function hasFlag(flags: number, flag: TransactionFlag): boolean {
  return (flags & flag) !== 0;
}

export function setFlag(flags: number, flag: TransactionFlag): number {
  return flags | flag;
}

export function clearFlag(flags: number, flag: TransactionFlag): number {
  return flags & ~flag;
}
```

### 2.3 分类系统（双轨制）

> **订正**：采用「设计决策 1」的双轨制。分类匹配算法参考 BeeCount 的 `CategoryMatcher`，但分类与 Beancount 账户的关系用 `linkedAccount` 映射，而非 BeeCount 的独立表。完整设计见前置章节「决策 1」。

```typescript
// src/domain/categories.ts
export interface Category {
  id: string;
  name: string;
  parentId?: string;          // 层级（UI 展示用，不映射到 .bean）
  type: 'income' | 'expense';
  linkedAccount: string;      // ★ 映射到 Beancount 账户，记账时作为 posting account
  icon?: string;              // UI 增强（仅本地）
  color?: string;             // UI 增强（仅本地）
  keywords: string[];         // 自动匹配关键词（仅本地匹配逻辑）
}

// 分类匹配：精确匹配 → 关键词匹配 → 模糊匹配 → 规则匹配 → 兜底"其他"
export function matchCategory(
  text: string,
  categories: Category[]
): Category | null {
  // 1. 精确匹配
  const exact = categories.find(c => c.name === text);
  if (exact) return exact;
  
  // 2. 关键词匹配
  const byKeyword = categories.find(c => 
    c.keywords.some(k => text.includes(k))
  );
  if (byKeyword) return byKeyword;
  
  // 3. 模糊匹配（包含关系）
  const fuzzy = categories.find(c => 
    text.includes(c.name) || c.name.includes(text)
  );
  if (fuzzy) return fuzzy;
  
  // 4. 兜底
  return categories.find(c => c.name === '其他') ?? null;
}

// ★ 映射失效处理：账户被桌面端删除/重命名时，记账前校验
export function resolveCategoryAccount(
  category: Category,
  ledger: LedgerIndex
): { account: string; valid: boolean; fallbackReason?: string } {
  if (ledger.accounts.has(category.linkedAccount)) {
    return { account: category.linkedAccount, valid: true };
  }
  // 优雅降级到 Uncategorized
  const fallback = category.type === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized';
  return { 
    account: fallback, 
    valid: false, 
    fallbackReason: `分类「${category.name}」映射的账户 ${category.linkedAccount} 已不存在，已降级` 
  };
}
```

分类管理页面：

```tsx
function CategoryScreen() {
  const [categories, setCategories] = useState<Category[]>([]);
  
  return (
    <View>
      <SectionList
        sections={[
          { title: '支出分类', data: categories.filter(c => c.type === 'expense') },
          { title: '收入分类', data: categories.filter(c => c.type === 'income') },
        ]}
        renderItem={({ item }) => (
          <CategoryItem
            category={item}
            onEdit={() => editCategory(item)}
            onDelete={() => deleteCategory(item.id)}
          />
        )}
        renderSectionHeader={({ section }) => <Text>{section.title}</Text>}
      />
      <FAB onPress={addCategory} />
    </View>
  );
}
```

### 2.4 标签系统（双轨制）

> **订正**：标签采用双轨制。应用 `tags` 表存 UI 增强（name/color），记账时写回 `.bean` 用 Beancount 原生 `#tag` 语法（见 `serializeTransaction`）。

```typescript
// src/domain/tags.ts
export interface Tag {
  id: string;
  name: string;     // 同时作为 #tag 名（写回 .bean）
  color: string;    // UI 增强（仅本地，不写回 .bean）
}

// 交易标签关联（应用本地，用于 UI 筛选/统计；写回 .bean 时序列化为 #tag）
export interface TransactionTag {
  transactionId: string;
  tagId: string;
}

// 标签名 → #tag 语法的序列化（在 serializeTransaction 中已支持）
// 例：tagName 'food' → narration 末尾追加 ' #food'
export function tagsToBeanSyntax(tags: Tag[]): string {
  return tags.map(t => ` #${t.name}`).join('');
}
```

标签管理 + 筛选：

```tsx
function TagScreen() {
  return (
    <View>
      <FlatList
        data={tags}
        renderItem={({ item }) => (
          <TagChip tag={item} onPress={() => filterByTag(item.id)} />
        )}
      />
    </View>
  );
}
```

### 2.5 CSV 导入增强

```typescript
// 扩展适配器支持
type StatementAdapter = 
  | 'alipay-csv-v1'      // 支付宝
  | 'wechat-csv-v1'      // 微信支付
  | 'bank-csv-v1'        // 通用银行
  | 'cmb-csv-v1'         // 招商银行
  | 'icbc-csv-v1'        // 工商银行
  | 'custom-csv-v1';     // 自定义格式
```

补充招商银行和工商银行适配器解析逻辑：

```typescript
// src/domain/adapters/cmb.ts
export function parseCmbCsv(content: string): ImportedEvent[] {
  // 招商银行 CSV 格式：交易日期,交易时间,摘要,交易金额,余额,交易类型
  const rows = parseCsv(content);
  return rows.map(row => ({
    id: `CMB:${row['交易序号']}`,
    occurredAt: `${row['交易日期']} ${row['交易时间']}`,
    amount: row['交易金额'],
    currency: 'CNY',
    direction: parseDirection(row['摘要']),
    channel: 'Bank:CMB',
    counterparty: row['交易对方'] || '',
    memo: row['摘要'] || '',
    raw: row,
  }));
}

// src/domain/adapters/icbc.ts
export function parseIcbcCsv(content: string): ImportedEvent[] {
  // 工商银行 CSV 格式：交易时间,交易类型,交易金额,余额,对方户名,对方账号,摘要
  // ...类似实现
}
```

### 2.6 规则引擎增强（JS 规则执行器）

参考 AutoAccounting 的 QuickJS 规则引擎，支持用户自定义 JS 规则：

```typescript
// src/domain/ruleEngine.ts
import { QuickJS } from 'quickjs-emscripten';

interface Rule {
  id: string;
  name: string;
  priority: number;
  enabled: boolean;
  isSystem: boolean;        // 系统规则 vs 用户规则
  // 匹配条件（静态，用于快速预筛选）
  channel?: string;
  direction?: Direction;
  counterpartyContains?: string;
  memoContains?: string;
  minAmount?: string;
  maxAmount?: string;
  currency?: string;
  // JS 规则体（动态，用于精确匹配和字段提取）
  jsCode?: string;
  // 输出
  channelAccount: string;
  categoryAccount: string;
  narration?: string;
  tags?: string[];
  // 统计
  hits: number;
  lastHitAt?: string;
}

// 规则执行器
class RuleEngine {
  private vm: QuickJS;
  
  async init() {
    this.vm = await QuickJS.newQuickJS();
  }
  
  // 执行 JS 规则
  async executeRule(rule: Rule, eventData: Record<string, unknown>): Promise<Record<string, unknown> | null> {
    if (!rule.jsCode) return null;
    
    const ctx = this.vm.newContext();
    ctx.setProp(ctx.global, 'data', ctx.newObject());
    
    // 注入事件数据
    for (const [key, value] of Object.entries(eventData)) {
      ctx.setProp(ctx.getProp(ctx.global, 'data'), key, ctx.newString(String(value)));
    }
    
    // 注入通用函数
    ctx.setProp(ctx.global, 'print', ctx.newFunction('print', (msg) => {
      console.log('[Rule]', msg);
      return ctx.undefined;
    }));
    
    const result = ctx.evalCode(rule.jsCode);
    if (result.error) {
      console.error(`Rule ${rule.id} failed:`, result.error);
      return null;
    }
    
    // 解析返回的 JSON
    try {
      return JSON.parse(result.value.toString());
    } catch {
      return null;
    }
  }
  
  // 批量执行规则（用户规则优先，系统规则其次）
  async matchEvent(
    event: ImportedEvent,
    rules: Rule[]
  ): Promise<{ rule: Rule; result: Record<string, unknown> } | null> {
    // 按优先级排序：用户规则 > 系统规则
    const sortedRules = rules
      .filter(r => r.enabled)
      .sort((a, b) => {
        if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1;
        return b.priority - a.priority;
      });
    
    for (const rule of sortedRules) {
      // 静态条件预筛选
      if (!matchStaticConditions(rule, event)) continue;
      
      // JS 规则执行
      if (rule.jsCode) {
        const result = await this.executeRule(rule, eventToData(event));
        if (result) {
          rule.hits++;
          rule.lastHitAt = new Date().toISOString();
          return { rule, result };
        }
      } else {
        // 无 JS 代码，静态匹配成功
        rule.hits++;
        rule.lastHitAt = new Date().toISOString();
        return { rule, result: staticExtract(rule, event) };
      }
    }
    
    return null;
  }
}
```

### 2.7 转账智能识别

参考 AutoAccounting 的 TransferRecognizer + BillMerger。

> **执行顺序（审查订正）**：转账识别**必须先于去重**执行。参考 AutoAccounting 的 `BillManager.groupBillInfo`：先把 Income+Expend 配对识别为 Transfer，再对剩余账单去重。一笔账单既可能是转账的一方又可能是重复时，转账识别优先（合并为单条转账交易，而非丢弃重复）。完整责任链见 **2.8 的 `billPipeline`**。

```typescript
// src/domain/transferRecognizer.ts
import { compareDecimals, negateDecimal } from './decimal';

interface TransferPair {
  left: ImportedEvent;
  right: ImportedEvent;
  confidence: 'high' | 'medium' | 'low';
  reason: string;
}

// 转账识别：Income+Expend → Transfer
export function recognizeTransfers(events: ImportedEvent[]): TransferPair[] {
  const pairs: TransferPair[] = [];
  const used = new Set<string>();
  
  for (const event of events) {
    if (used.has(event.id)) continue;
    
    // 寻找匹配的对端交易
    const match = findTransferMatch(event, events, used);
    if (match) {
      pairs.push(match);
      used.add(event.id);
      used.add(match.left.id);
    }
  }
  
  return pairs;
}

function findTransferMatch(
  event: ImportedEvent,
  allEvents: ImportedEvent[],
  used: Set<string>
): TransferPair | null {
  const TIME_WINDOW = 3 * 24 * 60 * 60 * 1000; // 3天
  const eventTime = Date.parse(event.occurredAt);
  
  for (const candidate of allEvents) {
    if (used.has(candidate.id)) continue;
    if (candidate.id === event.id) continue;
    
    // 时间窗口检查
    const candidateTime = Date.parse(candidate.occurredAt);
    if (Math.abs(eventTime - candidateTime) > TIME_WINDOW) continue;
    
    // 金额匹配（一正一负）
    const eventAmount = parseFloat(event.amount);
    const candidateAmount = parseFloat(candidate.amount);
    if (compareDecimals(String(Math.abs(eventAmount)), String(Math.abs(candidateAmount))) !== 0) continue;
    
    // 方向检查：必须一个是收入，一个是支出
    if (event.direction === candidate.direction) continue;
    
    // 账户关联检查
    const confidence = checkAccountLink(event, candidate);
    
    return {
      left: event.direction === 'expense' ? event : candidate,
      right: event.direction === 'income' ? event : candidate,
      confidence: confidence.level,
      reason: confidence.reason,
    };
  }
  
  return null;
}

function checkAccountLink(
  event: ImportedEvent,
  candidate: ImportedEvent
): { level: 'high' | 'medium' | 'low'; reason: string } {
  // 高置信度：已知关联账户（如支付宝→银行卡）
  if (isKnownTransferChannel(event.channel, candidate.channel)) {
    return { level: 'high', reason: `已知转账通道: ${event.channel} → ${candidate.channel}` };
  }
  
  // 中置信度：对手方匹配
  if (event.counterparty && candidate.counterparty) {
    if (event.counterparty.includes(candidate.counterparty) ||
        candidate.counterparty.includes(event.counterparty)) {
      return { level: 'medium', reason: `对手方匹配: ${event.counterparty}` };
    }
  }
  
  // 低置信度：仅金额+时间匹配
  return { level: 'low', reason: '金额+时间匹配' };
}
```

### 2.8 交易去重

> **覆盖场景**：CSV 导入、OCR 识别、通知/短信监听、手工新增

#### 去重方案设计

**去重场景**：

| 场景     | 触发方式       | 去重策略                      |
| -------- | -------------- | ----------------------------- |
| CSV 导入 | 用户选择文件   | 事件 ID + 时间窗口 + 金额匹配 |
| OCR 识别 | 无障碍服务截屏 | 时间窗口 + 金额 + 账户关联    |
| 通知监听 | 系统通知       | 通知内容哈希 + 时间窗口       |
| 短信监听 | 短信接收       | 短信内容哈希 + 时间窗口       |
| 手工新增 | 用户手动输入   | 时间 + 金额 + 账户 + 对手方   |

**去重条件**（必须同时满足）：

1. **时间窗口**：同一笔交易的时间差异 ≤ 5 分钟
2. **金额匹配**：交易金额完全一致（绝对值比较）
3. **账户关联**：存在转账关系（如微信→银行卡）

#### 多通道联合去重

参考 AutoAccounting 的跨通道去重——微信通知+银行短信+应用 Hook 对同一笔交易去重：

```typescript
// src/domain/dedup.ts

import { compareDecimals } from './decimal';
import type { ImportedEvent, Transaction, TransactionDraft } from './types';

export interface DedupConfig {
  enabled: boolean;
  timeWindowMinutes: number;    // 默认 5 分钟
  accountLinks: AccountLink[];  // 账户关联配置
}

export interface AccountLink {
  channel: string;              // 渠道（如 'Alipay', 'WeChat'）
  linkedAccounts: string[];     // 关联账户（如 ['Bank:CMB', 'Bank:ICBC']）
}

export interface DedupResult {
  isDuplicate: boolean;
  reason?: string;
  existingTransaction?: Transaction;
  confidence: 'high' | 'low' | 'none';
}

// 生成去重指纹
export function generateDedupFingerprint(
  date: string,
  amount: string,
  channel: string,
  counterparty: string
): string {
  const normalizedDate = date.slice(0, 16);
  const normalizedAmount = Math.abs(parseFloat(amount)).toFixed(2);
  const raw = `${normalizedDate}_${normalizedAmount}_${channel}_${counterparty}`;
  return simpleHash(raw);
}

function simpleHash(str: string): string {
  let h = 0;
  for (let i = 0; i < str.length; i++) {
    h = Math.imul(31, h) + str.charCodeAt(i) | 0;
  }
  return (h >>> 0).toString(16);
}

// 检查是否重复（支持多通道）
export function checkDuplicate(
  newEvent: ImportedEvent | TransactionDraft,
  existingTransactions: Transaction[],
  config: DedupConfig,
  importedEvents: ImportedEvent[] = []  // 本次导入的事件（跨通道去重）
): DedupResult {
  if (!config.enabled) {
    return { isDuplicate: false, confidence: 'none' };
  }

  const newTime = 'occurredAt' in newEvent ? newEvent.occurredAt : newEvent.date;
  const newAmount = 'amount' in newEvent ? newEvent.amount : newEvent.postings[0]?.amount ?? '0';
  const newChannel = 'channel' in newEvent ? newEvent.channel : undefined;
  const newCounterparty = 'counterparty' in newEvent ? newEvent.counterparty : newEvent.payee;

  // 1. 与已有交易比较
  for (const existing of existingTransactions) {
    const result = compareEvents(newEvent, existing, config);
    if (result.isDuplicate) return result;
  }

  // 2. 与本次导入的其他事件比较（跨通道去重）
  for (const imported of importedEvents) {
    if ('id' in newEvent && imported.id === (newEvent as ImportedEvent).id) continue;
    
    const result = compareImportedEvents(newEvent as ImportedEvent, imported, config);
    if (result.isDuplicate) return result;
  }

  return { isDuplicate: false, confidence: 'none' };
}

function compareEvents(
  newEvent: ImportedEvent | TransactionDraft,
  existing: Transaction,
  config: DedupConfig
): DedupResult {
  const newTime = 'occurredAt' in newEvent ? newEvent.occurredAt : newEvent.date;
  const newAmount = 'amount' in newEvent ? newEvent.amount : newEvent.postings[0]?.amount ?? '0';
  const newChannel = 'channel' in newEvent ? newEvent.channel : undefined;
  const newCounterparty = 'counterparty' in newEvent ? newEvent.counterparty : newEvent.payee;

  const timeDiff = Math.abs(Date.parse(newTime) - Date.parse(existing.date));
  const maxDiff = config.timeWindowMinutes * 60 * 1000;
  if (timeDiff > maxDiff) {
    return { isDuplicate: false, confidence: 'none' };
  }

  const existingAmount = existing.postings[0]?.amount ?? '0';
  if (compareDecimals(Math.abs(parseFloat(newAmount)).toFixed(2), 
                      Math.abs(parseFloat(existingAmount)).toFixed(2)) !== 0) {
    return { isDuplicate: false, confidence: 'none' };
  }

  // 账户关联检查
  if (newChannel && config.accountLinks.length > 0) {
    const isLinked = checkAccountLink(newChannel, existing, config.accountLinks);
    if (!isLinked) {
      return { isDuplicate: false, confidence: 'none' };
    }
  }

  // 对手方匹配（高置信度）
  if (newCounterparty && existing.payee) {
    if (newCounterparty.includes(existing.payee) || existing.payee.includes(newCounterparty)) {
      return {
        isDuplicate: true,
        reason: `时间相近（${Math.round(timeDiff / 60000)}分钟）、金额相同、对手方匹配`,
        existingTransaction: existing,
        confidence: 'high'
      };
    }
  }

  return {
    isDuplicate: true,
    reason: `时间相近（${Math.round(timeDiff / 60000)}分钟）、金额相同`,
    existingTransaction: existing,
    confidence: 'low'
  };
}

function checkAccountLink(
  channel: string,
  existing: Transaction,
  accountLinks: AccountLink[]
): boolean {
  const link = accountLinks.find(l => l.channel === channel);
  if (!link) return true;

  for (const posting of existing.postings) {
    if (link.linkedAccounts.some(acc => posting.account.includes(acc))) {
      return true;
    }
  }
  return false;
}

// 批量去重（导入时使用）
export function batchDedup(
  events: ImportedEvent[],
  existingTransactions: Transaction[],
  config: DedupConfig
): { accepted: ImportedEvent[]; duplicates: ImportedEvent[] } {
  const accepted: ImportedEvent[] = [];
  const duplicates: ImportedEvent[] = [];

  for (const event of events) {
    const result = checkDuplicate(event, existingTransactions, config, accepted);
    if (result.isDuplicate) {
      duplicates.push(event);
    } else {
      accepted.push(event);
    }
  }

  return { accepted, duplicates };
}
```

#### 账单处理责任链（billPipeline）

> **新增（审查订正）**：参考 AutoAccounting 的 `BillService.analyze` + `deduplicationMutex`，统一所有入口（手动/CSV/OCR/通知/短信）的处理顺序与并发控制。这是决策 6「跨进程通信」中 JS 层的唯一入库入口。

**执行顺序**（必须严格遵循）：

```
原始事件（手动/CSV/OCR/通知/短信）
    ↓
1. 转账识别（先于去重，合并 Income+Expend → Transfer）
    ↓
2. 多通道去重（时间窗口 + 金额 + 账户关联 + 渠道）
    ↓
3. 资产映射（规则 → AI → 算法兜底，确定 channelAccount）
    ↓
4. 分类映射（规则 → 关键词 → 模糊 → 兜底 Uncategorized，含 linkedAccount 校验）
    ↓
5. 备注生成（模板引擎）
    ↓
6. 串行入库（billPipeline 锁 + mobile.bean 原子写入）
```

**并发串行化**（参考 AutoAccounting `deduplicationMutex`）：

```typescript
// src/domain/billPipeline.ts（Step 4 实现）
export class BillPipeline {
  private mutex: Promise<unknown> = Promise.resolve();
  
  // 所有入口（手动/CSV/OCR/通知/短信）都经过此方法，保证串行
  async process(events: ImportedEvent[], ctx: PipelineContext): Promise<ProcessResult> {
    // withLock：串行化整个识别→去重→入库流程
    return this.withLock(async () => {
      // 1. 转账识别（先）
      const { transfers, remaining } = recognizeTransfers(events);
      // 2. 去重（后）
      const { accepted } = batchDedup(remaining, ctx.existingTransactions, ctx.dedupConfig);
      // 3-5. 资产映射 + 分类 + 备注
      const drafts = [...transfers, ...accepted].map(e => this.enrich(e, ctx));
      // 6. 串行写入 mobile.bean（MobileBeanStore 内部已有写入锁）
      for (const draft of drafts) {
        await ctx.mobileStore.append(draft.draft, ctx.ledger);
      }
      return { processed: drafts };
    });
  }
  
  private withLock<T>(fn: () => Promise<T>): Promise<T> {
    const run = this.mutex.then(fn, fn);
    this.mutex = run.then(() => undefined, () => undefined);
    return run;
  }
}
```

**为什么串行化重要**：若 OCR 和通知同时识别到同一笔交易，并发处理会导致：转账识别漏配对、去重失效、mobile.bean 写入交错损坏。串行化是正确性前提，性能影响小（毫秒级）。

### 2.9 备注模板系统

参考 AutoAccounting 的 Remark Template Engine：

```typescript
// src/domain/remarkTemplate.ts
interface RemarkTemplate {
  id: string;
  name: string;
  template: string;  // 支持占位符
  enabled: boolean;
}

// 占位符定义
const PLACEHOLDERS: Record<string, (event: ImportedEvent) => string> = {
  '【商户名称】': (e) => e.counterparty || '未知商户',
  '【金额】': (e) => e.amount,
  '【时间】': (e) => e.occurredAt.slice(11, 16),  // HH:mm
  '【日期】': (e) => e.occurredAt.slice(0, 10),    // YYYY-MM-DD
  '【渠道】': (e) => e.channel || '',
  '【卡片】': (e) => e.memo?.match(/尾号(\d{4})/)?.[1] ?? '',
  '【AI】': (e) => e.aiSuggestion || '',  // AI 识别的备注
};

// 生成备注
export function generateRemark(
  template: string,
  event: ImportedEvent
): string {
  let remark = template;
  
  for (const [placeholder, resolver] of Object.entries(PLACEHOLDERS)) {
    remark = remark.replace(new RegExp(placeholder.replace(/[【】]/g, '\\$&'), 'g'), resolver(event));
  }
  
  // 相邻重复归一化（如 "买菜买菜" → "买菜"）
  remark = normalizeAdjacentDuplicates(remark);
  
  return remark;
}

// 相邻重复归一化
function normalizeAdjacentDuplicates(text: string): string {
  // 检测连续重复的中文词组
  return text.replace(/(.{2,4})\1+/g, '$1');
}
```

### 2.10 关键词过滤系统

参考 AutoAccounting 的 AnalysisUtils：

```typescript
// src/domain/keywordFilter.ts
interface KeywordFilter {
  whitelist: string[];  // 白名单：必须包含任一关键词
  blacklist: string[];  // 黑名单：包含任一关键词则跳过
}

const DEFAULT_FILTER: KeywordFilter = {
  whitelist: ['交易', '消费', '收入', '支出', '转账', '收款', '付款', '充值', '提现'],
  blacklist: ['广告', '推广', '优惠券', '红包封面', '会员到期'],
};

export function passesKeywordFilter(
  text: string,
  filter: KeywordFilter = DEFAULT_FILTER
): boolean {
  // 黑名单检查
  if (filter.blacklist.some(kw => text.includes(kw))) {
    return false;
  }
  
  // 白名单检查
  if (filter.whitelist.length === 0) return true;
  return filter.whitelist.some(kw => text.includes(kw));
}
```

---

## Phase 3: OCR 自动记账（2-3 周，仅 Android）[P0]

> **参考 BeeCount 分层处理架构 + AutoAccounting 无障碍实现**
> **PP-OCRv5 为核心 OCR 引擎，ML Kit 为备选**

### 3.1 分层处理架构（参考 BeeCount）

```
输入（截图/通知/短信）
    ↓
Layer 1: 规则匹配（快速，无成本）
    ├─ 匹配成功 → 直接创建交易
    └─ 匹配失败 ↓
Layer 2: 本地 OCR + 规则（中等精度，无成本）
    ├─ OCR 识别 → 规则匹配 → 创建交易
    └─ 匹配失败 ↓
Layer 3: AI Vision（高精度，有成本）- 可选
    └─ 调用云端 AI → 创建交易
```

**设计原则**：

- 优先使用快速、无成本的规则匹配
- 本地 OCR 作为主要识别手段（隐私保护）
- AI Vision 作为可选后备（需要用户启用）
- 每层失败后自动降级到下一层

### 3.2 原生模块架构（Config Plugin 形态）

> **订正**：原方案的 `android/app/src/main/java/...` 裸 Kotlin 目录会被 `expo prebuild` 清空（见决策 4）。改为 Config Plugin + RN Module 结构。

```
plugins/
├── accessibility/           # 无障碍服务
│   ├── app.plugin.js        # Expo Config Plugin（注册 manifest service + xml）
│   └── android/
│       ├── OcrAccessibilityService.kt
│       ├── PageSignatureManager.kt
│       ├── BillParser.kt
│       ├── FloatingBillView.kt
│       ├── RuleMatcher.kt
│       └── res/xml/accessibility_service_config.xml
├── ppocr/                   # PP-OCRv5 原生模块
│   ├── app.plugin.js        # Config Plugin（打包 assets 模型 + native 依赖）
│   ├── android/
│   │   ├── OcrModule.kt     # React Native Bridge
│   │   └── OcrManager.kt    # OCR 处理（Layer 2）
│   └── assets/              # PP-OCRv5 ONNX 模型文件（det/rec 的 .onnx + 字典）
├── notification-listener/   # 通知监听
│   ├── app.plugin.js
│   └── android/NotificationListenerService.kt
├── sms-receiver/            # 短信监听
│   ├── app.plugin.js
│   └── android/SmsReceiver.kt
├── floating-window/         # 浮窗（权限注册）
│   └── app.plugin.js
└── ocr-tile/                # 快速设置磁贴
    ├── app.plugin.js
    └── android/OcrTileService.kt

app.json 注册：
{ "plugins": ["./plugins/accessibility", "./plugins/ppocr", ...] }
```

**每个 Config Plugin 的职责模板**：

```javascript
// plugins/accessibility/app.plugin.js
const { withAndroidManifest } = require('@expo/config-plugins');

module.exports = (config) => {
  return withAndroidManifest(config, (config) => {
    const manifest = config.modResults;
    // 1. 注册无障碍 service
    // 2. 添加 BIND_ACCESSIBILITY_SERVICE 权限
    // 3. 复制 accessibility_service_config.xml
    return config;
  });
};
```

### 3.3 Layer 1: 规则预匹配（快速，无成本）

```kotlin
// plugins/accessibility/android/RuleMatcher.kt（Config Plugin 形态）
object RuleMatcher {
    private val rules = listOf(
        Rule(
            name = "微信支付",
            pattern = Regex("""微信支付.*?(\d+\.\d+)元.*?商户：(.+?)(?:\s|$)"""),
            extractors = mapOf(
                "amount" to Regex("""(\d+\.\d+)元"""),
                "merchant" to Regex("""商户：(.+?)(?:\s|$)"""),
                "time" to Regex("""(\d{4}[-/]\d{2}[-/]\d{2}\s+\d{2}:\d{2})""")
            )
        ),
        Rule(
            name = "支付宝",
            pattern = Regex("""支付宝.*?(\d+\.\d+)元.*?收款方：(.+?)(?:\s|$)"""),
            extractors = mapOf(
                "amount" to Regex("""(\d+\.\d+)元"""),
                "merchant" to Regex("""收款方：(.+?)(?:\s|$)""")
            )
        ),
        Rule(
            name = "银行卡",
            pattern = Regex("""尾号(\d{4}).*?消费.*?(\d+\.\d+)元"""),
            extractors = mapOf(
                "amount" to Regex("""(\d+\.\d+)元"""),
                "card" to Regex("""尾号(\d{4})""")
            )
        )
    )
  
    fun match(text: String, appPackage: String): ImportedEvent? {
        for (rule in rules) {
            if (rule.pattern.containsMatchIn(text)) {
                return rule.extract(text, appPackage)
            }
        }
        return null
    }
}
```

### 3.4 Layer 2: 本地 OCR（中等精度，无成本）

> **订正 1**：删除原方案的 ML Kit 降级链（AutoAccounting 根本不用 ML Kit，只用 PP-OCRv5+NCNN）。改为单引擎 PP-OCRv5，并补充 AutoAccounting 的关键性能优化细节。
>
> **订正 2（决策 7）**：引擎从 NCNN 改为 **ONNX Runtime**。原因：NCNN 在 Windows 开发机的模型转换链路（`Paddle→ONNX→NCNN`）过于折腾，ONNX Runtime 只需一步（`Paddle→ONNX`）或直接下社区现成模型，且微软官方维护、跨平台。完整对比见「决策 7」。

```kotlin
// plugins/ppocr/android/OcrModule.kt（Config Plugin 形态，见决策 4 + 决策 7）
@ReactModule(name = "PpOcr")
class OcrModule(private val context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
    // 单引擎：PP-OCRv5 + ONNX Runtime（替代 NCNN，见决策 7）
    private var ortEnv: OrtEnvironment? = null
    private var detSession: OrtSession? = null   // ppocrv5_det.onnx
    private var recSession: OrtSession? = null   // ppocrv5_rec.onnx

    @ReactMethod
    fun recognizeText(imageBase64: String, promise: Promise) {
        scope.launch {
            val bitmap = decodeBase64(imageBase64)
            val scaled = scaleDownForOcr(bitmap, 720)
            // det（DB 文本检测）→ rec（CTC 文本识别）
            val blocks = runInference(scaled)
            promise.resolve(blocks.joinToString("\n") { it.text })
        }
    }
}
```

**性能优化（参考 AutoAccounting `OcrProcessor.kt` / `OcrService.kt`）**：

| 优化项 | 做法 | 效果 |
|--------|------|------|
| 执行器 | ONNX Runtime CPU，`intraOp=2 / interOp=2` | 兼容性最稳，避免部分设备 GPU 崩溃 |
| 图像压缩 | 短边缩放到 `OCR_MAX_SHORT_EDGE=720` | 像素量比 1440p 减少 75%，识别速度大幅提升 |
| det 边长限制 | `DET_LIMIT_MAX_SIDE=960`，尺寸对齐 32 | PaddleOCR 默认上限，控制推理耗时 |
| rec 高度固定 | `REC_IMAGE_HEIGHT=48`，宽度上限 320 | 匹配模型输入规格，避免单行过长 |
| AI 输入编码 | `bitmapToBase64` 用 JPEG 质量 60 | 送 AI Vision 时体积减小 |
| ABI 限制 | 仅 `arm64-v8a` | 减小包体积（主流设备已 arm64） |

### 3.5 Layer 3: AI Vision（高精度，有成本）- 可选

```typescript
// src/ocr/AiVisionProcessor.ts
export async function processAiVision(image: string): Promise<ImportedEvent | null> {
  const config = await getConfig('ai_vision');
  if (!config.enabled || !config.apiKey) return null;
  
  const response = await fetch(config.apiUrl, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${config.apiKey}` },
    body: JSON.stringify({
      image: image,
      prompt: '识别账单信息，返回 JSON 格式：{amount, merchant, time, direction}'
    })
  });
  
  return await response.json();
}
```

### 3.6 无障碍服务实现（参考 AutoAccounting）

> **重要**：参考 AutoAccounting，无障碍服务需伪装为系统服务以避免被支付 App 检测屏蔽。

```kotlin
// 使用 SelectToSpeakService 伪装（参考 AutoAccounting）
class OcrAccessibilityService : AccessibilityService() {
    private val ocrManager by lazy { OcrManager(applicationContext) }
    private val ruleMatcher by lazy { RuleMatcher }
    private val pageSignatureManager by lazy { PageSignatureManager(applicationContext) }
    private var ocrDoing = false  // 去重守卫，防止重复触发
  
    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        if (ocrDoing) return  // 正在处理中，跳过
        
        when (event?.eventType) {
            AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
                val packageName = event.packageName?.toString() ?: return
                if (isPaymentApp(packageName)) {
                    // 记录页面签名
                    pageSignatureManager.recordPage(
                        packageName, event.className?.toString() ?: ""
                    )
                    takeScreenshotAndProcess(packageName)
                }
            }
            // 页面切换自动触发（参考 AutoAccounting PageSignature）
            AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
                val packageName = event.packageName?.toString() ?: return
                if (pageSignatureManager.matchesCurrentPage(packageName)) {
                    takeScreenshotAndProcess(packageName)
                }
            }
        }
    }
  
    private fun takeScreenshotAndProcess(packageName: String) {
        ocrDoing = true
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            takeScreenshot(
                Display.DEFAULT_DISPLAY,
                mainExecutor,
                object : TakeScreenshotCallback {
                    override fun onSuccess(result: ScreenshotResult) {
                        val bitmap = Bitmap.wrapHardwareBuffer(
                            result.hardwareBuffer, result.colorSpace
                        )
                        result.hardwareBuffer.close()
                        bitmap?.let { processWithLayers(it, packageName) }
                    }
                    override fun onFailure(errorCode: Int) { ocrDoing = false }
                }
            )
        }
    }
  
    private fun processWithLayers(bitmap: Bitmap, packageName: String) {
        scope.launch {
            try {
                val text = ocrManager.recognizeText(bitmap)
                
                // Layer 1: 规则预匹配
                val layer1Result = ruleMatcher.match(text, packageName)
                if (layer1Result != null) {
                    showFloatingBill(layer1Result)  // 浮窗显示
                    sendToReactNative(layer1Result, "rule")
                    return@launch
                }
              
                // Layer 2: OCR + 正则解析
                val layer2Result = BillParser.parse(text, packageName)
                if (layer2Result != null) {
                    showFloatingBill(layer2Result)
                    sendToReactNative(layer2Result, "ocr")
                    return@launch
                }
              
                // Layer 3: AI Vision（可选）
                sendToReactNative(null, "ai_vision_needed")
            } finally {
                ocrDoing = false
            }
        }
    }
  
    private fun showFloatingBill(event: ImportedEvent) {
        // 参考 AutoAccounting 的 BillWindowManager 浮窗
        val floatingView = FloatingBillView(applicationContext, event)
        floatingView.show()
    }
  
    private fun isPaymentApp(packageName: String): Boolean {
        return packageName in listOf(
            "com.eg.android.AlipayGphone",
            "com.tencent.mm",
            "com.unionpay",
        )
    }
}
```

### 3.7 页面签名系统（参考 AutoAccounting）

```kotlin
// src/ocr/PageSignatureManager.kt
class PageSignatureManager(context: Context) {
    private val prefs = context.getSharedPreferences("page_signatures", Context.MODE_PRIVATE)
    
    fun recordPage(packageName: String, activityName: String) {
        val signatures = getSignatures().toMutableList()
        if (!signatures.any { it.packageName == packageName && it.activityName == activityName }) {
            signatures.add(PageSignature(packageName, activityName))
            saveSignatures(signatures)
        }
    }
    
    fun matchesCurrentPage(packageName: String): Boolean {
        return getSignatures().any { it.packageName == packageName }
    }
}
```

### 3.8 浮窗账单提示（参考 AutoAccounting）

```kotlin
// src/ocr/FloatingBillView.kt
class FloatingBillView(context: Context, private val event: ImportedEvent) {
    private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
    private val view = LayoutInflater.from(context).inflate(R.layout.floating_bill, null)
    
    fun show() {
        val params = WindowManager.LayoutParams(
            WindowManager.LayoutParams.WRAP_CONTENT,
            WindowManager.LayoutParams.WRAP_CONTENT,
            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
            PixelFormat.TRANSLUCENT
        ).apply {
            gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
            y = 100
        }
        
        // 显示金额、商户、时间
        view.findViewById<TextView>(R.id.amount).text = event.amount
        view.findViewById<TextView>(R.id.merchant).text = event.counterparty
        view.findViewById<TextView>(R.id.time).text = event.occurredAt
        
        // 点击确认记账
        view.findViewById<Button>(R.id.confirm).setOnClickListener {
            sendToReactNative(event, "confirmed")
            dismiss()
        }
        
        windowManager.addView(view, params)
        
        // 3 秒后自动消失
        Handler(Looper.getMainLooper()).postDelayed({ dismiss() }, 3000)
    }
    
    private fun dismiss() {
        try { windowManager.removeView(view) } catch (_: Exception) {}
    }
}
```

### 3.9 JS 层分层处理

```typescript
// src/ocr/OcrProcessor.ts
import { OcrBridge } from './OcrBridge';
import { processAiVision } from './AiVisionProcessor';
import { importStatement, type ImportedEvent } from '../domain';

export async function processOcrScreenshot(): Promise<ImportedEvent | null> {
  const base64Image = await OcrBridge.takeScreenshot();
  if (!base64Image) return null;

  const topApp = await OcrBridge.getTopApp();
  if (!topApp) return null;

  const ocrText = await OcrModule.recognizeText(base64Image);
  if (!ocrText) return null;

  const event = await OcrModule.processWithLayers(base64Image, topApp);
  
  if (event === null && await shouldUseAiVision()) {
    return await processAiVision(base64Image);
  }
  
  return event;
}

async function shouldUseAiVision(): Promise<boolean> {
  const config = await getConfig('ai_vision');
  return config?.enabled ?? false;
}
```

### 3.10 横屏免打扰模式

参考 AutoAccounting 的 Landscape DND——游戏/视频时自动暂存账单：

```kotlin
// 在 OcrAccessibilityService 中添加
private var isLandscapeDnd = false

private fun checkLandscapeDnd() {
    val display = getSystemService(DisplayManager::class.java)
        .getDisplay(Display.DEFAULT_DISPLAY)
    val rotation = display.rotation
    isLandscapeDnd = rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
    
    if (isLandscapeDnd) {
        // 暂存账单，稍后显示
        pendingBills.clear()
    } else {
        // 恢复显示暂存的账单
        showPendingBills()
    }
}
```

### 3.11 快速设置磁贴

参考 AutoAccounting 的 OcrTileService：

```kotlin
// src/ocr/OcrTileService.kt
class OcrTileService : TileService() {
    override fun onStartListening() {
        super.onStartListening()
        qsTile?.state = Tile.STATE_ACTIVE
        qsTile?.label = "OCR 记账"
        qsTile?.updateTile()
    }
    
    override fun onClick() {
        super.onClick()
        // 触发一次 OCR 截图
        val service = getSystemService(OcrAccessibilityService::class.java)
        service?.triggerManualOcr()
    }
}
```

### 3.12 平台兼容性

> **订正**：删除 ML Kit OCR 行（项目不用 ML Kit）。iOS 的 OCR 改走「截图自动记账通道 + AI Vision」（见 Phase 3.13），不依赖本地 OCR 模型。

| 功能       | Android | iOS | 说明         |
| ---------- | ------- | --- | ------------ |
| 无障碍截屏 | ✓      | ✗  | iOS 限制     |
| PP-OCRv5（本地 OCR） | ✓ | ✗ | 单引擎，仅 Android |
| 截图自动记账 | ✓    | ✓  | Android ContentObserver / iOS AppIntent（见 3.13） |
| AI Vision（云端） | ✓  | ✓  | 替代本地 OCR，iOS 主要路径 |
| 通知监听   | ✓      | ✗  | iOS 限制严格 |
| 短信解析   | ✓      | ✗  | iOS 限制严格 |
| 规则匹配   | ✓      | ✓  | 纯逻辑       |
| 页面签名   | ✓      | ✗  | 仅 Android   |
| 浮窗提示   | ✓      | ✗  | 仅 Android   |
| 快速设置磁贴 | ✓    | ✗  | 仅 Android   |
| 快速设置磁贴 | ✓    | ✗  | 仅 Android   |

### 3.13 截图自动记账通道（新增）

> **新增（审查订正）**：这是比无障碍截屏更**轻量、更合规**的高频入口。用户在支付宝/微信/银行 App 截图，应用自动识别。参考 BeeCount 的 `ScreenshotObserver.kt`（Android）+ `AutoBillingAppIntent.swift`（iOS）。

**Android（ContentObserver 监听 MediaStore Screenshots）**：

```kotlin
// plugins/screenshot-monitor/android/ScreenshotObserver.kt
class ScreenshotObserver(
    context: Context,
    private val onScreenshot: (Uri) -> Unit
) : ContentObserver(Handler(Looper.getMainLooper())) {
    
    private val resolver = context.contentResolver
    private val SCREENSHOTS_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    private val processedPaths = mutableSetOf<String>()  // 去重
    private var lastCheckTime = System.currentTimeMillis()
    
    override fun onChange(selfChange: Boolean, uri: Uri?) {
        super.onChange(selfChange, uri)
        uri ?: return
        // 1. 查询新增截图（30 秒内）
        val projection = arrayOf(MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_ADDED)
        val selection = "${MediaStore.Images.Media.DATE_ADDED} > ?"
        val cursor = resolver.query(uri, projection, selection, arrayOf((lastCheckTime / 1000 - 30).toString()), null)
        cursor?.use {
            while (it.moveToNext()) {
                val path = it.getString(0)
                // 2. 关键词匹配（过滤非截图）
                if (!listOf("screenshot", "截屏", "截图", "screen_shot").any { kw -> path.contains(kw, true) }) continue
                if (path.endsWith(".pending-")) continue  // 过滤小米临时文件
                if (processedPaths.contains(path)) continue
                processedPaths.add(path)
                // 3. 去抖（500ms）后回调
                onScreenshot(uri)
            }
        }
        lastCheckTime = System.currentTimeMillis()
    }
}
```

**iOS（AppIntent 后台执行）**：

```swift
// ios/AutoBillingAppIntent.swift（iOS 16+）
struct AutoBillingAppIntent: AppIntent {
    static var title: LocalizedStringResource = "截图记账"
    static var openAppWhenRun: Bool = false  // 后台执行（关键）
    
    @Parameter(title: "截图", description: "待识别的截图")
    var screenshot: IntentFile
    
    func perform() async throws -> some IntentResult {
        // 1. 保存到临时目录
        let tmpUrl = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png")
        try screenshot.data.write(to: tmpUrl)
        // 2. 发事件给 RN（经 AppIntentsBridge）
        await AppIntentsBridge.sendEvent([
            "type": "screenshot",
            "path": tmpUrl.path
        ])
        // 3. 等待记账完成（iOS 后台窗口约 30s，实测 4-9s）
        await waitForBillingComplete()
        return .result()
    }
}
```

**与无障碍 OCR 的区别**：

| 维度 | 无障碍截屏 OCR | 截图自动记账 |
|------|--------------|-------------|
| 触发 | 进入支付 App 自动截屏 | 用户主动截图 |
| 权限 | 无障碍（敏感） | 读媒体文件（Android）/ AppIntent（iOS） |
| 合规 | 高风险（侧载保留） | 低风险（上架也能用） |
| 频次 | 全自动 | 半自动（需用户截图） |
| iOS 支持 | ✗ | ✓（AppIntent） |

**JS 层处理（统一入口）**：

截图到达后，与无障碍 OCR 走**相同的 billPipeline**（决策 6），只是输入源标记为 `screenshot`。

---

## Phase 4: 通知/短信监听（1-2 周，仅 Android）[P0]

> **提升为 P0**：实现简单、用户价值高，是重要的数据捕获通道

### 4.1 通知监听服务

参考 AutoAccounting 的 `NotificationListenerService`（含去重守卫 + 关键词黑白名单）：

```kotlin
class NotificationListenerService : NotificationListenerService() {
    private val md5Hashes = mutableSetOf<String>()  // 去重守卫
    private val keywordFilter = KeywordFilter()  // 关键词过滤
    
    override fun onNotificationPosted(sbn: StatusBarNotification?) {
        val packageName = sbn?.packageName ?: return
        if (!isPaymentApp(packageName)) return
      
        val notification = sbn.notification
        val extras = notification.extras
        val title = extras?.getString(Notification.EXTRA_TITLE) ?: ""
        val text = extras?.getString(Notification.EXTRA_BIG_TEXT)
            ?: extras?.getString(Notification.EXTRA_TEXT) ?: ""
        
        // 关键词过滤
        if (!keywordFilter.passesFilter("$title$text")) return
        
        // MD5 去重
        val hash = md5("$title$text")
        if (md5Hashes.contains(hash)) return
        md5Hashes.add(hash)
        
        val event = NotificationParser.parse(title, text, packageName)
        if (event != null) {
            sendToReactNative(event)
        }
    }
  
    private fun isPaymentApp(packageName: String): Boolean {
        return packageName in listOf(
            "com.eg.android.AlipayGphone",
            "com.tencent.mm",
            "com.unionpay",
            "com.cmbchina",
            "com.icbc",
        )
    }
}
```

### 4.2 短信监听服务

参考 AutoAccounting 的 `SmsReceiver`（含关键词过滤）：

```kotlin
class SmsReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
      
        val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
        for (message in messages) {
            val sender = message.displayOriginatingAddress ?: ""
            val body = message.displayMessageBody ?: ""
          
            if (isBankSms(sender, body)) {
                val event = SmsParser.parse(sender, body)
                if (event != null) {
                    sendToReactNative(event)
                }
            }
        }
    }
  
    private fun isBankSms(sender: String, body: String): Boolean {
        val bankKeywords = listOf("交易", "消费", "收入", "支出", "余额")
        return bankKeywords.any { body.contains(it) }
    }
}
```

### 4.3 短信解析器

```kotlin
object SmsParser {
    private val amountPattern = Regex("""(\d+\.\d+)元""")
    private val cardPattern = Regex("""尾号(\d{4})""")
    private val timePattern = Regex("""(\d{1,2}月\d{1,2}日\s*\d{1,2}:\d{2})""")
  
    fun parse(sender: String, body: String): ImportedEvent? {
        val amount = amountPattern.find(body)?.groupValues?.get(1) ?: return null
        val card = cardPattern.find(body)?.groupValues?.get(1) ?: ""
        val time = timePattern.find(body)?.groupValues?.get(1) ?: ""
      
        val direction = when {
            body.contains("收入") || body.contains("转入") -> "income"
            body.contains("支出") || body.contains("消费") || body.contains("转出") -> "expense"
            else -> "expense"
        }
      
        return ImportedEvent(
            id = "sms-${System.currentTimeMillis()}",
            occurredAt = parseTime(time),
            amount = if (direction == "expense") "-$amount" else amount,
            currency = "CNY",
            direction = direction,
            channel = "Bank",
            counterparty = "短信通知",
            memo = "银行卡${card}尾号",
            raw = mapOf("sender" to sender, "body" to body)
        )
    }
}
```

### 4.4 每日提醒通知

参考 BeeCount 的每日记账提醒：

```typescript
// src/services/reminder.ts
import * as Notifications from 'expo-notifications';

export async function setupDailyReminder(hour: number = 20, minute: number = 0) {
  await Notifications.cancelAllScheduledNotificationsAsync();
  
  await Notifications.scheduleNotificationAsync({
    content: {
      title: '记账提醒',
      body: '今天还没有记账哦，点击开始记录',
      data: { screen: 'add-transaction' },
    },
    trigger: {
      hour,
      minute,
      repeats: true,
    },
  });
}

export async function setupCreditCardReminder(
  billingDay: number,
  paymentDay: number,
  cardName: string
) {
  // 账单日提醒（提前1天）
  await Notifications.scheduleNotificationAsync({
    content: {
      title: `${cardName} 账单日提醒`,
      body: `明天是 ${cardName} 的账单日，请确认已记账`,
    },
    trigger: {
      day: billingDay - 1,
      hour: 9,
      minute: 0,
      repeats: true,
    },
  });
  
  // 还款日提醒（提前3天）
  await Notifications.scheduleNotificationAsync({
    content: {
      title: `${cardName} 还款提醒`,
      body: `还有3天是 ${cardName} 的还款日`,
    },
    trigger: {
      day: paymentDay - 3,
      hour: 9,
      minute: 0,
      repeats: true,
    },
  });
}
```

---

## Phase 5: 高级功能（3-4 周）[P1]

### 5.1 账户树与报表

```typescript
interface AccountTree {
  Assets: {
    'Alipay': AccountInfo;
    'WeChat': AccountInfo;
    'Bank:*': AccountInfo[];
  };
  Liabilities: { ... };
  Income: { ... };
  Expenses: { ... };
  Equity: { ... };
}

function calculateReport(ledger: LedgerIndex, period: string) {
  return {
    income: calculateIncome(ledger, period),
    expense: calculateExpense(ledger, period),
    netIncome: calculateNetIncome(ledger, period),
    assets: calculateAssets(ledger),
    liabilities: calculateLiabilities(ledger),
    netWorth: calculateNetWorth(ledger),
  };
}
```

### 5.2 图表与可视化

参考 BeeCount 的 fl_chart 实现：

```tsx
// src/components/charts/MonthlyReport.tsx
import { LineChart, PieChart } from 'react-native-chart-kit';

function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
  const monthlyData = groupByMonth(transactions);
  const categoryData = groupByCategory(transactions);
  
  return (
    <View>
      <Text>月度趋势</Text>
      <LineChart
        data={{
          labels: monthlyData.map(d => d.month),
          datasets: [{ data: monthlyData.map(d => d.total) }],
        }}
        width={Dimensions.get('window').width}
        height={220}
      />
      
      <Text>分类占比</Text>
      <PieChart
        data={categoryData.map(d => ({
          name: d.category,
          amount: d.total,
          color: d.color,
          legendFontColor: '#7F7F7F',
        }))}
        width={Dimensions.get('window').width}
        height={220}
      />
    </View>
  );
}
```

#### 年度报告

参考 BeeCount 的年度报告：

```typescript
// src/domain/annualReport.ts
interface AnnualReport {
  year: number;
  totalIncome: string;
  totalExpense: string;
  netIncome: string;
  topCategories: { category: string; amount: string; percentage: number }[];
  monthlyTrend: { month: string; income: string; expense: string }[];
  dayOfWeekBreakdown: { day: string; total: string }[];  // 周几消费分布
  largestTransaction: Transaction;
  averageDailyExpense: string;
}

export function generateAnnualReport(
  transactions: Transaction[],
  year: number
): AnnualReport {
  const yearTransactions = transactions.filter(t => t.date.startsWith(String(year)));
  
  return {
    year,
    totalIncome: calculateTotal(yearTransactions, 'income'),
    totalExpense: calculateTotal(yearTransactions, 'expense'),
    netIncome: calculateNetIncome(yearTransactions),
    topCategories: getTopCategories(yearTransactions, 10),
    monthlyTrend: getMonthlyTrend(yearTransactions),
    dayOfWeekBreakdown: getDayOfWeekBreakdown(yearTransactions),
    largestTransaction: getLargestTransaction(yearTransactions),
    averageDailyExpense: calculateAverageDaily(yearTransactions),
  };
}
```

#### 净资产趋势

参考 BeeCount 的净资产追踪：

```typescript
// src/domain/netWorth.ts
interface NetWorthPoint {
  date: string;
  assets: string;
  liabilities: string;
  netWorth: string;
}

export function calculateNetWorthTrend(
  ledger: LedgerIndex,
  startDate: string,
  endDate: string
): NetWorthPoint[] {
  const points: NetWorthPoint[] = [];
  const days = getDaysBetween(startDate, endDate);
  
  for (const day of days) {
    const assets = calculateAssets(ledger, day);
    const liabilities = calculateLiabilities(ledger, day);
    points.push({
      date: day,
      assets,
      liabilities,
      netWorth: subtractDecimals(assets, liabilities),
    });
  }
  
  return points;
}
```

#### 日历热力图

参考 BeeCount 的日历视图：

```tsx
// src/components/charts/CalendarHeatmap.tsx
function CalendarHeatmap({ transactions, year, month }: Props) {
  const dailyTotals = groupByDay(transactions, year, month);
  const maxDaily = Math.max(...Object.values(dailyTotals).map(Number));
  
  return (
    <View style={styles.calendar}>
      {getDaysInMonth(year, month).map(day => {
        const total = dailyTotals[day] || 0;
        const intensity = maxDaily > 0 ? total / maxDaily : 0;
        return (
          <View
            key={day}
            style={[
              styles.dayCell,
              { backgroundColor: getHeatColor(intensity) },
            ]}
          >
            <Text style={styles.dayText}>{day}</Text>
          </View>
        );
      })}
    </View>
  );
}
```

### 5.3 日历视图

参考 BeeCount 的日历视图——按日期浏览交易：

```tsx
// src/app/calendar/index.tsx
function CalendarScreen() {
  const [selectedDate, setSelectedDate] = useState(new Date());
  const transactions = useTransactionsForDate(selectedDate);
  
  return (
    <View>
      <Calendar
        onDayPress={(day) => setSelectedDate(day.date)}
        markedDates={getMarkedDates(allTransactions)}
        theme={calendarTheme}
      />
      <TransactionList transactions={transactions} />
    </View>
  );
}

// 获取有交易的日期标记
function getMarkedDates(transactions: Transaction[]) {
  const marked: Record<string, { dots: { color: string }[] }> = {};
  
  for (const t of transactions) {
    const date = t.date.slice(0, 10);
    if (!marked[date]) marked[date] = { dots: [] };
    
    const color = t.postings.some(p => p.amount.startsWith('-'))
      ? theme.colors.expense
      : theme.colors.income;
    marked[date].dots.push({ color });
  }
  
  return marked;
}
```

### 5.4 信用卡管理

参考 BeeCount 的信用卡管理：

```typescript
// src/domain/creditCards.ts
interface CreditCard {
  id: string;
  name: string;
  lastFour: string;
  bankName: string;
  billingDay: number;      // 账单日（1-28）
  paymentDay: number;      // 还款日（1-28）
  creditLimit: string;     // 信用额度
  currentBalance: string;  // 当前欠款
  currency: string;
  linkedAccount: string;   // 关联的 Beancount 账户
}

// 计算信用卡账单周期
function calculateBillingPeriod(
  card: CreditCard,
  currentDate: Date
): { periodStart: string; periodEnd: string; dueDate: string } {
  const year = currentDate.getFullYear();
  const month = currentDate.getMonth();
  
  // 账单周期：上月账单日 ~ 本月账单日
  const periodStart = new Date(year, month - 1, card.billingDay);
  const periodEnd = new Date(year, month, card.billingDay);
  const dueDate = new Date(year, month, card.paymentDay);
  
  return {
    periodStart: periodStart.toISOString().slice(0, 10),
    periodEnd: periodEnd.toISOString().slice(0, 10),
    dueDate: dueDate.toISOString().slice(0, 10),
  };
}

// 计算本期应还金额
function calculateStatementAmount(
  transactions: Transaction[],
  card: CreditCard,
  periodStart: string,
  periodEnd: string
): string {
  const periodTransactions = transactions.filter(t => {
    const date = t.date.slice(0, 10);
    return date >= periodStart && date < periodEnd &&
           t.postings.some(p => p.account.includes(card.linkedAccount));
  });
  
  return periodTransactions.reduce((sum, t) => {
    const expense = t.postings
      .filter(p => p.amount.startsWith('-'))
      .reduce((s, p) => addDecimals(s, p.amount.replace('-', '')), '0');
    return addDecimals(sum, expense);
  }, '0');
}
```

### 5.5 预算管理

参考 BeeCount 的月度总预算 + 分类预算 + 超支提醒：

```typescript
// src/domain/budgets.ts
export interface Budget {
  id: string;
  name: string;
  amount: string;
  period: 'monthly' | 'weekly' | 'yearly';
  categoryId?: string;  // undefined = 总预算
  startDate: string;
}

// 预算进度计算
export function calculateBudgetProgress(
  budget: Budget,
  transactions: Transaction[]
): { spent: string; remaining: string; percentage: number } {
  const periodTransactions = filterByPeriod(transactions, budget.period);
  const categoryTransactions = budget.categoryId
    ? periodTransactions.filter(t => t.postings.some(p => p.account.includes(budget.categoryId!)))
    : periodTransactions;
  
  const spent = categoryTransactions.reduce((sum, t) => {
    const amount = t.postings.find(p => p.amount.startsWith('-'))?.amount ?? '0';
    return addDecimals(sum, amount.replace('-', ''));
  }, '0');
  
  const remaining = subtractDecimals(budget.amount, spent);
  const percentage = (parseFloat(spent) / parseFloat(budget.amount)) * 100;
  
  return { spent, remaining, percentage };
}
```

### 5.6 周期性记账

参考 BeeCount 的 RecurringTransactions：

```typescript
// src/domain/recurring.ts
export interface RecurringTransaction {
  id: string;
  name: string;
  draft: TransactionDraft;
  frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
  interval: number;  // 间隔（如每2周 = weekly + interval: 2）
  nextDueDate: string;
  enabled: boolean;
}

// 检查到期的周期性交易
export function getDueRecurring(
  recurring: RecurringTransaction[],
  currentDate: string
): RecurringTransaction[] {
  return recurring.filter(r => 
    r.enabled && r.nextDueDate <= currentDate
  );
}

// 计算下次到期日期
export function calculateNextDueDate(
  frequency: string,
  interval: number,
  lastDueDate: string
): string {
  const date = new Date(lastDueDate);
  switch (frequency) {
    case 'daily': date.setDate(date.getDate() + interval); break;
    case 'weekly': date.setDate(date.getDate() + 7 * interval); break;
    case 'monthly': date.setMonth(date.getMonth() + interval); break;
    case 'yearly': date.setFullYear(date.getFullYear() + interval); break;
  }
  return date.toISOString().slice(0, 10);
}
```

### 5.7 附件支持

参考 BeeCount 的 TransactionAttachments：

```typescript
// src/domain/attachments.ts
export interface Attachment {
  id: string;
  transactionId: string;
  fileName: string;
  fileUri: string;
  mimeType: string;
  createdAt: string;
}
```

### 5.8 多币种支持

参考 BeeCount v30 的交易级多币种：

```typescript
// src/domain/currency.ts
export interface ExchangeRate {
  from: string;
  to: string;
  rate: string;
  date: string;
}

// 交易级多币种
export interface TransactionCurrency {
  currencyCode: string;      // 交易货币（如 USD）
  nativeAmount: string;      // 原始金额（如 100.00）
  exchangeRate: string;      // 记账时的汇率快照
  baseCurrency: string;      // 基础货币（如 CNY）
}

export function convertCurrency(
  amount: string,
  from: string,
  to: string,
  rates: ExchangeRate[]
): string {
  if (from === to) return amount;
  const rate = rates.find(r => r.from === from && r.to === to);
  if (!rate) throw new Error(`No exchange rate for ${from} -> ${to}`);
  return multiplyDecimals(amount, rate.rate);
}

// 手动汇率覆盖
export interface ExchangeRateOverride {
  from: string;
  to: string;
  rate: string;
  enabled: boolean;
}

// 获取汇率（优先使用手动覆盖）
export function getEffectiveRate(
  from: string,
  to: string,
  autoRates: ExchangeRate[],
  overrides: ExchangeRateOverride[]
): ExchangeRate | null {
  // 检查手动覆盖
  const override = overrides.find(o => 
    o.enabled && o.from === from && o.to === to
  );
  if (override) {
    return { from, to, rate: override.rate, date: new Date().toISOString().slice(0, 10) };
  }
  
  // 使用自动汇率
  return autoRates.find(r => r.from === from && r.to === to) ?? null;
}
```

### 5.9 导出功能

```typescript
// 导出为 zip 账本包
async function exportLedgerBundle(ledger: LedgerIndex, mobileBean: string) {
  const files = {
    ...Object.fromEntries(ledger.files.map(f => [f.path, f.content])),
    'mobile.bean': mobileBean,
  };
  const zipPath = await createZip(files);
  return zipPath;
}

// 导出为 Excel（参考 BeeCount 的 Excel 导出）
async function exportToExcel(transactions: Transaction[], filePath: string) {
  // 使用 xlsx 库生成 Excel
  const workbook = XLSX.utils.book_new();
  const data = transactions.map(t => ({
    日期: t.date,
    摘要: t.narration,
    收入: t.postings.find(p => !p.amount.startsWith('-'))?.amount ?? '',
    支出: t.postings.find(p => p.amount.startsWith('-'))?.amount ?? '',
    标签: t.tags.join(', '),
  }));
  const sheet = XLSX.utils.json_to_sheet(data);
  XLSX.utils.book_append_sheet(workbook, sheet, '交易');
  XLSX.writeFile(workbook, filePath);
}
```

### 5.10 分享海报生成

参考 BeeCount 的 Share Poster：

```typescript
// src/services/sharePoster.ts
import { captureRef } from 'react-native-view-shot';

interface PosterConfig {
  type: 'year' | 'month' | 'ledger';
  title: string;
  period?: string;
  transactions: Transaction[];
}

export async function generateSharePoster(config: PosterConfig): Promise<string> {
  // 生成统计数据
  const stats = calculatePosterStats(config);
  
  // 渲染海报视图
  const posterRef = useRef<View>(null);
  
  // 截图保存
  const uri = await captureRef(posterRef, {
    format: 'png',
    quality: 1.0,
  });
  
  return uri;
}

function calculatePosterStats(config: PosterConfig) {
  const { transactions, period } = config;
  const filtered = period ? filterByPeriod(transactions, period) : transactions;
  
  return {
    totalIncome: calculateTotal(filtered, 'income'),
    totalExpense: calculateTotal(filtered, 'expense'),
    transactionCount: filtered.length,
    topCategory: getTopCategories(filtered, 1)[0],
    averageDaily: calculateAverageDaily(filtered),
  };
}
```

### 5.11 诊断页面

```typescript
function DiagnosticsScreen({ ledger }: { ledger: LedgerIndex }) {
  return (
    <View>
      <Text>语法问题：{ledger.diagnostics.length}</Text>
      <Text>保留的高级指令：{ledger.unsupported.length}</Text>
      <FlatList
        data={ledger.diagnostics}
        renderItem={({ item }) => <DiagnosticCard diagnostic={item} />}
      />
    </View>
  );
}
```

### 5.12 规则导入/导出

参考 AutoAccounting 的云规则分发系统：

```typescript
// src/services/ruleSync.ts
export async function exportRules(rules: Rule[]): Promise<string> {
  const data = JSON.stringify(rules, null, 2);
  const path = `${FileSystem.documentDirectory}rules-export.json`;
  await FileSystem.writeAsStringAsync(path, data);
  return path;
}

export async function importRules(filePath: string): Promise<Rule[]> {
  const content = await FileSystem.readAsStringAsync(filePath);
  const rules = JSON.parse(content) as Rule[];
  // 验证规则格式
  return rules.filter(validateRule);
}
```

---

## Phase 6: 同步功能（可选，3-4 周）[P2]

> **重写**：原 Phase 6 过于简略，此处补充完整的同步架构

### 6.1 同步架构设计

参考 BeeCount 的双模式同步架构：

```
同步模式：
├── 增量同步（BeeCount Cloud 风格）
│   ├── ChangeTracker 记录本地变更
│   ├── SyncCoordinator 监听变更并触发同步
│   ├── Push: 上传本地变更
│   ├── Pull: 拉取远程变更
│   └── 冲突解决：最后写入胜出 + 手动合并
│
└── 快照同步（Supabase/WebDAV/S3 风格）
    ├── 全量上传 mobile.bean
    ├── 全量下载 mobile.bean
    └── 冲突解决：用户选择保留哪个版本
```

### 6.2 Git 同步

```typescript
// src/services/sync/gitSync.ts
interface GitSyncConfig {
  remoteUrl: string;
  branch: string;
  autoSync: boolean;
  syncInterval: number;  // 分钟
}

export async function syncWithGit(config: GitSyncConfig) {
  // 1. 拉取远程变更
  await git.pull(config.remoteUrl, config.branch);
  
  // 2. 合并 mobile.bean（解决冲突）
  const conflicts = await mergeMobileBean();
  if (conflicts.length > 0) {
    // 显示冲突解决界面
    showConflictResolver(conflicts);
    return;
  }
  
  // 3. 提交并推送
  await git.add('mobile.bean');
  await git.commit(`Sync mobile.bean from mobile app [${new Date().toISOString()}]`);
  await git.push(config.remoteUrl, config.branch);
}
```

### 6.3 WebDAV 同步

```typescript
// src/services/sync/webdavSync.ts
interface WebDAVSyncConfig {
  url: string;
  username: string;
  password: string;
  remotePath: string;
}

export async function syncWithWebDAV(config: WebDAVSyncConfig) {
  const client = new WebDAVClient(config.url, config.username, config.password);
  
  // 1. 获取远程文件信息
  const remoteInfo = await client.stat(config.remotePath);
  
  // 2. 对比本地和远程
  const localContent = await readMobileBean();
  const remoteContent = await client.getFile(config.remotePath);
  
  if (localContent === remoteContent) {
    return; // 无变更
  }
  
  // 3. 冲突检测
  if (remoteInfo.lastModified > localLastSyncTime) {
    // 远程有更新，需要解决冲突
    showConflictDialog(localContent, remoteContent);
  } else {
    // 上传本地版本
    await client.putFile(config.remotePath, localContent);
  }
}

// WebDAV 备份管理（参考 AutoAccounting 的 WebDAVManager）
export async function backupToWebDAV(config: WebDAVSyncConfig) {
  const backupDir = `${config.remotePath}/backups/`;
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  const backupPath = `${backupDir}backup-${timestamp}.zip`;
  
  // 创建备份
  const backupContent = await createBackupArchive();
  await client.putFile(backupPath, backupContent);
  
  // 清理旧备份（保留最近 10 个）
  const backups = await client.listFiles(backupDir);
  if (backups.length > 10) {
    const sorted = backups.sort((a, b) => a.name.localeCompare(b.name));
    for (const old of sorted.slice(0, backups.length - 10)) {
      await client.deleteFile(old.path);
    }
  }
}
```

### 6.4 iCloud 同步（iOS）

```typescript
// src/services/sync/icloudSync.ts
import * as FileSystem from 'expo-file-system';

export async function syncWithICloud() {
  const container = await FileSystem.iCloudFileSystemAsync();
  
  // 读取 iCloud 中的 mobile.bean
  const icloudPath = `${container}/mobile.bean`;
  const localPath = `${FileSystem.documentDirectory}mobile.bean`;
  
  const icloudContent = await FileSystem.readAsStringAsync(icloudPath);
  const localContent = await FileSystem.readAsStringAsync(localPath);
  
  if (icloudContent === localContent) return;
  
  // iCloud 优先（iOS 原生同步更可靠）
  await FileSystem.writeAsStringAsync(localPath, icloudContent);
  await rebuildCacheFromFiles();
}
```

### 6.5 同步错误持久化

参考 BeeCount 的 SyncPullErrors：

```sql
-- 同步失败记录表
CREATE TABLE sync_errors (
  id TEXT PRIMARY KEY,
  operation TEXT NOT NULL,      -- 'push' | 'pull' | 'merge'
  entity_type TEXT NOT NULL,    -- 'transaction' | 'rule' | 'attachment'
  entity_id TEXT NOT NULL,
  error_message TEXT NOT NULL,
  created_at TEXT NOT NULL,
  resolved INTEGER DEFAULT 0   -- 0=未解决, 1=已重试, 2=已跳过
);
```

```typescript
// src/services/sync/syncErrorManager.ts
export async function recordSyncError(error: SyncError) {
  await db.runAsync(
    `INSERT INTO sync_errors (id, operation, entity_type, entity_id, error_message, created_at)
     VALUES (?, ?, ?, ?, ?, ?)`,
    generateId(), error.operation, error.entityType, error.entityId,
    error.message, new Date().toISOString()
  );
}

export async function getUnresolvedErrors(): Promise<SyncError[]> {
  return db.getAllAsync<SyncError>(
    'SELECT * FROM sync_errors WHERE resolved = 0 ORDER BY created_at DESC'
  );
}

export async function resolveError(id: string, resolution: 'retry' | 'skip') {
  await db.runAsync(
    'UPDATE sync_errors SET resolved = ? WHERE id = ?',
    resolution === 'retry' ? 1 : 2, id
  );
}
```

---

## Phase 7: iOS 自动记账方案（1-2 周）[P1]

> **降低预期**：iOS 平台限制严重，以下方案均为实验性，标注为实验性功能

### 7.1 iOS 快捷指令（Shortcuts）[实验性]

参考 BeeCount 的"双击背部触发快捷指令"：

```yaml
# iOS Shortcuts 工作流
1. 触发方式：
   - 双击背部（Back Tap）
   - 主屏幕快捷指令按钮
   - 分享菜单（Share Extension）
2. 工作流程：
   - 截取当前屏幕（或接收分享的截图）
   - 通过 AppIntent 后台发送到 beancount-mobile（见 3.13）
   - 应用内走 AI Vision 识别（iOS 不依赖本地 OCR 模型）
   - 经 billPipeline 入库
```

> **订正**：原方案"调用 ML Kit OCR"不准确（项目不用 ML Kit）。iOS 路径走「截图 AppIntent + AI Vision」，参考 BeeCount 的 `AutoBillingAppIntent.swift`（`openAppWhenRun=false` 后台执行，30s 窗口实测 4-9s）。
> **注意**：Apple 对金融类快捷指令 API 限制多，此方案仅作为辅助手段。

### 7.2 分享扩展（Share Extension）[实验性]

从其他 App（支付宝/微信/银行 App）分享账单截图到 beancount-mobile：

```typescript
// src/services/shareExtension.ts
export async function handleShareExtension(imageUri: string) {
  // 1. OCR 识别截图
  const text = await OcrModule.recognizeText(imageUri);
  // 2. 解析账单信息
  const event = BillParser.parse(text);
  // 3. 创建交易草稿
  if (event) {
    await importStore.getState().addEvent(event);
  }
}
```

### 7.3 SiriKit 集成（受限）[实验性]

```typescript
// 通过 Siri 快捷指令触发记账
// "嘿 Siri，记一笔"
// 受限于 SiriKit Intent 定义，功能有限
```

### 7.4 iOS 通知权限（受限）

iOS 不允许第三方应用监听其他应用的通知，但可以：
- 使用应用自己的通知（每日提醒）
- 用户手动从通知中心复制账单信息
- 通过 Share Extension 接收分享的账单截图

---

## Phase 8: AI 功能增强（2-3 周）[P1]

### 8.0 AI Provider 抽象层（前置基础）

> **新增（审查订正）**：原方案的 `callAiProvider` 是黑盒，未定义 Provider 抽象、配置加密、隐私脱敏、流式、视觉模式。参考 AutoAccounting 的 `BaseOpenAIProvider`（8 个 Provider 共用 OpenAI 兼容协议）+ Gemini 独立实现。

**Provider 抽象**：

```typescript
// src/ai/provider.ts
export interface AiProvider {
  id: string;
  name: string;
  // 文本对话（支持流式）
  chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
  chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
  // 视觉（图片识别）
  vision?(messages: ChatMessage[], image: string, options?: ChatOptions): Promise<string>;
}

// OpenAI 兼容协议（8 个 Provider 共用：DeepSeek/Kimi/智谱/OpenRouter/QWen/硅基流动/MiMo/ChatGPT）
export abstract class BaseOpenAIProvider implements AiProvider {
  constructor(protected config: { apiKey: string; baseUrl: string; model: string }) {}
  
  async chat(messages: ChatMessage[], options?: ChatOptions): Promise<string> {
    const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.config.apiKey}`,
      },
      body: JSON.stringify({
        model: this.config.model,
        messages,
        ...options,
      }),
    });
    const data = await response.json();
    return data.choices[0].message.content;
  }
  
  async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
    // SSE 流式解析
    const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
      method: 'POST',
      headers: { /* ... */, 'Accept': 'text/event-stream' },
      body: JSON.stringify({ model: this.config.model, messages, stream: true, ...options }),
    });
    // 解析 SSE data: {...} 行，逐块 yield
    // ...
  }
}

// 预置 Provider（参考 AutoAccounting 的 9 个）
export const providers: Record<string, AiProvider> = {
  deepseek: new (class extends BaseOpenAIProvider {
    constructor() { super({ apiKey: '', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' }); }
    id = 'deepseek'; name = 'DeepSeek';
  })(),
  zhipu: new (class extends BaseOpenAIProvider {
    constructor() { super({ apiKey: '', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' }); }
    id = 'zhipu'; name = '智谱清言';
  })(),
  // ... Kimi/OpenRouter/QWen/SiliconFlow/MiMo/ChatGPT
  gemini: new GeminiProvider(),  // 独立实现（非 OpenAI 协议）
};
```

**配置加密存储**（apiKey 等敏感信息）：

```typescript
// src/ai/config.ts
import * as SecureStore from 'expo-secure-store';

export async function saveProviderConfig(providerId: string, config: { apiKey: string; baseUrl?: string; model?: string }) {
  // SecureStore：iOS Keychain / Android Keystore 加密存储
  await SecureStore.setItemAsync(`ai_provider_${providerId}`, JSON.stringify(config));
}

export async function loadProviderConfig(providerId: string) {
  const json = await SecureStore.getItemAsync(`ai_provider_${providerId}`);
  return json ? JSON.parse(json) : null;
}
```

**隐私脱敏**（账单/截图发往 AI 前过滤敏感字段）：

```typescript
// src/ai/privacy.ts（参考 BeeCount lib/ai/privacy/）
export function sanitizeForAi(text: string): string {
  return text
    .replace(/\b\d{16,19}\b/g, '[卡号]')           // 银行卡号
    .replace(/\b\d{17,18}(\d|X)\b/g, '[身份证]')     // 身份证
    .replace(/1[3-9]\d{9}/g, '[手机号]')             // 手机号
    .replace(/\b\d{6}\b/g, (match, offset, str) => {
      // 6 位数字可能是验证码，上下文判断
      return /验证码|验证|code/i.test(str.slice(Math.max(0, offset - 20), offset + 20)) ? '[验证码]' : match;
    });
}
```

**去除思考链（removeThink）**：部分模型（DeepSeek-R1 等）返回 `<think>...</think>` 思考过程，需在展示前剥离（参考 AutoAccounting `removeThink()`）。

```typescript
export function removeThink(text: string): string {
  return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}
```

### 8.1 AI 聊天助手

参考 BeeCount 的 AI Chat Assistant（GLM-4）：

```typescript
// src/ai/chatAssistant.ts
interface ChatMessage {
  role: 'user' | 'assistant';
  content: string;
}

interface ChatConversation {
  id: string;
  messages: ChatMessage[];
  createdAt: string;
}

// 自然语言记账
export async function processNaturalLanguage(
  input: string,
  ledger: LedgerIndex
): Promise<TransactionDraft | string> {
  const prompt = `你是一个记账助手。用户输入："${input}"
  
  请解析为交易信息，返回 JSON：
  {
    "type": "expense" | "income" | "transfer",
    "amount": "数字",
    "currency": "CNY",
    "account": "账户名",
    "counterparty": "对方",
    "narration": "备注",
    "tags": ["标签"]
  }
  
  如果无法解析为交易，返回建议文本。`;
  
  const response = await callAiProvider(prompt);
  
  try {
    const draft = JSON.parse(response);
    if (draft.amount && draft.type) {
      return draft as TransactionDraft;
    }
  } catch {
    // 非 JSON，返回建议文本
  }
  
  return response;
}

// 示例对话：
// 用户："昨天在星巴克花了35块"
// AI：解析为 expense, amount: 35, counterparty: 星巴克
```

### 8.2 语音记账

参考 BeeCount 的 Voice Billing（长按说话）：

```typescript
// src/ai/voiceInput.ts
import { Audio } from 'expo-av';

export async function startVoiceInput(): Promise<string> {
  // 1. 请求录音权限
  const permission = await Audio.requestPermissionsAsync();
  if (!permission.granted) throw new Error('需要录音权限');
  
  // 2. 开始录音
  const { recording } = await Audio.Recording.createAsync(
    Audio.RecordingOptionsPresets.HIGH_QUALITY
  );
  
  // 3. 等待用户松手（长按模式）
  // ... 用户松手后停止录音
  
  // 4. 发送到 AI 识别
  const uri = recording.getURI();
  const transcription = await transcribeAudio(uri);
  
  return transcription;
}

// 语音转文字后调用 processNaturalLanguage
export async function processVoiceInput(): Promise<TransactionDraft | null> {
  const text = await startVoiceInput();
  const result = await processNaturalLanguage(text, currentLedger);
  
  if (typeof result === 'object') {
    return result;
  }
  return null;
}
```

### 8.3 AI 月度总结

参考 AutoAccounting 的 AI Monthly Summary：

```typescript
// src/ai/monthlySummary.ts
export async function generateMonthlySummary(
  transactions: Transaction[],
  year: number,
  month: number
): Promise<string> {
  const monthTransactions = transactions.filter(t => {
    const date = new Date(t.date);
    return date.getFullYear() === year && date.getMonth() === month - 1;
  });
  
  const stats = {
    totalExpense: calculateTotal(monthTransactions, 'expense'),
    totalIncome: calculateTotal(monthTransactions, 'income'),
    topCategories: getTopCategories(monthTransactions, 5),
    transactionCount: monthTransactions.length,
  };
  
  const prompt = `请根据以下数据生成月度财务总结：

总支出: ${stats.totalExpense}
总收入: ${stats.totalIncome}
交易笔数: ${stats.transactionCount}
主要支出分类: ${stats.topCategories.map(c => `${c.category}: ${c.amount}`).join(', ')}

请用简洁的中文总结消费习惯，给出理财建议。`;
  
  return await callAiProvider(prompt);
}
```

### 8.4 AI 分类识别

参考 AutoAccounting 的 AI Category Recognition：

```typescript
// src/ai/categoryRecognizer.ts
export async function aiRecognizeCategory(
  event: ImportedEvent,
  categories: Category[]
): Promise<Category | null> {
  const categoryList = categories.map(c => `${c.name} (${c.type})`).join(', ');
  
  const prompt = `根据以下交易信息，判断最合适的分类：

交易: ${event.counterparty} - ${event.amount}元 - ${event.memo}
可用分类: ${categoryList}

返回分类名称。`;
  
  const response = await callAiProvider(prompt);
  return categories.find(c => c.name === response.trim()) ?? null;
}
```

### 8.5 AI 资产映射

参考 AutoAccounting 的 AI Asset Mapping：

```typescript
// src/ai/assetMapper.ts
export async function aiMapAsset(
  channel: string,
  counterparty: string,
  accounts: Account[]
): Promise<Account | null> {
  const accountList = accounts.map(a => a.name).join(', ');
  
  const prompt = `根据以下支付信息，判断最合适的资产账户：

支付渠道: ${channel}
对方: ${counterparty}
可用账户: ${accountList}

返回账户名称。`;
  
  const response = await callAiProvider(prompt);
  return accounts.find(a => a.name === response.trim()) ?? null;
}
```

---

## Phase 9: 测试与工程化（1-2 周）[P1]

### 9.1 测试框架配置

```json
// package.json
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  },
  "devDependencies": {
    "vitest": "^3.2.0",
    "@testing-library/react-native": "^13.0.0",
    "msw": "^2.0.0"
  }
}
```

### 9.2 单元测试

| 测试类型   | 覆盖范围                              |
| ---------- | ------------------------------------- |
| 账务校验   | 平衡/不平衡分录、Decimal 精度、多币种 |
| 解析兼容   | 公开 Beancount 示例、中文账户名       |
| 账单适配器 | 收入、支出、退款、手续费、重复流水    |
| 规则引擎   | 优先级匹配、未匹配处理、规则统计、JS 规则执行 |
| 分类匹配   | 精确/关键词/模糊/兜底匹配            |
| 去重逻辑   | 时间窗口、金额匹配、账户关联、多通道去重 |
| 转账识别   | 金额匹配、方向检查、账户关联         |
| 备注模板   | 占位符替换、相邻重复归一化           |
| 信用卡     | 账单周期计算、应还金额               |
| 多币种     | 汇率转换、手动覆盖                   |

### 9.3 集成测试

```
导入账本 → 导入支付宝账单 → 命中规则 → 修改草稿 → 确认 → 导出 → 桌面端校验通过
```

### 9.3.1 Beancount 生态兼容性回归（新增）

> **新增（审查订正）**：项目目标是"完全兼容 Beancount 生态"，但原方案只有一句"桌面端校验通过"。需系统化的兼容性回归。

**测试矩阵**：

| 测试项 | 工具 | 验证点 |
|--------|------|--------|
| 语法正确性 | `bean-check` | 移动端写入的 mobile.bean 通过 bean-check 无报错 |
| 报表生成 | `bean-report` | 能生成 balance_journal/balance_account 等报表 |
| Web 查看器 | `bean-web` / `fava` | mobile.bean 在 fava 中正常显示、可查询 |
| 真实账本导入 | 公开示例账本 | 导入 beancount 官方 example.beancount 解析无误 |
| 双轨制映射 | - | 分类 linkedAccount 映射的账户在桌面端可见 |
| 标签语法 | `bean-check` | `#tag` 语法被桌面端正确识别为 tag |

**回归用例**（纳入 `tests/` 持续运行）：

```typescript
// tests/ecosystem.test.ts
describe('Beancount 生态兼容性', () => {
  it('写入的 mobile.bean 通过 bean-check 语法校验', async () => {
    // 1. 用领域层生成 mobile.bean 内容
    const mobileBean = generateSampleMobileBean();
    // 2. 写入临时文件，调用 bean-check（CI 环境装 beancount）
    const result = await runBeanCheck(mobileBean);
    expect(result.errors).toEqual([]);
  });
  
  it('解析官方 example.beancount 不丢失交易', () => {
    const official = readFixture('example.beancount');
    const ledger = parseLedger([{ path: 'example.beancount', content: official }]);
    // 不认识的指令记入 unsupported，但交易全部解析
    expect(ledger.transactions.length).toBeGreaterThan(0);
  });
});
```

### 9.4 端到端测试

- iOS/Android 真机验证离线记账
- 数据库加密验证
- 文件导入导出验证
- 应用锁和崩溃恢复
- 引导流程完整性

### 9.5 崩溃上报

参考 AutoAccounting 的 Bugly：

```typescript
// src/services/crash.ts
import * as Sentry from '@sentry/react-native';

export function initCrashReporting() {
  Sentry.init({
    dsn: 'YOUR_SENTRY_DSN',
    environment: __DEV__ ? 'development' : 'production',
    tracesSampleRate: 1.0,
  });
}
```

### 9.6 错误恢复 UI

参考 AutoAccounting 的 ErrorActivity：

```tsx
// src/app/_error.tsx
function ErrorScreen({ error, resetError }: { error: Error; resetError: () => void }) {
  return (
    <View style={styles.container}>
      <Icon name="error-outline" size={64} color={theme.colors.error} />
      <Text style={styles.title}>应用遇到问题</Text>
      <Text style={styles.message}>{error.message}</Text>
      
      <View style={styles.actions}>
        <Button onPress={resetError}>重试</Button>
        <Button onPress={clearCacheAndRestart}>清除缓存并重启</Button>
        <Button onPress={exportLogs}>导出日志</Button>
      </View>
      
      <Text style={styles.help}>
        如果问题持续出现，请导出日志并提交到 GitHub Issues
      </Text>
    </View>
  );
}
```

### 9.7 CI/CD

```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run typecheck
      - run: npm run test
      - run: npm run lint
```

---

## Phase 10: 孤儿文件清理与维护（可选）[P2]

### 10.1 孤儿文件扫描

参考 BeeCount 的 Orphan File GC：

```typescript
// src/services/maintenance.ts
export async function scanOrphanFiles(): Promise<OrphanFile[]> {
  const orphans: OrphanFile[] = [];
  
  // 扫描孤立附件
  const attachments = await getAllAttachments();
  const usedUris = new Set(
    (await getAllTransactions()).flatMap(t => t.attachmentUris || [])
  );
  
  for (const attachment of attachments) {
    if (!usedUris.has(attachment.fileUri)) {
      orphans.push({
        type: 'attachment',
        path: attachment.fileUri,
        size: await getFileSize(attachment.fileUri),
      });
    }
  }
  
  // 扫描孤立缩略图
  const thumbnails = await listThumbnails();
  for (const thumb of thumbnails) {
    if (!attachments.some(a => a.fileUri.includes(thumb.name))) {
      orphans.push({
        type: 'thumbnail',
        path: thumb.uri,
        size: thumb.size,
      });
    }
  }
  
  return orphans;
}

export async function cleanOrphanFiles(orphanFiles: OrphanFile[]): Promise<number> {
  let cleaned = 0;
  for (const file of orphanFiles) {
    try {
      await FileSystem.deleteAsync(file.path);
      cleaned++;
    } catch (e) {
      logger.warn('maintenance', `Failed to clean orphan: ${file.path}`, e);
    }
  }
  return cleaned;
}
```

### 10.2 数据库维护

```typescript
// 数据库压缩（减小文件大小）
export async function compactDatabase(db: SQLite.SQLiteDatabase) {
  await db.execAsync('VACUUM');
}

// 数据完整性检查
export async function checkDataIntegrity(db: SQLite.SQLiteDatabase): Promise<IntegrityReport> {
  const issues: string[] = [];
  
  // 检查孤立的 imported_events
  const orphanEvents = await db.getAllAsync<{ id: string }>(
    `SELECT ie.id FROM imported_events ie
     LEFT JOIN ledger_files lf ON ie.committed_transaction IS NOT NULL
     WHERE lf.path IS NULL`
  );
  if (orphanEvents.length > 0) {
    issues.push(`${orphanEvents.length} orphan imported_events`);
  }
  
  // 检查损坏的 JSON payload
  const invalidPayloads = await db.getAllAsync<{ id: string }>(
    `SELECT id FROM imported_events WHERE json_valid(payload) = 0`
  );
  if (invalidPayloads.length > 0) {
    issues.push(`${invalidPayloads.length} invalid JSON payloads`);
  }
  
  return { clean: issues.length === 0, issues };
}
```

---

## 性能预算（新增）

> **新增（审查订正）**：原方案缺少可量化的性能指标，"数据库作为读缓存"的必要性无法验证。以下为各关键路径的性能预算（中端设备基准，超标需优化）。

| 操作 | 预算 | 触发优化的阈值 | 优化手段 |
|------|------|--------------|---------|
| 大账本解析（10MB `.bean`，~5000 交易） | < 2s | > 3s | 分页解析 / 增量解析 / Worker 线程 |
| SQLite 缓存重建（5000 交易） | < 1s | > 2s | 批量事务 / 关闭 WAL 同步 |
| OCR 单次识别（PP-OCRv5，720px） | < 1.5s | > 3s | 短边压缩 / CPU 亲和性 |
| 冷启动到可交互 | < 2s | > 3s | 懒加载 / 缓存预热 |
| mobile.bean 追加写入（万条交易后） | < 100ms | > 300ms | 仅 append 不重写 / 按月分文件 |
| 交易列表滚动（1000 条） | 60fps | < 50fps | FlatList 虚拟化 / SQLite 分页 |
| 交易搜索（万条） | < 200ms | > 500ms | SQLite FTS5 全文索引 |
| 去重查询（万条历史） | < 100ms | > 300ms | 时间窗口 + 金额复合索引 |

**测量方法**：

```typescript
// src/utils/perf.ts（性能埋点）
export function measure<T>(label: string, fn: () => T): T {
  const start = performance.now();
  const result = fn();
  const duration = performance.now() - start;
  logger.debug('perf', `${label}: ${duration.toFixed(0)}ms`);
  // 超预算告警
  const budget = PERF_BUDGETS[label];
  if (budget && duration > budget) {
    logger.warn('perf', `${label} 超预算: ${duration.toFixed(0)}ms > ${budget}ms`);
  }
  return result;
}
```

**回归监控**：性能预算纳入 CI，关键操作每次变更后跑基准测试，超标阻断合并。

---

## `.bean` 格式自身演进策略（新增）

> **新增（审查订正）**：mobile.bean 格式需要演进（如新增 metadata 字段），同时桌面端 Beancount 升级会引入新语法，移动端需跟进。

### mobile.bean 自身格式演进

- **向前兼容**：新版本写入的 mobile.bean 必须能被旧版本读取（旧版本忽略不认识的字段）
- **向后兼容**：旧版本写入的 mobile.bean 必须能被新版本读取（新版本识别旧格式）
- **策略**：mobile.bean 只用 Beancount 标准语法（不发明私有语法），演进通过 metadata（`key: "value"`）扩展，旧版本忽略未知 metadata
- **版本标记**：mobile.bean 首行可加注释 `; beancount-mobile v1.2`，便于诊断

```beancount
; beancount-mobile v1.2
2026-07-13 * "咖啡店" "冰美式" #food
  bm-category: "Food"        ; 应用私有 metadata，桌面端忽略
  bm-source: "ocr"           ; 来源标记
  Assets:Alipay  -24.50 CNY
  Expenses:Food   24.50 CNY
```

### 桌面端 Beancount 升级跟进

- 持续跟踪 [beancount CHANGELOG](https://github.com/beancount/beancount/blob/master/CHANGES)
- 新增指令：先记入 `unsupported`（只读保留），评估使用频率后再决定是否支持解析
- 语法变更：更新 `ledger.ts` 解析器，跑 9.3.1 生态兼容性回归

---

## 应用无障碍（Accessibility）适配（新增）

> **新增（审查订正）**：记账类应用用户群体含视障人士。本节指 VoiceOver / TalkBack 适配（非无障碍服务）。原方案全文 0 字提及。

**适配要求**：

| 组件 | 适配项 |
|------|--------|
| 按钮控件 | `accessibilityRole="button"` + `accessibilityLabel`（语义化描述） |
| 金额输入 | `accessibilityLabel="金额"` + 朗读格式（"24 元 5 角"） |
| 交易列表项 | `accessibilityLabel` 拼接"日期 摘要 金额"（如"7月13日 咖啡店 支出24.5元"） |
| 图表 | `accessibilityLabel` 提供文字摘要（"本月支出 2000 元，比上月减少 15%"） |
| 图标按钮 | 必须有文字 label（如相机图标 → "拍照识别"） |
| 颜色信息 | 不依赖颜色传递信息（如支出/收入同时用 +/- 符号 + 颜色） |
| 焦点顺序 | 遵循阅读顺序（左到右、上到下） |
| 触摸目标 | 最小 44x44 pt（iOS）/ 48x48 dp（Android） |

**测试**：

- iOS：开启 VoiceOver，全流程走查
- Android：开启 TalkBack，全流程走查
- 自动化：`@testing-library/react-native` 的 accessibility 断言

---

## 预估工期

| 阶段                           | 优先级 | 工作量   | 产出                               |
| ------------------------------ | ------ | -------- | ---------------------------------- |
| Phase 0: 基础架构              | P0     | 1-2 周   | 状态管理、迁移、安全、备份、错误处理、引导流程、日志 |
| Phase 1: UI 框架               | P0     | 2 周     | 基础页面、主题、国际化、搜索、深度链接、Speed Dial |
| Phase 2: 核心功能              | P0     | 3-4 周   | 交易管理、导入、规则引擎(JS)、去重、分类、标签、信用卡、备注模板、关键词过滤 |
| Phase 3: OCR 自动记账          | P0     | 2-3 周   | 分层处理、PP-OCRv5核心、无障碍、页面签名、浮窗、快速设置磁贴、横屏免打扰 |
| Phase 4: 通知/短信监听         | P0     | 1-2 周   | 实时账单捕获、每日提醒、信用卡提醒 |
| Phase 5: 高级功能              | P1     | 3-4 周   | 报表、图表、年度报告、净资产趋势、日历视图、预算、周期、附件、多币种增强、导出、海报生成 |
| Phase 6: 同步（可选）          | P2     | 3-4 周   | Git/WebDAV/iCloud、冲突解决、错误持久化、增量/快照双模式 |
| Phase 7: iOS 自动记账          | P1     | 1-2 周   | 快捷指令、分享扩展、SiriKit（均为实验性） |
| Phase 8: AI 功能增强           | P1     | 2-3 周   | AI 聊天、语音记账、月度总结、分类识别、资产映射 |
| Phase 9: 测试与工程化          | P1     | 1-2 周   | 测试框架、CI/CD、崩溃上报、错误恢复UI |
| Phase 10: 维护（可选）         | P2     | 1 周     | 孤儿文件清理、数据库维护 |
| **总计**                       |        | **20-28 周** |                                    |

### 版本里程碑

| 版本 | 包含 Phase | 预计工期 |
|------|-----------|----------|
| **v1.0 MVP** | Phase 0-4 | 9-13 周 |
| **v1.1 增强** | Phase 5, 7-9 | 7-11 周 |
| **v2.0 进阶** | Phase 6, 10 | 4-6 周 |

---

## 参考项目

### BeeCount（UI/UX + 分层架构参考）(./reference_project/BeeCount)

- 5 种云同步方案实现（Supabase/WebDAV/S3/iCloud/BeeCount Cloud）
- AI 智能记账（对话/OCR/语音/截图）
- **分层处理架构**（规则匹配 → 本地 OCR → AI Vision）
- 30 版本数据库 schema 迁移
- 29 个 Riverpod provider 文件
- 9 大主题 + 暗黑模式 + 设计令牌系统
- 国际化（简中/繁中/英文/韩语）
- 二级分类 + 标签系统 + 预算管理
- 图表分析（fl_chart）
- 应用锁（生物识别/PIN）+ 隐私模糊
- **信用卡管理**（账单日/还款日/额度/提醒）
- **日历视图** + **年度报告** + **净资产趋势**
- **首页小组件**（iOS/Android）
- **深度链接**（beecount:// URL scheme）
- **分享海报生成**
- **自定义月起始日**
- **交易级多币种**（nativeAmount + 汇率快照）
- **交易标志**（excludeFromStats / excludeFromBudget）
- **孤儿文件 GC** + **日志中心**
- **AI 聊天助手**（GLM-4）+ **语音记账**

### AutoAccounting（OCR/自动化参考）(./reference_project/AutoAccounting)

- `OcrModule.kt`：PP-OCRv5 本地模型（ONNX Runtime，见决策 7）
- `NotificationListenerService.kt`：通知监听（含 MD5 去重 + 关键词黑白名单）
- `SmsReceiver.kt`：短信监听
- `RuleGenerator.kt`：JS 规则引擎（QuickJS，系统/用户规则分离）
- `RegexRuleTool.kt`：AI 生成正则规则
- `PageSignatureManager.kt`：页面签名自动触发 OCR
- `BillWindowManager.kt`：浮窗账单提示（Channel 队列 + 300ms 节流）
- 无障碍服务伪装（SelectToSpeakService）
- 嵌入式 Ktor HTTP 服务器（localhost:52045）
- 云规则分发系统（版本化 ZIP 包）
- **TransferRecognizer**：转账智能识别（Income+Expend → Transfer）
- **BillMerger**：智能字段合并（已知资产优先、名称长度偏好）
- **DuplicateDetector**：多通道联合去重
- **Remark Template Engine**：15+ 占位符 + 相邻重复归一化
- **Bill Flag System**：FLAG_NOT_COUNT / FLAG_NOT_BUDGET
- **Landscape DND**：横屏免打扰模式
- **OcrTileService**：快速设置磁贴
- **ErrorActivity**：崩溃恢复 UI
- **WebDAVManager**：WebDAV 备份 + 自动清理
- **9 AI Providers**：ChatGPT/DeepSeek/Gemini/Kimi/智谱/OpenRouter/通义/硅基流动/MiMo
- **Statistics Service**：591 行综合统计（分类/商家/时段/同比）

---

## 文件清单

### 新增文件

```
src/
├── app/                    # Expo Router 页面
│   ├── (tabs)/
│   ├── transaction/
│   ├── category/
│   ├── tag/
│   ├── budget/
│   ├── calendar/
│   └── credit-card/
├── components/             # 可复用组件
│   ├── DedupBanner.tsx
│   ├── SearchBar.tsx
│   ├── CategoryPicker.tsx
│   ├── TagPicker.tsx
│   ├── CalendarView.tsx
│   ├── SpeedDial.tsx
│   └── charts/
├── store/                  # Zustand 状态管理
│   ├── ledgerStore.ts
│   ├── importStore.ts
│   └── settingsStore.ts
├── hooks/                  # 自定义 Hooks
│   ├── useSearch.ts
│   ├── useCategory.ts
│   └── usePrivacyBlur.ts
├── services/               # 业务逻辑服务
│   ├── security.ts
│   ├── backup.ts
│   ├── privacyBlur.ts
│   ├── deepLink.ts
│   ├── reminder.ts
│   └── crash.ts
├── i18n/                   # 国际化
│   ├── index.ts
│   ├── zh.ts
│   └── en.ts
├── theme/                  # 主题系统
│   ├── tokens.ts
│   ├── presets.ts
│   ├── createTheme.ts
│   └── storage.ts
├── ocr/                    # OCR 模块（Android）
│   ├── OcrBridge.ts
│   ├── OcrProcessor.ts
│   └── AiVisionProcessor.ts
├── ai/                     # AI 功能
│   ├── chatAssistant.ts
│   ├── voiceInput.ts
│   ├── monthlySummary.ts
│   ├── categoryRecognizer.ts
│   └── assetMapper.ts
├── domain/
│   ├── dedup.ts
│   ├── categories.ts
│   ├── tags.ts
│   ├── budgets.ts
│   ├── recurring.ts
│   ├── attachments.ts
│   ├── currency.ts
│   ├── creditCards.ts
│   ├── transferRecognizer.ts
│   ├── remarkTemplate.ts
│   ├── keywordFilter.ts
│   ├── transactionFlags.ts
│   ├── annualReport.ts
│   ├── netWorth.ts
│   └── ruleEngine.ts
├── storage/
│   └── migrations.ts
└── utils/
    ├── errorHandler.ts
    └── logger.ts

android/app/src/main/java/com/beancount/mobile/
├── ocr/
│   ├── OcrModule.kt
│   ├── OcrAccessibilityService.kt
│   ├── OcrManager.kt
│   ├── RuleMatcher.kt
│   ├── BillParser.kt
│   ├── PageSignatureManager.kt
│   ├── FloatingBillView.kt
│   └── OcrTileService.kt
├── notification/
│   └── NotificationListenerService.kt
├── sms/
│   └── SmsReceiver.kt
└── res/xml/
    └── accessibility_service_config.xml

tests/
├── domain/
│   ├── rules.test.ts
│   ├── statements.test.ts
│   ├── dedup.test.ts
│   ├── categories.test.ts
│   ├── decimal.test.ts
│   ├── transferRecognizer.test.ts
│   ├── remarkTemplate.test.ts
│   ├── creditCards.test.ts
│   ├── currency.test.ts
│   └── ruleEngine.test.ts
├── store/
│   └── ledgerStore.test.ts
└── services/
    ├── security.test.ts
    └── syncErrorManager.test.ts
```

### 修改文件

```
app.json                    # 添加 iOS/Android 配置
package.json                # 添加依赖
tsconfig.json               # 添加 paths 配置
android/app/build.gradle    # 添加 ML Kit + PP-OCRv5 依赖
android/app/src/main/AndroidManifest.xml  # 注册服务
```