DriftLedger/plan.md
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

4576 lines
152 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# beancount-mobile 开发方案
> **已整合 BeeCount 分层处理架构 + AutoAccounting 无障碍实现**
> **v3 — 基于 BeeCount / AutoAccounting 参考项目全面审查修订**
> **采用 MVP + 演进路线图模式,明确各版本边界**
## 项目概述
**beancount-mobile** 是一个面向 Beancount 用户的离线移动端应用,支持 iOS 和 Android。核心原则
- 账本文件(`.bean`)是唯一权威来源
- 应用只写入 `mobile.bean`,不改写桌面维护的源文件
- 复式记账原生支持,完全兼容 Beancount 生态
### 版本策略
| 版本 | 目标 | 核心功能 |
|------|------|----------|
| **v1.0 MVP** | 可用的移动端记账 | 基础架构 + UI + 交易管理 + CSV 导入 + 规则引擎 + 去重 + OCRAndroid |
| **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可选 | - |
| OCRAndroid | 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` 写入 narrationBeancount 原生 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 ReleaseAndroid 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 政策)不再适用,可保留全功能。
### 决策 4Expo 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 说明。
### 决策 5SQLCipher 密钥管理
**问题**:技术栈表写"expo-sqlite (SQLCipher)",但全文未讨论密钥从哪来、存哪、丢失怎么办。
**方案**
```
首次启动:
随机生成 32 字节密钥 → 存入 expo-secure-storeiOS 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 识别到账单、收到通知)
原生 ModuleKotlin
↓ NativeEventEmitter.emit('billDetected', payload)
RN JS 主进程
↓ 监听事件
领域层(统一入库入口)
↓ 走 billPipeline转账识别 → 去重 → 资产映射 → 分类 → 入库)
SQLite 缓存 + mobile.bean
```
**关键点**
- 原生服务**不直接写库**,只通过事件桥把原始数据推给 JS 层
- JS 层的 `billPipeline` 是唯一入库入口,保证去重/转账/分类逻辑统一(避免 AutoAccounting 那样各通道各自处理)
- 并发控制JS 层用 Promise 队列串行化(参考 AutoAccounting 的 `deduplicationMutex`),见 Phase 2 的 `billPipeline`
**对 plan 的影响**Phase 3OCR、Phase 4通知/短信)的"原生服务直接 sendToReactNative"描述改为"通过 NativeEventEmitter 事件桥推送给 JS 层 billPipeline"。
### 决策 7OCR 引擎改用 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 |
| postingaccount + 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 2mobile.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`,生产环境可选接入 SentryPhase 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.tsStep 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不重写整个文件
- 进一步优化v2mobile.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.tsStep 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.beanMobileBeanStore 内部已有写入锁)
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.ktConfig 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.ktConfig 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)
// detDB 文本检测)→ recCTC 文本识别)
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云端 | ✓ | ✓ | 替代本地 OCRiOS 主要路径 |
| 通知监听 | ✓ | ✗ | iOS 限制严格 |
| 短信解析 | ✓ | ✗ | iOS 限制严格 |
| 规则匹配 | ✓ | ✓ | 纯逻辑 |
| 页面签名 | ✓ | ✗ | 仅 Android |
| 浮窗提示 | ✓ | ✗ | 仅 Android |
| 快速设置磁贴 | ✓ | ✗ | 仅 Android |
| 快速设置磁贴 | ✓ | ✗ | 仅 Android |
### 3.13 截图自动记账通道(新增)
> **新增(审查订正)**:这是比无障碍截屏更**轻量、更合规**的高频入口。用户在支付宝/微信/银行 App 截图,应用自动识别。参考 BeeCount 的 `ScreenshotObserver.kt`Android+ `AutoBillingAppIntent.swift`iOS
**AndroidContentObserver 监听 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()
}
}
```
**iOSAppIntent 后台执行)**
```swift
// ios/AutoBillingAppIntent.swiftiOS 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/ AppIntentiOS |
| 合规 | 高风险(侧载保留) | 低风险(上架也能用) |
| 频次 | 全自动 | 半自动(需用户截图) |
| 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 }) {
// SecureStoreiOS 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 AssistantGLM-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-checkCI 环境装 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-OCRv5720px | < 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 ptiOS/ 48x48 dpAndroid |
**测试**
- 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 周 |
---
## 参考项目
### BeeCountUI/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+ **语音记账**
### AutoAccountingOCR/自动化参考)(./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 # 注册服务
```