刷到一个挺有意思的话题,结合自己之前的经验,整理了一下核心要点。
个人记账看似只是一个“收入减支出”的简单应用,但真正把它做成可持续用的产品后,开发者很快会遇到一系列工程问题:账户余额如何避免并发错乱,多账本数据如何隔离,账单导入怎样防止重复,AI 能力如何与核心业务解耦,PC 和移动端又该共享代码还是独立维护。
Smart Ledger 是一个开源的个人和家庭记账系统。它采用 Node.js、Express 和 MySQL 构建后端服务,用原生 HTML、CSS 和 JavaScript 实现前端页面,并提供 H5 移动端与 PC 桌面端两套界面。项目还集成了自然语言记账、小票识别、语音输入、账单分析、预算管理、借贷跟踪、多人协同和还款提醒等功能。
本文以该项目为例,从系统定位、整体架构、数据模型、核心业务流程、AI 能力集成、前后端设计、工程化建设和后续演进等方面,探讨一个记账系统背后的技术取舍。
一、项目定位:记账系统的繁琐度被低估了
许多人第一次设计记账系统时,会先建立一张“账单表”,其中包含金额、类型、时间和备注。这个模型足以应对单机笔记本,却很难承载真实家庭场景。
一个成熟的记账系统至少需要考虑以下需求:
1. 一笔钱从哪个账户流出,进入哪个账户;
2. 转账时如何保证两个账户余额同时变化;
3. 用户修改或删除账单后,账户余额怎样回溯;
4. 一个家庭账本中有哪些成员,各自拥有什么权限;
5. 分类如何支持多级结构和用户自定义;
6. 微信和支付宝账单如何导入;
7. 如何分析消费趋势、预测月末结余;
8. 如何向用户推送还款和信用卡账单提醒;
9. 数据导出、备份和删除如何满足安全要求;
10. 同一套业务如何同时适配移动端和桌面端。
因此,记账系统本质上不是一个简单的表单应用,而是一个具有账户一致性、权限边界、数据迁移和业务提醒要求的轻量级财务系统。
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 分类体系
项目预置了多组一级和二级分类。一级分类负责宏观统计,例如餐饮、交通和居住;二级分类负责细化场景,例如餐饮可以继续分为早餐、午餐、晚餐、外卖和奶茶咖啡。
系统需要处理好三件事:
1. 预置分类与用户自定义分类共存;
2. 用户删除分类后,历史账单仍然可以显示原分类名称;
3. 旧数据中的分类结构升级后可以平滑迁移。
这意味着分类不能被简单删除,至少应在业务层区分“停用”和“物理删除”。历史账单保存的该是分类快照或稳定标识,不能因为用户修改了当前分类名称,就改变过去的统计结果。
四、并发记账:金融场景务必解决的一致性问题
转账接口是记账系统中最典型的一致性场景。假设账户 A 有 1000 元,账户 B 有 500 元,两个并发请求分别执行:
- 从 A 向 B 转账 200 元;
- 从 B 向 A 转账 100 元。
如果程序先查询余额,再在应用层计算,最后执行更新,就可能出现两个请求同时读到旧余额,最终金额取决于哪一次写入后发生。即使金额计算在单个 Node.js 进程中没有出现竞态,只要未来增加集群、定时任务或批量导入,风险仍然存在。
项目使用数据库事务和 SELECT ... FOR UPDATE 锁定账户记录,这是一个合理的基础方案。其思路是:
1. 开启数据库事务;
2. 按固定顺序锁定相关账户;
3. 查询当前余额;
4. 校验余额和账户状态;
5. 更新账户余额;
6. 写入账单和审计流水;
7. 提交事务。
多账户按 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 导入流程
完整的导入流程可以分成六步:
1. 上传原始文件;
2. 检测编码并转换为统一文本;
3. 跳过说明行、汇总行和空行;
4. 识别表头与字段映射;
5. 逐行解析交易;
6. 返回可导入记录、跳过记录和分类建议。
用户确认后再执行第二步落库:
1. 开启数据库事务;
2. 根据交易单号判断是否已经导入;
3. 创建账单记录;
4. 更新相关账户余额;
5. 写入余额审计流水;
6. 保存导入批次;
7. 提交事务。
通过交易单号进行幂等判断,可以避免用户重复上传同一份文件时产生重复账单。
6.2 分类匹配不能影响用户确认
系统可以根据交易对方、商品名称和金额自动匹配分类,但自动匹配只能作为建议。最终分类和排除规则必须经过用户确认,因为“美团”可能对应餐饮、超市或电影,“转账”也可能属于人情、还款或账户内部划转。
如果文件过大,还该引入批次、进度、失败原因和继续导入机制。导入失败不能只提示“处理失败”,而应明确告诉用户:第几行金额格式错误、哪个字段为空、哪条记录已经导入。
七、AI 能力:把不确定性的模型放在业务边界之外
Smart Ledger 的 AI 功能不是简单地把用户输入发送给大模型,而是把 AI 作为业务辅助层。
项目接入了文本和视觉模型,用于自然语言记账、小票识别、消费分析、异常检测、月末预测、预算建议用和饮食建议。不同能力承担的职责并不相同。
AI 能力
输入
核心输出
业务风险
自然语言记账
文本
类型、金额、分类、账户、日期和备注
字段解析错误
小票识别
图片
商户、金额和日期
图片模糊或模型幻觉
账单分析
统计后的账单数据
消费结构与建议
过度解读数据
异常检测
两个周期统计数据
增幅与异常分类
周期数据不足
月末预测
当前支出和天数
月末支出或结余预测
线性假设失真
预算建议用
历史分类数据
分类预算建议
特殊月份干扰
7.1 自然语言记账
用户输入“今天午餐外卖30元”后,后端需要将其转换为结构化字段:
{
"type": "expense",
"amount": 30,
"category": "餐饮",
"subCategory": "外卖",
"account": "微信钱包",
"date": "2026-10-22",
"remark": "午餐外卖"
}
但 AI 输出不能直接写入数据库。后端仍要做好:
- 校验金额是否为正数;
- 判断账单类型是否合法;
- 校验账户是否属于当前账本;
- 校验分类是否存在;
- 对日期和时区进行标准化;
- 对低置信度结果要求用户确认。
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:个人中心和系统设置。
9.1 请求层需要统一封装
即使不使用框架,也应统一处理:
- 接口地址;
- 请求头;
- Token 注入;
- 登录失效;
- 业务错误码;
- 网络异常;
- 加载状态;
- 参数序列化。
9.2 弹窗与表单校验
记账弹窗、分类选择、筛选条件和确认删除都属于高频交互。建议建立统一的弹窗组件和表单校验函数,至少支持:
- 必填校验;
- 金额格式校验;
- 日期范围校验;
- 账户归属校验;
- 分类是否存在;
- 操作确认;
- 键盘与点击关闭。
十、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 时的空状态;
- 分类过多时的聚合;
- 日期不足一个完整周期;
- 图表与统计数字口径一致;
- 长分类名称截断;
- 高分屏下的内存占用。
十一、语音记账:能力增强不能替代手动输入
语音记账的流程是:
1. 浏览器获取麦克风权限;
2. 将语音转换为文字;
3. 对文字进行自然语言解析;
4. 预填记账表单;
5. 用户确认后保存。
Web Speech API 的浏览器支持并不均衡。Chrome 和 Edge 的支持相对较好,Firefox 默认未启用,iOS Safari 则对 HTTPS 和版本存在要求。 因此,语音只能作为快捷入口,不能成为唯一记账方式。
系统应在页面加载时进行能力检测:
const SpeechRecognition =
window.SpeechRecognition ||
window.webkitSpeechRecognition;
if (!SpeechRecognition) {
disableVoiceButton('当前浏览器不支持语音识别');
}
同时提供清晰的状态提示:
- 等待说话;
- 正在识别;
- 识别结果;
- 网络异常;
- 用户取消;
- 浏览器不支持。
十二、权限与数据安全:账本不是简单的用户数据
多人协同使权限模型从“用户拥有数据”变成“用户通过成员关系访问账本”。
系统至少需要区分:
- 账本所有者;
- 编辑者;
- 查看者。
角色
查看账单
新建账单
修改账单
删除账单
管理成员
所有者
是
是
是
是
是
编辑者
是
是
是
视规则
否
查看者
是
否
否
否
否
所有账单、账户、借贷和预算查询都必须先校验当前用户是否属于目标账本。不能只验证用户是否登录,否则可能出现水平越权。
密码应使用 bcrypt 等慢哈希算法处理,JWT 只用于无状态认证。生产环境还应:
- 使用 HTTPS;
- 设置合理的 Token 有效期;
- 对登录失败进行限流;
- 校验文件上传类型和大小;
- 防止 SQL 注入;
- 对敏感配置使用环境变量;
- 记录关键操作和异常请求。
十三、工程化与部署:让系统可以持续运行
项目通过环境变量区分开发和生产配置,使用 PM2 管理 Node.js 进程,并可配合 Nginx 进行反向代理。对于个人项目而言,这是一套成本低、可维护性较高的部署方案。
部署时需要重点关注:
1. 数据库定时备份;
2. 上传文件异地备份;
3. 环境变量与代码分离;
4. 日志自动轮转;
5. 服务异常重启;
6. 数据库迁移版本管理;
7. 接口请求日志;
8. 定时任务执行记录;
9. 上传目录权限;
10. 安全更新。
13.1 数据库迁移
项目将初始化脚本和增量脚本分开管理,这是一种值得坚持的实践。全新安装可以执行初始化脚本,已有系统则按日期顺序执行增量脚本。
升级前应备份数据库,升级后要检查:
- 表结构是否创建成功;
- 索引是否正常;
- 默认值是否生效;
- 历史数据是否兼容;
- 上传文件路径是否需要迁移。
13.2 日志要同时服务运维和审计
后端日志至少应区分:
- 请求日志;
- 业务日志;
- 数据库错误;
- AI 调用日志;
- 文件导入日志;
- 登录日志;
- 定时任务日志。
十四、可观测性与测试:家庭项目也需要质量底线
开源项目初期通常以功能实现为主,但随着用户增加,缺乏测试和日志会显著增加维护成本。
至少需要补充以下测试:
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 和反向代理支持自托管部署。
记账系统最核心的原则始终是:界面可以简单,交互可以快捷,但金额必须准确、权限必须清晰、操作必须可追踪。只要守住这三条底线,再逐步增加 AI、协同和数据分析,系统才会在功能变多的同时保持可信和可持续维护。
这篇笔记就先到这里,后面用到新的思路或者发现有问题再补充。
评论 (0)
暂无评论