资讯动态

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

发布时间:2026/10/29 12:16:41 来源:尧图企业网站定制
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。第三支持单位后缀。3535元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_code403) 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 到现在跑了小半年我的实际记账率从每天一成多涨到了接近九成最大的变化不是多了多少图表而是我终于不再需要记得去记账了。后面我打算做的扩展是家庭共享账本让同一个聊天窗口里能区分谁记的账、合并看总账这块的技术难点不在解析而在账户归属和统计口径等跑通了我再单独写一篇。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑