ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自建个人财务服务:从账单解析到预算提醒的完整实践

自建个人财务服务:从账单解析到预算提醒的完整实践

这两年我越来越确信一件事:个人财务管理这件事,工具链的尽头往往是自建。不是因为记账软件不好用,而是因为市面上的记账应用,要么把数据锁在别人的服务器上,要么报表逻辑对不上你自己的消费习惯。我手里这个代号为financial-services的项目,最初只是一堆处理银行流水 CSV 的脚本,后来一步步长成了包含数据清洗、交易分类、预算计算、超支提醒的完整服务。这篇文章就把整个项目的设计方案、核心代码逻辑、以及我在部署运营中踩过的坑完整记录下来。

如果你也想把个人财务数据完全握在自己手里,或者正在为"账单导入了怎么分类不准""预算提醒老误报"这类问题头疼,这篇文章应该能帮你省下大量试错时间。我会从数据模型讲到分类引擎,再讲到报表口径,最后是部署阶段的真实教训,全程给结论、给代码、给理由,不绕弯子。

1. 记账工具的困局与自建财务服务的切入点

1.1 市面记账应用为什么总在半年后被我放弃

先说一个反直觉的结论:市面记账应用最强大的功能——自动同步账单——恰恰是我放弃它们的根本原因。银行流水自动同步看似省事,但大多数应用对交易数据的处理是黑盒:某笔支出被归到"餐饮"还是"购物",你只能事后手动调整。时间一长,分类错误累积到报表层面,月度统计的参考价值就大打折扣。

另一个问题是数据所有权。你的每一笔消费记录都存在别人的数据库里,一旦服务调整运营策略或停止维护,导出数据的格式还不一定完整。我试过从某记账应用导出三年流水,结果商户名称被脱敏、备注字段被截断,导出的 CSV 清洗成本比重新记账还高。

还有一个经常被忽略的痛点:预算逻辑不够灵活。市面应用的预算大多是"月度总额"或"按固定类目设限",但我的实际需求是——餐饮类预算按滚动 30 天计算、交通类预算按季度重置、年度大额支出(保险、体检)单独设池子。这些在现成应用里基本很难配置,即使有高级会员功能,规则也受限于对方的实现思路。

1.2 自建 financial-services 要解决的三个具体问题

决定自建之后,我先明确了项目的边界。这个服务不需要做花哨的可视化,不需要多用户系统,甚至不需要实时性,它只需要稳定解决三个问题:

第一,多渠道账单的规范化摄入。我日常涉及四类数据源:银行储蓄卡流水、信用卡账单 PDF、支付宝账单导出、微信支付账单。它们的字段命名、日期格式、金额正负含义完全不一致,需要一套统一的处理管道。

第二,可解释的交易分类。每一笔交易最终要落到一个二级类目上,而且分类结果必须能回溯——也就是能说清楚"这笔为什么被归为餐饮",而不是依赖某个黑盒模型。这才是自建方案真正的价值点。

第三,以预算为核心的报表服务。项目产出的核心不是一堆图表,而是一个可以直接回答问题的接口:这个月餐饮还剩多少额度?过去 30 天总支出环比变化如何?哪些固定支出快要扣款了?

这三件事做完,项目就可以脱离"玩具"阶段,成为每天都会被调用的基础设施。

1.3 项目能力边界:哪些不该自己做

自建并不等于一切从零造轮子。我在立项时划了几条硬边界:

  • 不做 OCR:PDF 账单统一走手动导出或银行网页端的"下载流水"功能,能拿到结构化数据就绝不碰图像识别。
  • 不做自动同步:所有数据源都采用"手动导出 + 自动导入"的方式,导入脚本一次性运行,不常驻监听。
  • 不做投资组合管理:股票基金持仓的估值逻辑和普通消费流水完全两套模型,混在一起只会增加复杂度,我单独用另一个工具处理投资侧数据。
  • 不做多用户权限系统:服务只跑在本地网络,通过反代访问,不暴露公网入口,用户模型简化为单一管理员。

这些边界的意义在于把项目控制在一个周末能完全看懂代码的规模。个人项目最怕的就是复杂度失控,每加一个功能,后续维护成本都会指数级上升。

2. 数据管道设计:账单摄入到标准交易模型的转换

2.1 交易数据模型定义

整个服务的核心是一个标准交易表。所有数据源最终都要转换到这个模型里来,我用 SQLite 存储,表结构经过几轮迭代后稳定成下面这样:

CREATE TABLE transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, tx_date TEXT NOT NULL, -- 交易日期,统一为 YYYY-MM-DD tx_time TEXT, -- 交易时间,可选 account TEXT NOT NULL, -- 来源账户,如 alipay / wechat / cmb merchant TEXT NOT NULL, -- 商户名称(原始名称清洗后) amount_cents INTEGER NOT NULL, -- 金额,单位:分,支出为负,收入为正 category TEXT NOT NULL, -- 一级类目 sub_category TEXT, -- 二级类目 raw_merchant TEXT, -- 原始商户名,保留用于排查 raw_row TEXT, -- 原始账单行,JSON 序列化 imported_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (account, tx_date, amount_cents, merchant) );

这里有个关键设计:金额存整数分,而不是浮点数。银行账单里的金额虽然显示为"128.30"这样的两位小数,但在 CSV 解析和后续计算中,浮点误差会累计到报表层面。用分做单位,加减乘除全部走整数运算,彻底绕开精度问题。我见过太多项目栽在金额用 float 存储上,最后对账差了 0.01 元都要排查半天。

另一个细节是UNIQUE约束的字段组合。为什么是 account + tx_date + amount_cents + merchant 而不是直接加一个业务流水号?因为支付宝和微信的导出文件里,有些早期记录根本没有流水号字段。用这四列组合做幂等键,配合部署端的导入去重逻辑,可以有效防止同一个账单文件被重复导入两遍。

2.2 多渠道账单的规范化解析

数据摄入管道的核心是解析器按源类型分流。我维护了一个ingest/目录,里面每个文件对应一个数据源:

ingest/ __init__.py base.py # 定义 IngestionResult 数据结构 alipay.py # 解析支付宝 CSV wechat.py # 解析微信支付账单 cmb.py # 解析招商银行流水 cmb_pdf.py # 处理信用卡 PDF(借助文本抽取) common.py # 公共工具:日期解析、金额清洗

每个解析器的职责是:读入原始文件 → 按该源的字段映射规则提取字段 → 输出统一的交易记录列表 → 调用 common 里的清洗函数 → 写入数据库。

以支付宝账单为例,原始 CSV 表头是"交易时间, 交易分类, 交易对方, 商品说明, 收/支, 金额, 支付方式, 交易订单号"。注意这里的"收/支"字段在早年间可能为空,需要根据金额正负来判断方向。我的解析逻辑里加了一步兜底:如果收支字段为空,则按"收入金额 = 整笔交易金额绝对值,支出金额 = 0"这种支付宝导出的惯例去处理。

微信支付账单的格式类似,但表头是"交易时间,交易类型,交易对方,商品,收/支,金额(元),支付方式,当前状态,交易单号"。这里最坑的是"交易类型"字段里会出现"商户消费""微信红包""零钱提现""亲属卡交易"等混合类型,如果不做映射,这些交易会漏掉或者被错误归为支出。我的做法是把这些交易类型映射到内部行为:零钱提现 → 转账,微信红包 → 社交往来,亲属卡交易 → 按实际收支方向处理。

2.3 为什么用 SQLite 而不是直接上 MySQL 或 PostgreSQL

在数据量评估上,一个人五年内的交易记录大概在几千到两万条之间。这个量级 SQLite 处理起来毫无压力,而且带来三个显著好处:

  • 零运维成本。不需要单独装数据库服务,SQLite 就是一个文件,备份整个库等于cp一条命令。
  • 事务能力完全够用。导入操作虽然涉及多行写入,但一个BEGIN TRANSACTION ... COMMIT就能保证原子性,不需要主从复制、连接池这些东西。
  • 便于审计。整个库就是一个文件,随时可以拷贝到别的机器上查询,配合sqlite3命令行可以快速做临时分析。

当然也有一个前置条件:项目本身是单机运行的。如果你的场景是多设备协作、多人同时写入,SQLite 的写入锁会变成瓶颈,那时候再迁移 PostgreSQL 也不迟,因为业务层的 SQL 基本不需要改。技术选型的核心逻辑是先匹配真实规模,而不是先考虑天花板的性能。

数据写入后的第一件事是跑一遍校验脚本,重点检查几个容易出问题的点:

  • 是否存在金额为 0 的"测试交易"
  • 是否存在超过当前日期的"未来交易"(通常是时区问题)
  • 是否有同一个交易单号出现在不同账户里(比如用支付宝绑定的信用卡付款,同一笔会同时出现在支付宝账单和信用卡账单里)

最后这个问题很隐蔽,属于典型的"双记录"陷阱。如果不做交叉去重,月末总支出会被重复计算。我的方案是在导入后专门跑一个deduplicate.py脚本,按照"金额 + 时间窗口(±1分钟)+ 商户名模糊匹配"的方式标记疑似重复交易,输出到待确认列表,人工确认后才标记为 duplicate。

3. 交易分类引擎:规则优先级与自学习兜底

3.1 基于关键词+商户名的分类规则

交易分类是这类财务服务里最容易翻车的模块。一开始我也尝试过用现成的分类 API,但发现两个问题:一是隐私,所有交易明细都要发给第三方服务,心理这关就过不去;二是效果不稳定,同一个商户在不同平台的名字可能完全不同,API 的分类结果经常前后矛盾。

最终我采用的方案是"关键词规则 + 商户名特征"的组合。每条规则是一个字典,结构如下:

RULES = [ { "category": "餐饮", "sub_category": "外卖", "keywords": ["美团外卖", "饿了么", "星巴克专星送", "肯德基宅急送"], "merchant_pattern": r"(外卖|咖啡|餐厅|食堂|小面|烧烤)", }, { "category": "交通", "sub_category": "网约车", "keywords": ["滴滴出行", "高德打车", "曹操出行", "T3出行"], "merchant_pattern": r"(打车|出行|停车|高速|加油)", }, ... ]

匹配流程是:先匹配精确 keyword 列表,再按 merchant_pattern 正则做模糊匹配。规则的顺序有讲究——精确关键词优先,正则兜底。这样做的原因是避免"美团外卖"这类交易被"外卖"这个宽泛关键词之外的规则抢先归到其他类目。

分类引擎跑完后,我还会做一次"合理性抽查":随机抽取 5% 的交易,人工核对分类结果。如果某条规则的错误率超过 15%,就说明该规则太宽泛,需要拆分或增加排除条件。这个反馈循环是分类准确率能稳步提升的关键。

3.2 规则优先级冲突处理

在规则引擎的设计上,优先级是一个必须明确的语义。我总结了两种冲突类型:

  • 同一交易命中多条规则:例如"滴滴出行"既有"打车"模式又出现在"出行"这个大词规则里。解决方式是给每条规则设定一个显式优先级数值,优先级高的先匹配,匹配成功后立即终止。
  • 排除规则与包含规则冲突:例如"瑞幸咖啡"应该归为"饮品"而不是"餐饮-咖啡店",但你又有"咖啡"关键词规则。这种场景需要用exclude_keywords字段来标记,匹配时先检查排除词。

我的规则类目列表里最终维护了约 80 条规则,其中约三成是排除规则。排除规则看起来繁琐,但它是防止误分类最有效的工具,尤其适合处理那些名字里带行业词但实际用途不同的商户。

3.3 未匹配交易的兜底策略

无论规则库怎么扩充,总会有漏网的交易。我的统计里,规则覆盖后的未匹配率通常在 6%-10% 之间。这 6% 不能不管,否则月末报表的"其他"类目会越来越大,失去参考价值。

兜底策略分三层:

第一层是高频未匹配交易自动建议。每次导入完成后,脚本会把未匹配交易按商户名聚合,按笔数和金额排序,打印一份 Top 20 列表。我会定期根据这份列表补充规则,通常一次补充能覆盖未来同类交易的八成以上。

第二层是学习型分类。针对某些固定商户(比如每月固定的水电燃气费),虽然首次导入时未匹配,但只要我在某个月的分类结果里手动把它标记为"居住-水电"一次,系统会自动记住该商户名,后续再出现同一商户名的交易直接套用这个分类。这个功能我把它叫做"一键记住",配合一个merchant_fixed_category表来实现。

第三层是低置信度人工审核队列。金额大于某一阈值(我设为 500 元)且未匹配的交易,会进入一个待审核列表,按周推送给自己。高金额交易即使占比小,对报表的影响也大,人工审核优先级必须排在高频交易前面。

这个三层结构下来,我目前的分类准确率稳定在 97% 左右,剩余 3% 主要来自"同一商户名出现不同消费性质"的场景,例如在加油站不仅加油还买了便利店商品,这类交易单凭商户名无法区分,只能接受模糊。

4. 报表与预算服务:从数据到可执行结论

4.1 月度支出结构与环比计算

分类做完之后,报表服务才有意义。这一层我用 Python 写了一套查询模块,输出直接是 JSON,前端可以任意消费。

月度支出结构计算的核心 SQL 是类目维度的聚合:

SELECT category, sub_category, SUM(amount_cents) AS total_cents, COUNT(*) AS tx_count FROM transactions WHERE tx_date >= :start_date AND tx_date < :end_date AND amount_cents < 0 GROUP BY category, sub_category ORDER BY total_cents ASC;

注意支出在模型里是负数,所以这里用amount_cents < 0来筛选支出,聚合时SUM得到的是负值。报表输出时要转成正的金额展示。

环比计算的关键是"月份边界"的定义。我踩过一个坑:月初 1 号凌晨的消费到底是算上个月还是这个月?按银行账单的入账日算,很多 1 号凌晨的交易其实是上月末的消费延迟入账。我最终统一采用"交易日期"作为归属月份的依据,而不是"入账日期"。这两者之间的差异,在支付宝和微信账单里尤其明显,因为它们的"交易时间"字段是下单时间,而银行流水的"记账日期"才是实际扣款时间。为了统一口径,所有数据源在解析阶段就保留原始时间字段,转换时优先使用交易时间,这个规则写死在解析器里,避免不同月份对不齐。

4.2 预算执行率的计算口径

预算模块是这套服务里最个性化、也最需要精准定义的地方。我设计了三类预算池:

  • 月度固定池:餐饮、购物、娱乐等,每月 1 日重置。
  • 滚动池:用过去 30 天作为统计窗口,适合交通、医疗这类消费不均匀的类目。
  • 年度池:保险、体检、旅行等大项,按年度总额控制,月度报表里只展示已用额度占比。

每个预算池在数据库里是这样定义的:

CREATE TABLE budget_pools ( id INTEGER PRIMARY KEY, pool_type TEXT NOT NULL, -- monthly / rolling / yearly category TEXT NOT NULL, -- 关联的一级或二级类目 amount_cents INTEGER NOT NULL, -- 预算总额 start_date TEXT, -- 仅年度池需要 end_date TEXT );

执行率的计算口径有一个容易踩的坑:是否包含退款交易。假设我这个月点外卖花了 300,但其中一笔 60 元的订单退款了,那么预算消耗应该算 240 而不是 300。我的处理方式是:在预算计算 SQL 里,退款交易(通过商户名里的"退款"关键字或单独标记的 refund 字段识别)在分类归入原类目的同时,计算预算消耗时会把它从总额中扣除。这个逻辑需要在报表层做,原因是在数据模型层面退款和原消费是两条独立记录,只有在聚合时才能正确配对。

4.3 预算超支提醒的触发逻辑

提醒功能是预算模块的"最后一公里"。如果只是月底看一下报表,预算约束的意义就大打折扣。我在服务里加了一个reminder.py脚本,每天早晨通过本地通知渠道推送一条摘要消息。

触发逻辑是:按预算池类型分别计算当前消耗比例,超过阈值(默认 70% 和 90% 两级)才推送提醒。为了避免骚扰,同一预算池在 7 天内只推送一次。滚动池的提醒逻辑特殊一点,因为它的窗口每天滑动,消耗比例天然会波动,所以阈值提高到 80% 才触发。

这里有一个我实际调整过的细节:月度固定池在月初 3 天内不触发提醒。原因是月初集中支付房租、水电等固定支出,导致餐饮额度看起来"消耗过快",但实际是固定支出占用了预算池的额度。我把固定支出单独设了一个"居住"预算池后,这个问题自然消失,餐饮池的月初误报率显著下降。

另外还要考虑"预算池未分类交易"的边界。有一些交易属于"其他"类目,不归属任何预算池,它们不会触发提醒,但会在报表里单独展示。这样做是合理的——预算控制的核心永远是可控类目,而不是那些偶发不可预测的支出。

5. 部署运营中的四个真实教训

5.1 时区与"交易日"的错位

第一个教训是时区问题,看着小,影响却很大。我的服务部署在一台 Linux 主机上,系统时区是 UTC。而账单里的交易时间都是北京时间。如果直接用系统时间去解析账单日期,晚上 10 点到凌晨 0 点之间的消费会被算到前一天。

解决方式是在所有解析器里强制指定时区:

from datetime import datetime from zoneinfo import ZoneInfo CN_TZ = ZoneInfo("Asia/Shanghai") def parse_tx_datetime(raw: str) -> str: dt = datetime.fromisoformat(raw) if dt.tzinfo is None: dt = dt.replace(tzinfo=CN_TZ) else: dt = dt.astimezone(CN_TZ) return dt.date().isoformat()

这是一个典型的"开发时环境正常、部署后发现数据全偏一天"的案例。现在我把时区转换收敛到common.py的单一函数里,任何新增数据源都必须调它,不允许在各自的解析器里另写一套日期解析逻辑。

5.2 退款与冲正交易的重复计算

第二个教训是重复计算。有一段时间我的月度总支出明显偏高,排查后发现是退款交易没处理好。

场景是这样的:在电商平台买了一件商品,付款 500 元,次日退款 500 元。在支付宝账单里,这会显示为两笔交易:一笔支出 500、一笔收入 500。如果报表只按"支出"聚合,这两笔都在交易表里,账单导入脚本也会把两笔都写进去。看起来一正一负应该抵消,但如果退款和原交易发生在不同月份——例如 12 月底下单、1 月初退款——那么 12 月支出多算 500,1 月收入也多算 500,两边的报表都不干净。

我的解法是在分类引擎里增加退款识别。退款交易会打上refund=1的标记,并且在月度报表里单独归入"退款"类目,不参与总支出的正向统计,也不参与任何预算池的消耗计算。同时,退款对应的原交易无论在哪个月入账,都保持原分类,不做重算。这个方案不是完美的——跨月退款会让退款发生月的"收入"虚高——但从个人财务管理的角度看,把退款单独呈现比混在支出里强得多,至少能一眼看到这个月有多少钱退回来了。

5.3 加密存储与密钥管理的取舍

说句实话,本地自建服务在数据安全上有一个天然优势:数据不离开自己的设备。但这也意味着一旦设备丢失,账单数据、分类规则、预算配置全都没了。我的方案是:

  • SQLite 数据库文件存储在家目录的一个加密卷里,系统层面用 LUKS 或类似工具加密。
  • 数据库文件每周末自动备份到另一块移动硬盘,备份策略是保留最近 4 份全量备份,外加每月一份归档。
  • 账单原始 CSV 文件在导入完成后按月份归档到只读目录,与数据库分开存放。

密钥管理方面,我没有引入复杂的密钥服务,而是把加密卷密码放在密码管理工具里,同时在一张离线纸上留了恢复抄写。这个取舍的逻辑是:个人项目的首要目标是防丢失,其次是防非授权访问。过度复杂的密钥体系反而会因为"忘记密码"导致数据根本解不开,那就得不偿失了。

5.4 账单导入自动化的稳定性

最后一个教训关于自动化本身。账单导入这个动作,我前后尝试过几种自动化方式,最后固定成了"每周手动下载 + 定时脚本导入"的半自动流程。

完全自动化的尝试失败在验证码上。部分银行网页登录需要短信验证或滑块验证,自动登录方案一旦触发风控,账号可能被临时冻结。为了省几分钟操作去冒账号冻结的风险不值得。

半自动流程是这样运作的:

  1. 每周日花十分钟从各平台导出最近一周的账单 CSV。
  2. 把文件放到incoming/目录。
  3. 执行./ingest.sh,脚本自动识别文件类型、解析、校验、写入数据库。
  4. 查看导入报告,处理未匹配交易和疑似重复交易。

这套流程的稳定性已经跑了一年多,唯一一次出问题是因为某个平台的导出字段顺序调整了,解析脚本读到错位的列。从那以后,我在解析器里加了表头校验,一旦发现表头与预期不符就停止导入并报警,而不是静默地把错误数据写进库。这个"fail loudly"的原则,我认为是这类个人数据管道里最重要的一条纪律。

最后分享一个我一直在用的小技巧:把所有分析查询都封装成只读视图,不允许任何脚本直接对transactions表做 update 或 delete。所有数据修正都通过审批式的修复脚本走,每条记录在修改前先记录到审计表。这样一来,即使某次分类规则调整导致历史数据被批量更新,也永远能回溯到修改前的状态。财务数据服务最需要的不是花哨,而是每一笔数字都能说清来龙去脉。这套项目从第一行代码跑到现在,我最大的体会是:个人财务工具的真正价值不在于精确到分,而在于你完全清楚每一块钱数据的前世今生。按照这套思路去搭自己的服务,你也会体会到那种"数据完全在掌控中"的踏实感。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进