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 安全用例等)。

屏幕截图 2026-11-07 191009


三、软件运行截图

演示数据:examples/sample-ledger.json,虚构记录)。

FDC256CBDA27DFD945FDFC77B5BE1E09

92764CAABC7BBDAB7614C9BD46B35C22

79C3C2D699AEC8A37272E114B50EF6F5


四、关键代码

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',前端零外部请求。

79C3C2D699AEC8A37272E114B50EF6F5


五、软件亮点(1–3 项)

亮点 1:金额全程整数分,杜绝浮点误差

输入用 Decimal 严格校验(正则限定最多 8 位整数、2 位小数),存储一律转为整数分,展示时才格式化回元。0.10 + 0.20 精确等于 0.30,任意笔数累加都不会出现「0.1+0.2=0.30000000000000004」这类经典问题。有专项测试用例保证。

亮点 2:「事务锁 → 读取 → 校验 → 临时文件落盘 → 原子替换」的可靠保存链

  • 跨进程文件锁(Windows msvcrt / 类 Unix fcntl):多个程序实例同时写入时按序完成,测试模拟 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 参考


九、GitHub 仓库与 commit 记录

  • 仓库地址:https://github.com/ACAct/tallybook/tree/main
  • 协作方式:主干开发 + 每人独立分支提交 → Pull Request → Review 合并;每人负责模块的 commit 均由本人 GitHub 账号提交(详见 docs/团队分工.md 与 docs/成员认领与提交模板.md)。
  • Release:已在 GitHub 发布 Release 版本。

1

2

posted @ 2026-11-07 19:21  干的湿垃圾  阅读(32)  评论(0)    收藏  举报