阶段5的毕业作品来了。这一篇不学新概念——第49到59篇的手艺你已经全学完了:建表(第50篇)、增删改查(第51~52篇)、排序分页(第53篇)、多表关联(第54篇)、Python 接管数据库(第55篇)、网页操作数据库(第56篇)、备份(第57篇)、CSV 搬家(第58篇)、索引提速(第59篇)。今天干一件更大的事:把散装手艺攒成一台会转的机器。还记得第22篇那个简易记账本吗?今天给它换一颗”数据库心脏”:能记账、能改账、能按分类看汇总、网页上点一点就能用,老账本还能整箱搬进新家。做完这篇,”数据管理”这件事你就正式出师了。
一、真实场景:记账本的”中年危机”
第22篇的记账本,用到现在应该积了不少账了。当时它还是个 CSV 文件,几百条账目用着挺顺,可账目一上千,它就开始犯难:
- 想查”10月买菜花了多少”?得把整个文件读进来,一行行筛;
- 发现上周有笔账记错了?得把整份文件重写一遍;
- 想看看每月总支出的走势?二十行循环起步;
- 想在手机上随手记一笔?CSV 文件表示做不到。
这不是 CSV 的错——它只是个表格文件,本来就不该干这些活。数据一多、要求一高,就该搬进数据库了。 这正是第49篇开头讲过的那句话,今天轮到亲手兑现它。
不过今天不只是”搬个家”,而是把这一个阶段学的所有数据手艺整合起来,做一个完整的数据管理系统——一个”账本大脑”:数据有结构、查账有目录、改账不动全家、坏了有备份、网页上就能用。
写程序的高手和新手,差距往往不在”会多少招”,而在”能不能把招连成套路”。今天练的就是连招。
二、项目蓝图:先画图纸,再动工
老规矩,动手前先画图纸(这个习惯从第48篇就开始养了)。一个完整的数据管理系统 = 表结构 + 功能清单 + 文件分工。
表结构:两张表,一次关联
账目和分类是”多对一”的关系:很多笔账属于同一个分类。所以拆两张表(第54篇的思路):
categories分类表:id、name(买菜、交通、房租……)——分类名只存这一份;entries账目表:id、date、category_id(指向分类表)、amount、note——每笔账用一个 id 指向自己的分类。
为什么非要拆?把分类名直接写进每行账目当然也能跑,但哪天想把”买菜”改叫”食品”,一千行账目就得改一千次;拆开之后分类只存一份,改名只动一行,账目用 id 指过去就行。
功能清单:这就是今天的施工范围
| 功能 | 用到的手艺 | 出处 |
|---|
|——|———–|——|
| 建库建表 + 索引 | CREATE TABLE / CREATE INDEX | 第50、59篇 |
|---|---|---|
| 记一笔 / 改一笔 / 删一笔 | INSERT / UPDATE / DELETE | 第52篇 |
| 查账单(倒序 + 分页) | SELECT / ORDER BY / LIMIT | 第51、53篇 |
| 按分类汇总 | JOIN + Python 累加 | 第54、55篇 |
| 老 CSV 账本搬家 | csv 读取 + 批量插入 | 第25、58篇 |
| 网页记账 | Flask + SQLite | 第56篇 |
| 备份 + 校验 | 文件复制 + 行数对账 | 第57篇 |
文件分工:一个项目不止一个文件
setup_db.py—— 地基:建库、建表、预置分类;ledger.py—— 手脚:记账、查账、改账、删账;migrate.py—— 搬家:把老 CSV 账本导进来;backup.py—— 后手:备份加校验;app.py—— 门面:网页界面。
每个文件只管一摊,出问题好定位,改起来不互相踩——这比把几百行代码塞一个文件里,是”正经软件”和”随手脚本”的分水岭。
三、动手:一步步把账本大脑搭起来
第 1 步:建库建表(setup_db.py)
# setup_db.py — 账本大脑的"地基":建库、建表、预置分类
import sqlite3
DB = "ledger.db"
def init_db():
conn = sqlite3.connect(DB)
cur = conn.cursor()
# 分类表:账目的"户口本",分类名只存这一份
cur.execute("""
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
""")
# 账目表:每笔账用 category_id 指向分类表(多对一)
cur.execute("""
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
category_id INTEGER NOT NULL,
amount REAL NOT NULL,
note TEXT DEFAULT '',
FOREIGN KEY (category_id) REFERENCES categories(id)
)
""")
# 索引:查账最常按日期查,给它开本"目录"(第59篇手艺)
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date)
""")
# 预置几个常用分类(UNIQUE + IGNORE:跑几遍都不会重复)
for name in ["买菜", "交通", "房租", "娱乐", "医疗", "其他"]:
cur.execute("INSERT OR IGNORE INTO categories (name) VALUES (?)", (name,))
conn.commit()
conn.close()
print("账本大脑就绪:ledger.db 已建好")
if __name__ == "__main__":
init_db()
第 2 步:核心增删改查(ledger.py)
# ledger.py — 账本大脑的"手脚":记一笔、查账、改账、删账
import sqlite3
DB = "ledger.db"
def get_conn():
conn = sqlite3.connect(DB)
conn.execute("PRAGMA foreign_keys = ON") # 坑点疫苗1:外键不开等于白建
return conn
def add_entry(date, category_name, amount, note=""):
"""记一笔:分类没有就顺手建一个"""
conn = get_conn()
cur = conn.cursor()
cur.execute("SELECT id FROM categories WHERE name = ?", (category_name,))
row = cur.fetchone()
if row:
category_id = row[0]
else:
cur.execute("INSERT INTO categories (name) VALUES (?)", (category_name,))
category_id = cur.lastrowid
cur.execute(
"INSERT INTO entries (date, category_id, amount, note) VALUES (?, ?, ?, ?)",
(date, category_id, amount, note),
)
conn.commit()
conn.close()
print(f"记上了:{date} {category_name} {amount} 元 {note}")
def list_entries(page=1, per_page=10):
"""查账单:倒序 + 分页,分类名用 JOIN 带出来"""
conn = get_conn()
cur = conn.cursor()
offset = (page - 1) * per_page
cur.execute("""
SELECT e.id, e.date, c.name, e.amount, e.note
FROM entries e
JOIN categories c ON e.category_id = c.id
ORDER BY e.date DESC, e.id DESC
LIMIT ? OFFSET ?
""", (per_page, offset))
rows = cur.fetchall()
conn.close()
for r in rows:
print(f"[{r[0]}] {r[1]} {r[2]} {r[3]} 元 {r[4]}")
return rows
def update_entry(entry_id, amount=None, note=None):
"""改一笔:只改传进来的字段"""
conn = get_conn()
cur = conn.cursor()
if amount is not None:
cur.execute("UPDATE entries SET amount = ? WHERE id = ?", (amount, entry_id))
if note is not None:
cur.execute("UPDATE entries SET note = ? WHERE id = ?", (note, entry_id))
conn.commit()
conn.close()
print(f"改好了:第 {entry_id} 号账目")
def delete_entry(entry_id):
"""删一笔"""
conn = get_conn()
conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
conn.commit()
conn.close()
print(f"删掉了:第 {entry_id} 号账目")
if __name__ == "__main__":
# 快速自测:记两笔、翻一页、改一笔、删一笔
add_entry("2026-11-06", "买菜", 35.5, "早市")
add_entry("2026-11-06", "交通", 2, "公交")
list_entries()
update_entry(1, amount=33.5, note="早市(讲价成功)")
list_entries()
delete_entry(2)
list_entries()
第 3 步:老账本搬家(migrate.py)
第58篇的手艺今天正式派上用场:把第22篇攒下的 CSV 账本整箱搬进数据库。
# migrate.py — 老账本搬家:把 CSV 记账本整箱搬进数据库
import csv
import sqlite3
DB = "ledger.db"
CSV_FILE = "记账本.csv" # 第22篇格式:日期,分类,金额,备注
def migrate():
conn = sqlite3.connect(DB)
conn.execute("PRAGMA foreign_keys = ON")
cur = conn.cursor()
with open(CSV_FILE, encoding="utf-8") as f:
reader = csv.DictReader(f)
count = 0
for row in reader:
cur.execute("SELECT id FROM categories WHERE name = ?", (row["分类"],))
r = cur.fetchone()
if r:
category_id = r[0]
else:
cur.execute("INSERT INTO categories (name) VALUES (?)", (row["分类"],))
category_id = cur.lastrowid
cur.execute(
"INSERT INTO entries (date, category_id, amount, note) VALUES (?, ?, ?, ?)",
(row["日期"], category_id, float(row["金额"]), row.get("备注", "")),
)
count += 1
conn.commit()
conn.close()
print(f"搬家完成:{count} 笔账目已入库")
if __name__ == "__main__":
migrate()
如果你第22篇的 CSV 列名跟这里写的不一样(比如叫”时间”而不是”日期”),把你的表头发给 AI:”这是我的 CSV 表头和两行样例,帮我改 migrate.py 里的字段映射”——30 秒的事,别硬改半小时。
第 4 步:网页门面(app.py)
第56篇的手艺:让网页替你操作数据库。手机、电脑打开就能记账。
# app.py — 账本大脑的"门面":网页上记账、查账、删账
from flask import Flask, request, redirect, render_template_string
import sqlite3
DB = "ledger.db"
app = Flask(__name__)
PAGE = """
账本大脑
日期
分类
金额
备注
日期 分类 金额 备注
{% for e in entries %}
{{ e[1] }} {{ e[2] }} {{ e[3] }} {{ e[4] }}
{% endfor %}
{% if page > 1 %}上一页{% endif %}
第 {{ page }} 页
下一页
| 按分类看汇总
"""
SUMMARY = """
按分类汇总
分类 合计
{% for name, total in rows %}
{{ name }} {{ total }} 元
{% endfor %}
"""
def query(sql, args=()):
conn = sqlite3.connect(DB)
conn.execute("PRAGMA foreign_keys = ON")
cur = conn.cursor()
cur.execute(sql, args)
rows = cur.fetchall()
conn.close()
return rows
@app.route("/")
def index():
page = int(request.args.get("page", 1))
entries = query("""
SELECT e.id, e.date, c.name, e.amount, e.note
FROM entries e JOIN categories c ON e.category_id = c.id
ORDER BY e.date DESC, e.id DESC LIMIT 10 OFFSET ?
""", ((page - 1) * 10,))
return render_template_string(PAGE, entries=entries, page=page)
@app.route("/add", methods=["POST"])
def add():
date = request.form["date"]
category = request.form["category"]
amount = float(request.form["amount"])
note = request.form.get("note", "")
conn = sqlite3.connect(DB)
conn.execute("PRAGMA foreign_keys = ON")
cur = conn.cursor()
cur.execute("SELECT id FROM categories WHERE name = ?", (category,))
row = cur.fetchone()
if row:
category_id = row[0]
else:
cur.execute("INSERT INTO categories (name) VALUES (?)", (category,))
category_id = cur.lastrowid
cur.execute(
"INSERT INTO entries (date, category_id, amount, note) VALUES (?, ?, ?, ?)",
(date, category_id, amount, note),
)
conn.commit()
conn.close()
return redirect("/")
@app.route("/delete/", methods=["POST"])
def delete(entry_id):
conn = sqlite3.connect(DB)
conn.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
conn.commit()
conn.close()
return redirect("/")
@app.route("/summary")
def summary():
# 汇总交给 Python 累加(第08篇的字典手艺),不用新概念
rows = query("""
SELECT c.name, e.amount
FROM entries e JOIN categories c ON e.category_id = c.id
""")
totals = {}
for name, amount in rows:
totals[name] = totals.get(name, 0) + amount
summary_rows = sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
return render_template_string(SUMMARY, rows=summary_rows)
if __name__ == "__main__":
app.run(debug=True, port=5000)
第 5 步:备份留后手(backup.py)
第57篇的铁律今天再强调一次:备份没验证过 = 没有备份。
# backup.py — 给账本留后手:带日期的副本 + 行数校验
import shutil
import sqlite3
from datetime import datetime
DB = "ledger.db"
def backup():
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = f"ledger-{stamp}.db"
shutil.copy2(DB, target) # 先复制,再校验
# 校验:备份文件能打开、行数对得上,才算备份成功
src = sqlite3.connect(DB)
dst = sqlite3.connect(target)
n1 = src.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
n2 = dst.execute("SELECT COUNT(*) FROM entries").fetchone()[0]
src.close()
dst.close()
if n1 == n2:
print(f"备份成功:{target}({n1} 笔账目,行数对账通过)")
else:
print("备份可疑!行数不一致,原件千万别删")
if __name__ == "__main__":
backup()
第 6 步:跑起来看效果
按顺序来,每步都该看到对应的输出:
$ python setup_db.py
账本大脑就绪:ledger.db 已建好
$ python migrate.py
搬家完成:1043 笔账目已入库
$ python ledger.py
记上了:2026-11-06 买菜 35.5 元 早市
记上了:2026-11-06 交通 2 元 公交
[2] 2026-11-06 交通 2 元 公交
[1] 2026-11-06 买菜 35.5 元 早市
改好了:第 1 号账目
[2] 2026-11-06 交通 2 元 公交
[1] 2026-11-06 买菜 33.5 元 早市(讲价成功)
删掉了:第 2 号账目
[1] 2026-11-06 买菜 33.5 元 早市(讲价成功)
$ python backup.py
备份成功:ledger-20261106-120000.db(1044 笔账目,行数对账通过)
$ python app.py
* Running on http://127.0.0.1:5000
浏览器打开 http://127.0.0.1:5000:记一笔、翻一页、删一笔、点”按分类看汇总”——十一篇手艺在这一刻全部咬合转动。这就是”完整的数据管理系统”:不是新招,是连招。
四、改进:跟AI一起升级
系统能跑了,下一步是”越用越顺手”。还是那套学习法:跟AI说需求 → 读懂它的方案 → 提出改进。比如把这段发给 AI:
“这是一个记账系统的表结构和核心代码(贴 setup_db.py + ledger.py)。我想加两个功能:1)按月汇总,输出’2026-10:买菜 812 元、交通 156 元……’;2)在网页上按关键词搜备注。请先给我改进方案(动哪些函数、加什么查询),确认后再给代码。”
先看方案,再拿代码。 读懂它打算”多查一次”还是”多算一遍”,比直接复制粘贴值钱十倍。方案里它大概率会给两个方向:
改进1:搜索账目。 在 ledger.py 里加一个 search(keyword),查询就一句 WHERE e.note LIKE ?,参数用 f"%{keyword}%"。注意:中间通配的 LIKE 走不了索引(第59篇坑点疫苗1讲过),账目几万条之前不用管它,知道边界就行。
改进2:按月汇总。 你会看到两种写法:一种是 Python 字典累加(就是 app.py 里那种),一种是一个新句式 GROUP BY——”按分类分组、每组求和”,SQL 一句话干完 Python 十行的活。让 AI 把两种写法并排讲给你听,看懂之后选一种。这就是”改进”的意义:不是加功能,是借着功能把理解再往前推一步。
五、四个必踩的坑(先打疫苗)
- 外键不开等于白建。 SQLite 默认不检查外键,
FOREIGN KEY写了也白写——每条连接开头都要PRAGMA foreign_keys = ON(代码里每处get_conn()都加了,不是啰嗦,是 SQLite 的规矩)。不开的后果很隐蔽:分类删了,账目还指着一个不存在的 id,JOIN 时这笔账直接”人间蒸发”。
- 金额别拿浮点数较真。
0.1 + 0.2在计算机里不等于0.3,是浮点数的老毛病。记账本用REAL存”元”,教学和个人记账够用;但你心里要有数:正经的财务系统都用整数存”分”(33.5 元存 3350),彻底绕开误差。哪天这个账本要管真钱,第一个要改的就是它。
- SQL 一律用
?占位符,不拼字符串。'INSERT ... VALUES (' + amount + ')'这种写法,金额里混进一个引号就是一场小灾难(注水攻击叫 SQL 注入)。哪怕工具只有自己用,习惯从第一天养——这些代码以后是要复制进”正经软件”的。
- 搬家前先备份,防”搬两次记两遍”。
migrate.py再跑一次,一千多笔账就变成两千多笔——它只管搬,不管查重。搬家前python backup.py走一遍(顺手也练了第57篇),万一重复了,删库重来就是。另外 CSV 里的日期格式(”10/5″还是”2026-10-05″)最好统一,不然按月汇总时它会被当成两个月——搬家清单里的”格式核对”,跟搬家公司是一个道理。
六、跟AI协作的正确姿势(项目特供版)
- 先给图纸,再要代码。 把表结构(建表语句)和功能清单贴给 AI:”按这个蓝图施工”。不给图纸,AI 只能凭感觉盖房子,盖出来的多半不是你要的户型。
- 分文件逐个要,每要一个跑通一个。 “帮我写整个项目”一口气拿回五文件几百行,出错根本没法定位。正确的节奏是:setup_db.py 跑通了,再要 ledger.py,再要 app.py——每一步都验证,问题永远只藏在最后那一步里。
- 让AI列”改进清单”,而不是东改西改。 “这个系统还能怎么改进?按’实用性高低’列五条”——从清单里挑着做。功能加着加着失控,是所有小项目烂尾的第一步;蓝图内的克制,比代码量更像”正经软件”。
七、今日小练习
练习1:给账本加”收入”。 现在只能记支出,让工资也能进账。两种设计摆在你面前:给 entries 表加一列 type(支出/收入),或者约定”金额为负 = 支出”。把两种方案的取舍发给 AI 让它讲透,选一种实现,并让分类汇总能分出”收入合计、支出合计”(提示:改 add_entry 加个参数,summary 里按类型分开累加;想想哪种设计以后加”转账”类型时更省事)。
练习2:做一份月度汇总报告。 输出”2026-10:买菜 812 元、交通 156 元、合计 xxx 元”这样的按月报表。先用 Python 字典累加实现(提示:键用”年月”字符串如 row[1][:7],字典套字典或元组当键都行);然后让 AI 教你 GROUP BY 版本,对比两种写法的行数和速度——看得懂两种,才算真会。
练习3:来一次备份恢复演习。 备份 → 故意 DELETE 掉三条账目 → 写几行代码从备份文件把数据”捞”回来(提示:连上备份文件把 entries 里的行读出来再插回主库;捞完 COUNT(*) 对账)。想想第57篇那句话:没验证过恢复的备份,不算备份——今天就验证它。
下篇预告
下一篇(第61篇):画第一张图 — matplotlib 基础。 阶段6″可视化”正式开张:数据躺在表里,看半天没感觉;画成图,一眼就懂。先画出人生第一张图,再一步步学会折线图、柱状图、饼图、散点图……让数据”开口说话”。你的账本大脑,马上就要配上一块”会画图的屏幕”了。