SlimeWorks

流水账模块(Ledger)

模块概述

流水账是日常记账 + 银行账单自动入账的模块,两条录入路径共用一份 SQLite 账本:


目录结构

lib/
  pages/ledger/
    ledger_screen.dart              - 首页(月度概览 + 日趋势 + 最近流水)
    ledger_records_screen.dart      - 流水明细(筛选 + 分页 + 编辑 + 日历视图)
    ledger_stats_screen.dart        - 统计(自由区间 / 聚合轴 / 类别环图 / 资产趋势)
    ledger_pending_screen.dart      - 待确认账单(邮件溯源 + 逐笔确认)
    ledger_settings_screen.dart     - 账单邮箱(规则、模板预览、连接自检、抓取日志)
    ledger_accounts_screen.dart     - 账户(含商户自动归类习惯)
    ledger_organize_screen.dart     - 分类与标签(类别两级树 + 标签分组)
    ledger_templates_screen.dart    - 模板与定时记账(点模板记一笔 + 规则开关)
    ledger_data_screen.dart         - 导入与导出(CSV/JSON 出,表格预览后落库)
    components/                     - 共享组件(tab/底部导航/月份条/图表/各类编辑器)
    models/ledger_models.dart       - Dart 侧模型 + 月份/金额 helper
  core/services/ledger_service.dart - GetIt 单例:FFI 包装 + 口令安全存储 + 节点中转
  core/services/ledger_stub_store.dart - 后端未接入那几样的进程级桩仓库(见下方边界)
  view_models/ledger/               - 9 个页面 VM
  core/routes/routes/ledger_routes.dart - 9 条 typed route(part of app_routes.dart)
  src/rust/api/ledger.dart          - FRB 生成(勿改)

rust/
  email_module/                     - 邮件协议层(imap/pop3/smtp + MIME 解码 + TLS 策略)
  ledger_module/                    - 账本(SQLite 存储 + 模板解析 + 调度器 + FFI 面)

ledger_module 只依赖 email_module(协议栈收信)+ slime_logger,账本用的是自带的 rusqlite(bundled),与 db_module 的 redb 无关;email_module 无内部依赖。两者都是静态链接,走 rust/src/api/ledger.rs 转发给 FRB。


页面与路由

路由 页面 侧栏
/ledger LedgerScreen 顶层唯一一格
/ledger/records LedgerRecordsScreen sidebarParent=/ledger
/ledger/stats LedgerStatsScreen 同上
/ledger/pending LedgerPendingScreen 同上
/ledger/settings LedgerSettingsScreen 同上
/ledger/accounts LedgerAccountsScreen 同上
/ledger/organize LedgerOrganizeScreen 同上
/ledger/templates LedgerTemplatesScreen 同上
/ledger/data LedgerDataScreen 同上

侧栏只露”流水账”一格,子页挂在它下面:宽屏靠页内的胶囊 tab(LedgerTabs)互跳,窄屏与手机改走底部导航(LedgerBottomNav,四个落点 + 中间一颗”记一笔”);把每一页都平铺到侧栏会把一个账本读成六个功能。两条导航的第一项都叫”概览”,同一个目的地不该有两种叫法。

/ledger/accounts、/ledger/organize、/ledger/templates 与 /ledger/data 不在两条导航里(它们是设置页的下级):账户与分类两页的入口有三处——设置页的 LedgerNavTile、账户页里跳分类的 LedgerNavTile、侧栏展开后的子项;模板页与导入导出页只从设置页进,”常记的那几笔”和”搬账本”都不该和”看账”抢位置。

权限统一 Permission.accessLedger,分组 sidebarGroupId: 'ledger'(sort: 44)。


记账增强的边界:哪些是真数据,哪些是桩

这一轮按”先铺 UI 骨架、后端下一轮补”推进,分界线就一条:后端已经有的继续打 Rust,后端没有的走 LedgerStubStore。

数据 来源 说明
流水 / 账户 / 类别 LedgerService → Rust 真实数据,邮件账单链路已在跑
标签、标签分组 LedgerStubStore(进程级单例 + SharedPreferences) 演示数据,页面上挂 LedgerStubMark 水印
记账模板、定时规则 同上 演示数据,但按模板记下来的那一笔走的是真账(ledgerCheckDuplicate → ledgerAddTransaction),所以水印只写在模板与规则那两块,不写”这笔账是假的”
附件、备份记录 同上 同上
CSV/JSON 导入导出 编解码在 LedgerStubStore(纯文本),读写的两头都是真账 导出打 ledgerListTransactions,导入逐笔 ledgerAddTransaction;整页不挂水印,只有”标签那一列导进来会丢”单独说清——后端还没有标签表,那是真存不住

模板与规则把账户与类别按名字也存了一份(accountName / categoryName):桩阶段没有外键拦着,类别与账户随时会被删,只存 id 的那一半就会指错。showLedgerTxEditor 拿到草稿先按 id 认一次,认不到再按名字认一次,两边都对不上就留空让用户自己选——预选一个不相干的类别比空着更糟。这条回认在真库上同样成立,接后端时不用改界面。

桩仓库的方法名与签名按未来的 FFI 契约写(Future + 同名动词),下一轮把方法体换成 rust_api.ledgerXxx() 即可,界面代码一行不用改。测试里必须 await LedgerStubStore.instance.resetForTest()——它是进程级单例,不清零会上一个用例建的分组漏到下一个。

下一轮补 Rust 时的清单


表结构(<app_support>/ledger/ledger.db,WAL)

表 作用 关键约束
accounts 账户:credit_card / debit_card / cash / deposit / other,last4 用于卡片尾号匹配  
categories 类别,direction = expense / income / transfer,icon 是图标标识由 Dart 映射到 StrokeIcons name UNIQUE;首次建库播种 22 个内置类别 + 一个”现金”账户,升级按名 INSERT OR IGNORE 补插
transactions 流水。amount 恒为正数,正负只看 direction;bill_date 冗余存 YYYY-MM-DD 并建索引 见下方去重
email_rules 收信规则(协议/主机/用户名/发件人与标题匹配/模板/轮询间隔/每日时点/默认账户/auto_apply/accept_invalid_certs) 口令不落这张表
email_rule_secrets 只存 secret_ref(ledger_rule_<id>),指向真实密钥位置  
emails 已收取邮件的存档 + 溯源信息(笔数、可用额度、积分、告警) UNIQUE (rule_id, message_uid)
fetch_logs 每次抓取的耗时/新邮件/新流水/跳过笔数/详情  
merchant_memory 商户 → 类别的记账习惯,确认一次后同类商户自动归类  

去重是三层

  1. 硬幂等:transactions 上 CREATE UNIQUE INDEX ... ON (rule_id, email_uid, dedup_key) WHERE source = 'email'。同一封邮件重复投递撞唯一键,insert_transaction 返回 0 而不是写脏数据。dedup_key = 交易时刻|金额(两位小数)|商户|银行类型|r行序。尾巴那个行序是必需的:月度电子账单只给 MMDD、连时分都没有,同一天两笔同额同商户的真实重复扣款若不编进行序,第二笔就会被唯一键当重复挡掉。
  2. 邮件级跳过:抓取前先读 known_email_uids(rule_id),已知 UID 直接不解析、不计数。
  3. 软重复提示:手动录入时 ledger_check_duplicate 按”同日 + 同账户 + 同商户 + 金额差 < 0.005”找已入账的那笔,UI 弹提示但不拦死。

状态机

transactions.status:pending(待确认,不进任何统计)→ posted(已入账)或 ignored(已忽略)。source:manual / email。所有 stats_* 查询硬带 status = 'posted',所以待确认队列里的钱绝不会提前出现在概览上。


邮件入账链路

调度器(每 60s 看一次到点规则)     用户在待确认页/设置页点"立即收取"
        └────────────┬────────────────────────┘
              check_by_rule(rule_id, password)          ← 只取收件箱最新 10 封
        ┌────────────▼────────────┐
        │ email_module 收信        │  IMAP/POP3 over TLS,MIME 解码出 html+text
        └────────────┬────────────┘
     message_matches(发件人 + 标题,可选正则)
        └─▶ resolve_template_id ─▶ 三种模板之一
              └─▶ parse_message ─▶ ParseResult{bill_date, transactions[], available_credit, points_balance, warnings[]}
                    └─▶ 逐笔 insert_transaction(账户按卡片尾号回落规则默认账户)
                          └─▶ record_email 落待确认队列

「回补历史」(ledger_backfill_rule)复用上面这一整段编排,只把抓取深度从 10 封换成最近 200 封:定时收取够快,但第一次配规则、口令失效停摆几天、或那 10 封里恰好夹了广告时,过去的账单就永远进不来,得靠这个入口回灌。重复点不会记两遍——已收过的 UID 先跳过,流水表还有 UNIQUE (rule_id, email_uid, dedup_key) 兜底。

模板

id 说明
cmb_daily_bill 招商银行账单,两种版式都走它(id 不变,规则里显式选它也照此分流):
· 每日明细:fixBand4 切明细区,fixband5 取日期、fixband12 取金额/尾号/商户/卡种三段式;
· 月度电子账单(2026 版):band 容器不带引号(<SPAN id=fixBand15>)、明细单元格一个 id 都没有、表头全是图片,于是按 <tr> 取叶子 <td>,从行尾那个金额格倒推列位(交易日|入账日|摘要|交易金额|卡号末四位|币种|人民币金额),入账取人民币金额列,MMDD 的年份由账单期间 2026-08-22-2026-09-21 补(月日晚于期间末的算期间起始那年)。
两种版式共用 fixBand4,光看容器在不在选不对分支:先按每日版式试,谁真解析得出笔数听谁的,两边都空则报错并留档原文。
generic_keyword 通用关键字:配日期/金额/摘要关键字,给格式已知但没有内置模板的账单
custom_regex 一条带命名组的正则吃整行,最灵活也最容易写错,改完必须用预览验证
auto(规则默认值) 命中招行指纹(标题含”每日账单/消费明细/交易明细” 且正文含 bill_templet_resource / s3gw.cmbimg.com / cmbchina)走内置模板,否则落 generic_keyword

解析出来一笔都没有时返回明确错误(”命中了招行账单模板但一笔都没解析出来”),因为那几乎总是银行改了邮件模板,不该静默当”今天没消费”。

命中规则却解析不了的邮件,原文会写到账本旁边的 ledger/parse_failures/rule<规则id>_<UID>_<主题>.html,概况与日志里给出这个目录路径——银行改版时只有原文能说明新结构长什么样,把它贴回规则编辑器的「用一段 HTML 验证模板」或直接发给作者即可。没命中规则的邮件一封都不落盘。

调度器

std::thread + 分片 1 秒睡眠(停止请求最坏 1 秒生效),每 60 秒一轮 run_due_rules()。规则靠 interval_minutes(间隔轮询)或 daily_time(HH:MM 每日时点,当天已跑过就不再跑)判定到点。ledger_scheduler_status 回 {running, enabled, check_interval_secs, last_check_at, next_check_at, active_rules, last_summary} 给设置页显示。

口令常驻进程内存(scheduler::SECRETS: RwLock<HashMap<i64, String>>),所以后台自动收信只在本机跑过服务时成立。Dart 侧 ensureInitialized() 会把安全存储里的口令推进内存(_warmRuleSecrets),且只有存在”启用 + 协议支持”的规则时才拉起线程。


凭证与 TLS 约束


移动端与节点中转

Rust 的 SQLite/线程在移动端跑不动后台收信,所以:


UI 约定


测试

文件 覆盖
rust/ledger_module/tests/cmb_parser_test.rs 招行正文解析(fixture 已脱敏)
rust/ledger_module/tests/storage_test.rs 建库/播种/去重唯一键/统计口径
rust/ledger_module/tests/ffi_json_test.rs JSON 面契约
rust/email_module/tests/{config_policy,dispatch,mime}_test.rs TLS 策略、协议分发、MIME 解码
test/ledger_models_test.dart Dart 模型与月份 helper(含区间闭合性)
test/ledger_service_test.dart RustLib.initMock 桩 FFI:节点/本地双路径、口令只走安全存储
test/ledger_render_test.dart 桌面 + 手机 golden 25 张、逐帧排版冒烟、首屏数据断言、分类两级树与标签块的交互、模板与定时的交互(回认/入账/级联删除/规则开关)、导入导出(预览的行归认、兜底账户、确认与回执)
test/ledger_icons_test.dart 图标标识 → StrokeIcons 映射全覆盖

桩 FFI 的返回类型必须与 FRB 生成出来的签名一致,而不是与 Rust 源码的直觉一致:ledgerDeleteCategory 生成为同步 String,桩就要给裸字符串,给 Future<String> 会在返回类型检查处炸掉,症状是页面回执永远为空。ledgerCheckRule / ledgerBackfillRule 反过来是 Future,桩得给 Future<String>.value(...)。另外 ledgerListCategories 的 direction 传空串是”收支都要”,桩得回两个方向的并集——只回支出会让所有读类别的页面少一半。

golden 用 --update-goldens 重生成之后别只盯新图:拿旧基线做逐像素 diff(差异框 + 占比),确认改动落在预期的那块区域,再按 1:1 裁切目视。整页 30% 的位移通常只是底部导航把内容顶高了。

widget 测试在这一页上有五个坑,都是踩过才知道的:


已知限制