2026秋软件工程实践现场编程——第五组汇报报告
「轻记 · 个人记账本」现场编程作业博客
| 项目 | 内容 |
|---|---|
| 作业所属课程 | 软件工程与软件工程实践(2026-01) |
| 班级 | https://edu.cnblogs.com/campus/fzu/2026-01SoftwareEngineeringandSoftwareEngineeringPractice/ |
| 作业要求 | 现场编程作业要求 |
| 作业的目标 | 开发一个个人记账本,支持收入、支出的记录、查看与统计,数据保存在本地文件中;同时实践多人团队基于 GitHub 的协作开发流程——通过模块化分工、接口契约先行、代码复审与合并,按时交付一个可现场演示、可发布的成品。 |
| 团队名称 | 第五组 |
| 团队成员 | 戴锴斌-102302152、周兴宇-072308214、刘昭杉-052401324、叶思铖-102402141、陈勇昊-102401323、刘怀正-052404147、林彦羽-052404129、沈冠宇-032401339、曾炜毅-102401314、郭泽强-052402234、丁维彤-102402123、陈睿涛-102402120 |
软件名:轻记(qingji),一款在本机运行的中文个人记账软件:用浏览器操作,数据保存在本地 JSON 文件,不依赖云服务、账号或任何第三方 Python 库。
一、组员职责分工
项目按 数据 / 业务 / 统计 / 接口前端 四层拆成 12 个模块,每人独立认领、独立实现、独立提交(分工方案见仓库 docs/团队分工.md):
| 成员 | 学号 | 负责模块 | 主要交付物 |
|---|---|---|---|
| 郭泽强 | 052402234 | A1 金额与字段校验 | ledger/fields.py:cents()、valid_date()、text_field() |
| 曾炜毅 | 102401314 | A2 记录结构与分类 | ledger/schema.py:类别、唯一 id、文档结构校验 |
| 叶思铖 | 102402141 | A3 存储与事务锁 | ledger/storage.py:JsonStore 原子写入、跨进程锁 |
| 周兴宇 | 072308214 | B1 记录新增 | ledger/record_add.py:LedgerAdd.add() |
| 林彦羽 | 052404129 | B2 记录修改与删除 | ledger/record_edit.py:update()、delete() |
| 刘怀正 | 052404147 | B3 组合筛选 | ledger/query.py:LedgerQuery.query() |
| 陈睿涛 | 102402120 | C1 收支汇总 | ledger/summary.py:总额、按月、按类别聚合 |
| 陈勇昊 | 102401323 | C2 导入与合并 | ledger/importer.py:import_json() 去重合并 |
| 丁维彤 | 102402123 | C3 导出 JSON 与 CSV | ledger/exporter.py:完整备份、CSV 转义与公式防护 |
| 沈冠宇 | 032401339 | D1 本机接口与安全 | ledger/server.py、app.py:路由、CSP、Host/Origin 白名单 |
| 刘昭杉 | 052401324 | D2 页面结构与样式 | static/index.html、static/styles.css:语义化结构、响应式 |
| 戴锴斌 | 102302152 | D3 前端交互与可视化 | static/app.js:增删改查交互、柱状图/环形图、导入导出 |
协作约定:A 组(数据层)先定接口并同步《接口契约》,B/C 组依赖契约用模拟数据并行开发,D 组最后联调;每人只改自己负责的函数/区块,测试通过后经 Review 合并,全部在 main 分支集成后统一回归。
二、程序运行环境
- 运行环境:Python 3.9 或以上(已在 Python 3.9.6 验证)、支持
<dialog>的现代浏览器(Chrome / Edge / Safari 均可);零第三方依赖,无需pip install、无需 Node.js、无需联网。 - 运行方式:在项目目录执行
python app.py(macOS 可双击start.command,Windows 可双击start.bat),浏览器自动打开 http://127.0.0.1:8765 。 - 服务仅监听本机
127.0.0.1,不用于公网部署;数据保存为本地明文 JSON(data/ledger.json)。 - 测试环境:标准库
unittest,共 13 项自动化测试通过(含多进程并发写入、损坏文件保护、HTTP 安全用例等)。

三、软件运行截图
演示数据:
examples/sample-ledger.json,虚构记录)。



四、关键代码
4.1 金额校验:用 Decimal 校验、整数分存储(模块 A1)
def cents(value, allow_zero=False):
# ① 拒绝布尔与非数字类型(bool 是 int 的子类,必须先挡掉)
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
raise ValueError("金额须为数字")
value = str(value).strip()
# ② 用正则限定「最多 8 位整数 + 最多 2 位小数」,从源头禁止 0.1+0.2 这类浮点误差
if not re.fullmatch(r"\d{1,8}(?:\.\d{1,2})?", value):
raise ValueError("金额最多 8 位整数、2 位小数")
# ③ Decimal 精确换算成「整数分」再落库,全程不做浮点运算
result = int(Decimal(value) * 100)
if result < (0 if allow_zero else 1) or result > MAX_CENTS:
raise ValueError("金额须大于 0,且不超过 99,999,999.99 元")
return result
说明:保存的金额始终是整数(分),展示时再除以 100,从根源上避免了浮点累计误差,测试 0.10 + 0.20 == 0.30 精确成立。
4.2 持久化:跨进程事务锁 + 临时文件原子替换(模块 A3)
@contextmanager
def transaction(self):
"""整个读改写过程持有同一个锁文件,避免多个实例丢失更新。"""
with self.lock:
stream = open(str(self.path) + ".lock", "a+b")
with stream:
# ① 操作系统级文件锁:Windows 走 msvcrt,Linux/macOS 走 fcntl,
# 两个 app.py 实例同时写入时,后一个会在此阻塞等待
if os.name == "nt":
import msvcrt
...
msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1)
else:
import fcntl
fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
try:
yield # ② 持有锁期间完成 读取 → 修改 → 写回
finally:
... # ③ 无论成功失败都释放锁
def write(self, records):
# ④ 先写入同目录临时文件并 fsync 落盘,再用 os.replace 原子替换正式文件,
# 中途断电/崩溃不会留下半个 JSON
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8",
dir=self.path.parent, prefix=".ledger-", suffix=".tmp", delete=False) as stream:
tmp = stream.name
json.dump({"version": 1, "records": records}, stream, ensure_ascii=False, indent=2)
stream.flush()
os.fsync(stream.fileno())
os.replace(tmp, self.path)
说明:读改写全过程持锁,多进程并发写入不丢数据(自动化测试用 12 个进程并发提交 24 笔记录验证);写入采用「临时文件 + 原子替换」,损坏的 JSON 只会报出错误并保留原文件,绝不自动清空。
4.3 导入合并:整批校验、去重、冲突即整体取消(模块 C2)
def import_json(self, document):
incoming = validate_document(document) # ① 先把整个文件结构校验完
with self.store.transaction(): # ② 整批操作在事务锁内完成
records = self.store.read()
by_id = {row["id"]: row for row in records}
added = skipped = 0
for row in incoming:
if row["id"] in by_id:
if row != by_id[row["id"]]:
# ③ 相同编号但内容不同 = 备份版本冲突:抛异常使整批取消,
# 已读的旧账本不受影响,保证导入原子性
raise ValueError("备份中有相同编号的不同记录,导入已取消;请先核对备份版本")
skipped += 1 # ④ 完全相同的记录跳过(幂等,重复导入不产生重复数据)
else:
records.append(row)
added += 1
if len(records) > MAX_RECORDS:
raise ValueError("导入后超过 20,000 笔记录上限")
if added:
self.store.write(records)
return {"added": added, "skipped": skipped}
说明:导入是合并而非覆盖——重复导入同一个备份只会跳过相同记录;任何一条冲突都会让整个导入回滚,杜绝「导入一半」的中间状态。
4.4 收支汇总:一次遍历同时得到总额、月度与类别聚合(模块 C1)
@staticmethod
def summarize(records):
income = expense = 0
categories = defaultdict(lambda: {"income_cents": 0, "expense_cents": 0, "count": 0})
months = defaultdict(lambda: {"income_cents": 0, "expense_cents": 0})
for row in records:
key = row["type"] + "_cents" # ① income/expense 拼出计数器字段名
if row["type"] == "income":
income += row["amount_cents"]
else:
expense += row["amount_cents"]
categories[row["category"]][key] += row["amount_cents"] # ② 按类别聚合
categories[row["category"]]["count"] += 1
months[row["date"][:7]][key] += row["amount_cents"] # ③ 日期前 7 位即 YYYY-MM,按自然月聚合
return {"income_cents": income, "expense_cents": expense,
"balance_cents": income - expense, "count": len(records),
"categories": sorted(...), "months": sorted(...)}
说明:总额、结余、笔数、按类别、按月份五种统计在同一次遍历内完成,天然保证「列表、统计、图表共用同一份筛选结果」——用户改任何筛选条件,三个图表和汇总卡都同步刷新。
4.5 前端图表:无第三方图表库,原生 SVG 绘制(模块 D3)
function renderTrend(summary) {
// ① 只取筛选结果里最近最多 12 个自然月,与后端 months 聚合结果对齐
const series = summary.months.slice(-12);
...
const svg = svgElement('svg', { viewBox: '0 0 540 184', role: 'img', 'aria-label': '按月收入与支出柱状图' });
svg.append(svgElement('title', {}, '月度收支:' + series.map(...).join(';'))); // ② <title> 提供无障碍读屏文本
...
const rect = svgElement('rect', { x: center + offset, y: 144 - height, width: barWidth, height, rx: 3, fill });
rect.append(svgElement('title', {}, `${row.month} 收入/支出金额`)); // ③ 悬停显示具体数值
container.replaceChildren(svg);
}
说明:不引入 ECharts 等任何库,用 createElementNS 原生生成 SVG,月度柱状图、支出环形图和类别明细表全部由筛选后的同一份数据驱动;配合 CSP 的 script-src 'self',前端零外部请求。

五、软件亮点(1–3 项)
亮点 1:金额全程整数分,杜绝浮点误差
输入用 Decimal 严格校验(正则限定最多 8 位整数、2 位小数),存储一律转为整数分,展示时才格式化回元。0.10 + 0.20 精确等于 0.30,任意笔数累加都不会出现「0.1+0.2=0.30000000000000004」这类经典问题。有专项测试用例保证。
亮点 2:「事务锁 → 读取 → 校验 → 临时文件落盘 → 原子替换」的可靠保存链
- 跨进程文件锁(Windows
msvcrt/ 类 Unixfcntl):多个程序实例同时写入时按序完成,测试模拟 12 进程并发提交 24 笔全部保留; - 临时文件写入 +
fsync+os.replace原子替换:崩溃或断电不会留下写了一半的 JSON; - 损坏文件只报错、保留原样,绝不自动清空,用户可从 JSON 备份整体恢复;
- JSON 导入同样走整批事务:冲突即整体取消,重复导入幂等跳过。
亮点 3:本机服务的默认安全姿态
仅监听 127.0.0.1;HTTP 层设置严格 CSP(default-src 'self' 等);校验 Host 与 Origin 白名单,拒绝跨来源请求;静态资源接口有路径白名单,目录穿越请求读不到任何文件;请求体设 64 MiB 上限。对一个不做公网部署的本地工具来说,安全默认值做到了「不演示就安全」。
六、花费时间较长、收获较大的事件(≥3 项)
事件 1:进程内锁挡不住多实例并发——复审返工(约 1.5 小时)
初版 JsonStore 只用了 threading.RLock,单人测试完全正常。独立复审时提出:用户可能同时开两个程序窗口记账,两个进程各持各的锁,「读 → 改 → 写」会互相覆盖。我们为此新增了多进程并发测试(12 个进程同时提交),先观察失败、再引入基于锁文件的跨进程事务锁(msvcrt.locking / fcntl.flock)修复,最终 24 笔全部保留。收获:并发问题不能靠代码审查看出来,必须用能复现它的测试逼出来;「锁的粒度」要覆盖到进程级,而不只是线程级。
事件 2:导入体积上限的边界之争——32 MiB 还是 64 MiB(约 40 分钟)
有成员发现:前端把导入上限写死为 32 MiB,但账本最大 20,000 笔 × 最长合法备注时,合法导出的备份文件本身就可能超过 32 MiB——意味着用户导出的备份永远导不回来。争论点在于「砍上限」还是「放宽验证」,最终选择前后端统一为 64 MiB,并额外限制备份时间字符串长度、补了「最大体积导出 < 导入上限」的回归测试。收获:上限类约束必须成对设计——导出能造出的最大产物,必须小于导入能接受的最大输入,否则功能组合起来就是断的。
事件 3:12 人并行开发的接口契约先行(约 1 小时的设计讨论)
为了避免 12 个人各写各的、联调时互相打架,我们先冻结了《接口契约》:记录结构(字段名、金额单位)、筛选参数语义、HTTP 路由与错误格式全部写死成文档,任何人改接口必须先经组长确认。B/C 组在数据层还没写完时就能用模拟数据按契约并行开发。收获:小团队里「文档先于代码」不是形式主义——契约就是 12 条并行工作流之间的编译器,省下的联调时间远超写文档的时间。
事件 4:前端借鉴的合规处理(约 30 分钟)
D2/D3 的设计借鉴了三个开源项目(分层范式、记账页交互、仪表盘布局)。我们专门写了 docs/前端借鉴说明.md 逐条注明借鉴点与来源,并确认只借鉴思路、全部代码自写。收获:课程作业使用 AI 生成与参考开源实现是常态,但「借鉴什么、自写什么」必须留痕、可说清楚,这也是诚实记录的一部分。
七、每位组员的团队编程体验
- 郭泽强(A1 金额与字段校验):第一次意识到「金额是字符串还是数字」是个严肃问题。为了挡住
bool、NaN、八位小数这类千奇百怪的输入,我把校验写成了「白名单式」的正则 + Decimal 组合,并明白了为什么真正的金融系统都以「分」为单位存储。 - 曾炜毅(A2 记录结构与分类):设计文档结构时纠结过要不要存「类别字典表」,最终选择类别内联 + 校验时聚合,简单且够用。最大的收获是学会了用
validate_document()把「整个文件是否合法」和「单条记录是否合法」分开校验。 - 叶思铖(A3 存储与事务锁):从「写个 JSON 读读写写」到跨进程事务锁 + 原子替换,是这次作业我最大的成长。Windows 和 macOS 的锁 API 完全不同,写兼容分支时踩了不少坑,也让我理解了为什么数据库要有 WAL 和事务。
- 周兴宇(B1 记录新增):看似简单的新增功能,要处理 20,000 笔上限、返回带 id 的记录、失败不污染账本。我学会了「写功能前先写它失败的用例」。
- 林彦羽(B2 记录修改与删除):编辑和删除最大的坑是「记录不存在」和「保留 id 与 created_at」。删除前的前端确认窗口展示日期、类别、金额,是和小组成员讨论后加上的细节,用户误删率明显降低。
- 刘怀正(B3 组合筛选):五个条件(日期、类型、类别、金额区间、关键词)要同时生效,且边界都要「包含」。我学会了把每个条件写成独立的纯函数谓词再 AND 起来,测试和维护都轻松很多。
- 陈睿涛(C1 收支汇总):用一次遍历同时算出总额、按月、按类别三种统计,写完发现这比「查三次库」既快又不会口径不一致——数据只过一遍,口径天然统一。
- 陈勇昊(C2 导入与合并):导入的难点不在「读文件」,在「失败时怎么收场」。整批校验 + 事务锁 + 冲突抛异常,让我第一次认真理解了数据库事务的原子性到底保护了什么。
- 丁维彤(C3 导出 JSON 与 CSV):CSV 导出踩了电子表格公式的坑:备注以
=开头会被 Excel 当公式执行。加了单引号前缀防护和 UTF-8 BOM 后,中文 CSV 在 Excel 里也能直接正确打开。 - 沈冠宇(D1 本机接口与安全):写 HTTP 层最有趣的是「默认安全」:CSP、Host/Origin 白名单、路径穿越防护全是「没要求也做了」的部分。路径穿越测试用
../逐个试,确实全部 403/404,很有成就感。 - 刘昭杉(D2 页面结构与样式):为了 390px 窄屏下「文档宽度恰好 390px、表格在自身区域内横向滚动」,调了很久媒体查询。学会了用「语义化标签 + CSS 变量(明暗主题)」组织样式,而不是堆 class。
- 戴锴斌(D3 前端交互与可视化):没引任何图表库,用原生 SVG 画柱状图和环形图,悬停数值用
<title>实现还顺带支持了读屏。最大的体会是:交互状态(筛选、分页)全部收敛到一个render(),改一处全站刷新,比散落的事件回调好维护得多。
八、SP 参考
- http://www.cnblogs.com/vertextao/p/7469789.html
- http://www.cnblogs.com/vertextao/p/7469789.html%EF%BC%9B
九、GitHub 仓库与 commit 记录
- 仓库地址:https://github.com/ACAct/tallybook/tree/main
- 协作方式:主干开发 + 每人独立分支提交 → Pull Request → Review 合并;每人负责模块的 commit 均由本人 GitHub 账号提交(详见
docs/团队分工.md与docs/成员认领与提交模板.md)。 - Release:已在 GitHub 发布 Release 版本。



浙公网安备 33010602011771号