ARTICLE DETAIL

资讯详情

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

用Python+Flask+SQLite打造自己的记账软件:从数据模型到报表

用Python+Flask+SQLite打造自己的记账软件:从数据模型到报表 简介这是一套基于Python开发的简易记账软件完整源码面向个人开发者、小型企业或刚入门PythonWeb前端的编程学习者用于快速搭建日常收支记录与订单管理的小型工具。源码包共37个文件包含20个Python脚本负责业务逻辑、数据访问与控制层、5个JavaScript脚本和4个HTML/CSS文件构建前端交互与界面另附配置文件与图片整体仅848KB轻量易部署。项目采用controller、dao、service分层结构并集成Bootstrap前端框架用户可据此理解前后端分离与分层设计思路。资源已有717人学习下载适合作为课程设计、毕业设计或练手项目参考可直接运行修改为学习Python桌面/Web应用开发提供完整示例。1. 为什么自己写一个 Python 记账软件市面上记账 App 多到数不清但真正让人坚持下来的没几个。要么被云端同步和会员体系绑架要么报表维度固定改不了分类规则更别提把账本数据导出做自己的分析。用 Excel 记又太松散公式改着改着就崩了。这份基于 Python 的简易记账软件设计源码正是为了解决这个矛盾——它不追求大而全而是用最直接的 Flask SQLite JavaScript 组合给你一套能完全掌控的数据模型和界面。你拿到手后可以改分类、改统计口径、加图表甚至接入自己的爬虫账单解析而不必等某个厂商发版。适合刚学完 Python 入门想练手项目的人也适合已经有几年经验、想把记账迁移到自托管体系下的开发者。方向很清晰先看数据层怎么建模再看前后端怎么对接最后落到报表和排错。2. 数据层设计从 SQLite 表结构到收支分类建模记账软件的核心不在界面而在数据模型。如果表结构设计得随意后面统计时你就得反复 join 和 case when性能差还容易错。这个源码使用的是 SQLitePython 标准库sqlite3直接驱动零配置起步单文件存储搬迁只需复制一个.db文件。理解这套结构是改写一切功能的前提。2.1 选型SQLite 还是 JSON 文件很多演示项目喜欢把账目存成 JSON 列表因为读写代码短比如json.dump(records, f)两行就完事。但一旦数据量超过几千条你要按月筛选、按分类聚合就得把整个 JSON 加载进内存写一堆手动循环代码膨胀不说性能也下降。SQLite 在本地单用户场景下是更合理的选择支持标准 SQLGROUP BY、ORDER BY、WHERE直接交给查询引擎事务机制保证写入中间出错不会让账本损坏索引能加速日期和分类字段的检索数据类型约束让金额、日期不会变成脏数据。如果你真打算用 JSON 做一个原型至少也要设计成按月份分文件或者引入jsonlines格式否则后面做统计时一定会后悔。这个源码采用的是 SQLite我就是基于这套表结构来讲的。2.2 表结构与索引设计账本领域最基础的两个实体是「账目记录」和「分类」。分类要支持层级比如「餐饮」下面有「早餐」「午餐」「外卖」这样才能在聚合时既看大项又看细项。源码的表结构大致如下CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, parent_id INTEGER DEFAULT 0, type TEXT NOT NULL DEFAULT expense CHECK(type IN (income, expense)) ); CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL CHECK(amount 0), category_id INTEGER NOT NULL, record_date TEXT NOT NULL, note TEXT, created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT ); CREATE INDEX idx_records_date ON records(record_date); CREATE INDEX idx_records_category ON records(category_id);categories表里的parent_id指向自身 ID0 表示顶级分类。type字段区分收入和支出避免在 records 里用正负号混淆。records表里没有直接存分类名而是存category_id这是为了规范化和后续改分类名时不用批改旧记录。特别注意CHECK(amount 0)它把金额的正负约束放在了数据库层而不是等 Python 代码来过滤能拦截掉大部分异常写入。idx_records_date和idx_records_category是两棵独立索引。日常查询通常按日期范围定位然后按分类聚合这两类索引可以显著减少扫描行数。当账目超过十万条时不加索引的 SQLite 查询可能延迟数秒加了索引后通常能降到几十毫秒。记得record_date统一存成YYYY-MM-DD字符串ISO 格式排序等价于时间排序不需要再转成时间戳。2.3 核心 CRUD 实现与参数说明数据访问层我用sqlite3.Row做行映射这样查询结果既能按下标访问又能按字段名访问比默认的元组更安全。下面是添加账目和按月汇总的核心函数import sqlite3 from contextlib import closing DB_PATH ledger.db def add_record(amount, category_id, record_date, note): with closing(sqlite3.connect(DB_PATH)) as conn: conn.execute( INSERT INTO records (amount, category_id, record_date, note) VALUES (?, ?, ?, ?), (amount, category_id, record_date, note) ) conn.commit() return True def month_summary(year, month): start f{year:04d}-{month:02d}-01 if month 12: next_start f{year 1:04d}-01-01 else: next_start f{year:04d}-{month 1:02d}-01 with closing(sqlite3.connect(DB_PATH)) as conn: conn.row_factory sqlite3.Row rows conn.execute( SELECT c.type, c.name, SUM(r.amount) AS total FROM records r JOIN categories c ON r.category_id c.id WHERE r.record_date ? AND r.record_date ? GROUP BY c.type, c.name ORDER BY total DESC , (start, next_start)).fetchall() return [dict(row) for row in rows]with closing(...)保证连接即使异常也会释放。加了commit()是因为 SQLite 默认在execute后必须手动提交否则下次连接看不到变更。month_summary的日期区间用 start AND next_start而不是直接BETWEEN这是为了避免字符串拼接处理月末最后一天时出错。GROUP BY c.type, c.name可以按「收入支出」和「分类名」双层汇总返回的列表每个元素都包含type、name、total三个字段前端拿过去直接渲染。注意conn.row_factory sqlite3.Row这段必须放在每次连接创建之后因为在with closing里连接的创建发生在赋值之前所以我把赋值放在with内第一行。如果你用连接池记得检查row_factory是否被其他线程改过。3. 前端交互Flask API 与 JavaScript 账单面板既然关键词里有前端开发和 JavaScript就说明这个源码不是纯命令行工具而是有一个浏览器端界面。Flask 在其中扮演的角色是轻量 API 服务它只负责接受请求、调用数据层、返回 JSON不在后端渲染 HTML 表格。这样前后端职责分离后续你想换 React 或 Vue 也不用改 Python 代码。3.1 Flask 路由与 JSON 接口项目里 Flask 部分主要暴露五个接口获取分类列表、新增账目、删除账目、按日查询、按月汇总。一个典型的路由定义如下from flask import Flask, request, jsonify from datetime import datetime app Flask(__name__) app.route(/api/records, methods[POST]) def create_record(): data request.get_json(forceTrue) try: amount round(float(data.get(amount, 0)), 2) if amount 0: return jsonify({error: 金额必须大于0}), 400 category_id int(data.get(categoryId)) record_date data.get(date, datetime.now().strftime(%Y-%m-%d)) except (TypeError, ValueError): return jsonify({error: 参数格式错误}), 400 add_record(amount, category_id, record_date, data.get(note, )) return jsonify({status: ok}), 201这里request.get_json(forceTrue)可以强制解析请求体即使前端忘了设置Content-Type: application/json也能拿到字典。round(float(...), 2)做了金额精度收敛避免浮点误差累积。删除接口我习惯用DELETE方法而不是GET因为删除是不可逆操作语义上必须区分app.route(/api/records/int:record_id, methods[DELETE]) def delete_record(record_id): with closing(sqlite3.connect(DB_PATH)) as conn: cur conn.execute(DELETE FROM records WHERE id ?, (record_id,)) conn.commit() if cur.rowcount 0: return jsonify({error: 记录不存在}), 404 return jsonify({status: deleted}), 200cur.rowcount是 SQLite 驱动提供的受影响行数用它在删除任务失败时返回 404比盲目返回成功更利于前端排错。注意这个接口依赖closing函数你需要在文件顶部同样from contextlib import closing不要重复发明上下文管理。3.2 前端与 API 对接前端页面用一个index.html承载业务逻辑写在app.js里通过fetch调用接口。请看下面的核心片段const API_BASE /api; async function loadCategories(type expense) { const resp await fetch(${API_BASE}/categories?type${type}); const data await resp.json(); const sel document.getElementById(categorySelect); sel.innerHTML ; data.forEach(c { const opt document.createElement(option); opt.value c.id; opt.textContent c.name; sel.appendChild(opt); }); } async function submitRecord(event) { event.preventDefault(); const payload { amount: Number(document.getElementById(amount).value), categoryId: Number(document.getElementById(categorySelect).value), date: document.getElementById(date).value, note: document.getElementById(note).value.trim() }; const resp await fetch(${API_BASE}/records, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }); if (resp.ok) { await refreshMonthTable(); } else { const err await resp.json(); alert(保存失败: ${err.error}); } }Number()转换后不会自动去空格前端表单里最好用input typenumber step0.01来限制输入。encodeURIComponent不需要用在categorySelect这个 ID 上因为它是数字。submitRecord里event.preventDefault()阻止表单默认刷新行为是必须的否则页面会闪一下所有状态清零。日期字段建议直接使用input typedate浏览器会输出YYYY-MM-DD格式与后端 SQLite 存储格式一致。如果你偏要自己拼日期字符串注意补零2024-5-1和2024-05-01在字符串范围比较时结果不同前面这个会排到所有 2024 年 5 月记录的最后因为字符5的 ASCII 码大于0。3.3 表单校验与金额处理金额是记账软件最容易出问题的字段前后端都要做校验。前端除了required属性我还建议加一个自定义函数来处理负数场景function validateAmount(input) { const val input.value.trim(); const regex /^\d(\.\d{1,2})?$/; if (!regex.test(val) || Number(val) 0) { input.classList.add(is-invalid); return false; } input.classList.remove(is-invalid); return true; }这个正则只允许「整数部分 最多两位小数」00.10这类值会被拦下0.1能通过但会被后端 round 成0.10。为什么不直接Number(val)再判断因为Number(1e3)等于 1000会被误判为合法而记账时一般人不会输入科学计数法。正则做一次结构性校验后端再做一次数值范围校验两层防护才能挡住脏数据。4. 统计报表用 pandas 做月度分析并生成图表记账的最终目的是看钱花到哪儿了。这个源码的报表模块用 pandas 代替手写 SQL 聚合因为 pandas 在二次加工和可视化方面的生态更完整。你可以直接操作 DataFrame 做透视表、环比计算再一行代码出图。下面讲讲我常用的统计方案。4.1 聚合查询与透视表按月汇总后如果还想看到每个大类下的小分类分布SQL 写起来会越来越绕。这时候把原始记录拉出来交给 pandas 更灵活import pandas as pd def load_records_df(start_date, end_date): with closing(sqlite3.connect(DB_PATH)) as conn: df pd.read_sql_query( SELECT r.id, r.record_date, r.amount, r.note, c.name AS category, c.type FROM records r LEFT JOIN categories c ON r.category_id c.id WHERE r.record_date BETWEEN ? AND ? , conn, params(start_date, end_date)) return df def pivot_by_category(df): df[month] pd.to_datetime(df[record_date]).dt.to_period(M) pivot df.pivot_table( indexcategory, columnsmonth, valuesamount, aggfuncsum, fill_value0 ) return pivotpd.read_sql_query的params参数用来绑定 SQL 变量防止注入。BETWEEN在这个场景里可以安全使用因为end_date你可以在外部补成月末最后一天。dt.to_period(M)是 pandas 里把时间戳归一到「月」的最快方式比strftime(%Y-%m)快得多因为它生成的是 Period 对象后续排序天然正确。透视表的行是分类列是月份值是金额总和。这个结构非常适合画堆叠柱状图比如看餐饮连续三个月的上升趋势。4.2 图表展示与中文乱码处理如果你用 matplotlib 生成图表中文标签默认会变成方框。原因是 matplotlib 的默认字体列表里没有中文字体需要手动指定import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [Microsoft YaHei, SimHei, PingFang SC] plt.rcParams[axes.unicode_minus] Falseaxes.unicode_minus设置为 False这样负号显示不会是方块。接下来画月度支出对比图def plot_monthly_expense(df): expense df[df[type] expense].copy() expense[month] pd.to_datetime(expense[record_date]).dt.to_period(M).astype(str) monthly expense.groupby(month)[amount].sum() plt.figure(figsize(10, 5)) monthly.plot(kindbar, color#4C72B0) plt.title(每月支出总额) plt.xlabel(月份) plt.ylabel(金额元) plt.tight_layout() plt.savefig(static/monthly_expense.png, dpi150)astype(str)这一步很关键。直接对 Period 索引做 bar 图有时会报负值索引错误转成字符串后索引变成最普通的[2024-01, 2024-02]排序也正确。tight_layout()避免标签被截断。生成出来的 PNG 放在static目录里前端用img src/static/monthly_expense.png引入即可。4.3 性能优化预聚合与缓存当账目数据量增长到几十万条每次报表请求都重新查全表再 groupby 就不是好方案了。常见做法是在写入记录时同步更新一张日汇总表CREATE TABLE IF NOT EXISTS daily_summary ( summary_date TEXT NOT NULL, type TEXT NOT NULL, category_id INTEGER NOT NULL, total_amount REAL NOT NULL, PRIMARY KEY (summary_date, category_id) );每次add_record成功后在同一个事务里插入或累加daily_summary查询报表时直接SELECT * FROM daily_summary WHERE summary_date BETWEEN ? AND ?数据量从几十万降到几百行速度提升两个数量级。这个预聚合思路本质上就是金融系统里的「日终跑批」只不过这里用事务实时更新。维护成本是要防重复更新所以我在源码里建议用INSERT ... ON CONFLICT DO UPDATE做幂等写入INSERT INTO daily_summary (summary_date, type, category_id, total_amount) VALUES (?, ?, ?, ?) ON CONFLICT(summary_date, category_id) DO UPDATE SET total_amount total_amount excluded.total_amount;excluded是 SQLite 对插入但未生效的值的引用。这个语句会先尝试插入如果主键冲突就累加。但你要注意用这个模式时删除记录必须同步回扣否则报表会虚高。我一般把删除操作也包一层事务在删records的同时更新daily_summary并在出错时rollback。5. 进阶技巧数据导入导出、定时备份与常见坑这部分是把项目真正用起来的关键。很多人写完记账软件就丢了因为数据是孤岛。把导出、备份和边界情况处理好这个工具才能陪你度过长期记账的周期。5.1 导出 CSV/Excel财务报表经常要提交给 Excel 做二次加工所以导出功能很有必要。CSV 是最通用的格式但 Excel 打开 CSV 中文容易乱码因为 Python 默认用 UTF-8 编码而 Windows 版 Excel 默认吃 GBK。解决方案是写入 BOM 头import csv def export_csv(rows, filepath): with open(filepath, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[id, record_date, category, amount, note]) writer.writeheader() writer.writerows(rows)utf-8-sig会在文件开头写入三个字节的 BOMExcel 看到 BOM 就自动识别为 UTF-8中文不再乱码。newline防止 Windows 下出现多余空行。如果你要导出 Excel 的.xlsx更推荐用pandas.to_excel前提是装了openpyxldf.to_excel(账本导出.xlsx, indexFalse, sheet_name流水)注意直接在文件名里用中文是可以的但路径中如果有特殊字符最好os.path.abspath规范化一次。5.2 定时备份与版本迁移SQLite 是单文件备份最简单的方式就是用 Python 的sqlite3.backup方法它可以在数据库运行期间做一致性快照比复制文件安全得多。下面实现一个每天凌晨执行一次的备份任务import sqlite3 import shutil from datetime import datetime def backup_db(): src sqlite3.connect(DB_PATH) dst_path fbackups/ledger_{datetime.now():%Y%m%d_%H%M%S}.db dst sqlite3.connect(dst_path) src.backup(dst) dst.close() src.close() return dst_path这个方法在线备份时不会锁库因为 SQLite 的 backup API 会自动处理读事务。如果只是简单复制一旦有写入操作正在执行复制出的文件可能是损坏的。我通常用系统计划任务每天调一次backup_db保留最近 30 份早期版本删除。如果之后你要改表结构比如给 records 表加一个账户字段先备份再执行ALTER TABLE。SQLite 对ALTER TABLE的支持有限加列可以改列类型不能直接做。稳妥的做法是新建新表把旧数据 insert 过去然后DROP旧表这个迁移过程要放到事务里with closing(sqlite3.connect(DB_PATH)) as conn: conn.executescript( BEGIN; CREATE TABLE records_v2 (...新结构...); INSERT INTO records_v2 (id, amount, category_id, record_date, note) SELECT id, amount, category_id, record_date, note FROM records; DROP TABLE records; ALTER TABLE records_v2 RENAME TO records; COMMIT; )5.3 常见坑及排查金额浮点误差SQLite 的REAL是 IEEE 双精度0.1 0.2会得到0.30000000000000004。虽然保存时 round 到两位但累计求和时误差会放大。最稳妥的做法是金额直接存「分」为整数展示时再除以 100。如果没改表结构至少在聚合查询里用ROUND(SUM(amount), 2)。时区问题datetime(now, localtime)虽然取了本地时间但如果部署在服务器上服务器时区没设对记录时间就错乱。建议在连接建立后执行PRAGMA timezone检查或者统一用 UTC 存储展示时再转本地时区。这里源码用的是本地时间所以部署时记得timedatectl set-timezone Asia/Shanghai。SQLite 并发写入默认下 SQLite 同一时间只允许一个写入连接多线程同时写会报database is locked。解决方式不是去加复杂机制而是在连接时设置较短超时并把写入操作合并到同一个事务里sqlite3.connect(DB_PATH, timeout10)timeout10表示等待 10 秒拿写锁超过就超时。如果你用了 Flask 的多线程模式建议把数据库连接控制在单例内或者交给threading.local()做线程隔离。绝大多数家庭和个人记账场景单连接就够不需要上分布式方案。前端 fetch 缓存开发时经常会碰到更新接口后前端还在用旧数据这不是后端 bug而是浏览器对GET请求默认做了缓存。在fetch里加cache: no-store即可绕过fetch(${API_BASE}/records?month${month}, { cache: no-store })或者更彻底一点在 Flask 响应头里加上Cache-Control: no-store。排查接口异常时先打开浏览器开发者工具的 Network 面板看响应状态码再结合 Python 日志看报错通常比乱猜快得多。本文还有配套的精品资源点击获取
返回列表