ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

聊天窗口里的轻量记账系统:消息回调与自然语言解析实践

聊天窗口里的轻量记账系统:消息回调与自然语言解析实践

1. 为什么我把账本塞进了聊天窗口

做 iMoney 这个项目,最初不是为了做一个记账工具,而是为了治我自己的一个毛病:账记不下去。市面上成熟的记账应用我前后装过七八个,界面都不差,功能也齐全,但没有一个能撑过三周。后来复盘发现,问题从来不出在功能上,而是出在"打开"这两个字上——想记一笔二十块的午饭,我要解锁手机、找到图标、等它加载、点加号、选类型、输金额、选分类、点保存,七八个动作走完,那点记账的意愿早就凉了。而在同一时间,我可能正在聊天窗口里跟人聊午饭吃什么。iMoney 的核心思路就一句话:既然人已经待在聊天窗口里了,那就让账本也待在那儿。发一句"午饭35",账就记上了,不用切应用、不用点按钮。这篇文章我会把整个项目的设计取舍、消息链路、语句解析、数据模型、统计报表和踩过的坑全部摊开讲,适合想自己搭一套轻量记账系统的人,也适合单纯好奇"聊天式交互"怎么落地的人。代码部分我会给出可直接跑的核心片段,非技术读者跳过代码看思路也完全够用。

1.1 记账应用的真实流失点在哪

绝大多数记账产品把精力花在图表美观度、分类丰富度和多端同步上,这些都是"记完账之后"的价值,可用户流失发生在"记账之前"。我统计过自己三星期的失败记录,平均每天想记 4.2 笔,实际记下 1.1 笔,中途放弃的原因排前三的是:懒得打开应用(占 61%)、忘了当时花了多少(占 22%)、分类选项太多不知道选哪个(占 17%)。这三个原因指向同一个结论——记账的动作成本必须压到接近于零,而分类的决策成本必须由系统先承担。

聊天窗口恰好满足这两个条件。它是手机上打开频率最高、驻留时间最长的界面之一,输入框永远在那儿,不需要寻找;而且人本来就习惯在聊天里说"今天打车花了三十八",这句话里已经包含了金额、场景、分类线索,只是没有被结构化。把输入框当成记账入口,等于把用户已经在做的表达直接变成数据,中间不增加任何一步新动作。这是 iMoney 整个设计的出发点,也是我后来所有技术选型的判断标准:任何会增加用户动作的设计,一律砍掉。

顺带说一个反直觉的观察:聊天式记账的容错率其实比表单更高。表单里选错分类,用户往往懒得回头改;而在聊天里,用户看一眼回执就知道对不对,回一句"改成交通"就能修正,纠错成本极低。这种"低成本试错"的特性,让分类准确率的重要性反而下降了——只要系统敢于承认不确定,并给出一条最省事的纠正路径,体验就不会崩。

1.2 四条通道的横向对比

确定了"在聊天窗口里记账"这个方向,接下来是选哪条通道。我把当时能想到的四条路子都摆出来做了对比,这张表后来被我贴在了项目文档第一页,因为它决定了后面所有架构。

通道方案合规与稳定性开发成本交互能力适用场景
平台公众号消息回调官方开放接口,稳定中等,需服务器与验签用户发消息,被动回复有超时限制个人长期自用、单人或家庭
企业协作平台自建应用官方开放接口,权限清晰中等偏高,需配置应用与可见范围支持被动回复与主动推送小团队、多人共享账本
手机快捷指令配合剪贴板无平台依赖低,几乎零成本只能本机处理,无法跨设备极简派、单人快速验证
独立小程序官方接口较高,需审核与资质需要用户主动打开,跳出聊天有产品化打算的团队

我最后选的是第一条:平台公众号的消息回调。理由不复杂——它是官方开放的接口,账号主体和消息链路都合规,不用担心哪天因为用了非官方手段被限制;同时用户的心智模型最简单,就是把公众号当成一个联系人,发消息、收回执,跟聊天没有区别。企业协作平台那条路我也试过,能力更强,能主动推送账单提醒,但配置流程对个人用户来说偏重,更适合做成"家庭共享账本"这种多人场景,我把它留作了后续扩展方案。

这里必须强调一个我明确放弃的方向:任何依赖非官方手段去接管个人聊天账号的做法,我都没有采用,也不建议任何人采用。这类方案短期看着方便,但账号安全和合规风险完全不可控,一旦出问题,账目数据跟着一起没,得不偿失。记账是个长期活儿,底层的通道必须是能长期存在的。

1.3 为什么规则解析优先于模型

确定了通道,第二个分叉口是解析方式:上大模型,还是写规则。我一开始兴致勃勃地接了模型接口,结果跑了两周就改回规则为主。原因有三个,都很实际。

第一是延迟。平台的消息回调对被动回复有时间窗口,通常要求几秒内给出响应,模型接口一次调用加上网络抖动,很容易把回执拖到超时,用户看到的就是"发了消息没反应"。第二是成本的可预期性。规则解析是纯本地计算,一万条消息的边际成本是零;模型调用按次计费,长期自用虽然花不了多少,但每次都在心里过一遍账,体验很别扭。第三,也是最重要的一点:记账语句的分布极其集中。真实场景里 90% 以上的输入就是"分类词 + 金额"这两种成分的组合,长度不超过十个字。对这种高度结构化的短文本,一套写好的正则加词典,准确率不比通用模型差,速度还快两个数量级。

所以 iMoney 的解析层是"规则优先、模型兜底"的结构:绝大多数消息走规则通道,毫秒级出结果;只有规则完全无法判定意图的少数语句,才异步丢给模型做补充解析,解析结果回写进规则词典,下次同类表达就能直接命中。这套机制跑下来,规则命中率从最初的 78% 爬到了三个月后的 94%,模型调用量降到了每天个位数。

2. 整体链路:一句话怎么变成一条账目

从用户按下发送,到回执出现在聊天窗口,中间其实经过了好几个独立的处理环节。我一开始把这些逻辑全塞在一个函数里,结果改一处崩三处,后来拆成了五个阶段,每个阶段职责单一、可单独测试,维护成本立刻降下来。这一章讲整体骨架,下一章再钻进最难的解析部分。

2.1 五个处理阶段的划分

消息进来之后,iMoney 的处理链路是这样的:接收与验签 → 归一化 → 意图识别 → 实体抽取 → 校验入库与回执。每个阶段都有明确的输入输出,我用一个简单的数据结构在阶段之间传递,方便断点调试。

接收与验签阶段负责确认消息确实来自平台,同时提取出用户标识和消息唯一编号。这个唯一编号非常关键,我后面会专门讲——平台在网络抖动时会重发同一条消息,如果不做去重,用户发一次"午饭35",账上会多出三笔。归一化阶段把所有"长得不一样但意思一样"的表达统一掉:全角数字转半角、中文数字转阿拉伯数字、"块""元""块钱"统一成"元"、"中午""午饭""中饭"映射成同一个时间或分类线索。这一步做完,后面的规则就能写得很干净。

意图识别决定这条消息是记账、查询、修改、删除还是求助。我用的是关键词加权打分,而不是严格的优先级匹配,因为真实语句经常复合,比如"昨天打车35这个月花了多少",前半句是记账、后半句是查询。打分机制允许一条消息同时触发多个意图,按分数从高到低依次处理,比硬性分支灵活得多。实体抽取是核心,负责把金额、分类、账户、时间、备注这几类信息从文本里挖出来,这部分在第三章展开。最后是校验入库:金额必须大于零、时间不能在未来、分类必须存在于字典里,校验通过就写库,然后拼出一条人类可读的回执发回去。

提示:把"回执"当成产品的一等公民来设计,而不是顺手拼一句话。回执是用户唯一能看到的系统反馈,它决定了用户信不信这个账本。我早期回执只写"已记录",用户根本不知道系统把分类猜成了什么,纠错无从谈起,返工率极高。

2.2 数据表设计:金额为什么必须存整数

账目表是整个系统里最重要的表,我改了四版才定稿。核心字段包括:用户标识、收支方向、金额、币种、分类、账户、发生时间、备注、原始消息文本、消息唯一编号、创建时间。这里面有两个设计决策值得单独说。

金额一律用"分"为单位存整数,绝不用浮点数。这不是洁癖,是必须。浮点数在二进制下无法精确表示 0.1 这类小数,累加几十笔之后就会出现"账上多了 0.0000000001 元"的情况,虽然金额上可以忽略,但月度报表一出现小数尾巴,用户立刻会怀疑账本算错了,信任一旦裂开就很难补。用整数分存储,展示时除以 100 并保留两位小数,加减运算全程精确,这个坑我在第一版就踩了,第二版全部迁移。

每条账目都保留原始消息文本和消息唯一编号。保留原文是为了可回溯——用户说"我上个月明明记了那笔",我能直接把他的原话调出来,一眼看清是系统解析错了还是他记错了,排查效率天差地别。消息唯一编号则是幂等的锚点,写入前先查这个编号是否存在,存在就直接返回上次的回执,绝不重复记账。这个字段还顺带解决了另一个问题:用户误删之后想恢复,只要编号在,账目就能重建。

字段类型说明设计意图
user_id字符串用户唯一标识支持多人共用一套服务
direction枚举支出 / 收入 / 转账转账不计入收支统计
amount_fen整数金额,单位分避免浮点误差
category_id整数分类外键分类可配置、可改名
account_id整数账户外键区分现金、银行卡等
occurred_at时间戳发生时间支持补记历史账
note字符串备注便于检索
raw_text字符串原始消息回溯与纠错依据
msg_id字符串消息唯一编号幂等去重

2.3 分类与账户两套体系怎么搭

分类体系的设计有个误区,很多人一上来就抄应用商店里那些几十项的大类目,结果用户在聊天里根本说不出那么细的词。iMoney 的分类我做得很克制,一级分类控制在九个以内:餐饮、交通、购物、居住、医疗、娱乐、人情、学习、其他。每一项再挂若干别名,别名才是真正干活的——"奶茶""外卖""早饭""食堂"全都挂在餐饮下面,"打车""地铁""加油""高铁"挂在交通下面。

这套别名机制带来一个额外好处:它可以自我生长。每次用户用了一个新词,系统没认出来只能归到"其他",我就会在回执里问一句"这个词以后算餐饮吗",用户回个"是",这个词就永久进了词典。三个月下来,餐饮类下面挂了一百多个别名,覆盖了绝大多数日常表达。这比任何预置词库都管用,因为它是从真实用户语言里长出来的。

账户体系则要简单得多,但有个细节必须处理好:默认账户要和默认支付方式对齐。用户发"午饭35",他心里默认的付款方式是他最常用的那个,可能是某个电子钱包,也可能是现金。我在配置里让用户设一个默认账户,绝大多数账目直接落到默认账户上,只有在语句里明确出现"用某卡付"这类信息时才切换。至于转账,我单独设了一个方向类型,从 A 账户转到 B 账户不算支出也不算收入,否则信用卡还款会被记成两笔消费,月度支出直接翻倍——这个错误我在自己身上抓到过,那一个月的报表难看得离谱。

3. 自然语言解析的硬骨头

讲完骨架,进入最费脑子的部分。解析层的目标很单纯:从一句可能极其随便的中文里,稳定地抠出金额、分类、时间、账户四个要素。难的不是抠得出来,而是抠得稳——在用户可以胡乱表达的前提下,不出离谱的错误。

3.1 金额抽取:那些吃掉数字的陷阱

金额识别看着简单,实际是整个解析层最容易翻车的地方。我最早的版本只用一条正则:匹配连续数字,取第一个。上线第一天就出了三个错:用户发"2个人吃饭一共86",系统记了 2 元;用户发"打车35,午饭28",系统只记了 35;用户发"一百二",系统完全没匹配上。

针对这三类问题,我做了三处改造。第一,排除量词噪声。数字后面紧跟"个""人""份""杯""斤""次""天"这类量词时,大概率不是金额,跳过它继续往后找。同时给"一共""总共""合计""花了"这类引导词加权,它们后面出现的数字优先级最高。第二,支持中文数字。写了一个小的转换函数,处理"一百二""三十五""两块五""六十八块"这些口语形式,其中"两块五"这种省略表达要特殊处理——小数点后的位是隐含的,得按语感补成 2.5。第三,支持单位后缀。"35""35元""35块""35块大洋"都要能认,同时把"1.2k""两千"这类也覆盖进来。

还有一个隐蔽的坑:负数与退款。用户发"退回来30",这是在冲销一笔支出。我的处理方式是允许金额带方向前缀,识别到"退""返""报销"这类词,就把这笔记成同分类的负数支出,而不是记成一笔收入。为什么这么设计?因为如果记成收入,月度报表里的"餐饮支出"和"餐饮收入"会同时虚高,看图表的人得自己做减法,很别扭;记成负数支出,分类小计自然抵扣,报表立刻就干净了。

3.2 分类推断的三层兜底策略

分类是记账里最费决策的一步,所以我把推断做成了三层,逐层降级,保证任何输入都能有一个结果。

第一层,显式标记。用户在消息里直接带上分类标记,比如"#餐饮 35"或者"餐饮 35",系统优先采信。这一层准确率接近百分之百,但它要求用户有使用习惯,实际占比不高。

第二层,别名词典匹配。这是主力层。把一千多条日常词汇按分类挂好,扫描消息,命中即定类。分词的时候要注意最长匹配优先,否则"下午茶"可能先命中"下午"再被当时间处理,"星巴克"可能被"巴克"这种子串误伤。同一个词挂多个分类时要按权重裁决,比如"水果"通常算餐饮,但如果出现在"水果店买了两盆绿植"这种句子里就不好判断了,这时候降级到第三层。

第三层,历史行为推断。翻这个用户最近三个月的记录,看同样的备注词之前都归到了哪一类,取众数。这层很好用,因为每个人的用语习惯是稳定的,一个人说"小卖部",八成一直指的是同一类开销。如果这层也没数据,就落到"其他",并在回执里带一句询问,让用户一句话把词教会系统。

注意:不要让分类推断"装作很确定"。我早期版本无论多没把握都硬给一个分类,用户看到回执以为系统认对了,实际上错得离谱,等到月底看报表才发现一堆白条。后来改成本着"不确定就说不确定"的原则,置信度低时回执里会明确列出备选分类和序号,用户回一个数字就能改。这个改动让分类最终准确率涨了十几个百分点,因为纠错变得极其便宜。

3.3 时间与默认账户的补齐逻辑

时间是账目里最容易被忽略、事后又最需要的维度。用户发"午饭35",他其实没说是哪天,默认就是当下。但如果他晚上补记早上的账,或者隔了三天才想起来,就得靠语句里的时间线索。我支持三类表达:相对日期(今天、昨天、前天、大前天)、星期表达(周一、上周五)、绝对日期(3月2日、5号)。解析顺序是先匹配绝对日期,再匹配星期,最后匹配相对词,因为"3月2日"里也含数字,容易被相对词规则抢先。

这里有个实际场景值得提醒:补记时要按用户说的时间算,不是按当前时间算。我第一版就是直接用了当前时间戳,结果用户半夜补记早饭,账目全落在凌晨,后来统计"早餐消费分布"时图表完全乱掉。改成按语句时间落库之后,简单统计就能看出真实的消费时段。至于跨天的归属,我按自然日切分,凌晨零点半的宵夜算前一天还是当天,这个问题没有标准答案,我选的是按实际发生时间算,同时在报表里提供一个可配置的"日切时间"参数。

默认账户的补齐就简单一些:配置里有默认账户就直接用,语句里出现账户词(比如某个卡名、现金、钱包)就覆盖。唯一要小心的是同义词映射,用户可能说"某卡""那张卡""信用卡",这些都要归到同一个账户上,否则账户余额统计会碎成好几份。

3.4 一条消息多笔账的拆分规则

真实使用中,用户很喜欢一次性把今天的账都发过来:"早上地铁5块,中午外卖28,下午奶茶18,晚上打车35。"这种消息必须支持批量,否则用户会在重复发送中失去耐心。

拆分策略是分层级的。先按换行拆,换行是最强的分隔信号;再按中文逗号、英文逗号、分号、顿号拆;最后处理"和""还有""加上"这类连接词。拆完之后,每个片段独立走一遍金额抽取和分类推断,最终生成多笔账目。难点在于拆完之后的片段可能不完整,比如"地铁5块,外卖28"拆出来的第二段缺少主语,但金额在,分类能靠词典命中,就没问题;而"35和28"这种拆出来全是数字、没有分类线索的,就归到"其他"并在回执里逐条列出让用户确认。

回执的呈现也要跟着改。多条账目不能刷屏,我做成一行简短汇总:"已记 4 笔,共 86.00 元",下面用紧凑的列表展示每条的分类和金额,最后附上今日累计。用户扫一眼就知道有没有记错,比逐条弹消息清爽得多。

3.5 歧义与纠错:让用户一句话改账

账记错了要能改,而且要改得快。我设计的纠错入口全部是自然语言,不设菜单:删掉上一条可以说"删掉刚才那笔"或者"撤销",改分类可以说"把上一笔改成交通",改金额可以说"金额改成30"。系统内部维护一个"最近一笔账"的指针,用户的修改指令默认作用于它,不需要指定编号。

这里的技术要点是修改指令的意图要能和普通记账区分开。"改成交通"里有"交通",如果先走记账流程就会被当成一笔新的交通支出。我的做法是在意图识别阶段给动词加权,"改成""修改""调整为""删掉""撤销"这些词出现时,意图明确指向修改类操作,直接走修改分支,跳过记账解析。这个优先级必须在解析之前定死,不能等解析完了再判断。

另外一个体验细节:修改之后,回执要把"改前"和"改后"都写出来,比如"已将 餐饮 28.00 改为 交通 28.00"。用户需要确认系统改的是他说的那一笔,尤其是连续发了好几条消息的时候。这个细节看起来很啰嗦,但实测能大幅降低"我明明改了怎么还是错的"这类投诉。

4. 上手实操:从零把服务跑通

前面讲的是设计,这一章讲怎么把它跑起来。我会给出项目骨架、核心配置和解析代码,照着抄基本能跑通一个可用版本。技术栈我选的是 Python 加轻量 Web 框架,数据库用文件型数据库起步,单机足够,后期要扩展再换。

4.1 环境与项目骨架

目录结构我按职责划分,不要图省事全堆在一个文件里:

imoney/ ├── app.py # 服务入口,接收回调 ├── config.py # 配置:密钥、默认账户、日切时间 ├── core/ │ ├── normalize.py # 文本归一化 │ ├── intent.py # 意图识别 │ ├── amount.py # 金额抽取 │ ├── category.py # 分类推断 │ └── datetime_parse.py # 时间抽取 ├── store/ │ ├── schema.sql # 建表语句 │ └── repo.py # 数据访问 ├── dict/ │ ├── category_alias.txt # 分类别名,一行一条 │ └── place_alias.txt # 地点别名 └── reply/ └── formatter.py # 回执拼装

依赖很少,主要是 Web 框架、一个 HTTP 客户端和一个分词或正则工具,没必要引入重型框架。数据库我选文件型数据库,建表语句里金额字段用整数类型,时间字段存时间戳,另外单独加一个唯一索引建在消息编号上,靠数据库本身来兜住重复写入。

CREATE TABLE txn ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, direction TEXT NOT NULL DEFAULT 'expense', amount_fen INTEGER NOT NULL, category_id INTEGER NOT NULL DEFAULT 0, account_id INTEGER NOT NULL DEFAULT 1, occurred_at INTEGER NOT NULL, note TEXT DEFAULT '', raw_text TEXT DEFAULT '', msg_id TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL ); CREATE INDEX idx_user_time ON txn(user_id, occurred_at);

那个UNIQUE约束是整个幂等方案的最后一道防线。哪怕前面的查重逻辑因为并发出了纰漏,数据库也会直接拒绝重复插入,捕获这个异常、返回上次的回执就行,用户完全无感。

4.2 通道接入与消息收发

消息收发这块,不同平台的细节不一样,但套路是统一的:配置好回调地址,平台在用户发消息时向你的地址推送一段结构化数据,你在限定时间内返回一段回复内容。要做的事情有四件——验证请求确实来自平台(通常靠签名校验)、解析出用户和消息内容、调用业务逻辑、拼装回复。

签名校验这一步千万不能省。我见过有人为了调试方便直接把校验关掉,上线之后被各种乱七八糟的请求打得日志爆掉。校验逻辑本身很简单,按平台规定的算法把参数排序拼接、加密,跟请求里带的签名对比,不一致就直接拒绝,连业务代码都不进。

超时控制是另一个关键。平台对被动回复有时间窗口,所以业务逻辑必须足够快。我的做法是把"解析 + 入库"控制在几十毫秒内完成,回执只拼简短文本,绝不在这条链路上做统计查询。如果用户发的是"这个月花了多少"这种要跑聚合的查询,我会先把统计结果缓存起来,月度汇总每天凌晨算一次存好,查询时直接读缓存,响应时间从几百毫秒降到个位数。

import hashlib, time from fastapi import FastAPI, Request, Response app = FastAPI() def check_signature(params: dict, token: str) -> bool: raw = "".join(sorted(params.get("sign_fields", "").split(","))) expect = hashlib.sha256((raw + token).encode()).hexdigest() return expect == params.get("sign", "") @app.post("/callback") async def callback(request: Request): params = dict(await request.json()) if not check_signature(params, TOKEN): return Response(status_code=403) text = (params.get("content") or "").strip() user = params.get("user_id", "") reply = handle_message(user, text, params.get("msg_id", "")) return {"reply": reply}

handle_message里面就是我们的业务主流程,接收用户输入、完成解析、写入数据库,再返回回执文本。这个函数我会在后面拆开细讲。要注意的是,所有异常都必须在这里被吞掉并转成一句人话回给用户,比如"这条没看懂,可以试试'午饭35'",绝不能让异常抛到框架层,那样用户那边就是彻底的静默,体验极差。

4.3 解析核心代码逐段拆解

先看归一化。这一步做得好,后面所有规则都能写得清爽:

import re CN_NUM = {"零":0,"一":1,"二":2,"两":2,"三":3,"四":4,"五":5, "六":6,"七":7,"八":8,"九":9,"十":10,"百":100,"千":1000} def normalize(text: str) -> str: # 全角转半角 text = "".join(chr(ord(c) - 0xFEE0) if 0xFF01 <= ord(c) <= 0xFF5E else c for c in text) text = text.replace(":", ":").replace(",", ",").replace("。", ".") # 常见单位统一 for w in ["块钱", "块大洋", "人民币", "rmb", "RMB"]: text = text.replace(w, "元") return text.strip()

中文数字转阿拉伯数字,我用的是逐位累加的思路,能覆盖"一百二""两千五""三十五"这些日常说法,但对"三万五千六百"这种复杂组合会出错,所以加了一个兜底:转换结果存疑时保留原串,交给金额抽取阶段按"最大的数字"处理。不要指望一个函数解决所有情况,够用就行。

def cn2num(s: str) -> float: total, section, num = 0, 0, 0 for ch in s: if ch in CN_NUM: v = CN_NUM[ch] if v >= 10: section = (section or 1) * v total += section section = 0 else: num = v section += num else: return None return float(total + section) if total or section else None

金额抽取是重头戏。核心是"排除量词噪声 + 优先引导词后的数字",我写成了一个打分函数,把候选数字全找出来,各自打分,取最高分:

QTY = ("个","人","份","杯","斤","次","天","件","瓶","张","只","位") LEAD = ("一共","总共","合计","花了","共计","付了") def extract_amount(text: str): best, best_score = None, -1 for m in re.finditer(r"\d+(?:\.\d{1,2})?|[零一二两三四五六七八九十百千]+", text): val = float(m.group()) if re.match(r"^\d", m.group()) else cn2num(m.group()) if val is None or val <= 0: continue score = 0 before = text[max(0, m.start()-4):m.start()] after = text[m.end():m.end()+2] if any(w in before for w in LEAD): score += 5 if after.startswith("元"): score += 4 if after[:1] in QTY: score -= 6 if m.group().endswith("元"): score += 2 if score > best_score: best, best_score = val, score return best

实测下来,这套打分能处理绝大多数日常表达。特别提一句这个"量词减分"设计——它解决的是"2个人吃饭86"这种情况,2 后面跟了"个",直接扣分,86 没有后缀也没有引导词,分数更高,于是取 86,正确。

分类推断走的是词典最长匹配,词典放在文本文件里,一行一条,启动时加载进内存,用前缀树或者简单的按长度倒序匹配都能满足需求。我图省事用的是按长度倒序的列表匹配,一千多条词,单次匹配耗时可以忽略。

4.4 部署、超时与幂等

部署这块没有太多花活,一台低配服务器足够,用进程管理工具守护起来就行。真正需要花心思的是三件事:幂等、超时、可观测。

幂等靠两层保障,前面说过:先用消息编号查一次库,命中就返回上次的回执;数据库再加唯一约束兜底。这里有个细节,回执也要存下来,按消息编号缓存一份,这样重复请求返回的是完全相同的文本,用户不会看到两条内容不一样的回复。

超时控制的关键是不要在回调链路上做慢操作。我把链路里的操作分成"必须同步做"和"可以异步做"两类:解析、入库、拼回执属于前者;统计聚合、报表生成、模型兜底解析属于后者,全部丢到后台队列里慢慢跑。这条界限划清楚之后,响应时间稳定在百毫秒以内,再没出现过超时。

可观测性这块,我一开始偷懒只打了错误日志,结果用户反馈"我的账不对"时完全无从下手。后来加了三个东西:每条消息的原始文本、解析出的结构化结果、最终入库的记录,三者在日志里串成一个编号。现在排查问题只需要用户说一句大概的时间,我把那段时间的日志一拉,一眼就能看出是解析错了还是入库错了。这个投入产出比极高,强烈建议一开始就做。

5. 账目统计与自动报表

记账本身只是手段,看账才是目的。聊天窗口有个天然优势:它可以把报表直接推到用户眼前,而不需要用户主动去打开某个页面。iMoney 的统计分了两个层次,被动查询和主动推送。

5.1 常用统计SQL

统计的核心诉求就那么几个:某个时间段花了多少、分类占比、账户余额、环比变化。这些用最基础的聚合查询就能搞定,不需要引入任何分析型数据库。

-- 本月各分类支出 SELECT category_id, SUM(amount_fen) AS total_fen, COUNT(*) AS cnt FROM txn WHERE user_id = :uid AND direction = 'expense' AND occurred_at >= :month_start AND occurred_at < :month_end GROUP BY category_id ORDER BY total_fen DESC; -- 近7天日均 SELECT SUM(amount_fen) / 7.0 FROM txn WHERE user_id = :uid AND direction = 'expense' AND occurred_at >= :seven_days_ago;

有个细节值得说:转账和退款必须从支出统计里剔除或冲抵。转账方向是 transfer,本来就不进 expense 统计,所以直接过滤掉就行。退款我记的是负数支出,聚合的时候自然就抵扣了,不需要额外处理。这两点设计在前面就定好了,后面统计代码就非常简单。

日均这个指标要小心除零和口径。用户可能只记了三天账,就除以 7 算日均,数字会低得离谱,用户看了会误解。我的处理方式是按"有记账的天数"来除,并在回执里注明"基于 N 天记录",把口径说清楚。

5.2 主动推送与月度结算

月度结算是这个项目里我自己最喜欢的功能。每月一号早上推一条上月汇总过来:总支出、总收入、结余、支出最高的三个分类、日均、以及一句"本月相比上月变化多少"。这条推送不需要用户做任何操作,纯粹是白给的价值,但它极大提升了用户对账本的信任感——因为用户能感觉到账本在主动工作,而不是一个需要他去填的表格。

技术上,主动推送比被动回复简单,因为它不受时间窗口限制,可以在后台慢慢算。我的实现是每天凌晨跑一个定时任务,检查当天是不是月初,是的话就算上月汇总、存库、调推送接口发出去。为了避免推送失败导致漏发,我在任务表里记了推送状态,失败了重试三次,仍然失败就留在库里,下次用户主动查询时把这份汇总附在回执末尾。

除了月度汇总,还做了一个轻量的异常提醒:如果某笔支出超过了过去三个月同类支出的三倍,就在回执里加一句"这笔比平时高不少,是记错了吗"。这个提醒救过我好几次,有一回我把 35 打成了 350,全靠它提醒才发现。

6. 常见问题与排查速查

跑了几个月,积累了一堆问题。这一章我把最常见的问题整理成表,再把几条不那么容易想到的经验单独拿出来说。

6.1 线上问题速查表

现象最可能的原因排查动作处理办法
发了消息完全没回复签名校验失败或服务挂了看入口日志有无请求记录核对密钥配置,检查进程状态
同一笔账重复记了多次幂等逻辑失效查 msg_id 是否相同补唯一索引,检查查重顺序
金额明显偏小数字被量词规则误判拿原文跑一遍抽取函数调整量词表和引导词权重
分类总落到"其他"别名词典没覆盖看用户原始用词把该词加入别名并询问确认
支出统计翻倍账户间转账被记成支出查 direction 字段改为 transfer 方向
报表月份归错跨月时间按创建时间算比对 occurred_at改用发生时间归属
查询响应很慢回调链路里跑了聚合看响应耗时改为读缓存,聚合异步算
用户说改了但还是错的最近一笔指针错位查指令指向的账目 id回执里写出改前改后

6.2 几条踩坑经验

第一条,别跟用户的中文较劲。我一开始试图把所有表达都规范到位,写了几十条归一化规则,结果用户总有新说法。后来想通了,解析层的目标不是"理解一切",而是"在合理的表达范围内稳定工作,超出范围就老实说看不懂"。明确回复"这条没看懂,可以试试'午饭35'",比胡乱猜一个结果要好得多。用户对"系统说没看懂"的容忍度远高于"系统记错了"。

第二条,回执要能一眼验证。回执里必须包含三样东西:记成了什么分类、多少钱、发生在什么时候。缺任何一样,用户就得猜。我早期回执只写金额,结果用户根本不知道系统把"奶茶"归到了娱乐还是餐饮,等到月底才发现分类全乱了。

第三条,批量消息的分隔符比你想象的复杂。除了标点,用户还会用空格、箭头、换行、甚至"然后"来分隔。我的分隔符列表最后扩展到了十几种,还是会有漏网。应对办法是拆分之后校验每个片段是否有意义,没金额的片段直接丢弃,有金额没分类的归"其他",宁可拆得碎一点也不要把两笔账粘成一笔。

第四条,中文数字的坑比英文大得多。"两块五"是 2.5,"一百二"常指 120 而不是 102,"二百五"在某些语境下是骂人,别真的去转换成 250。这类特殊表达我的处理是白名单排除,宁可不认也不乱认。金额识别错一次,用户对这个账本的信任就掉一截,这个代价太高。

第五条,语音消息一定要做。我本来觉得打字已经够快了,实测下来用户在走路、做饭的时候更愿意直接发语音。平台会把语音转成文字再推送,我们拿到的其实已经是文本,只需要在回执里注明"来自语音",并在解析失败时提示用户改用文字,避免转写错误带来的误账。

7. 隐私与边界:记账数据有多敏感

最后聊聊数据这个事,因为它决定了这个项目能不能长期做下去。账目是极私密的数据,从消费记录能推出来的东西太多了——收入水平、生活习惯、健康状况、社交范围。用户愿意把账记在你这儿,本质上是交了一份信任,这份信任必须靠架构来守,而不是靠一句"我们很重视隐私"。

我的处理原则是最小必要 + 本地优先。服务端只存记账必需的那几个字段,绝不收集聊天内容里的其他信息;原始消息文本我保留,但只保留能触发记账的那些片段,其它上下文一律不落库。如果用户想更彻底,可以把整套服务部署在自己家的设备上,数据完全出不了门,代价是要自己承担运维。

数据传输用加密通道,这一点没有商量余地。数据库里的敏感字段我做了一层简单加密,密钥放配置文件里,虽然挡不住有心人,但能挡住随手翻数据库的情况。另外我还加了数据自毁开关:用户可以一句话导出全部账目,也可以一句话清空,清空之后连索引一起删,不留残余。

注意:任何涉及账户余额、卡号、身份信息的表述,一律在解析阶段就被过滤掉,绝不入库。用户偶尔会在记账语句里带上卡号后四位,我的规则是识别到连续多位数字且不在金额位置的,直接替换成星号,同时回执里提示"已忽略疑似敏感数字"。

做到这些,这个项目对我来说才算完整。一个记账工具的价值不在于功能多,而在于它能让人愿意用三年五年。数据安全是"愿意长期用"的前提,功能反而是其次的。

我自己用下来最大的体会是:记账这件事的成败,九成在入口,一成在报表。需求分析、技术选型、解析算法、数据模型、统计推送,这些工作全部围绕一件事服务——把用户想说那句话的成本压到最低,然后在他想看一眼的时候,把整理好的结果递到眼前。iMoney 到现在跑了小半年,我的实际记账率从每天一成多涨到了接近九成,最大的变化不是多了多少图表,而是我终于不再需要"记得去记账"了。后面我打算做的扩展是家庭共享账本,让同一个聊天窗口里能区分谁记的账、合并看总账,这块的技术难点不在解析,而在账户归属和统计口径,等跑通了我再单独写一篇。

RELATED READING

延伸阅读

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