简介:这份资源是一套基于Python开发的简易记账软件设计源码,面向Python学习者、前端开发人员以及需要日常收支管理的个人和小型企业。软件采用Python处理后端业务逻辑,结合HTML/CSS构建界面,并由JavaScript增强交互,实现账目记录、查询与管理等功能。压缩包共37个文件,大小约848KB,其中包含20个Python脚本作为核心业务模块,5个JavaScript脚本和4个CSS、4个HTML文件共同完成前端展示与动态交互,另含辅助配置与说明文件,整体结构清晰。源码按controller、dao、service等分层组织,体现了控制层、数据访问层与业务逻辑层分离的设计思想,便于阅读和二次开发。目前已有718人学习下载,适合希望参考完整项目管理方式、了解Python结合前端技术实现实际应用的开发者。
1. 一张 Python 记账源码到底能解决什么:先看清它的边界
微信、支付宝、银行卡三个 App 的账单各自为政,月底想对一笔账要来回切换,这时候“基于 Python 的简易记账软件设计源码”就派上用场了。它的核心并不复杂:把每一笔收支变成一行结构化数据,再配上增删改查、汇总和图表。标题里“简易”两个字很重要——它意味着不依赖外部服务、不部署数据库服务器、不改业务逻辑就能跑起来,数据全部留在本机一个文件里。适合刚入门 Python、想拿完整源码练手的学生,也适合习惯本地记账、不想把账目交给第三方 App 的用户。接下来这篇笔记会从数据模型、主流程、统计分析一路说到实测踩坑,照着复现一遍,你会拿到一个真正能记账、能查询、能出图的小系统。
2. 先定数据模型:简易记账为什么优先选 SQLite,而不是 CSV 文本
2.1 三种存储方案的取舍:CSV 方便打开,SQLite 更适合当正经账本
很多初学源码喜欢把账目存在二维列表或字典里,程序一退出数据就没了;稍微进阶一点的写法是存 CSV,用 Excel 能直接打开,看起来非常“透明”。但记账软件一旦运行超过一个月,CSV 的短板就暴露出来了:按日期区间查询需要遍历全部行,做月度汇总要把所有数据读进内存再分组,修改一条记录要先整文件读入、替换、再整文件写回。数据量到几千行时,这个开销还不明显,到几万行就会感觉到每次保存都变慢。更麻烦的是多进程或多次运行时,共享写同一个 CSV 会有数据覆盖风险。
我一般会把存储方案压缩成一张表来选型。
| 存储方式 | 适合场景 | 主要缺点 |
|---|---|---|
| 纯文本/二维列表 | 学习演示、几十条数据 | 无索引、无并发、无法按条件高效查询 |
| JSON | 配置类数据、结构嵌套较多 | 只能整体加载,不适合聚合统计 |
| CSV | 需要频繁用 Excel 手工查看 | 没有事务、并发写有风险、修改麻烦 |
| SQLite | 本地账本、需要查询统计 | 需要会一点 SQL,但对记账足够了 |
SQLite 本质上是一个单文件的嵌入式关系型数据库,Python 标准库自带 sqlite3 模块,不需要安装额外服务。记账软件这个场景最大的需求就是“按日期过滤、按分类汇总”,这恰好是 SQL 的强项:一条 SELECT 加 GROUP BY 就能完成,不需要在 Python 里手写分组逻辑。对于单人使用的记账工具,SQLite 在可靠性、可移植性和查询能力之间是最平衡的选择。
2.2 核心表结构与建表语句:把账目拆成时间、分类、金额、备注四件事
设计数据模型时不要一上来就建立五张表做外键关联。简易记账的核心是流水,一张 transactions 表足够承载主要业务,再配一张 categories 表管分类,两张表足矣。transactions 表我习惯存放这些字段:id 主键、trans_date 交易日期、category 分类名、amount 金额、note 备注、is_deleted 软删除标记、created_at 创建时间。其中 is_deleted 不是神经过敏,而是为了给后面“误删后悔药”留后路,这一坑在第 5 章会专门展开。
CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, trans_date TEXT NOT NULL, category TEXT NOT NULL, amount REAL NOT NULL, note TEXT DEFAULT '', is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('expense', 'income')) ); INSERT OR IGNORE INTO categories(name, kind) VALUES ('餐饮', 'expense'), ('交通', 'expense'), ('购物', 'expense'), ('工资', 'income'), ('其他', 'expense');这段 SQL 里有两个细节需要注意:trans_date 用 TEXT 存而不是 DATETIME,是因为记账软件的日期比较永远走 ISO 格式字符串,比如 2025-01-15,字符串比较早、中、晚天然有序,不涉及时区问题;amount 用 REAL 存储会带来浮点尾差,这是第 5 章的重点,真正生产级账本更应该用“分”为单位的整数存储,源码阶段先保留 REAL 便于理解,后续迁移可以加一个转换函数。
初始化数据库的 Python 代码可以这样写:
import sqlite3 DB_PATH = "ledger.db" def get_conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def init_db(): conn = get_conn() conn.executescript(""" CREATE TABLE IF NOT EXISTS transactions (...); CREATE TABLE IF NOT EXISTS categories (...); INSERT OR IGNORE INTO categories(name, kind) VALUES ...; """) conn.commit() conn.close()逻辑说明:get_conn 里设置了 row_factory 为 sqlite3.Row,让查询结果可以按字段名访问而不是只能按下标取,可读性大幅提升。init_db 用 executescript 一次性执行多条 SQL 建表和种子数据插入。INSERT OR IGNORE 依赖 categories 表 name 字段的唯一索引,保证每次启动程序重复执行初始化脚本都不会产生重复分类。数据库文件 ledger.db 会生成在当前工作目录下,建议在项目根目录运行脚本,避免路径错乱找不到账本文件。
3. 把记账主流程跑通:增删改查与每日汇总的最小实现
3.1 录入和查询:先把一笔支出安静地写进库里
记账软件最核心的两个操作是写和读。录入时要注意“先校验、后落库”,尤其是日期和金额的合法性。日期格式统一为 YYYY-MM-DD,金额要能转成浮点数且大于 0。校验放在录入函数入口处的好处是:后面不管是命令行调用、GUI 调用还是未来写 Web 接口,所有入口共享同一套校验逻辑,不会因为入口变多而出现脏数据。
def add_transaction(trans_date, category, amount, note=""): conn = get_conn() try: conn.execute( "INSERT INTO transactions(trans_date, category, amount, note) " "VALUES(?, ?, ?, ?)", (trans_date, category, amount, note) ) conn.commit() except sqlite3.Error as e: conn.rollback() print(f"入库失败: {e}") finally: conn.close()参数说明:trans_date 必须是字符串,格式 YYYY-MM-DD;category 建议先用 categories 表里的名字,如果传入不存在的分类,当前设计并不会报错,这也是简易版本刻意降低耦合的做法,一旦实际使用中发现分类混乱,再改成外键约束也不迟;amount 用浮点数,存在尾差问题,见第 5 章。execute 使用了 ? 占位符传参,而不是用字符串拼接,这是为了防止 SQL 注入,也是一名 Python 开发者的基本习惯:任何进数据库的字符串都不得直接拼进 SQL。
查询函数则需要支持参数控制条数,List 场景下通常只取最近 20 笔,而不是一次加载全部历史记录。
def list_transactions(limit=20): conn = get_conn() rows = conn.execute( "SELECT id, trans_date, category, amount, note " "FROM transactions " "WHERE is_deleted = 0 " "ORDER BY trans_date DESC, id DESC " "LIMIT ?", (limit,) ).fetchall() conn.close() return rows逻辑说明:排序先按日期倒序,同一天的记录再按 id 倒序,这样新录入的账目会排在最前面。LIMIT 参数由调用方传入,避免数据量变大后一次性拉回全表。is_deleted = 0 是软删除过滤条件,配合后面 update 和 delete 使用。查询返回的是 sqlite3.Row 对象列表,在打印或模板渲染时可以通过 row["amount"] 这样的方式取字段。
3.2 修改与删除:先查再改,避免批量误伤
改账和删账是最容易出事故的地方,萌新经常犯的错误是“先改再说”,结果 WHERE 条件写错整列被更新。我的习惯是修改操作强制分两步:第一步根据 id 查出原记录,确认存在;第二步执行 UPDATE。两步操作不用放在一个事务里,但代码结构上必须清晰。
def update_transaction(txn_id, trans_date=None, category=None, amount=None, note=None): conn = get_conn() row = conn.execute( "SELECT id FROM transactions WHERE id = ? AND is_deleted = 0", (txn_id,) ).fetchone() if not row: conn.close() print(f"记录 {txn_id} 不存在或已删除") return fields = [] values = [] if trans_date is not None: fields.append("trans_date = ?") values.append(trans_date) if category is not None: fields.append("category = ?") values.append(category) if amount is not None: fields.append("amount = ?") values.append(amount) if note is not None: fields.append("note = ?") values.append(note) if not fields: conn.close() return values.append(txn_id) conn.execute( f"UPDATE transactions SET {', '.join(fields)} WHERE id = ?", values ) conn.commit() conn.close()逻辑说明:这里用动态拼接字段的方式,就是为了让调用方可以只修改一个字段而不影响其他字段。字段名集合是代码写死的,不会因用户数据改变,所以不存在 SQL 注入风险。f-string 拼接的是白名单字段名,VALUES 仍然走占位符。删除操作不建议物理删除,用 is_deleted 标记即可。
def soft_delete(txn_id): conn = get_conn() conn.execute( "UPDATE transactions SET is_deleted = 1 WHERE id = ?", (txn_id,) ) conn.commit() conn.close()逻辑说明:删除实际是 UPDATE 语句,改一个标记位。好处是误删后还有恢复的可能,也保住了所有历史流水不被物理抹掉。代价是每次查询都要记得加 is_deleted = 0 过滤,代码里所有 SELECT 都要带上这个条件。想真正“扔掉”数据时,可以定期执行 PURGE 语句把 is_deleted = 1 的记录删掉。
3.3 每日汇总:一条 GROUP BY 算出今天花了多少
每日汇总放在记账软件里是最高频的操作,很多人记完账就想看“今天合计花了多少”。按分类聚合,可以很清楚看到钱流进了哪些地方。
def daily_summary(day): conn = get_conn() rows = conn.execute( "SELECT category, SUM(amount) AS total, COUNT(*) AS cnt " "FROM transactions " "WHERE trans_date = ? AND is_deleted = 0 " "GROUP BY category " "ORDER BY total DESC", (day,) ).fetchall() conn.close() return rows逻辑说明:WHERE 先按日期缩窄范围,然后 GROUP BY category 做分类汇总,SUM(amount) 得到每个分类的总金额,COUNT(*) 得到笔数。排序按金额从大到小,一眼看到今天最花钱的地方。在 Python 里调用时,day 参数格式必须严格是 YYYY-MM-DD,否则 SQL 字符串比对根本匹配不上。这一行 GROUP BY 就是 SQLite 相对 CSV 最大的优势,换成 CSV 方案要写 20 行以上的 Python 循环才能做到同样的效果。
4. 让账目会说话:按月统计、分类占比与两张图的画法
4.1 用 pandas 做数据层聚合:直接从 SQLite 读成 DataFrame
SQL 能做的聚合语法比较单调,想按月份画趋势、按分类算占比,直接在 Python 里处理会更顺手。常见做法是把 SQLite 查出来的结果交给 pandas,pandas 的 groupby 和 resample 在时间维度上非常顺手。
import pandas as pd def load_tx(): conn = get_conn() df = pd.read_sql_query( "SELECT trans_date, category, amount, note " "FROM transactions WHERE is_deleted = 0", conn ) conn.close() df["trans_date"] = pd.to_datetime(df["trans_date"]) return df def monthly_report(df): df["month"] = df["trans_date"].dt.to_period("M") expense = df.groupby("month")["amount"].sum() return expense逻辑说明:load_tx 一次性读出全部未删除流水,转成 DataFrame 后把 trans_date 从文本转成 pandas 时间类型。monthly_report 里用 dt.to_period("M") 把每个日期截断到月份,生成 month 列,然后按月份求和,得到类似 2025-01 3580.50 这样的月度支出序列。pandas 的 read_sql_query 是通用做法,但要注意它要求 sqlite3.Connection 对象,不能传 DB_PATH 字符串。如果你还没有装 pandas,用 python 安装 pandas 的方式执行 pip install pandas 即可,这一步几乎不需要费心,唯一的坑是 matplotlib 的中文字体,下面马上讲。
4.2 画月度趋势和分类占比:中文字体是第一个坑
matplotlib 默认字体不支持中文,画出来的图往往是方块。解决方式是在绘图前设置字体参数,同时关掉 Unicode 负号,否则纵轴的负号也显示异常。Windows 系统通常可用 SimHei,Linux 上用 WenQuanYi Micro Hei 或 Noto Sans CJK SC。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "WenQuanYi Micro Hei", "Arial Unicode MS"] plt.rcParams["axes.unicode_minus"] = False def draw_monthly_trend(df): monthly = monthly_report(df) monthly.plot(kind="line", marker="o", title="月度支出趋势") plt.ylabel("金额") plt.xlabel("月份") plt.tight_layout() plt.savefig("monthly_trend.png", dpi=150) plt.show()逻辑说明:monthly_report 返回的是 pandas Series,Series 自带 plot 方法,kind="line" 指定折线图,marker="o" 让每个月份数据点显示为圆点,方便看具体位置。savefig 的 dpi 参数控制清晰度,150 足够在屏幕上展示。分类占比更适合用饼图,需要先过滤出支出类数据再画。
def draw_category_pie(df, month=None): data = df.copy() if month: data = data[data["trans_date"].dt.to_period("M") == month] category_sum = data.groupby("category")["amount"].sum() category_sum.plot(kind="pie", autopct="%.1f%%", figsize=(8, 8)) plt.title("分类支出占比") plt.ylabel("") plt.tight_layout() plt.savefig("category_pie.png", dpi=150) plt.show()参数说明:autopct 控制饼图上百分比的小数位数,%.1f%% 表示保留一位小数并带上百分号;figsize=(8, 8) 让饼图保持正圆,否则容易变形。month 参数如果传入 pandas「Period」对象而不是字符串,需要调用方保持一致,最简单的用法是传入类似 pd.Period("2025-01", "M")。这两个绘图函数组合起来,就完成了“月度总额怎么看”和“钱花在哪类”两个核心分析诉求。
5. 避坑:记账源码接入真实数据时的 4 个常见坑
5.1 日期格式不统一,月度统计串了行
现象:账本里既有 2025-01-15,又有 2025/01/15,还有 20250115,月度汇总结果出现同一个月份被拆成多行,甚至 2025-1-15 排在 2025-01-02 之前。
原因:录入时没有统一日期格式,字符串排序在“1”和“01”上产生不同结果,SQLite 不会替你纠正格式不规范的数据。
解决:在写入前强制做一次解析再写回。
from datetime import datetime def normalize_date(value): return datetime.strptime(value, "%Y-%m-%d").date().isoformat()参数说明:strptime 要求输入严格匹配 %Y-%m-%d,比如 2025-01-15。如果报错,说明调用方传入了错误格式,应当让异常抛出来而不是静默处理。我见过一些源码用 replace("/", "-") 来“补救”,结果 2025/1/5 变成 2025-1-5,依然不是规范格式。正确做法是强制所有入口统一按 YYYY-MM-DD 字符串传入,在 GUI 或命令行入口层就限制用户输入格式。
5.2 金额出现 0.00000001 尾差,对账对不上
现象:显示上明明是 0.19 + 0.01 = 0.20,程序里 SUM 的结果却是 0.19999999999999998,打印出来带着一长串尾数。
原因:REAL 是 IEEE 754 双精度浮点数,无法精确表示十进制的 0.1 和 0.19 这类分数。SQLite 的 SUM 基于浮点计算,尾差会在聚合过程中积累。
解决:最稳的方案是账本内部金额全部用整数“分”存储。建表时定义 amount_cents INTEGER,录入时把元换算成分为单位,展示和统计时再除以 100 转回元。
def yuan_to_cents(amount): return int(round(amount * 100)) def cents_to_yuan(cents): return cents / 100逻辑说明:round 先对浮点数做四舍五入再转 int,避免出现 19.999999 转成 19 的情况。统计时对 amount_cents 做 SUM,得到整数,再统一转回元,尾差问题彻底消失。如果旧数据已经用 amount REAL 存了,迁移脚本可以用 SQL 内联函数近似修正:UPDATE transactions SET amount_cents = CAST(ROUND(amount * 100) AS INTEGER),但历史脏数据仍建议人工抽查几笔对账。
5.3 导出 CSV 用 Excel 打开中文全乱
现象:用 pandas 或 csv 模块导出的 .csv 文件,用记事本打开正常,Excel 直接打开却是乱码。
原因:Excel 默认以 GBK 编码解析 CSV,而 Python 写入文件默认或代码中指定的 utf-8 编码,两边编码不一致。
解决:导出 CSV 时用 utf-8-sig 编码,它会在文件开头写入 BOM(字节序标记),Excel 识别 BOM 后自动按 UTF-8 解析。
df.to_csv("export.csv", index=False, encoding="utf-8-sig")参数说明:index=False 防止把 DataFrame 索引写成第一列无意义的数字;encoding="utf-8-sig" 是唯一关键点。顺带一提,如果这个 CSV 是给其他 Linux 服务用,utf-8-sig 不会影响解析,BOM 在 Python 打开时会自动忽略,所以这个参数几乎是零成本解决兼容问题。
5.4 删除账目没有后悔药,误删直接消失
现象:想删一笔重复账,结果 WHERE 条件写少了,整天的记录全部被删掉。SQLite 默认关闭外键和事务回滚,一旦 COMMIT,数据就永久没了。
原因:初版源码直接执行 DELETE FROM,没有软删除标记,也没有备份机制。
解决:为 transactions 表增加 is_deleted 字段,把所有删除改成 UPDATE 置 1。同时在每次程序退出或每天第一次记账前自动备份一份账本文件到 backup 子目录。
import shutil import os from datetime import date def backup_db(): os.makedirs("backup", exist_ok=True) target = f"backup/ledger_{date.today().isoformat()}.db" shutil.copy2(DB_PATH, target)逻辑说明:copy2 保留文件修改时间,备份文件名带上日期,一天一行备份文件,不会互相覆盖。这样即使软删除后想恢复,也可以从当天的备份文件里找回记录,再把 is_deleted 改回 0。我在源码里见过的翻车情况大多不是程序逻辑写错,而是没有留恢复通道,记住一句话:能恢复的账本,才敢放手去删。
6. 更进一步的三种做法:给源码加上桌面表单、自动备份与数据迁移
6.1 用 Tkinter 把命令行入口换成桌面表单
简易源码落地后,大部分人很快就会不满足于在终端里敲函数,而是想要一个能填表单的窗口。Python 自带的 tkinter 不需要额外安装依赖,做最简单的记账窗口足够:日期输入框、分类下拉框、金额输入框、保存按钮,布局用 pack 或 grid 都可以。
import tkinter as tk from tkinter import ttk def save_from_gui(date_var, category_var, amount_var, status_label): add_transaction( trans_date=date_var.get(), category=category_var.get(), amount=float(amount_var.get()) ) status_label.config(text="已保存")逻辑说明:GUI 层只是薄薄一层壳,最终调用的仍然是第 3 章里的 add_transaction 函数,业务逻辑没有被 GUI 替代。这样做的好处是命令行和窗口共用一套校验与入库逻辑,不会出现“窗口能存、命令行不能存”的分叉。想快速验证效果,直接 pip install 不存在的依赖,不用额外装任何东西,tkinter 是 Python 标准库的一部分。如果你习惯用 pandas 和 matplotlib 做统计,可以在这个界面里放一个“打开报表”按钮,直接调用第 4 章的绘图函数。
6.2 备份、数据迁移与对账习惯
账本运行半年后,数据量会从几十行涨到几千行,CSV 或 SQLite 都不至于撑不住,但备份必须坚持。我自己的习惯是每周手动执行一次备份命令,同时把关键流水导出成 CSV 存档。数据迁移最常见的是 SQLite 到 CSV、以及旧账本到新表结构,两者都可以用 pandas 一行完成:
df_old = pd.read_sql_query("SELECT * FROM transactions", conn_old) df_old.to_csv("legacy_backup.csv", index=False, encoding="utf-8-sig")逻辑说明:迁移前第一步永远是先导出一份 CSV 快照,这是后悔药的后悔药。新库建好后,再用 pandas 的 to_sql 写入或批量 INSERT 导入。金额列如果从元改成“分”,这一步也是做数据清洗的最佳时机,把 amount 改成 amount_cents 并在 SQL 层校验没有脏数据。对我个人而言,记账软件最容易坚持不下去的原因不是功能不够,而是数据不可信。我有一段时间每天对账都对不上,后来发现是浮点尾差累积,把金额改成整数“分”存储之后,这个现象再也没出现过。给账本做一次干净迁移,比加十个新功能都值钱。
希望这套从数据模型到统计可视化,再到避坑恢复的流程,能帮你少走一些弯路。如果你也是自己动手记过账的人,应该明白:工具好不好用是其次,账目真实、能随时翻出来,才能真正坚持下来,希望这篇笔记对你有用。
本文还有配套的精品资源,点击获取