ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建个人记账应用:数据库设计与联调实战

从零搭建个人记账应用:数据库设计与联调实战 第二篇来了。上期把从头开始的一个项目的技术方案定下来之后一直有朋友追问后续该怎么走数据库怎么设计前后端到底怎么才能连起来这期我直接用实际进度来回答——项目已经从只有 README 和 hello world 的空壳推进到了能真实记账、能查流水、能看月汇总的可运行状态。这个系列记录的是我用业余时间从零搭建一个个人记账 Web 应用的完整过程项目暂定名轻账本。选记账应用当练手项目是因为它足够典型有清晰的数据模型、有增删改查、有统计聚合、有前后端交互但又不会复杂到让人写到一半想放弃。如果你刚学完基础语法、正愁不知道下一步干什么或者想看看一个看起来真能用的小项目是怎么一步步长出来的这篇值得读完。这一期完全按照实际开发顺序来写数据库设计、后端接口、前端骨架、端到端联调最后附上这期真实踩过的坑和排查方法。所有代码都是运行过、验证过的版本不是纸上谈兵。1. 项目回顾与本期目标1.1 第一期做了什么简单交代一下上一期的成果。第一期没有急着写业务代码而是做了三件打地基的事。第一需求收敛。我把记账这个模糊的词拆成了四个核心场景记一笔账区分收入和支出、查看账目流水、按分类和月份筛选、看月度收支汇总。整个项目后续的所有设计都是从这四个场景推导出来的。当时多花了一个晚上做这个拆解后面写表和接口的时候基本没有推倒重来过。第二技术选型。最终定的是前后端分离架构后端 Python Flask前端 Vue 3 Vite数据库 SQLite。选型理由其实很朴素个人项目追求低摩擦。SQLite 不用单独安装数据库服务一个文件就是整个库备份就是把文件复制走Flask 的路由和请求处理很直观适合快速把接口立起来Vue 3 的 Composition API 配合script setup语法写页面逻辑比选项式 API 清爽不少。第三环境落地。Python 3.11、Node.js 18、Vite 脚手架全部装好前后端两个目录各自初始化前端调通了后端的一个 /api/ping 探活接口。这步的价值是把环境问题和业务问题提前切开之后写业务代码再遇到报错基本不用怀疑是环境没配好。1.2 第二期的四个交付里程碑这一期动手前我给自己定了四个里程碑每个都有明确的可验证标准数据库层三张核心表建好初始化脚本能跑通测试数据能写入。接口层记账、列表、月度汇总三个接口全部可用Postman 调用返回正确。页面层页面路由能跳转记账表单和流水列表的框架搭起来。集成层浏览器里提交一笔账数据落库列表页回显整条链路通。四个里程碑的顺序其实就是依赖关系先有表才有接口先有接口才有联调。每个里程碑完成都会立刻验证而不是攒到最后一起测试。这样做的实际好处是出问题时能快速锁层——页面空白查前端接口 500 查后端数据不出来查格式。对业余时间开发来说这种随时能停下来、进度不丢的节奏太重要了。1.3 动手前的最后一件事git 分支留痕既然这个系列叫记录那代码本身的记录也不能马虎。写代码前我先把仓库清理了一遍main 分支保持可运行状态拉了一个 dev 分支专门开发这期功能每个里程碑跑通后打一个 tag比如 v0.2-db、v0.2-api。这个小习惯在后期排查时帮了大忙。有一次我把记账接口的参数名改了导致前端提交一直 400我直接 diff 两个 tag 之间的代码几秒钟就定位到了改动点。如果当时不分支不打 tag全靠记忆回溯心态很容易崩。这个系列之所以能一直往下写靠的就是这种每一步都有记录的踏实感。2. 数据库设计先把数据的容器想清楚2.1 从场景推导出三张表很多新手拿到需求第一反应是建一张大表把所有字段塞进去。我不建议这么干。我是先从四个业务场景出发找出里面反复出现的名词再把名词归纳成实体。四个场景里反复出现的名词有钱、分类、时间、备注、收入/支出类型。再往下抽象会发现三个独立的实体分类category、账目记录transaction、以及一个看起来不起眼但其实很关键的东西——账本ledger。虽然我一开始只做单用户不涉及多账本但给每笔账挂一个 ledger_id 几乎不增加成本以后如果要支持家庭共享账本或者多账本切换就不用动表结构了。所以最终是三张表ledger账本、category分类、transaction账目流水。这个结构既不复杂又给未来留了扩展位。设计数据库最忌讳的就是面向当前写死稍微预留一点后面能省很多事。2.2 三个关键设计决定设计字段时我做了三个比较关键的决定每一个都是踩过或者想过坑之后定下来的。第一个决定金额用整数存分不用浮点数。这是记账类应用最经典的一个坑。0.1 0.2 在浮点数运算里不等于 0.3如果直接用 REAL 存金额累计统计时会出现 0.30000000000000004 这种结果。前端展示时用户可以输入12.5但后端统一转换成 1250分存储只有展示时才除以 100。整数运算永远精确这条原则不能妥协。第二个决定时间统一存文本格式 YYYY-MM-DD HH:MM:SS。SQLite 本身有 datetime 类型但文本格式在排序、比较、传输时最不容易出歧义。尤其前后端之间传时间时ISO 格式字符串是大家都能理解的通用语言。我这里用本地时间而不是 UTC 存储因为记账是强本地场景用户记一笔晚上8点吃饭存成 UTC 会因为时区转换变成中午12点展示时还得转回来纯属自找麻烦。第三个决定分类表里加一个 type 字段区分收入分类和支出分类。一开始我想偷懒用固定列表写死分类但转念一想分类是典型的字典数据未来用户肯定想自定义。把分类独立成表再通过 type 区分收入和支出添加、修改分类就都变成了普通的增删改查不需要改代码。2.3 建表脚本与初始化数据数据库脚本我放在后端项目的 db/ 目录下文件名叫 schema.sql方便版本管理。建表的核心 SQL 如下CREATE TABLE IF NOT EXISTS ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE TABLE IF NOT EXISTS category ( id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_id INTEGER NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN (expense, income)), created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)), FOREIGN KEY (ledger_id) REFERENCES ledger(id) ); CREATE TABLE IF NOT EXISTS transaction ( id INTEGER PRIMARY KEY AUTOINCREMENT, ledger_id INTEGER NOT NULL, category_id INTEGER NOT NULL, amount_cents INTEGER NOT NULL CHECK (amount_cents 0), type TEXT NOT NULL CHECK (type IN (expense, income)), note TEXT DEFAULT , happened_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)), FOREIGN KEY (ledger_id) REFERENCES ledger(id), FOREIGN KEY (category_id) REFERENCES category(id) );注意几个细节amount_cents 用了整数并加了 CHECK 约束防止负金额入库category 和 transaction 都通过 ledger_id 关联到账本type 字段用 CHECK 约束限定只能填 expense 或 income从数据库层面杜绝脏数据。初始化时我会先插入默认的账本和分类数据让页面一打开就有内容可看。默认分类我设计了九宫格支出类放餐饮、交通、购物、居住、娱乐、医疗收入类放工资、奖金、其他。每个分类一条 INSERT 语句放在 seed.sql 里和建表脚本分开这样以后重置数据不会误删表结构。2.4 顺手封装一个数据访问层表建好之后我没有直接在路由函数里写 SQL而是单独封装了一个 db.py 模块。里面主要做两件事获取数据库连接、执行查询并返回字典格式的结果。import sqlite3 from flask import g DATABASE instance/ledger.db def get_db(): if db not in g: g.db sqlite3.connect(DATABASE) g.db.row_factory sqlite3.Row g.db.execute(PRAGMA foreign_keys ON) return g.db def close_db(eNone): db g.pop(db, None) if db is not None: db.close()这里有两个值得说的细节。第一row_factory 设为 sqlite3.Row查询结果就能像字典一样用字段名访问而不是只能按下标取。第二每次连接都执行 PRAGMA foreign_keys ON确保外键约束真正生效——SQLite 默认不检查外键不显式开启的话category_id 填一个不存在的 id 也照样能插入这是个很容易被忽略的坑。3. 后端接口让数据流动起来3.1 路由规划遵循 REST 风格数据库就绪后我开始写接口。接口路径我完全按照 REST 风格规划一眼就能看出操作对象和动作POST /api/transactions新增一笔账GET /api/transactions获取账目列表支持分类、月份筛选DELETE /api/transactions/ 删除一笔账GET /api/summary?month2025-07获取月度收支汇总GET /api/categories获取分类列表选择 REST 不是因为它流行而是因为它把资源和方法拆得很清楚。比如 /api/transactions 这个路径配合不同的 HTTP 方法就对应了不同的操作前端调用时逻辑非常直观。另外路径里尽量避免动词比如不要写成 /api/get_transactions因为获取这个动作本身已经由 GET 表达了。3.2 记账接口的实现细节新增记账是最核心的接口我把它完整贴出来看app.route(/api/transactions, methods[POST]) def create_transaction(): data request.get_json(silentTrue) if not data: return jsonify({code: 400, message: 请求体不是合法的 JSON}), 400 required [ledger_id, category_id, amount_cents, type, happened_at] for field in required: if field not in data: return jsonify({code: 400, message: f缺少必填字段: {field}}), 400 if data[type] not in (expense, income): return jsonify({code: 400, message: type 只能是 expense 或 income}), 400 db get_db() db.execute( INSERT INTO transaction (ledger_id, category_id, amount_cents, type, note, happened_at) VALUES (?, ?, ?, ?, ?, ?), (data[ledger_id], data[category_id], data[amount_cents], data[type], data.get(note, ), data[happened_at]) ) db.commit() return jsonify({code: 0, message: ok, id: db.execute(SELECT last_insert_rowid()).fetchone()[0]})这个接口看着简单但里面有三个容易被忽略的点。第一使用了参数化查询? 占位符而不是字符串拼接 SQL。这是防 SQL 注入的基本功哪怕是自己一个人用的项目也应该养成习惯字符串拼接 SQL 的教训在网上随便一搜就是一片。第二校验逻辑放在最前面逐项检查必填字段。接口是系统的入口关卡不合法请求一定要在入口拦下而不是等到数据库报错。这里我采用快速失败原则——第一个错误就返回避免用户改完一个错又冒出另一个错。第三get_json(silentTrue) 这个参数的用意是如果请求体不是合法 JSON不抛异常而是返回 None再由我统一返回 400。否则 Flask 默认会抛 400 异常返回的错误信息是英文 HTML 页面前端很难友好处理。3.3 列表接口与月度汇总的聚合查询列表接口要比新增接口复杂一些因为涉及筛选。我的做法是先写出基础 SQL然后根据请求参数动态拼接 WHERE 条件app.route(/api/transactions, methods[GET]) def list_transactions(): db get_db() sql SELECT * FROM transaction WHERE ledger_id ? params [request.args.get(ledger_id, 1, typeint)] month request.args.get(month) if month: sql AND substr(happened_at, 1, 7) ? params.append(month) category_id request.args.get(category_id, typeint) if category_id: sql AND category_id ? params.append(category_id) sql ORDER BY happened_at DESC LIMIT 100 rows db.execute(sql, params).fetchall() return jsonify({code: 0, data: [dict(r) for r in rows]})这里用了一个小技巧通过 substr(happened_at, 1, 7) 截取日期的前 7 位得到YYYY-MM直接和 month 参数比较。因为 happened_at 是文本格式的 ISO 时间这种字符串截取天然高效不用额外做日期函数转换。月份筛选这个需求就被一行代码解决了。月度汇总接口则是典型的聚合查询SQL 如下SELECT type, SUM(amount_cents) AS total_cents FROM transaction WHERE ledger_id 1 AND substr(happened_at, 1, 7) 2025-07 GROUP BY type;返回结果后后端把分转换成元的字符串再返回给前端。需要强调一点单位转换只在展示边界做后端存的是分前端显示时除以 100。不要在数据库层存元也不要在接口层传元否则迟早会出现某处忘记转换、汇总差 100 倍的 bug。3.4 统一响应结构与异常兜底所有接口我都统一返回 {code, message, data, id} 这种结构。code 为 0 表示成功非 0 表示业务错误HTTP 状态码也同步配合。之所以要统一是为了让前端只需要写一套处理逻辑先看 code再看 data。如果每个接口返回格式都不一样前端每个调用都要单独写解析代码会迅速发臭。另外我注册了一个全局异常处理器app.errorhandler(500) def handle_500(e): return jsonify({code: 500, message: 服务器内部错误}), 500SQLite 是单文件数据库偶尔会出现数据库被锁、磁盘空间不足这类运行时错误。如果不做兜底前端拿到的会是 HTML 错误页用户体验很差。有了这个全局处理器至少前端能拿到结构化错误信息排查也方便。4. 前端骨架与前后端联调4.1 页面规划与路由设计前端我用 Vue 3 Vite 搭的。这一期只规划了两个页面登录页和首页。登录页暂时只是门面——因为单用户项目还没有完整的用户体系我把它设计成输入账本名称和密码后进入首页后续再接真正的认证逻辑。首页是这个应用的主战场分三个区域顶部是本月收支汇总卡片中间是记账表单底部是最近的账目流水列表。路由设计也很有讲究。我用了两个路由const routes [ { path: /, component: LoginPage }, { path: /home, component: HomePage, meta: { requiresAuth: true } } ]path 用语义化的名称而不是 /page1、/page2。meta 里标了 requiresAuth之后接路由守卫拦截未登录访问时只需要在守卫里读这个标记就行不用为每个页面单独写判断。4.2 记账表单的实现记账表单是这一期前端最核心的部分。我用 Composition API 实现核心代码如下script setup import { reactive, computed } from vue const form reactive({ amount: , category_id: null, type: expense, note: , happened_at: }) const amountCents computed(() { const v parseFloat(form.amount) return isNaN(v) ? 0 : Math.round(v * 100) }) async function submit() { if (!form.amount || !form.category_id || !form.happened_at) { alert(请填写金额、分类和时间) return } const resp await fetch(/api/transactions, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ ...form, amount_cents: amountCents.value }) }) const result await resp.json() if (result.code 0) { // 重新拉取列表并清空表单 loadTransactions() form.amount form.note } else { alert(result.message) } } /script这里有一个我在第一期就定下的约定前端只负责录入和展示所有计算和校验以业务关键逻辑为准。比如金额转换用户输入12.5元前端用 Math.round(v * 100) 转成 1250 分传给后端。如果后端发现这个字段缺失或类型不对直接返回 400前端弹出 message。前后端各守一道关但不重复实现相同的复杂逻辑。4.3 CORS 与本地代理配置前后端分离开发时最常用的调试方式是把前端跑在 5173 端口后端跑在 5000 端口。这时候浏览器发起的请求属于跨域请求如果你直接在前端代码里把接口地址写成 http://localhost:5000会撞上浏览器的同源策略。处理这个问题的标准做法有两种后端开 CORS或者前端配代理。我的选择是前端配代理。在 vite.config.js 里加一段配置export default defineConfig({ server: { proxy: { /api: { target: http://localhost:5000, changeOrigin: true } } } })这样前端代码里所有 fetch(/api/transactions) 都是相对路径开发时由 Vite 代理转发到后端部署时只要把前端静态文件交给同一个后端服务托管路径不用改一行。这个方案的优雅之处在于代码里永远不用出现写死的后端地址环境差异被代理层消化掉了。5. 这一期踩过的坑与排查实录5.1 金额浮点数精度问题我在联调时第一次发现金额不对是在测试0.1 0.2这个经典场景。当时数据库里已经存了 REAL 类型统计 0.1 和 0.2 两笔账期望 0.3结果返回 0.30000000000000004。排查过程其实不难先在数据库里直接跑 SELECT SUM(amount) FROM transaction发现返回的就已经是那个奇怪的浮点数说明问题在存储层和前端无关。解决方案就是 2.2 节说的金额字段改为整数存分。这里补充一个迁移时的注意事项不要在原有表上直接修改字段类型SQLite 对 ALTER COLUMN 的支持很有限。正确做法是新建一张新表、把旧数据转换后插入、然后删掉旧表改名为新表。数据量小的时候这个操作非常快几秒钟就完成。5.2 SQLite 并发写导致的 database is locked有一次我在一个请求里先开启了事务然后在前端快速点了两次提交第二次请求直接报 database is locked。这是因为 SQLite 同一时间只允许一个写连接两个写请求并发到达时后来的会拿不到写锁。解决方案有两个层面。第一层是代码层面所有写操作保持短小及时 commit不要在一个事务里做耗时的外部调用比如请求第三方接口。第二层是配置层面SQLite 连接时可以设置 timeoutsqlite3.connect(DATABASE, timeout10) 表示拿锁等待最多 10 秒超过才报错。我两个层面都做了后来再没遇到这个错。5.3 时间显示的时区偏差联调时我还发现一个现象我在页面上选2025-07-20 20:30记账数据库里存的却是2025-07-20 12:30。查了一圈发现是前端原生 input datetime-local 组件输出的值是本地时间但我在 JavaScript 里用 new Date().toISOString() 转换时它把本地时间当成 UTC 输出导致整体偏移了 8 小时。解决方案很简单前端直接用组件输出的字符串传给后端不要在中间做任何 timezone 转换后端存储也保持原样接收。也就是说时间字段从输入到存储全程当字符串对待只在展示时格式化。想明白这一点之后时区问题就彻底消失了。凡是出现时间错乱九成是有人在某个环节偷偷做了时区转换。5.4 常见问题速查表整理一个这一期遇到问题的速查表方便你对照排查现象可能原因处理方式新增接口返回 400请求体缺少必填字段或不是合法 JSON检查前端提交字段名与后端 required 列表是否一致金额汇总多出小数尾巴金额用了浮点存储改为整数存分仅在展示层转元页面请求 /api 404前端代理没生效或后端未启动检查 Vite proxy 配置确认后端监听 5000 端口database is locked多个写请求并发写事务保持短小并 commit连接设置 timeout时间显示差 8 小时某处做了时区转换时间全链路按字符串处理不做 timezone 转换外键约束不生效SQLite 默认关闭外键检查连接后执行 PRAGMA foreign_keys ON这一期从建表到前后端打通总共花了我大概四个晚上的业余时间。说实话最终的成就感不是来自某一个单独的技术点而是来自那条完整的链路在页面上点一下提交数据穿过前端表单、代理、后端接口、SQLite最后又回到页面的列表里。这种自己能掌控一整条链路的感觉是看多少教程都换不来的。最后再分享一个小经验写这种从零开始的项目最重要的不是用多新潮的技术而是把每一步都变成可验证的小节点。每完成一个节点就提交一次代码、记录一下结果。我这个系列之所以敢说记录从头开始就是因为每一步都有据可查。下一期我计划把登录认证补上再用图表库把统计视图画出来。如果你也在做类似的项目希望你也能把自己的进度和踩坑记下来互相参照着往前走。
返回列表