个人记账看似只是一个“收入减支出”的简单应用,但真正把它做成可持续使用的产品后,开发者很快会遇到一系列工程问题:账户余额如何避免并发错乱,多账本数据如何隔离,账单导入怎样防止重复,AI 能力如何与核心业务解耦,PC 和移动端又该共享代码还是独立维护。
Smart Ledger 是一个开源的个人和家庭记账系统。它采用 Node.js、Express 和 MySQL 构建后端服务,使用原生 HTML、CSS 和 JavaScript 实现前端页面,并提供 H5 移动端与 PC 桌面端两套界面。项目还集成了自然语言记账、小票识别、语音输入、账单分析、预算管理、借贷跟踪、多人协同和还款提醒等功能。

本文以该项目为例,从系统定位、整体架构、数据模型、核心业务流程、AI 能力集成、前后端设计、工程化建设和后续演进等方面,探讨一个记账系统背后的技术取舍。
一、项目定位:记账系统的复杂度被低估了
很多人第一次设计记账系统时,会先建立一张“账单表”,其中包含金额、类型、时间和备注。这个模型足以应对单机笔记本,却很难承载真实家庭场景。
一个成熟的记账系统至少需要考虑以下需求:
因此,记账系统本质上不是一个简单的表单应用,而是一个具有账户一致性、权限边界、数据迁移和业务提醒要求的轻量级财务系统。
Smart Ledger 选择将前后端放在同一个代码仓库中维护,后端负责业务规则、数据一致性和外部接口,前端负责页面交互和数据展示。项目没有引入 Vue、React 等前端框架,也没有使用 Chart.js 等图表库,而是用原生技术实现页面与图表。这种选择降低了依赖数量和部署复杂度,特别适合希望自托管、二次开发或学习全栈开发的用户。
二、整体架构:一套 API,两套独立前端
项目采用典型的前后端不分离部署、但前后端职责分离的多页面架构。
浏览器
│
├── H5 移动端:底部导航、移动页面
├── PC 桌面端:侧边栏、顶部导航、桌面页面
│
▼
Node.js + Express 后端
│
├── 认证与权限中间件
├── 业务路由
├── 数据库访问层
├── 定时任务
├── 文件上传
└── AI 服务适配层
│
▼
MySQL 数据库

PC 端和 H5 端虽然共享同一套后端 API,但页面结构、导航方式、样式和交互脚本相互隔离。PC 端采用侧边栏加顶栏的桌面布局,适合展示账户卡片、报表图表和管理功能;移动端采用底部导航和单列流式布局,强调快速记账和随手查看。

这种架构有两种明显优势。
首先,两套界面可以根据设备特点分别优化。桌面端可以并排展示预算进度、支出排行和近期记录,移动端则更适合“点击悬浮按钮、快速记账、立即返回”的短流程。若强行共用同一套响应式布局,组件很容易因为同时兼容键盘、鼠标、触摸和大屏而变得复杂。
其次,业务规则不会因端侧差异而重复。无论用户在 PC 还是手机上发起转账,账户锁定、余额变更和审计流水都由后端统一处理。这样可以避免不同端出现计算口径不一致的问题。
不过,两套独立页面也会带来维护成本。公共交互组件、请求封装、弹窗和通知逻辑需要在两个端分别维护;如果后期缺少统一规范,可能出现同一功能在两套页面中表现不一致的情况。因此,更合理的做法不是机械地共享所有代码,而是抽取请求层、校验规则、状态文案和通用样式变量,同时保留界面布局与交互的独立性。
三、数据模型:围绕账本建立数据边界
记账系统的核心并不是“账单”一个实体,而是用户、账本、账户、分类、账单、借贷和通知等多个实体之间的协作。
可以简化为核心实体关系:
用户
│
├── 账本成员
│
账本
├── 账户
├── 分类
├── 账单
├── 借贷
├── 预算
└── 成员权限
其中,账本是最重要的数据边界。家庭账本、旅行账本和个人账本应相互隔离;一个用户可以通过成员关系加入多个账本,同一个账本也可以拥有多个成员。
项目采用逻辑删除字段 del_flag,而不是物理删除业务数据。这种设计在记账和财务场景中非常重要:已删除的账单仍然保留在数据库中,管理员可以在必要时恢复数据、检查操作历史或排查对账问题。

3.1 账户与账单的关系
账户可以分为现金、微信、支付宝、银行卡、信用卡、投资理财等类型。账单则至少包含以下信息:
- 收支类型:收入、支出或转账;
- 金额与方向;
- 所属账本;
- 记账账户;
- 目标账户,用于转账场景;
- 一、二级分类;
- 交易日期;
- 商家和备注;
- 图片凭证;
- 外部账单来源与唯一编号。

转账与普通收支的区别是:普通收支通常只影响一个账户,而转账同时影响转出账户和转入账户。后端必须把它当作一个整体事务,不能先更新账户 A,再在网络中断或程序异常后留下未更新的账户 B。
3.2 分类体系
项目预置了多组一级和二级分类。一级分类负责宏观统计,例如餐饮、交通和居住;二级分类负责细化场景,例如餐饮可以继续分为早餐、午餐、晚餐、外卖和奶茶咖啡。

系统需要处理好三件事:
这意味着分类不能被简单删除,至少应在业务层区分“停用”和“物理删除”。历史账单保存的应该是分类快照或稳定标识,不能因为用户修改了当前分类名称,就改变过去的统计结果。
四、并发记账:金融场景必须解决的一致性问题
转账接口是记账系统中最典型的一致性场景。假设账户 A 有 1000 元,账户 B 有 500 元,两个并发请求分别执行:
- 从 A 向 B 转账 200 元;
- 从 B 向 A 转账 100 元。

如果程序先查询余额,再在应用层计算,最后执行更新,就可能出现两个请求同时读到旧余额,最终金额取决于哪一次写入后发生。即使金额计算在单个 Node.js 进程中没有出现竞态,只要未来增加集群、定时任务或批量导入,风险仍然存在。
项目使用数据库事务和 SELECT … FOR UPDATE 锁定账户记录,这是一个合理的基础方案。其思路是:
多账户按 ID 升序锁定,可以降低两个事务以相反顺序申请锁而形成死锁的概率。MySQL 官方也将事务、行锁和一致性访问列为 InnoDB 表类型的重要特性。
4.1 推荐的事务伪代码
START TRANSACTION;
SELECT balance
FROM sl_biz_account
WHERE id = 1001
FOR UPDATE;
SELECT balance
FROM sl_biz_account
WHERE id = 2002
FOR UPDATE;
UPDATE sl_biz_account
SET balance = balance – 200
WHERE id = 1001;
UPDATE sl_biz_account
SET balance = balance + 200
WHERE id = 2002;
COMMIT;
在实际应用中,不能只执行“余额加金额”,还应保存变化前金额、变化金额、变化后金额和操作人。这样,即使某次余额异常,也能通过审计流水追溯原因。
4.2 事务设计原则
第一,事务应尽量短。文件上传、调用 AI、发送通知等操作不应放在数据库事务内部。这些外部操作可能耗时较长,会延长锁持有时间,并增加死锁概率。
第二,先验证再锁账。对参数、权限和账本归属的检查应提前完成,只有真正需要修改余额时才开启锁和事务。
第三,转账方向要统一。建议明确“正数表示流入、负数表示流出”,由统一函数计算账户金额变化,避免不同接口采用不同符号规则。
第四,失败必须整体回滚。账单写入成功后如果审计流水失败,系统不能继续提交事务;否则余额正确,审计记录却会丢失。
第五,重试要有边界。发生死锁或锁等待超时后,可以结合指数退避有限重试,但不能把外部 AI 请求、短信发送等非事务操作重复执行。
五、余额审计:让每一笔变化都可以追溯

记账系统不仅要知道账户当前有多少钱,还要回答“为什么变成这个金额”。
项目为每次余额变化记录流水,包括收入、支出、转账、期初金额和手动调整。完整的审计记录通常包含:
- 账户编号;
- 关联账单或借贷编号;
- 操作类型;
- 变化前金额;
- 变化金额;
- 变化后金额;
- 操作人;
- 操作时间;
- 账本编号。

这种设计与银行流水类似。当前余额是账户的“状态”,流水则是账户变化的“事实”。如果账户余额与流水合计不一致,系统可以自动扫描并生成对账异常。
5.1 状态与事实要分开
一个常见错误是让账单表同时承担余额计算职责。例如,每次查询账户余额时都汇总所有账单。这样做虽然实现简单,但随着账单增加,性能会持续下降,而且难以处理期初余额、人工调整和撤销操作。
更好的方式是:
- 账户表保存当前余额;
- 审计流水保存每次变化;
- 账单表保存交易事实;
- 定时任务校验三者的关系。
查询余额时直接读取账户表,对账时再通过审计流水进行校验。这样做兼顾性能与可追踪性。
六、账单导入:真实数据往往比接口更复杂

微信和支付宝账单导入是记账系统最有价值、也最容易出错的功能之一。它处理的不是结构稳定的内部 API,而是用户从第三方应用导出的 CSV、TXT 或 Excel 文件。
这些文件可能存在:
- UTF-8 或 GBK 编码;
- 文件头说明和汇总行;
- 金额中的逗号;
- 支出与收入符号差异;
- Excel 日期序列号;
- 合并单元格;
- 交易状态字段;
- 信用卡还款、充值提现等中性交易;
- 相同账单重复导出。
因此,账单导入不能简单使用 split(',') 解析,也不能直接把第一行当作表头。项目通过独立解析模块处理编码、日期和文件格式,并在导入前展示预览结果。
6.1 导入流程
完整的导入流程可以分成六步:
用户确认后再执行第二步落库:
通过交易单号进行幂等判断,可以避免用户重复上传同一份文件时产生重复账单。
6.2 分类匹配不能影响用户确认
系统可以根据交易对方、商品名称和金额自动匹配分类,但自动匹配只能作为建议。最终分类和排除规则必须经过用户确认,因为“美团”可能对应餐饮、超市或电影,“转账”也可能属于人情、还款或账户内部划转。
如果文件过大,还应该引入批次、进度、失败原因和继续导入机制。导入失败不能只提示“处理失败”,而应明确告诉用户:第几行金额格式错误、哪个字段为空、哪条记录已经导入。
七、AI 能力:把不确定性的模型放在业务边界之外

Smart Ledger 的 AI 功能不是简单地把用户输入发送给大模型,而是把 AI 作为业务辅助层。
项目接入了文本和视觉模型,用于自然语言记账、小票识别、消费分析、异常检测、月末预测、预算推荐和饮食建议。不同能力承担的职责并不相同。


|
自然语言记账 |
文本 |
类型、金额、分类、账户、日期和备注 |
字段解析错误 |
|
小票识别 |
图片 |
商户、金额和日期 |
图片模糊或模型幻觉 |
|
账单分析 |
统计后的账单数据 |
消费结构与建议 |
过度解读数据 |
|
异常检测 |
两个周期统计数据 |
增幅与异常分类 |
周期数据不足 |
|
月末预测 |
当前支出和天数 |
月末支出或结余预测 |
线性假设失真 |
|
预算推荐 |
历史分类数据 |
分类预算建议 |
特殊月份干扰 |
7.1 自然语言记账

用户输入“今天午餐外卖30元”后,后端需要将其转换为结构化字段:
{
"type": "expense",
"amount": 30,
"category": "餐饮",
"subCategory": "外卖",
"account": "微信钱包",
"date": "2026-10-22",
"remark": "午餐外卖"
}
但 AI 输出不能直接写入数据库。后端仍要完成:
- 校验金额是否为正数;
- 判断账单类型是否合法;
- 校验账户是否属于当前账本;
- 校验分类是否存在;
- 对日期和时区进行标准化;
- 对低置信度结果要求用户确认。
AI 负责理解语义,业务规则负责保证安全。即使模型没有识别出金额,也不能让应用层默认填入 0 或直接报错,而应提示用户补充。
7.2 OCR 小票识别

小票识别比文本记账更复杂,因为图片可能模糊、倾斜、缺角或包含多件商品。系统可以先让视觉模型提取总金额和商户,再由用户确认后生成账单。
金额属于高风险字段。即使 OCR 准确率较高,也应优先使用小票中的“合计”或“应收金额”,并对折扣、服务费和支付金额进行区分。OCR 结果只应作为表单预填,不应绕过人工确认直接记账。
7.3 AI 分析不是实时统计

账单分析、异常检测、预测和预算推荐都依赖聚合数据。正确顺序是先在数据库中进行 SQL 聚合,再把少量统计结果发送给模型。
不应把成千上万条原始账单直接发送给大模型。这样做会浪费令牌、暴露用户隐私,并降低响应速度。更合理的数据结构是:
{
"period": "2026-10",
"income": 12000,
"expense": 6800,
"categories": [
{"name": "餐饮", "amount": 1800},
{"name": "交通", "amount": 600}
],
"dailyAverage": 220,
"daysPassed": 22,
"totalDays": 31
}
模型只负责解释和提出建议,收支总额、分类占比等准确数字仍应由数据库计算。
八、定时任务与通知:提醒系统也要避免重复

借贷和信用卡提醒是典型的时间触发业务。系统需要在每天固定时间检查即将到期、已经逾期的记录,然后为相关用户生成通知。
项目采用个人通知与系统公告分离的设计:
- 系统公告面向全部或指定用户;
- 个人通知仅对接收人可见;
- 还款提醒根据到期日自动生成;
- 服务启动后可以补检漏掉的任务;
- 通知使用唯一索引防止重复。
这种设计的关键是“检查任务”与“用户通知”分离。定时任务只负责发现符合条件的借贷和信用卡记录,不直接承担消息展示逻辑;通知表负责记录接收人、关联业务、是否已读和通知类型。
8.1 防止重复提醒
如果服务在 09:00 前重启,或者定时任务重复执行,系统可能生成多条相同提醒。解决方式是在通知表中建立业务唯一键:
用户编号 + 业务类型 + 业务编号 + 提醒日期
例如,同一笔借贷在 2026-10-22 的“到期提醒”只能生成一次。即使定时任务多次执行,数据库也会通过唯一索引拒绝重复记录。
8.2 提醒规则要可解释
到期前三天、当天和逾期通知应生成不同的消息模板。例如:
- 距离还款日还有 3 天;
- 今天为还款日;
- 已经逾期,请尽快处理。
用户点击通知后,应跳转到对应的借贷或信用卡详情,而不能只展示一条无法关联业务的文字。
九、前端设计:为什么原生技术也能支撑复杂交互
项目前端没有使用主流框架,而是采用原生 JavaScript 实现页面。其主要优势是部署简单、运行环境透明、没有构建链依赖;缺点则是状态管理、组件复用和跨页面通信需要自行约束。
在记账场景中,页面可以围绕业务拆分:
- login.html:登录和注册;
- index.html:首页概览;
- bills.html:账单列表和日历;
- accounts.html:账户管理;
- reports.html:统计报表;
- loans.html:借贷中心;
- profile.html:个人中心和系统设置。
每张页面维护自己的 HTML、CSS 和 JavaScript,可以减少单文件膨胀,也便于理解页面边界。
9.1 请求层需要统一封装
即使不使用框架,也应统一处理:
- 接口地址;
- 请求头;
- Token 注入;
- 登录失效;
- 业务错误码;
- 网络异常;
- 加载状态;
- 参数序列化。
统一封装可以避免每个页面重复编写 Token 读取和错误提示。
9.2 弹窗与表单校验
记账弹窗、分类选择、筛选条件和确认删除都属于高频交互。建议建立统一的弹窗组件和表单校验函数,至少支持:
- 必填校验;
- 金额格式校验;
- 日期范围校验;
- 账户归属校验;
- 分类是否存在;
- 操作确认;
- 键盘与点击关闭。
移动端要特别注意点击区域、软键盘弹起和滚动穿透;PC 端则应支持键盘操作、表单回车和焦点管理。
十、Canvas 图表:手写绘制也要兼顾清晰度与性能

项目使用 Canvas 绘制饼图、折线图、柱状图和趋势图,而没有引入图表库。这种实现可以减少依赖,并根据记账业务定制交互细节,但需要自行处理响应式、高清显示和坐标计算。
在高分屏上,Canvas 模糊的常见原因是绘图缓冲区尺寸与 CSS 显示尺寸不一致。正确做法是按照设备像素比放大画布,再对绘图上下文进行缩放。
function resizeCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
return ctx;
}
报表页面还需要处理:
- 窗口尺寸变化;
- 深色模式切换;
- 数值为 0 时的空状态;
- 分类过多时的聚合;
- 日期不足一个完整周期;
- 图表与统计数字口径一致;
- 长分类名称截断;
- 高分屏下的内存占用。
当分类数量较多时,不应把几十个二级分类同时展示在饼图中,而应先聚合到一级分类,再提供点击展开或明细列表。
十一、语音记账:能力增强不能替代手动输入

语音记账的流程是:
Web Speech API 的浏览器支持并不均衡。Chrome 和 Edge 的支持相对较好,Firefox 默认未启用,iOS Safari 则对 HTTPS 和版本存在要求。 因此,语音只能作为快捷入口,不能成为唯一记账方式。
系统应在页面加载时进行能力检测:
const SpeechRecognition =
window.SpeechRecognition ||
window.webkitSpeechRecognition;
if (!SpeechRecognition) {
disableVoiceButton('当前浏览器不支持语音识别');
}
同时提供清晰的状态提示:
- 等待说话;
- 正在识别;
- 识别结果;
- 网络异常;
- 用户取消;
- 浏览器不支持。
识别失败时,用户可以继续手动输入或点击分类快速记账。语音转文字与 AI 解析都应设置超时时间,避免请求长时间占用界面。
十二、权限与数据安全:账本不是简单的用户数据

多人协同使权限模型从“用户拥有数据”变成“用户通过成员关系访问账本”。
系统至少需要区分:
- 账本所有者;
- 编辑者;
- 查看者。
不同角色对应不同能力:
|
所有者 |
是 |
是 |
是 |
是 |
是 |
|
编辑者 |
是 |
是 |
是 |
视规则 |
否 |
|
查看者 |
是 |
否 |
否 |
否 |
否 |
所有账单、账户、借贷和预算查询都必须先校验当前用户是否属于目标账本。不能只验证用户是否登录,否则可能出现水平越权。
密码应使用 bcrypt 等慢哈希算法处理,JWT 只用于无状态认证。生产环境还应:
- 使用 HTTPS;
- 设置合理的 Token 有效期;
- 对登录失败进行限流;
- 校验文件上传类型和大小;
- 防止 SQL 注入;
- 对敏感配置使用环境变量;
- 记录关键操作和异常请求。
AI 接口尤其需要限流。自然语言解析和小票识别会调用外部模型,如果缺少频率限制,容易被恶意请求消耗 API 额度。
十三、工程化与部署:让系统可以持续运行

项目通过环境变量区分开发和生产配置,使用 PM2 管理 Node.js 进程,并可配合 Nginx 进行反向代理。对于个人项目而言,这是一套成本低、可维护性较高的部署方案。
部署时需要重点关注:
13.1 数据库迁移
项目将初始化脚本和增量脚本分开管理,这是一种值得坚持的实践。全新安装可以执行初始化脚本,已有系统则按日期顺序执行增量脚本。
升级前应备份数据库,升级后要检查:
- 表结构是否创建成功;
- 索引是否正常;
- 默认值是否生效;
- 历史数据是否兼容;
- 上传文件路径是否需要迁移。
数据库升级不能只执行 SQL,还应验证业务接口。例如新增“外部账单编号”字段后,需要测试导入重复检测;新增信用卡账单日字段后,需要测试提醒任务。
13.2 日志要同时服务运维和审计
后端日志至少应区分:
- 请求日志;
- 业务日志;
- 数据库错误;
- AI 调用日志;
- 文件导入日志;
- 登录日志;
- 定时任务日志。
日志中不能记录完整密码、Token 或 API Key。涉及金额的操作应记录操作人、账本、业务编号和结果,但敏感字段需要脱敏。
十四、可观测性与测试:家庭项目也需要质量底线
开源项目初期通常以功能实现为主,但随着用户增加,缺乏测试和日志会显著增加维护成本。
至少需要补充以下测试:
14.1 接口测试
- 正常创建收支记录;
- 同一账户重复转账;
- 余额不足;
- 跨账本操作;
- 非账本成员访问;
- 删除分类;
- 修改已结算借贷;
- 重复导入账单;
- AI 返回异常字段;
- 文件编码异常。
14.2 并发测试
可以使用多个并发请求模拟同一账户同时记账。重点观察:
- 最终余额是否正确;
- 是否出现死锁;
- 是否生成重复流水;
- 失败请求是否回滚;
- 审计流水是否与余额一致。
并发测试不需要复杂压测工具。只要构造几十个并发请求,就足以暴露“先查后改”和事务边界错误。
14.3 业务监控
建议监控:
- 登录失败次数;
- AI 调用成功率;
- 平均响应时间;
- 账单导入失败率;
- 定时任务最后执行时间;
- 数据库慢查询;
- 磁盘剩余空间;
- 上传目录增长情况。
个人项目虽然不需要企业级可观测平台,但至少要能收到服务异常和定时任务失败的提醒。
十五、后续演进:从可用走向可维护
如果该项目继续演进,可以重点考虑以下几个方向。
15.1 抽取共享前端能力
PC 和移动端可以继续保持布局独立,但请求封装、错误码、日期工具、金额格式、权限判断和主题变量可以抽取为共享模块。这样既能保持端侧体验差异,又能减少重复逻辑。
15.2 引入领域服务层
目前后端路由承担了较多职责。后续可以将代码拆分为:
- 控制器:处理 HTTP;
- 服务层:编写业务规则;
- 仓储层:访问数据库;
- 领域模型:表达账户、账单和账本;
- 任务层:处理导入和提醒;
- 适配器:对接 AI 服务。
例如,转账、余额调整和审计流水应放入统一的账户服务,避免多个路由分别实现金额计算。
15.3 支持可插拔 AI 提供商
当前 AI 配置已经支持模型地址、文本模型和视觉模型。下一步可以进一步定义统一的 AI 请求和响应结构,让系统同时适配不同模型供应商。
业务层只关心:
parseBill(text): BillDraft
recognizeReceipt(image): ReceiptResult
analyzeReport(data): AnalysisResult
底层模型可以替换。这样既能降低供应商锁定,也方便用户使用自有模型或本地服务。
15.4 强化数据迁移能力
账单导入可以增加:
- 字段映射配置;
- 分类规则管理;
- 导入批次回滚;
- 失败记录重试;
- 大额交易二次确认;
- 导入前后余额校验。
对于家庭用户来说,导入结果比“支持多少种格式”更重要。用户必须能够清楚看到哪些记录被导入、哪些被跳过、哪些发生错误。
15.5 增加端到端测试
PC 和移动端页面可以采用不同的测试策略。移动端重点测试触摸、弹窗和快速记账,PC 端重点测试筛选、表格和报表。核心转账、账单导入和提醒任务则适合使用接口自动化测试长期守护。
十六、总结

Smart Ledger 的价值不仅在于提供了一套开源记账应用,更在于它展示了个人项目如何逐步面对真实软件工程问题。
从技术上看,它用相对朴素的技术栈覆盖了复杂业务:
- 使用数据库事务和行级锁保证账户一致性;
- 使用审计流水让余额变化可追溯;
- 使用账本和成员关系实现多人协同;
- 使用统一 API 支撑 PC 和移动端;
- 使用预览与幂等机制提高账单导入可靠性;
- 将 AI 置于业务边界之外,使其负责理解与建议;
- 使用定时任务和个人通知建立提醒体系;
- 通过环境变量、PM2 和反向代理支持自托管部署。
这个项目也说明,框架并不是衡量工程质量的唯一标准。原生 JavaScript 同样可以构建完整应用,但前提是开发者主动补足状态管理、组件复用、测试、日志和部署能力。
记账系统最核心的原则始终是:界面可以简单,交互可以快捷,但金额必须准确、权限必须清晰、操作必须可追踪。只要守住这三条底线,再逐步增加 AI、协同和数据分析,系统才会在功能变多的同时保持可信和可持续维护。


