
前言一个账本应用剥开界面看只有两层一笔一笔的记录事件流和各种角度的汇总视图。加一条流水、按月份看收支、按分类看花了多少——需求就这么点但里面藏着一个必须先做对的决策。第一个决策金额绝对不能用float存。这不是洁癖。Python 里0.1 0.2的结果是0.30000000000000004不是0.3把一串浮点数加起来误差会累积最终出现「账目差一分钱」这种无法解释的现象。正确做法是用整数存「分」或者用decimal.Decimal。SQLite 支持INTEGER用它存分所有加减都是精确整数运算汇总时再除以 100 展示。第二个决策SQLite 正好是这个场景的对口选择。账本是典型的单机、单人、读多写少、需要数据随身携带的应用——正是 SQLite 的设计目标。不用装服务、不用配账号整个账本就是一个文件拷走就能用。本文从表结构开始一路写到能跑的命令行账本包含录入、按月汇总、按分类汇总、删除和导出 CSV。所有 SQL 都参数化。一、表设计CREATE TABLE IF NOT EXISTS entry (id INTEGER PRIMARY KEY AUTOINCREMENT,occurred_on TEXT NOT NULL, -- YYYY-MM-DDkind TEXT NOT NULL CHECK (kind IN (income, expense)),category TEXT NOT NULL,amount_cents INTEGER NOT NULL CHECK (amount_cents 0),note TEXT NOT NULL DEFAULT );CREATE INDEX IF NOT EXISTS idx_entry_date ON entry(occurred_on);CREATE INDEX IF NOT EXISTS idx_entry_category ON entry(category);设计说明amount_cents存正整数方向由kind决定。另一种常见设计是「有符号金额」收入为正、支出为负。两种都可以但「正数 类型字段」更不容易在聚合时写错条件而且CHECK (amount_cents 0)能从数据库层挡住「金额为负或为零」的脏数据。occurred_on用TEXT存 ISO 8601 的YYYY-MM-DD。SQLite 没有独立的日期类型ISO 格式的字符串按字典序排序就等于按时间排序而且能被strftime()直接解析。这是 SQLite 里处理日期的通行做法。CHECK约束不是装饰。它挡住的是「应用层漏了校验」的情况比在 Python 里写if更可靠——因为任何绕过应用的写入路径都会被执行。索引加在occurred_on和category上因为这两个是汇总和筛选的主要条件。索引会让写入略慢一点对账本这种写少读多的场景是划算的。二、金额的输入与展示金额在「用户输入的元」和「数据库存的分」之间来回转换这个转换必须精确。# 适用于 Python 3.8from decimal import Decimal, ROUND_HALF_UP, InvalidOperationdef yuan_to_cents(text):把用户输入的元转成整数分。12.34 - 1234text (text or ).strip()if not text:raise ValueError(金额不能为空)try:value Decimal(text)except InvalidOperation:raise ValueError(f金额格式不正确: {text!r})if value 0:raise ValueError(金额必须大于 0)# 乘以 100 后用 HALF_UP 四舍五入到整数避免 float 的二进制误差return int((value * 100).quantize(Decimal(1), roundingROUND_HALF_UP))def cents_to_yuan(cents):整数分转成展示用的两位小数字符串。1234 - 12.34return f{Decimal(cents) / 100:.2f}为什么用Decimal而不是float因为Decimal(12.34)是精确的十进制表示float(12.34)不是。用float做「元转分」int(12.34 * 100)在某些数值上会得到1233而不是1234——这类错误极难排查因为它在绝大多数金额上都「碰巧对」。三、数据访问层# 适用于 Python 3.8import sqlite3from datetime import dateSCHEMA CREATE TABLE IF NOT EXISTS entry (id INTEGER PRIMARY KEY AUTOINCREMENT,occurred_on TEXT NOT NULL,kind TEXT NOT NULL CHECK (kind IN (income, expense)),category TEXT NOT NULL,amount_cents INTEGER NOT NULL CHECK (amount_cents 0),note TEXT NOT NULL DEFAULT );CREATE INDEX IF NOT EXISTS idx_entry_date ON entry(occurred_on);CREATE INDEX IF NOT EXISTS idx_entry_category ON entry(category);class Ledger:def __init__(self, path):self.con sqlite3.connect(path)self.con.row_factory sqlite3.Rowself.con.executescript(SCHEMA)self.con.commit()def close(self):self.con.close()def add(self, occurred_on, kind, category, amount_cents, note):with self.con:cur self.con.execute(INSERT INTO entry (occurred_on, kind, category, amount_cents, note) VALUES (?, ?, ?, ?, ?),(occurred_on, kind, category, amount_cents, note),)return cur.lastrowiddef remove(self, entry_id):with self.con:cur self.con.execute(DELETE FROM entry WHERE id ?, (entry_id,))return cur.rowcountdef recent(self, limit20):return self.con.execute(SELECT id, occurred_on, kind, category, amount_cents, note FROM entry ORDER BY occurred_on DESC, id DESC LIMIT ?,(limit,),).fetchall()def monthly(self, yearNone):按月汇总收入与支出。sql SELECT strftime(%Y-%m, occurred_on) AS month,SUM(CASE WHEN kind income THEN amount_cents ELSE 0 END) AS income_cents,SUM(CASE WHEN kind expense THEN amount_cents ELSE 0 END) AS expense_centsFROM entryparams ()if year is not None:sql WHERE occurred_on LIKE ?params (f{year}-%,)sql GROUP BY month ORDER BY month DESCreturn self.con.execute(sql, params).fetchall()def by_category(self, month):某个月的分类明细按金额从大到小。return self.con.execute(SELECT kind, category, SUM(amount_cents) AS total_cents FROM entry WHERE occurred_on LIKE ? GROUP BY kind, category ORDER BY total_cents DESC,(f{month}-%,),).fetchall()几个细节值得停一下with self.con:包住每个写操作退出时自动提交、异常时自动回滚。但要记住它不关闭连接——连接的生命周期由close()负责。ORDER BY occurred_on DESC, id DESC同一天有多笔时按 id 倒序保证最新的在最前面不会因为日期相同而出现随机顺序。LIKE 2026-%因为日期是 ISO 文本这个前缀匹配就等价于「按月份筛选」。百分号在参数值里SQL 本身是纯占位符所以依然安全。月份如果来自用户输入必须先校验格式比如用datetime.strptime(month, %Y-%m)试解析一次把不合法输入挡在进数据库之前。strftime(%Y-%m, occurred_on)依赖occurred_on确实是 ISO 格式如果表里有历史脏数据比如2026/10/07strftime会返回NULL这些行会被分到NULL组里。入库时就统一格式别指望查询时兜底。四、命令行界面# 适用于 Python 3.8import csvimport sysfrom datetime import dateKINDS {1: income, 2: expense}def show_recent(ledger):rows ledger.recent()if not rows:print(还没有任何记录)returnprint(f{id:5}{日期:12}{类型:6}{分类:10}{金额:10} 备注)for r in rows:mark if r[kind] income else -print(f{r[id]:5}{r[occurred_on]:12}{r[kind]:6}f{r[category]:10}{mark}{cents_to_yuan(r[amount_cents]):9} {r[note]})def show_monthly(ledger, year):rows ledger.monthly(year)if not rows:print(该年份没有记录)returnprint(f{月份:10}{收入:12}{支出:12}{结余:12})for r in rows:income r[income_cents] or 0expense r[expense_cents] or 0print(f{r[month]:10}{cents_to_yuan(income):12}f{cents_to_yuan(expense):12}{cents_to_yuan(income - expense):12})def show_category(ledger, month):rows ledger.by_category(month)for r in rows:print(f {r[kind]:8}{r[category]:12}{cents_to_yuan(r[total_cents]):10})def export_csv(ledger, filename):rows ledger.recent(limit10 ** 9)# 显式指定 encoding避免 Windows 上用默认编码写出乱码with open(filename, w, encodingutf-8, newline) as fp:writer csv.writer(fp)writer.writerow([id, 日期, 类型, 分类, 金额, 备注])for r in rows:mark if r[kind] income else -writer.writerow([r[id], r[occurred_on], r[kind],r[category], mark cents_to_yuan(r[amount_cents]),r[note]])print(f已导出 {len(rows)} 条记录到 {filename})def main():ledger Ledger(ledger.db)try:while True:print(\n1 记一笔 2 最近记录 3 按月汇总 4 分类明细 5 删除 6 导出CSV 0 退出)choice input(请选择: ).strip()if choice 0:breakelif choice 1:kind KINDS.get(input(类型(1收入/2支出): ).strip())if kind is None:print(类型无效)continuecategory input(分类: ).strip()if not category:print(分类不能为空)continuetry:amount_cents yuan_to_cents(input(金额(元): ))except ValueError as exc:print(f[输入有误] {exc})continueday input(f日期(YYYY-MM-DD回车用今天 {date.today()}): ).strip() or str(date.today())note input(备注(可空): ).strip()new_id ledger.add(day, kind, category, amount_cents, note)print(f已记录id {new_id})elif choice 2:show_recent(ledger)elif choice 3:show_monthly(ledger, input(年份(如 2026回车表示全部): ).strip() or None)elif choice 4:show_category(ledger, input(月份(YYYY-MM): ).strip())elif choice 5:try:entry_id int(input(要删除的 id: ).strip())except ValueError:print([输入有误] id 必须是整数)continueprint(已删除 if ledger.remove(entry_id) else 未找到该 id)elif choice 6:export_csv(ledger, input(导出文件名: ).strip() or ledger.csv)else:print(无效选项)except (KeyboardInterrupt, EOFError):print(\n再见)finally:ledger.close()if __name__ __main__:main()预期输出长这样1 记一笔 2 最近记录 3 按月汇总 4 分类明细 5 删除 6 导出CSV 0 退出请选择: 3月份 收入 支出 结余2026-10 8000.00 1234.56 6765.442026-09 8000.00 2010.00 5990.00注意int(input(...))只在删除那条分支里用了而且包在try里——任何来自input()的值都是字符串直接参与算术或当主键用之前一定要转换并处理转换失败。五、还能加什么这套骨架可以按需要往外长加预算提醒在by_category上比一个预算值、加recurring表记周期账单、给entry加account列做多账户。要提醒的是「多账户之间的转账」是一对记录一出一进必须放在同一个事务里。用with self.con:把两条插入包起来就是这个账本应用里第一个真正需要事务的地方。常见坑点1. 用float存金额❌amount REAL加总之后出现0.30000000000000004这类尾差账对不上。✅ 用INTEGER存「分」或用decimal.Decimal只在展示时转成两位小数。2. 用float做「元转分」❌int(12.34 * 100)——浮点误差可能算出1233。✅int((Decimal(12.34) * 100).quantize(Decimal(1), roundingROUND_HALF_UP))。3. 拼接 SQL 构造筛选条件❌sql SELECT ... WHERE occurred_on LIKE month -%——月份来自输入就是注入入口。✅sql SELECT ... WHERE occurred_on LIKE ?参数传(f{month}-%,)月份本身再用strptime校验一次格式。4. 拿input()的结果直接算❌amount input(金额: ); total total amount * 100——input()返回字符串12 * 100是重复字符串不是乘法。✅ 先转换再校验amount_cents yuan_to_cents(input(金额: ))。5. 汇总时忘了处理NULL❌ 某个月只有支出没有收入时SUM(CASE WHEN kindincome THEN ... END)返回NULL直接参与算术会抛TypeError。✅ 用r[income_cents] or 0兜底或者把CASE的ELSE分支写成ELSE 0本例就是这么做的。6. 日期格式不统一❌ 有的记录存2026-10-07有的存2026/10/07——strftime对后者返回NULL汇总时这些记录全被归到NULL组。✅ 入库前统一成 ISO 格式可以在表上加CHECK (occurred_on GLOB [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9])从数据层兜住。7. 导出 CSV 不写encoding和newline❌open(out.csv, w)——Windows 上默认编码写出中文会乱码csv模块还会因为换行处理产生多余空行。✅open(filename, w, encodingutf-8, newline)。8. 用with con:之后就以为连接关了❌with Ledger(ledger.db) as lg:在Ledger上加了__enter__/__exit__只管事务程序退出时连接没关Windows 上文件还被占用。✅try: ... finally: ledger.close()事务提交和连接关闭分开处理。总结环节正确做法金额整数「分」存储Decimal做元分转换展示时才除以 100日期TEXT存 ISOYYYY-MM-DD靠字典序排序靠strftime分组约束CHECK约束放数据库层别只靠应用层校验事务写操作用with con:跨记录的一致性如转账必须包在一个事务里查询一律?占位符月份前缀匹配也别拼字符串输出导出 CSV 写encodingutf-8和newline资源with只管事务close()自己调账本应用是个很好的练手题材它足够小能一次写完又足够真实逼你面对精度、事务、输入校验这三个数据库编程的核心问题。把金额用整数分存下来这件事比多加十个功能更能体现你懂不懂数据。