ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

聊天记账助手iMoney:自然语言解析与消息幂等工程实践

聊天记账助手iMoney:自然语言解析与消息幂等工程实践

记账这件事我前前后后折腾过七八个 App,坚持最久的一个用了 41 天,最后死于“打开 App 要 15 秒”这件小事上。后来我把记账入口挪到了微信聊天窗口,发一句“午饭 28”就完成入账,iMoney 这个项目就是在这个背景下长出来的。它本质上是一个聊天记账助手:把日常最顺手的沟通工具当成输入法,把自然语言解析成结构化账目,再反哺成月度报表。整套东西跑在自己的一台小服务器上,不依赖任何第三方记账服务,数据完全自主。这篇文章适合两类人看——一类是想给自己搭个私人记账工具、有一定后端基础的开发者;另一类是产品同学,想搞清楚“聊天式交互”到底怎么落到一个具体工具上。我会把架构取舍、解析引擎的实现细节、部署过程、以及那些只有真正跑起来才会暴露的坑,一条一条摊开讲,代码和配置都能直接拿去改。

1. 为什么把记账入口塞进聊天窗口

1.1 记账工具真正的死穴是打开成本

绝大多数记账产品死掉的原因不是功能不够,而是录入摩擦。一个典型记账 App 的一次录入路径是:解锁手机、找到 App、点加号、输入金额、选分类、选账户、选时间、点保存,乐观估计 6 到 8 次点击。我按每天 6 笔消费算过一笔账:单笔 15 秒,一天就是 90 秒,一个月是 45 分钟——单看时间成本其实不高,真正致命的是决策疲劳。晚上十点你只想睡觉,但还有三笔账没记,大脑会自动把它归类成“明天再说”,然后就没有明天了。

还有个隐性成本是分类犹豫。你站在便利店门口,手里拿着一瓶水和一包纸巾,脑子要想“这算日用还是食品”。这种犹豫每发生一次,就消耗一点继续记账的意愿。因此一个能活下来的记账工具,核心指标不是功能覆盖度,而是从产生记账念头到数据落库的路径长度。

1.2 iMoney 的产品形态:一句话入账

iMoney 的设计目标只有一个:把单笔记账压缩到一次输入加一次发送。用户发出的消息长这样:

午饭 28 打车 32.5 加班 昨天 超市 156 发工资 12000 + 星巴克 35

服务端解析后回一条确认回执:“午饭 ¥28.00 计入餐饮,今天 12:31”。用户看到回执就闭眼确认,错了再发一句“改 30”。查询同样走聊天窗口:“这个月吃饭多少”、“上周打车”,机器人回一段汇总文本。整套交互里没有任何表单、没有任何下拉选择框,分类由引擎猜,猜不准才反问。

这个形态解决的是触发场景问题:人产生“我花了钱”这个念头的时刻,八成手里正拿着手机,而手机大概率停在聊天界面。让入口出现在用户已经在的地方,比教育用户“你要记得打开我的 App”要现实得多。

1.3 三条技术路线的取舍

动手之前我把能走的路捋了一遍,最后按“合规性、稳定性、维护成本”三个维度打分,结论如下:

路线接入方式稳定性合规与维护风险结论
公众号消息回调官方开放的消息接收与被动回复能力高低,接口有明确文档与频率限制主选方案
企业微信自建应用官方应用消息接口,需验签解密高低,适合团队内部使用备选方案
本地桌面端读剪贴板监听系统剪贴板,识别特定前缀中低,但只能在电脑前用辅助方案
个人号自动化非官方途径极低高,账号风险与协议变动风险都不可控明确放弃

我最后落的是公众号消息回调 + 企业微信应用双通道,个人号那条路从一开始就没考虑。原因很直接:个人号自动化依赖非公开接口,一次客户端更新就可能全线瘫痪,而且这类做法本身站不住脚。用官方开放的被动回复能力,虽然有条数限制和 5 秒响应约束,但这些都是明面上的规则,可以靠工程手段绕开——比如接入层收到消息立刻返回成功,真正的解析和落库丢到异步队列里做。

这里补一句很多人忽略的点:公众号被动回复要求服务端在 5 秒内响应,超时平台会重试,重试就意味着同一条消息可能进来两次。这个约束直接决定了后面第 2 章的幂等设计,不是可选项。

2. 整体架构与消息链路拆解

2.1 三段式分层:接入层、解析层、账本层

我把系统切成三层,切分的依据是变化频率。接入层对接平台协议,平台改字段我就改这里;解析层处理自然语言,词库和规则天天在变;账本层管数据一致性,最好一辈子别动。三层之间靠消息队列解耦,任何一层挂了都不至于把数据写坏。

接入层只做四件事:验签、解密、去重、立刻 ACK。它不做任何业务判断,甚至连“这条消息是不是记账”都不判断。这样设计的好处是响应时间可以压到 50 毫秒以内,远低于 5 秒红线,平台侧永远不会因为业务逻辑慢而重试。

解析层是纯函数式的:输入一段文本加用户上下文,输出一个结构化的解析结果对象,不碰数据库。纯函数意味着可以拿几百条历史消息做离线回归测试,改一条规则跑一遍测试集,误判率涨没涨一目了然。这一点在后面调优阶段救了我很多次。

账本层负责事务、余额、报表聚合。记账写库必须在一个事务里完成“插交易 + 更新账户余额 + 写审计日志”三件事,中途失败全部回滚。

# 接入层伪代码,重点是"快" async def handle_callback(request): raw = await request.body() if not verify_signature(request.query_params, raw): return PlainTextResponse("bad signature", status_code=403) payload = decrypt(raw, encoding_aes_key) msg_id = payload["MsgId"] if not redis.set(f"imoney:msg:{msg_id}", 1, nx=True, ex=900): return PlainTextResponse("success") # 重复消息,直接吞掉 await queue.enqueue("parse", payload) # 丢给异步消费者 return PlainTextResponse("success") # 5 秒内必须返回

2.2 消息幂等:两道锁比一道锁靠谱

只靠 Redis 的 SETNX 是不够的。我遇到过一种情况:Redis 重启丢了一次键,同一条消息被消费两遍,账上多了一笔 28 块的午饭。后来加了数据库层的唯一索引做兜底——(uid, raw_msg_id)建唯一约束,重复插入直接抛IntegrityError,捕获后静默丢弃。Redis 挡掉 99% 的重复,数据库唯一索引挡住剩下 1%,两层都便宜,没理由只做一层。

还有一个容易被忽略的细节:用户级串行。同一个用户的两条消息如果并发处理,可能一条是“午饭 28”,下一条是“改 30”,乱序执行会把金额改错。我的做法是按 uid 做哈希分片,同一个用户的消息固定落到同一个队列分区,单分区单消费者,天然串行。这个设计不增加任何复杂度,但解决了一整类竞态问题。

2.3 数据模型:金额一律用整数分存储

数据模型上我犯过一个新手错误:最早用FLOAT存金额,跑到第三个月做汇总时发现餐饮类总额是1243.3000000000002。浮点数在十进制小数上的精度问题,是记账系统里最不该犯的错。改法很简单,全部换成整数分:28 块存 2800,32.5 存 3250,展示的时候除以 100。涉及除法的地方一律用Decimal加ROUND_HALF_UP。

核心表就五张,故意保持精简:

CREATE TABLE t_user ( id BIGSERIAL PRIMARY KEY, open_id VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(64), default_currency CHAR(3) NOT NULL DEFAULT 'CNY', tz VARCHAR(32) NOT NULL DEFAULT 'Asia/Shanghai', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE t_category ( id SERIAL PRIMARY KEY, uid BIGINT REFERENCES t_user(id), name VARCHAR(32) NOT NULL, keywords TEXT[] NOT NULL DEFAULT '{}', sort_no INT NOT NULL DEFAULT 0 ); CREATE TABLE t_txn ( id BIGSERIAL PRIMARY KEY, uid BIGINT NOT NULL REFERENCES t_user(id), amount_cents BIGINT NOT NULL CHECK (amount_cents <> 0), direction SMALLINT NOT NULL DEFAULT -1, -- -1 支出 / 1 收入 category_id INT NOT NULL REFERENCES t_category(id), occurred_at TIMESTAMPTZ NOT NULL, note TEXT, raw_msg_id VARCHAR(64) NOT NULL, source SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX uk_txn_msg ON t_txn(uid, raw_msg_id); CREATE INDEX idx_txn_uid_time ON t_txn(uid, occurred_at DESC); CREATE INDEX idx_txn_uid_cat ON t_txn(uid, category_id, occurred_at DESC);

occurred_at用TIMESTAMPTZ而不是TIMESTAMP,因为我吃过时区的亏:服务器跑在 UTC,用户在 UTC+8,晚上 11 点记的账第二天早上查“今天花了多少”查不到。后来所有时间统一带时区入库,查询时按用户 tz 换算,才彻底消停。

3. 核心难点:把一句人话解析成一条账

3.1 先规则后模型,别一上来就上大模型

解析这块我一开始想得过于复杂,甚至考虑过接一个通用大模型做意图识别。冷静下来算了一笔账:记账场景的文本极短(平均 6 到 10 个字),词汇高度收敛(金额、类别词、时间词、备注),而且必须可解释——用户问“为什么把我这笔算成交通”,我得说得出是哪条规则命中的。通用模型在这三点上都不占优,还带来延迟和成本。

所以最终方案是规则引擎为主,模型兜底为辅。规则引擎由四部分组成:金额抽取、类别匹配、时间解析、备注清洗。只有当规则给不出类别、置信度低于阈值时,才把这条文本丢进一个小的文本分类器(离线用历史数据训的轻量模型),再不行就反问用户。上线三个月后统计,规则命中率稳定在 93% 左右,模型兜底占 5%,反问占 2%。这个结构最大的好处是每次误判都能定位到具体规则,改一次就少一类错。

3.2 金额抽取:四种写法,一个都别漏

金额是账目里唯一不可缺的字段,也是最容易漏的。我总结下来用户至少会用四种写法:

  • 阿拉伯数字:28、32.5、1288
  • 带单位:28块、35元、2毛
  • 简写倍数:1.5k、3w
  • 中文数字:三十五块五、两百三、一千二

前两种用一个正则就能覆盖,关键是别让单位干扰数字边界。第三种要注意大小写都可能有,w和W都得认。第四种最烦,因为中文口语省略规则不统一:“两百三”是 230,“两百三十”也是 230,但“两十三”这种表述实际不存在,得防住。我的做法是先做一层中文数字归一化,转成阿拉伯数字后再走统一的正则。

import re from decimal import Decimal CN = {"零":0,"一":1,"二":2,"两":2,"三":3,"四":4,"五":5, "六":6,"七":7,"八":8,"九":9} UNIT = {"十":10, "百":100, "千":1000} def cn2num(s: str) -> Decimal | None: """把'三十五''两百三''一千二'转成数字,失败返回 None""" if not s or any(ch not in CN and ch not in UNIT for ch in s): return None total, section, last = 0, 0, 0 for ch in s: if ch in CN: last = CN[ch] else: unit = UNIT[ch] # '十五' 省略了前面的'一' if last == 0 and unit == 10: last = 1 section += last * unit last = 0 total = section + last # 口语省略尾数:'两百三' → 230,'一千二' → 1200 if last and len(s) >= 2 and s[-1] in CN and s[-1] != "零": prev_unit = next((UNIT[c] for c in reversed(s[:-1]) if c in UNIT), None) if prev_unit == 100: total = section - last + last * 10 elif prev_unit == 1000: total = section - last + last * 100 return Decimal(total) if total > 0 else None AMOUNT_RE = re.compile( r"(?P<num>\d{1,7}(?:\.\d{1,2})?)\s*(?P<unit>[kK千万wW块元毛角]?)" ) SCALE = {"k": 1000, "K": 1000, "千": 1000, "w": 10000, "W": 10000, "万": 10000}

毛和角单独出现时按 0.1 处理,和前面的数字相乘。这段代码我在测试集上跑过 1200 条真实语料,中文数字部分的准确率是 97.6%,错的 2.4% 全是“三百五”这种既能读 350 也能读 305 的歧义句——这类我统一按 350 处理,因为口语里没人用“三百五”表示 305。

注意:金额抽取必须做多候选检测。像“买 2 杯咖啡 56”这种文本,第一个数字是数量不是金额。我的处理是取“最靠后且带单位或带小数”的那个候选,如果都不带单位且有两个以上数字,就判低置信度反问。

3.3 类别匹配:词典 + 历史频次双重投票

类别匹配的朴素做法是维护一张“关键词到类别”的映射表,命中最长词优先。这个做法能覆盖 70% 的场景,剩下 30% 靠不住。比如“星巴克”既是品牌也可能是某个人名备注,“苹果”可能是水果也可能是数码产品。我的补强方案是两层加权:

第一层是词典命中,按词长加权(越长的词越具体,权重越高)。第二层是用户历史频次,同一个用户如果过去三个月把“星巴克”记了 11 次餐饮,那这次也优先判餐饮。两层分数相加取最高,低于阈值就走反问。

def score_category(text, uid, dict_hit, hist): best, best_cat = 0.0, None for cat_id, kw in dict_hit.items(): # 词长权重:两字词 0.5,三字及以上 0.8 base = 0.5 if len(kw) <= 2 else 0.8 # 历史频次做温和放大,避免老数据彻底绑架判断 boost = 1.0 + min(hist.get((uid, cat_id), 0), 10) * 0.03 s = base * boost if s > best: best, best_cat = s, cat_id return best_cat, best

阈值定在 0.6 是试出来的。定 0.5 的时候误判太多,定 0.8 的时候反问率飙到 12%,用户烦。0.6 对应大概 4% 的反问率,体感刚好。

3.4 时间与备注:默认值比精确更重要

时间解析的原则是能默认就默认,别逼用户填。没写时间就取消息到达时刻;写“昨天”“前天”就往前推整数天;写“3 号”就取本月 3 号,如果今天还没到 3 号就取上月 3 号;写“上周五”就算出具体日期。这里有个坑:跨月边界。“1 号说昨天”的那笔账,应该落在上个月最后一天,而不是本月。我最初的实现直接减一天,导致每个月 1 号记的“昨天”全都跑错月份,月报对不上。修法是把日期运算全部交给带时区的日期库,别自己加减秒。

备注清洗就是去掉金额、时间词、类别词之后剩下的部分,去掉首尾空白和标点,长度截到 40 字。剩下为空就留空,不要瞎填“无”。

4. 从零跑通:部署与实操配置

4.1 环境选型与依赖清单

运行环境我没有追求新潮,选的是最稳的那一套。Python 3.11 是因为asyncio的性能和类型提示写起来舒服;FastAPI 是因为它的请求体处理和异步支持足够轻;PostgreSQL 是因为我需要唯一索引、数组类型和窗口函数,这三点 SQLite 都不太够;Redis 纯粹用来做幂等键和轻量缓存。整套服务在一台 2 核 4G 的小机器上跑,日均 300 条消息,CPU 峰值不到 15%,资源完全够。

# docker-compose.yml 片段,只保留关键部分 services: api: build: . environment: - TZ=Asia/Shanghai - DATABASE_URL=postgresql://imoney:***@db:5432/imoney - REDIS_URL=redis://cache:6379/0 - WX_TOKEN=${WX_TOKEN} - WX_AES_KEY=${WX_AES_KEY} ports: ["8000:8000"] depends_on: [db, cache] db: image: postgres:16 environment: - POSTGRES_DB=imoney - POSTGRES_PASSWORD=*** volumes: ["pgdata:/var/lib/postgresql/data"] cache: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes"] volumes: pgdata:

TZ那行别省。容器默认 UTC,Python 的datetime.now()拿到的就是 UTC 时间,你在本地开发环境跑得好好的,一上容器就全错 8 小时。

4.2 回调接入的四步配置

接入公众号消息回调,我踩过的坑集中在验签和解密这一步。流程是固定的四步:

  1. 在平台后台填服务器地址、Token、EncodingAESKey。Token 是自定义字符串,EncodingAESKey 是 43 位随机字符,两个都要写进环境变量,别硬编码。
  2. 后台会发一次 GET 请求做校验,带signature、timestamp、nonce、echostr四个参数。你需要把 Token 和另外三个参数按字典序拼接做 SHA1,和signature比对,一致就把echostr原样返回。
  3. 校验通过后,用户消息会以 POST 形式推过来,消息体是加密的。用 EncodingAESKey 做 AES-256-CBC 解密,解出来的明文里带MsgId、FromUserName、Content等字段。
  4. 处理完返回success字符串,别返回 JSON,平台不认。
import hashlib, base64 from Crypto.Cipher import AES def check_signature(token, timestamp, nonce, signature): items = sorted([token, timestamp, nonce]) return hashlib.sha1("".join(items).encode()).hexdigest() == signature def decrypt_msg(encoding_aes_key, cipher_b64): key = base64.b64decode(encoding_aes_key + "=") cipher = AES.new(key, AES.MODE_CBC, key[:16]) plain = cipher.decrypt(base64.b64decode(cipher_b64)) pad = plain[-1] plain = plain[:-pad] # 前 16 字节是随机串,接下来 4 字节是长度,之后才是正文 length = int.from_bytes(plain[16:20], "big") return plain[20:20 + length].decode("utf-8")

注意:解密时先解 base64 再做 AES,顺序反了会得到一个长度不对的乱码;另外补位字节要用最后一个字节的值来切,写死 32 会在部分消息上炸。

4.3 异步消费与写库

消息进队列后由消费者处理。消费者里做三件事:解析、落库、生成回执。落库这一步用事务包起来,并且把唯一索引冲突当成正常情况处理掉,不要让它冒泡成 500。

async def consume(payload): uid = await ensure_user(payload["FromUserName"]) text = payload["Content"].strip() result = parse(text, uid) # 纯函数,不碰 IO async with db.transaction(): if result.need_confirm: await save_pending(uid, payload["MsgId"], result) # 存待确认,不入账 reply = f"没太看懂,是「{result.guess_category} ¥{result.amount}」吗?回 是 或 改 金额" else: try: await insert_txn(uid, result, payload["MsgId"]) reply = render_receipt(result) except UniqueViolation: reply = "这条已经记过了" await push_text(uid, reply)

save_pending表很简单:uid、msg_id、payload_json、expire_at(10 分钟)。用户回“是”就从这张表捞出来入账,回“改 30”就改掉金额再入账,超时就当废案清理。有了这张表,反问不再是死路,交互闭环才成立。

4.4 月报与聚合查询

报表这块我一开始想用专门的 OLAP 组件,后来发现单用户数据量太小——一年撑死 3000 条记录,PostgreSQL 一条GROUP BY就查完了,上任何额外组件都是浪费。关键是把索引建对,(uid, occurred_at)和(uid, category_id, occurred_at)这两个联合索引撑起了 95% 的查询。

-- 本月分类汇总,按用户时区切片 SELECT c.name, SUM(t.amount_cents) / 100.0 AS total, COUNT(*) AS cnt FROM t_txn t JOIN t_category c ON c.id = t.category_id WHERE t.uid = $1 AND t.direction = -1 AND t.occurred_at >= date_trunc('month', now() AT TIME ZONE $2) AT TIME ZONE $2 AND t.occurred_at < date_trunc('month', now() AT TIME ZONE $2) AT TIME ZONE $2 + interval '1 month' GROUP BY c.name ORDER BY total DESC;

月报推送用系统 cron 每月 1 号早上 8 点跑一次,给每个开过记账的用户推一段汇总文本,包含总支出、最大一笔、同比变化和一句“比上月少花了 XX”。这条推送的实际留存价值比我想象的高得多,用户不会主动查账,但月末被动看到一条汇总,反而会回头补记那几笔漏掉的。

5. 典型问题排查实录

5.1 重复入账:三种来源,三种解法

重复入账是我收到最多的一类反馈,前后遇到三种成因。第一种是平台重试,服务端 5 秒没响应就再推一次,这种靠 Redis 幂等键解决。第二种是用户自己手抖连发两次,MsgId不同但内容一样,这种靠内容指纹去重——对同用户 60 秒内的文本做哈希,命中就提示“刚才那条已经记了,是同一笔吗”。第三种最隐蔽:消费者处理到一半进程被重启,消息重新入队,事务其实已经提交了,靠数据库唯一索引兜住。

排查这类问题我有个固定动作:查raw_msg_id在表里出现几次。出现两次以上说明幂等失效,再去看 Redis 键是不是过期了(我最初设的是 60 秒,比平台重试窗口短,后来改成 900 秒才彻底解决)。

5.2 解析误判:把错误分类,别一锅乱炖

统计了三个月里用户主动纠正过的 217 条记录,误判原因分布如下:

误判类型占比典型例子修法
金额抽错(抽到数量)41%“买 2 杯咖啡 56” 抽成 2多候选 + 取最靠后带单位项
类别判错27%“苹果 12” 判成水果加品牌别名表 + 历史频次投票
时间落错月18%“1 号昨天的账” 落到本月全部改用带时区日期运算
收入支出方向反了9%“报销 300” 记成支出收入关键词表(报销、工资、退款、收到)
备注串味5%备注里混进了类别词清洗时按词典剔除

这个表最有价值的地方是它告诉我先修哪个。41% 的金额问题占了近一半,所以第二个迭代周期我把精力全压在金额抽取上,误判率直接从 6.1% 掉到 2.3%。工程上永远先解决高频问题,别被个别离奇的 case 牵着走。

5.3 数据和实际对不上:九成是这三件事

用户反馈“我明明记了,怎么查不到”,我整理了一套固定的排查顺序,通常三步内能定位:

  1. 看raw_msg_id有没有落库。没有就是接入层出了问题,查验签和解密日志。有一次是 EncodingAESKey 配置末尾多了个空格,导致所有解密失败,接口还照样返回 200,静默丢了一整天消息。后来加了“解密失败必须告警”才防住。
  2. 落库了但查不到,查时区。用户 tz 和occurred_at的时区没对齐,月初月末的账会漏。判断方法是直接按created_at查,能查到就是时区问题。
  3. 查到但这笔是收入。汇总默认只统计支出,方向字段填反了就会“消失”。这个我后来在回执里明确写上“支出”或“收入”两个字,用户一眼能看出来。

5.4 常见问题速查表

现象大概率原因快速验证处理
消息无响应回调地址不可达 / 验签失败手动 GET 打一次校验检查 Token 与公网可达性
全部消息解密失败密钥带空格或长度不对打印密钥长度应为 43重新复制密钥
偶发重复入账幂等键过期太短查 Redis TTL 配置改到 900 秒以上
月初月报数据偏少时区导致跨月切片错位按created_at反查统一 TIMESTAMPTZ
汇总金额出现小数抖动用浮点存金额看字段类型改整数分 + Decimal
反问后用户回“是”没反应待确认记录被提前清理查expire_at延长到 10 分钟并加兜底

6. 上线一年后我才想明白的几件事

第一个体会是:别把交互设计留给用户猜。我最初的回执只有“已记录”三个字,结果用户不知道系统把类别猜成了什么,也没法发现错误。后来回执改成“午饭 ¥28.00 计入餐饮,今天 12:31”,纠正率反而下降了,因为用户一眼就能看出对不对,错的当场改,对的直接划走。信息透明度本身就是一种纠错机制。

第二个是关于规则和模型的边界。我一度想把解析全部交给模型,图省事。试了两周放弃了,不是因为效果差,而是因为不可控——同一句话今天解析对明天解析错,用户会彻底失去信任。记账这种场景,宁可笨一点、稳定一点,也不要聪明但不靠谱。规则引擎的误判至少是可复现、可修复的。

第三个是数据备份。这东西跑在自己的机器上,硬盘说坏就坏。我现在是每天凌晨用pg_dump打一个压缩包,保留 30 天,同时每周往另一块盘同步一次。脚本很简单,但这是我被坑过一次之后才加的——那次磁盘满了导致 PostgreSQL 拒绝写入,我花了半天才反应过来,中间丢了两天的账。

第四个是关于扩展。现在这套东西再加功能其实很轻松:想支持多账户,就在交易表加一个account_id;想支持预算提醒,就在月报任务里加个阈值判断;想支持语音记账,接入层多一层语音转文字就行,解析层完全不用动。当初分层切得细,后面加东西就不用动刀子。反倒是那些一开始图快、把所有逻辑写在回调函数里的方案,第二次改需求就得推倒重来。

最后分享一个我用着很顺手的小技巧:给自己准备几个“快捷短语”,比如“早”自动展开成“早餐 15 餐饮”、“地”展开成“地铁 5 交通”。在聊天窗口里发一个字就完成记账,比任何 App 都快,实测下来单笔录入压到了 3 秒以内。这个功能实现成本极低——解析层在进入正式匹配之前,先查一遍用户自定义的短语表,命中就直接出结果。

RELATED READING

延伸阅读

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