项目与技术栈
Smart Ledger 是一个开源的个人与家庭记账系统。后端 Node.js + Express + MySQL,前端是原生 HTML/CSS/JavaScript,没有框架也没有图表库,图表用 Canvas 手绘;H5 移动端和 PC 桌面端是两套独立界面,共用同一套 API。功能覆盖自然语言记账、小票识别、语音输入、账单分析、预算管理、借贷跟踪、多人协同和还款提醒。
几个可以直接核对的数字:账户类型 6 种(现金、微信、支付宝、银行卡、信用卡、投资理财);真实家庭场景拆出 10 项要求;借贷提醒分到期前 3 天、当天、逾期三套模板;防重复提醒靠通知表上的唯一索引(用户编号 + 业务类型 + 业务编号 + 提醒日期);账本权限是所有者、编辑者、查看者的角色矩阵;Canvas 图表按 devicePixelRatio 放大画布再 scale,几十个二级分类先聚合到一级再画。
被低估的复杂度
很多人把记账当成一张 CRUD 表,实际落地时会发现它是个带账户一致性、权限边界、数据迁移和业务提醒的轻量级财务系统。那 10 项要求里,真正难的不是"记一笔",而是转账的原子性、删除之后的回溯、账本成员的权限、多级自定义分类、第三方账单导入、消费分析与预测、还款提醒、导出备份,以及移动端和桌面端同时适配。
一个约束贯穿全篇:业务规则不因为端不同而重复实现。两套前端共享的是公共层——请求封装、校验、状态文案、样式变量;布局和交互各自独立。这个切法是对的,共享"判断"而不是共享"界面"。
并发转账的行锁
最典型的坑是先查后改的竞态:两个请求同时读到余额 100,各自判断"够扣 60",然后都写回去,结果余额变成 40 而不是触发余额不足。几十个并发请求就能把它暴露出来。解法是事务加行锁 SELECT ... FOR UPDATE,并且涉及多个账户时按账户 ID 升序加锁,降低死锁概率。
下面这段是自写的事务伪代码,不是项目原文:
-- 转账必须整体成功或整体失败;按 account_id 升序加锁,避免互相等待成环
START TRANSACTION;
SELECT balance FROM account WHERE account_id = 7 FOR UPDATE;
SELECT balance FROM account WHERE account_id = 12 FOR UPDATE;
UPDATE account SET balance = balance - 200.00 WHERE account_id = 7;
UPDATE account SET balance = balance + 200.00 WHERE account_id = 12;
INSERT INTO ledger_flow (account_id, before_amount, change_amount, after_amount, bill_no)
VALUES (7, 1000.00, -200.00, 800.00, 'T20261027001'),
(12, 300.00, 200.00, 500.00, 'T20261027001');
COMMIT;
重点是这五条:事务要短,别在锁住账户的时候去调外部接口;先验证再锁账,能前置的判断都放前面;方向统一,入账为正、出账为负,别两边各写一套符号;失败整体回滚;重试要有边界,且不重复触发外部调用。锁住账户期间调模型或调第三方支付,是最容易把连接池拖死的做法。
账户状态与流水
数据模型上,账本是核心边界:用户—账本成员—账本—账户/分类/账单/借贷/预算/成员权限。逻辑删除用 del_flag,不物理删。
余额这块有一条硬经验:账户表存状态,流水表存事实,账单表存交易。每次余额变化都记一条流水,写清变化前金额、变化金额、变化后金额、操作人、时间和账单编号,再用定时任务对账。不要用账单表汇总去算余额——数据量上来之后性能撑不住,而且期初余额、手工调整、撤销这几类操作在汇总口径下极难处理。
分类是同一类问题。预置分类和自定义分类共存,删除分类不能让历史账单显示出错,所以要么停用而不是物理删除,要么在账单上存一份分类快照。
账单导入的幂等
第三方 CSV/TXT/Excel 的坑写出来很长:编码可能是 UTF-8 也可能是 GBK,文件头有说明行,末尾有汇总行,金额里带逗号,收支用不同符号表示,Excel 日期是序列号,还有合并单元格、交易状态、中性交易(比如还款、转账不计收支)和重复导出。
处理分成两段。解析六步:上传 → 编码检测 → 跳过说明行 → 表头映射 → 逐行解析 → 预览。落库两步:开事务 → 按交易单号做幂等判断 → 建账单 → 更新余额 → 写流水 → 存批次 → 提交。批次号的存在是为了能整体回滚和事后排查,这是导入类功能里最容易漏的一环。
自动分类只能当建议,必须用户确认。"美团"这一条可能是餐饮,也可能是超市或电影票,模型猜错了就是脏数据,而且比没有分类更难清理。
AI 的位置
这套系统对 AI 的处理很克制,原则一句话:AI 放在业务边界之外。自然语言记账让模型输出结构化字段,后端拿到之后照样全量校验——金额为正、类型合法、账户归属于当前账本、分类存在、日期标准化、低置信度的一律要求用户确认。小票 OCR 的金额属于高风险字段,优先取票面上的"合计"或"应收金额",并且只用来预填表单,不绕过确认步骤。
账单分析的做法是先用 SQL 聚合,把小的统计结果交给模型,而不是把原始账单全喂进去。原因很实在:费 token、泄隐私、还慢。这条对任何"给数据加个 AI 分析"的需求都适用——模型擅长读结论,不擅长扫明细。
语音输入的降级
语音记账用 Web Speech API,但浏览器支持并不齐:Chrome 和 Edge 较好,Firefox 默认未启用,iOS Safari 还有 HTTPS 和版本要求。做法是先做能力检测,拿不到就禁用按钮,别让用户点了才报错。
// 拿不到识别能力就把入口关掉,而不是等到点击时才失败
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
document.querySelector('#voice-btn').disabled = true;
document.querySelector('#voice-tip').textContent = '当前浏览器不支持语音输入,请手动记账';
}
// 高分屏上 Canvas 模糊:按 dpr 放大绘图缓冲区,再用 scale 把坐标系拉回来
function fitCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect();
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}
两段代码解决的是同一类事:能力不确定的时候,退化路径要在入口处就明确,而不是等运行时崩。语音只是快捷入口,永远不能是唯一记账方式。
权限与工程化
权限上有一条必须记住:所有查询先校验用户属于该账本,防的是水平越权,只验登录不够。密码用 bcrypt 慢哈希,会话走 JWT 无状态,配 HTTPS、Token 有效期、登录限流和 AI 接口限流。工程化部分是环境变量分环境、PM2 管进程、Nginx 反代、初始化脚本与增量脚本分离,日志按请求、业务、DB 错误、AI 调用、导入、登录、定时任务分类,且不记密码和 Token。
待观察的部分
要泼一盆冷水:这篇文章能看到的是设计思路和清单,不等于项目已经做到了。作者给出的接口测试清单写得对(重复转账、余额不足、跨账本、非成员访问、重复导入、AI 异常字段),监控清单也对(登录失败、AI 成功率、响应时间、导入失败率、定时任务最后执行时间),但清单本身不是覆盖率,也没看到具体的测试数量和监控接入情况。
同样地,"几十个并发请求就能暴露先查后改"这类结论需要压测数据支撑,读代码得出的判断只能算待验证。真要拿这个项目做参考,建议先把转账的并发用例跑一遍,把对账任务接上告警,再谈其他。