记账小助手迭代记录
这是《软件工程》第 3 讲课堂练习的提交物。选题是"个人校园消费记账 GUI"——给大学生记生活费的桌面小程序,要能记录、统计、提醒。这篇文章记录我用 AI 编程助手把它做出来的四轮过程,以及我后来怎么确认它是对的。
| 项目 | 内容 |
|---|---|
| 选题 | A. 个人校园消费记账 GUI |
| 工具 | AI 编程助手(对话式),四轮迭代 |
| 技术栈 | Python 3.14(3.10+ 均可)+ tkinter + sqlite3,除 pytest 外不引入第三方库 |
| 最终状态 | 123 个测试用例全绿,python main.py 可打开界面,python demo.py 可复现完整流程 |
第 0 步:把需求细化成可验证的意图描述
原始需求只有一行:"大学生生活费记账桌面 GUI,记录,统计,提醒……"。直接丢给 AI 只能得到一堆没法验收的代码,先拆成能被客观检验的描述。
| 模块 | 细化后的描述 | 怎么算达标 |
|---|---|---|
| 记录 | 一笔记录含日期、类型(支出/收入)、金额、分类、备注;可删除 | 保存后出现在列表里,关掉程序重开还在 |
| 统计 | 按月汇总总收入/总支出/结余;按分类汇总支出金额与占比 | 手算一遍结果能对上 |
| 提醒 | 每个分类设月度预算,用到 80% 提示"接近超支",到 100% 提示"已超支" | 79.99 / 80.00 / 99.99 / 100.00 四个点分别测 |
| 约束 | 只用标准库,不引入第三方依赖;数据存本地 SQLite | 换台电脑什么都不用装就能跑 |
关键是把"提醒"这种模糊词变成 80% 和 100% 两个阈值。不然 AI 只能猜,我也没法验收。
第 1 轮(初版)
我给 AI 的提示词
【项目背景】
技术栈:Python 3.10+,只用标准库,界面用 tkinter,存储用 sqlite3。
项目概况:单机桌面程序"校园记账小助手",给大学生记录生活费收支。
学生记性差但想控制花销,所以除了记账还要能看统计、能在快超支时被提醒。
【需求描述】
做一个单窗口程序:
1. 顶部一行放月份输入框和"收入/支出/结余"汇总
2. 中间一个表单:日期、类型(支出/收入)、金额、分类、备注 + 保存按钮
分类固定为:餐饮、交通、购物、学习、娱乐、通讯、其他
3. 下方用表格列出当月明细,按日期倒序,支持选中删除
4. 一个"分类统计"按钮,显示各分类支出金额
5. 数据存 SQLite,关掉程序再打开数据还在
【修改范围】
全新项目,从零开始写。可以自由设计文件和函数划分,但要求界面、校验、
统计逻辑分开放,不要全挤在一个文件里,后面我要写单元测试。
【约束边界】
- 除了 pytest(只用于测试),不引入任何第三方库
- 中文界面
- 代码要有中文注释
【验收标准】
- python main.py 能打开窗口
- 保存一笔记录后,表格里能看到,汇总数字跟着变
- 关掉再打开,记录还在
AI 生成的初版要点
生成了 6 个文件:main.py、ledger/__init__.py、models.py、storage.py、stats.py、gui.py。分层的要求它听进去了,统计和校验没塞进界面文件,这点符合期望。
但金额用的是 float,而且保存逻辑里没有 try、没有范围检查,日期也是原样存进去:
def on_save(self):
tx = Transaction(
id=None,
date=self.date_var.get(), # 原样存,没有校验
amount=float(self.amount_var.get()), # 裸转,填 abc 直接崩
...)
第一眼看着挺完整,实际上手一摸底全是洞。
第 2 轮(第一次迭代修改)
发现的问题
我没有直接看代码下结论,而是写了 5 个探针真跑了一遍。
| # | 输入 | 实际结果 |
|---|---|---|
| 1 | 金额填 abc |
ValueError 直接抛出,界面报错,已填的日期和备注全部丢失 |
| 2 | 金额填 -5 |
被 float() 接受,脏数据入库 |
| 3 | 3 笔 0.1 元求和 | 得到 0.30000000000000004 |
| 4 | 日期填 2026-9-1 |
存进去了但查不出来,这笔记录在列表里消失 |
| 5 | 存 12.30 取回 | 变成 12.3 |
探针 4 是最阴的:程序不报错、不崩溃,记录也确实写进了数据库,但用户在界面上就是看不见它。因为按月查询用的是 date LIKE '2026-09%',而 '2026-9-1' 匹配不上。这类静默丢数据靠读代码很难发现,必须真的跑一遍。
我给 AI 的修改要求
初版有三个问题,请分别修掉,不要把校验逻辑写在 gui.py 里:
1. 金额输入没有任何校验。on_save() 里裸的 float() 遇到 "abc" 就崩,
而且崩溃后用户已经填好的日期、备注也一起没了。
要求:金额必须是大于 0 的数值,单笔不超过 1,000,000 元;
非法时不写库,弹出说明"哪里不合法、该怎么填"的中文提示,
并且已经填好的其他字段要保留,用户可以只改错的那一项再保存。
2. 日期没有校验也没有规范化。用户填 "2026-9-1",入库就是 "2026-9-1",
而按月查询用的是 date LIKE '2026-09%',这条记录永远查不出来。
要求:校验是不是真实存在的日期(2026-02-30 要拒绝),
并统一规范成 YYYY-MM-DD 再入库,界面上把规范化后的日期回填给用户看。
3. 删除记录没有二次确认,点一下就没。要求:删除前弹确认框,
把要删的记录列出来给用户看,确认后才删。
把上面这些校验抽到单独的 validation.py 里做成纯函数抛 ValidationError,
我要能直接对它们写单元测试,不用开窗口。
AI 修改了什么
新增 validation.py:金额、日期、备注、类型、分类五个校验函数,统一抛自定义的 ValidationError。保存改成先把五个字段全部校验完再写库,任何一步失败就弹提示且不清空输入框。删除加了二次确认,并把待删记录列出来给用户看。日期规范化顺带把探针 4 的丢数据问题一并解决了。
第 3 轮(第二次迭代修改)
发现的问题
第 2 轮解决了非法输入,但浮点误差还在。这次盯着金额往下查,发现它其实有两个漏洞,不是一个。
第一个在存储层:金额以 REAL 存元,浮点累加有误差。第二个在解析层:用户输入 1.005 元,按理该四舍五入成 1.01 元,但因为 float("1.005") 实际是 1.00499999,结果被舍成了 1.00 元。
float("1.005") * 100 -> 100.49999999999999 -> round -> 100 分 (= 1.00 元)
Decimal("1.005") * 100 -> 100.5 -> HALF_UP -> 101 分 (= 1.01 元)
第二个漏洞是写测试时才发现的。 我本以为把存储换成整数分就完事了,结果写完 test_money.py 一跑,断言 1.005 元应得 101 分的那条用例红了。也就是说我只修了存储层的浮点,解析层的浮点还在,而解析层照样用的是 float。如果只修一半,账面上看不出任何异常,误差会一直悄悄少算用户的钱。
我给 AI 的修改要求
统计金额出现了 0.30000000000000004 这样的浮点误差,收入支出对不上。请把金额
全面改成整数「分」,要求:
1. 新增 money.py,集中放金额处理函数:to_fen(用户输入) -> int 分、
format_yuan(分) -> "12.30" 这样的字符串。
2. 数据库 amount 字段从 REAL 改成 INTEGER,存整数分。已经用旧版本记过账的库
必须能自动迁移,不能让用户手工处理:打开旧库时检测到 amount 是 REAL,
就把每行的 amount 乘以 100 四舍五入转成分。迁移要幂等,重复打开不能越迁越小。
3. 解析金额不要用 float。float("1.005") 是 1.00499999...,会把 1.005 元
舍成 1.00 元而不是 1.01 元。请用 decimal.Decimal 解析并 ROUND_HALF_UP。
4. stats.py / storage.py / models.py 内部一律用整数分,不要出现 / 100;
元↔分的转换只能出现在"用户输入"和"界面显示"两个边界。
5. 统计函数的语义不要变(收入/支出/结余/分类汇总)。
AI 修改了什么
新增 money.py:Decimal 解析 + ROUND_HALF_UP,格式化带千分位。Transaction.amount 类型改成 int,建表语句改成 INTEGER,并新增老库自动迁移。界面所有金额展示统一走 format_yuan。顺带发现 money.py 和 validation.py 会循环引用(两边都要用 ValidationError),把异常抽到了 errors.py。
这里要更正我第 4 条要求里的一处不准确。 我当时写"元↔分的转换只能出现在用户输入和界面显示两个边界",但实际实现里有第三处:storage._migrate_amount_to_fen 的老库迁移 SQL 里那句 CAST(ROUND(amount * 100) AS INTEGER),它把旧库的 REAL 元转成整数分。这确实是一次元↔分转换。准确的表述应该是:存储和统计的计算路径内部只用整数分,转换只发生在三个地方——用户输入解析、界面显示、老库迁移(一次性)。
第 4 轮(第三次迭代修改)
发现的问题
核心功能齐了,但需求里的"提醒"还完全没做,只有统计没有预警。另外我在读第 2 轮的代码时注意到,预算阈值如果直接写 used / limit >= 0.8,会重新引入第 3 轮刚修掉的浮点比较问题。
我给 AI 的修改要求
补上需求里的"提醒"功能:分类月度预算 + 超支预警。
1. 新增 budgets 表存每个分类的月度预算(整数分),提供读取和设置接口。
预算设为 0 或留空 = 取消该分类的预算。
2. stats.py 新增纯函数 budget_status(transactions, budgets),返回每个设了
预算的分类的执行情况:预算、已用、使用率、档位(正常/接近超支/已超支)。
3. 提醒规则:使用率达到 80% 但没到 100% 是"接近超支";达到或超过 100% 是"已超支";
没设预算的分类不提醒。
4. 阈值比较不要用浮点比值,用整数交叉相乘(used * 100 >= limit * 80),
理由和上一轮一样。
5. 边界必须处理:预算为 0 不能除零;使用率正好 80.00% 和正好 100% 都要触发提醒;
收入不计入预算消耗。
6. 界面顶部加一条常驻提醒横幅,超支红色、接近超支橙色、无提醒时灰字;
每次保存记录、改预算后自动刷新。再加一个"设置预算"按钮打开设置窗口。
7. 以上判定逻辑全部放在 stats.py 里做成纯函数,我要能直接写单元测试。
AI 修改了什么
storage.py 加 budgets 表和读取、设置、清除三个接口。stats.py 加 BudgetStatus 数据类与两个纯函数,档位判断用整数交叉相乘 used_fen * 100 >= limit_fen * 80。界面加顶部横幅(按最严重的档位变色)和预算设置窗口。
这一轮还修了一个测试脚手架的问题
我写了个 GUI 冒烟测试,真的建窗口、模拟点击。跑了几次发现偶发有 1 个用例被跳过,报的是"当前环境没有可用的图形界面",可这台机器明明有。查下去发现是我自己在 fixture 里写了:
try:
root = tk.Tk()
except tk.TclError:
pytest.skip("当前环境没有可用的图形界面") # 罪魁祸首
真实原因是 Windows 上在同一个进程里反复创建、销毁 Tcl 解释器时会偶发重新读取库文件失败,报的也是 TclError:
TclError: Can't find a usable tk.tcl in the following directories: ...
couldn't read file ".../tcl/tk8.6/icons.tcl": no such file or directory
我的 except 把真故障吞掉,伪装成了"环境不支持所以跳过"。
修法是整个测试会话只建一个 Tk root 并复用,弹窗用打桩替换,不再反复重建解释器。需要说明的是,这个 except tk.TclError: pytest.skip(...) 我并没有删掉,而是把它降级成了只在会话开始时判定一次——单例之后,那段代码只可能在真的没有图形环境时触发,不会再掩盖"反复初始化失败"这种偶发故障。GUI 子集的耗时从旧写法的约 3.2 秒降到约 1.9 秒,并且不再偶发报错。
我的检查与验证说明
1. 运行真实样例,而不是只跑测试
python demo.py 走一遍完整流程并打印结果,输入故意用真实场景里的脏数据(2026-9-1、2026/9/1 这类不补零日期)。下面是原样输出:
====================================================================
录入 8 笔记录(故意混用 2026-9-1 / 2026/9/1 这类不补零日期)
====================================================================
2026-9-1 -> 入库日期 2026-09-01 支出 13.50 元 餐饮 食堂午饭
2026/9/1 -> 入库日期 2026-09-01 支出 4.00 元 交通 地铁
2026-09-03 -> 入库日期 2026-09-03 支出 0.10 元 餐饮 打包盒
2026-09-05 -> 入库日期 2026-09-05 支出 0.20 元 餐饮 打包盒
2026-09-08 -> 入库日期 2026-09-08 支出 68.00 元 餐饮 室友聚餐
2026-09-12 -> 入库日期 2026-09-12 支出 0.07 元 餐饮 零头
2026-09-15 -> 入库日期 2026-09-15 支出 45.00 元 学习 教材
2026-09-20 -> 入库日期 2026-09-20 收入 1,500.00 元 其他 生活费
====================================================================
2026-09 月度汇总
====================================================================
记录条数:8
收入:1,500.00 元
支出:130.87 元
结余:1,369.13 元
====================================================================
分类支出汇总
====================================================================
餐饮 81.87 元 占 62.6%
学习 45.00 元 占 34.4%
交通 4.00 元 占 3.1%
====================================================================
设置分类月度预算并检查提醒
====================================================================
餐饮 已用 81.87 / 预算 100.00 元 81.9% [接近超支]
学习 已用 45.00 / 预算 200.00 元 22.5% [正常]
交通 已用 4.00 / 预算 20.00 元 20.0% [正常]
界面顶部会弹出的提醒:
⚠ 接近超支:餐饮 已用 81.87 元 / 预算 100.00 元(82%),剩余 18.13 元
====================================================================
持久化检查:关掉再打开,数据还在吗
====================================================================
重开后记录数:8 -> 8
金额合计一致:163087 == 163087
提示:图形界面请运行 python main.py
汇总数字我手算核对过:餐饮 13.50+0.10+0.20+68.00+0.07 = 81.87,支出 81.87+45.00+4.00 = 130.87,结余 1500.00−130.87 = 1369.13,与输出一致。最后两行是持久化验证,比对的是整数分 163087 而不是四舍五入后的浮点数,用浮点比对的话可能因为误差而假通过。
2. 分层测试,123 个用例
| 文件 | 用例数 | 覆盖 |
|---|---|---|
| test_validation.py | 41 | 金额/日期/备注/分类的合法与非法输入 |
| test_money.py | 26 | 分↔元换算、格式化、四舍五入方向 |
| test_stats.py | 8 | 汇总统计,含浮点误差回归用例 |
| test_budget.py | 18 | 预算阈值边界、排序、文案 |
| test_storage.py | 11 | 增删查、月份隔离、老库迁移、幂等 |
| test_gui_smoke.py | 19 | 真窗口端到端:录入/校验/提醒/删除 |
其中 104 个用例不需要开窗口就能跑,实测约 1.1 秒出结果(pytest -m "not gui")。这就是第 1 轮坚持"校验和统计不许写进界面文件"的回报——改逻辑时几秒钟就能知道有没有改坏。
3. 边界审查
- 预算阈值四个点:79.99 正常 / 80.00 接近超支 / 99.99 接近超支 / 100.00 已超支
- 除零:预算为 0 或负数一律视为未设置,直接跳过,不参与计算
- 日期:2026-02-30、2025-02-29 拒绝;2024-02-29 接受(闰年)
- 金额:0、-5、abc、1.2.3、--5 全部拒绝;1,234.5、带空格数字接受
- 月份隔离:2026-08-31 的记录不会混进 9 月的列表
- 收入不消耗预算:收入 500 元记在餐饮分类,餐饮已用仍是 0
4. 交叉验证浮点问题
第 3 轮的核心结论我用了三条独立证据确认,没有只信一个来源。
| 证据 | 方法 | 结果 |
|---|---|---|
| 浮点累加 | sum([0.1] * 3) |
0.30000000000000004,整数分是 0.30 |
| 真实月度账单 | 13 笔日常金额逐笔累加 | 浮点得 143.11999999999998,整数分得 143.12 |
| 解析方向 | float 与 Decimal 对比 | 100.49999999999999(舍成 1.00)对 100.5(进位 1.01) |
第二条的 13 笔金额是 13.5、0.1、0.2、68.0、0.07、45.0、4.0、0.29、8.29、0.07、3.3、0.1、0.2,你可以照着跑一遍核对。这正是我在下面这条上踩过的坑。
有一条差点写错的话值得记下来。 我一开始想当然地举"1000 笔 0.07 元累加"当反例,跑了一下发现 sum([0.07] * 1000) 恰好精确等于 70.0,这个反例根本不成立。如果没验就直接写进报告,那就是在编证据。浮点误差是"某些输入下会错",不是"一定会错",所以举反例必须真跑,不能凭印象。
5. 反向验证:红灯不一定是代码的错
整个过程中测试红了 11 个用例,外加 1 次伪装成"跳过"的假绿灯。我逐个查过原因,其中只有 1 次是真的代码 bug。
| 红灯原因 | 次数 | 处理 |
|---|---|---|
| 真 bug:1.005 元被舍成 1.00 而不是 1.01 | 1 | 改代码(Decimal 解析) |
| 接口契约变更后旧测试没跟上(返回值从元改成分) | 7 | 改测试 |
| 测试自己写错(如断言带空格的没设预算该被拒绝,其实代码做了容错是对的) | 3 | 改测试 |
| 测试脚手架把真故障伪装成"跳过" | 1 | 改测试 |
"测试红了"不等于"代码错了",得先判断红的是哪一边。反过来说,"测试绿了"也不等于"代码对了",如果测试本身写错,绿灯同样是假的。下面这一条就是活生生的例子。
6. 发布前复核:一个会自己过期的绿灯
写这篇文章之前,我把文档里所有对代码的断言列出来,交给几组独立的核查去逐条推翻——不是复核,是主动找反例。结果最有把握的那条被推翻了。
文档里我一直写着"121 个测试用例全绿"。复核时实跑 python -m pytest -q,结果是 8 failed, 113 passed。
根因查清了,而且不可思议地简单:界面默认显示"今天所在的月份"(gui.py 里 month_var 取 dt.date.today()),明细列表只列所选月份的记录;而我的 GUI 测试把日期写死成 2026-09-01。这套测试是 9 月 29 日写的,当时"今天"就在 9 月,两边对得上,全绿。10 月 1 日一过,记录进了 9 月、界面显示 10 月,rows(app) 恒为空,8 个用例一起变红。
这不是代码坏了,是测试的隐含前提过期了。 它在我提交之前一直是绿的,而且会永远绿下去——直到下个月。修法是在测试 fixture 里把月份钉死,让"这批用例固定在 9 月"这个前提从隐含变成显式,并补了两条回归用例把这个行为本身测出来。修完我用伪造的"今天"(2027-03-15、2026-09-30、2026-10-01、2026-11-20)各跑了一遍,四种日期下都是 123 passed。
同一轮复核还改掉了另外几处不准确的说法:
- "104 个用例 0.3 秒跑完"——0.3 秒只在当时那台机器的负载下成立,现在实测约 1.1 秒
- "从 1.3 秒降到 0.8 秒"——数字对不上,而且主语写错了(那是整轮的耗时,不是 GUI 子集的)。已改成"GUI 子集约 3.2 秒降到约 1.9 秒"
- "元↔分转换只出现在两个边界"——实际是三处,见第 3 轮末尾的更正
- 数据库"三张表"——实际只有两张业务表(transactions、budgets),第三张
sqlite_sequence是 SQLite 自己维护的内部表
验收自评
| 验收标准 | 自评 | 依据 |
|---|---|---|
| ① 功能跑通 | 达标 | python main.py 可打开界面,python demo.py 可复现完整流程,输出见验证说明第 1 节 |
| ② ≥2 轮迭代记录 | 达标 | 4 轮完整记录(1 轮初版 + 3 轮迭代),每轮都有"我给 AI 的修改要求"和"AI 实际改了什么" |
| ③ ≥1 次验证说明 | 达标 | 验证说明 6 节:真实样例、分层测试、边界审查、交叉验证、反向验证、发布前复核 |
| ④ 1 条心得 | 达标 | 见下节,回答"最大意外",并补充了"惊喜"的一面 |
心得
最大的意外:AI 写的测试脚手架会骗我。
第 4 轮那个 GUI 冒烟测试,跑几次就偶发冒出一个"跳过",理由是"当前环境没有可用的图形界面"。这台机器图形界面好好的,python main.py 能把窗口弹出来。我一开始差点就接受了,毕竟"跳过"看着不像失败,而且确实有些 CI 环境没有图形界面,跳过好像也合理。
真去挖才发现是我自己在 fixture 里写了 except tk.TclError: pytest.skip(...)。真实原因是 Windows 上反复创建销毁 Tcl 解释器会偶发读不到库文件,报的也是 TclError,于是真故障被那个宽泛的 except 吞掉,伪装成了一个看起来合理的"跳过"。
这件事比"AI 写错了代码"严重得多。代码写错,测试会红,我看得见;但测试自己坏了还装成没事,我看到的是绿的。绿色的假象比红色的错误危险。
最让我意外的一次"惊喜",是让 AI 去推翻我自己。
发布前我把文档里的断言交给几组独立核查去挑刺,本以为它们只会逐条确认,结果第一轮就推翻了我最有把握的那条——"121 个用例全绿"其实早就红了,而且红的原因和代码质量毫无关系,是测试的日期前提过期了。一个我打算拿去证明"这项目测试很扎实"的证据,本身就是个会过期的绿灯。
这两件事是同一个教训的两面,也正是这次练习最想让我记住的:验收的对象不只是代码,还包括"怎么证明代码是对的"那一套东西。 except 要尽量窄、skip 要给出真实原因、测试不能依赖"今天是几号"、文档里的每个数字都要能被人照着复现。这些以前觉得是经验之谈,这次是真被咬了一口才记住的。
做完这四轮我才明白课程里那句话的意思:人做"意图 + 决策 + 验证",AI 做"生成 + 检索 + 重复劳动"。AI 把写代码变便宜之后,验证反而成了最贵、也最不能外包的那一环。

浙公网安备 33010602011771号