Python 模型与 JSON:保存和恢复订单

课前先保留判断:课前预习;课后用你的结果复验:课后复习。

今天从哪里开始

昨天已经把规则拆成可调用的函数,但函数收到的字典仍可能缺字段,程序关闭以后内存里的订单也会消失。再加一个业务要求:商品明天改价,今天已经成交的金额必须保持不变。

今天分两段完成:先用模型守住字段、数量与价格快照,再把有效模型转成 JSON 写入文件。最后重新读取,确认恢复后的对象仍符合规则,而不只是磁盘上多了一个文件。

flowchart LR
  A[昨日规则函数] --> B[明细对象与价格快照]
  B --> C[JSON保存并重新校验]

今天要学会什么

  • 用类、实例、方法和 dataclass 表达订单明细
  • 用特殊方法展示、比较和计数,用 StrEnum 集中状态名
  • 比较组合、继承与拷贝,保持价格快照
  • 用 Path、UTF-8、with 和 JSON 保存恢复并区分错误

课堂结束时,用自己的项目检查:

  • 能做出来:修改商品当前价格,证明历史订单金额不变,并展示正常输入的结果。
  • 能查出问题:针对“把坏文件当空数据、Decimal 转 float、bool 冒充数量”中的风险,说明输入、预期与实际差异;失败后核对数据没有被误改。
  • 能解释修改:提交代码、测试和修改前后结果,讲清自己接受或拒绝了哪一处 AI 建议。

一条订单明细怎样拥有自己的数据和行为

昨天函数的输入是字典,字段写成 price 或 unit_price_yuan 会让调用者理解不一致。我用类给一条明细明确结构,用实例保存这次购买的数据。

from decimal import Decimal
 
class OrderLine:
    def __init__(self, product_id, unit_price, quantity):
        self.product_id = product_id
        self.unit_price = unit_price
        self.quantity = quantity
 
    def total(self):
        return self.unit_price * self.quantity
 
line = OrderLine("P1001", Decimal("129.00"), 2)
print(line.product_id, line.total())

class 定义类型;OrderLine(…) 创建实例并调用初始化方法;self 指当前实例;self.quantity 是实例属性;total 是方法,line.total() 会把 line 作为 self 传入。__init__ 初始化实例,不返回金额。先建立第二条明细,修改第二条数量,确认第一条仍为2。

这里是结构最小例,尚未校验;接回项目时 total 复用昨天的 calculate_line_total_yuan,构造时检查商品编号非空、单价为 Decimal、数量为真正正整数。属性不是自动校验器。

魔法方法:让自己的对象接入 Python 已有操作

双下划线特殊方法规定了对象参与内置操作时的行为,不是任意取个特殊名字。商城调试、展示、快照核对与购物车计数分别需要不同含义。

操作调用的协议商城用途与边界
创建 OrderLine(…)__init__初始化这次购买的字段,不等于保存文件
repr(line)__repr__开发者看字段,排查价格与数量
str(line)、print(line)__str__展示“P1001 × 2:258.00 元”
restored == line__eq__核对保存恢复后的值相等;不要求是同一个对象
len(cart)__len__约定为商品行数,不是总件数;返回非负整数

我先给刚才的类局部加入展示方法:

def __str__(self):
    return f"{self.product_id} × {self.quantity}:{self.total():.2f} 元"

这是放进类内部的方法片段。运行 print(line) 比较前后,再解释为什么不能在 str 中发起支付或修改库存。特殊方法只承担它约定的职责。

dataclass 减少重复写法,校验仍由我负责

先用普通类理解对象,再让标准库生成重复的初始化、开发展示与值比较。@dataclass 把类交给 dataclass 处理并绑定回来;暂时使用这个已有装饰器,明天再亲手写函数装饰器。

from dataclasses import dataclass
from decimal import Decimal
 
@dataclass(frozen=True)
class OrderLine:
    product_id: str
    unit_price: Decimal
    quantity: int
 
    def __post_init__(self):
        if not isinstance(self.product_id, str) or not self.product_id.strip():
            raise ValueError("商品编号不能为空")
        if not isinstance(self.unit_price, Decimal):
            raise ValueError("单价必须是Decimal")
        if not self.unit_price.is_finite() or self.unit_price < 0:
            raise ValueError("单价必须是有限非负数")
        if self.unit_price != self.unit_price.quantize(Decimal("0.01")):
            raise ValueError("单价最多两位小数")
        if type(self.quantity) is not int or self.quantity < 1:
            raise ValueError("数量必须是正整数")

这是可独立运行的校验小例。自动 init 赋值后调用 post_init;抛异常会让本次构造失败。frozen 阻止常规字段重新赋值,不会使嵌套列表深度不可变;注解不会执行类型校验。完整项目仍复用金额校验函数,避免两处维护。

dataclass 与运行时校验不是一回事

运行两个同值实例的 == 与 is:值相等可以为真,对象身份相同为假。再尝试数量True、0和单价NaN,说明拒绝发生在哪一行。repr、eq 可由 dataclass 生成,不必手写所有魔法方法。

订单状态只允许几个名字:StrEnum

把PAID在多处手写成PAIED会制造难查的状态。枚举把一组允许的值集中命名,Python 3.12的StrEnum也能作为字符串使用:

from enum import StrEnum
 
class OrderStatus(StrEnum):
    PENDING_PAYMENT = "PENDING_PAYMENT"
    PAID = "PAID"
    CANCELLED = "CANCELLED"
 
status = OrderStatus("PAID")
print(status.value)  # PAID
print(status == "PAID")  # True

OrderStatus继承StrEnum,成员名和值由上面的类体声明。未知字符串构造会抛ValueError。把外部字符串转成枚举只能保证名字属于允许集合,不能证明PAID允许取消;状态迁移仍交给昨天的业务函数。

练习:分别传入PAID与PAIED;再用合法的PAID尝试取消,确认“名称合法”和“动作合法”是两道检查。后续流式事件名复用这项语法,不再重新首讲。

购物车包含明细:组合、继承与拷贝的选择

购物车“有多条明细”,因此保存 OrderLine 列表,叫组合。购物车并不是一种订单明细,把 Cart 继承 OrderLine 会误导调用者。

class Cart:
    def __init__(self, lines=None):
        self.lines = [] if lines is None else list(lines)
 
    def __len__(self):
        return len(self.lines)
 
cart = Cart([OrderLine("P1001", Decimal("129.00"), 2)])
print(len(cart))  # 1行,2件

我把每实例的 lines 放进 init。若写成类体里的 lines = [],所有购物车会共用同一列表。list(lines) 复制外层列表,本例明细只读;可变嵌套字典仍需继续观察。

from copy import deepcopy
original = {"items": [{"quantity": 2}]}
shallow = original.copy()
isolated = deepcopy(original)
shallow["items"][0]["quantity"] = 9
print(original["items"][0]["quantity"])  # 9
print(isolated["items"][0]["quantity"])  # 2

赋值是别名,浅复制共享嵌套对象,深复制递归复制;价格快照优先明确选取字段,不把所有对象一律深复制。用同一实验解释改商品价格为什么不能改历史订单。

继承表示“是一种”,子类可继承并覆盖父类方法;super() 调用继承链上的下一份实现。先看可独立运行的物流规则小例,课程主项目不额外接物流服务:

class Delivery:
    def label(self):
        return "配送"
 
class ExpressDelivery(Delivery):
    def label(self):
        return super().label() + ":加急"
 
print(Delivery().label())
print(ExpressDelivery().label())

调用方统一调用label,得到“配送”与“配送:加急”,子类覆盖方法并复用父类行为。今天主项目用组合;D05 的 OrderInputError(ValueError) 会实际使用异常继承。方法覆盖与组合是选择,不要求为了知识覆盖给商城制造多层继承。

订单为什么保存价格快照

用户在 9:00 以 129.00 元下单,商品 14:00 改成 139.00 元。历史订单应该显示多少?

商品现价与订单历史价

我先用独立实验观察快照;下面需在已完成 OrderLine 的 total 方法后运行:

product = {"name": "轻羽无线鼠标", "unit_price_yuan": Decimal("129.00")}
line = OrderLine("P1001", product["unit_price_yuan"], 2)
product["unit_price_yuan"] = Decimal("139.00")
print(line.total())  # 258.00,未追随商品现价

累计订单的 OrderItem 还保存 product_name_snapshot 与 unit_price_yuan;本日小例只缩小字段说明快照机制。

这是一种有业务意义的冗余:换取历史事实稳定。Service(服务层) 创建订单时生成快照;CLI 不能提交或覆盖金额计算字段。

客服复核昨天订单时应使用哪个单价?为什么?

使用下单时保存的 129.00 元快照。商品现价服务新交易,历史订单记录已发生的事实,不能随商品改价重算过去金额。

为什么订单保存下单时的单价,而不是随时查商品现价?

历史订单是一笔已发生的交易事实。商品之后改价不能重写过去的金额,因此明细保存单价与名称快照;商品目录和订单快照承担不同职责。

内部不变量和跨对象规则怎样划分

先看五张变化卡:

变化应落在哪里为什么
数量必须大于 0Model(模型)单个明细在任何场景都应成立
商品是否存在/在售Service需要查询商品集合
已支付订单不得取消Service是跨状态业务动作
JSON 文件路径改变Storage/配置是持久化事实
CLI 提示改中文CLI是外部展示

如果只会背“高内聚低耦合”,请回到变化卡:修改一条规则时,哪些文件不应该被迫改变?

什么是持久化

到这里,对象在内存里已经满足规则。现在关掉程序:对象不会自动留在磁盘。接下来把“有效对象”送过文件边界,再用新的进程把它恢复回来。

持久化是把需要跨运行保留的事实写入进程之外的介质,并在下一次运行重新加载。

订单数据从 Python 对象进入 JSON 文件,再被新进程恢复

读图时先跟着蓝色主链走一遍,再看底部三种失败。文件不存在、JSON(结构化数据交换格式) 损坏和写入失败代表不同问题,后面的代码不能把它们一律处理成空列表。

Python 对象
→ 转成 JSON 可表达的数据
→ 写入 UTF-8 文件
→ 结束进程
→ 新进程读取文件
→ 恢复对象并重新校验

文件出现了还不够。我们还要判断:

  • 文本是否符合 JSON 语法;
  • 顶层形状是否符合 当天约定的 Schema(数据结构约束);
  • 恢复后的订单是否满足数量、金额、状态等业务不变量。

路径从哪里开始解释

下面两位同学都说“文件就在 data(数据) 目录”,结果一个能打开,一个报 FileNotFoundError。区别可能不在文件,而在当前工作目录(current working directory,cwd)。

from pathlib import Path
 
data_path = Path("data/orders.json")
print("cwd:", Path.cwd())
print("target:", data_path.resolve())
print("exists:", data_path.exists())

把上面的独立代码保存为你的项目的 labs/d04_path_probe.py,从项目根运行:

python labs/d04_path_probe.py

再进入 labs 后运行同一个脚本,比较 target。

相对路径怎样被解释

判断题

  1. Path("data/orders.json") 是相对脚本文件,还是相对当前工作目录?
  2. 为了让代码在同伴电脑上运行,应该硬编码某位同学的用户目录绝对路径吗?
  3. 文件不存在时,第一条证据应该是改代码,还是打印 cwd 和解析目标?

工程约定

课堂所有命令从项目根目录执行。代码使用相对路径,README(项目说明文档) 写清运行起点。

UTF-8、文本模式和 with

4.1 中文订单怎样进入文件:字符要先变成字节

运营导出的商品名称是中文。Python 中的 str(字符串)表达文字,磁盘保存的是字节。写入时把文字转换成字节叫编码,读回时把字节还原成文字叫解码。双方采用同一种规则,才能读回原来的名字。

"订单"(str) → encode("utf-8") → 字节(bytes) → decode("utf-8") → "订单"
     写入文本文件时由 Python 编码                  读取时由 Python 解码
名称是什么企业里什么时候遇到
ASCII主要表达英文字母、数字和基本符号的编码英文协议、老数据;不能编码“订单”
Unicode(统一码)给字符分配码点的标准;不是单一文件编码理解为什么同一个汉字能有不同字节表示
UTF-8Unicode 的一种编码;ASCII 字符占 1 字节,常用汉字通常占 3 字节本项目新建文本/JSON 文件统一采用它
GBK支持大量汉字的传统编码国内旧系统导出文件;先核实导出设置再读取
UTF-16Unicode 的另一种编码,常见字符用 2 字节,也有需要 4 字节的字符某些系统导出;按来源约定读取,不凭扩展名猜

下面是可单独运行的实验。len() 对字符串数的是码点,对字节串数的是字节;不能用文件字节数直接当中文字数。

text = "订单"
raw = text.encode("utf-8")
print("文字:", text, "长度:", len(text))
print("字节:", raw, "长度:", len(raw))
print("恢复:", raw.decode("utf-8"))
try:
    text.encode("gbk").decode("utf-8")
except UnicodeDecodeError:
    print("解码失败:写入规则和读取规则不一致。")

编码用错可能报 UnicodeDecodeError(解码错误),也可能读出乱码而不报错。先查来源编码、保留原文件,再按正确编码读取;不要用 errors="ignore" 悄悄丢掉字符。encoding="utf-8" 是约定解码方式,不是自动探测或修复乱码。

4.2 文本模式与二进制模式:你要读文字,还是原始字节

文本模式 t 是默认值,"r" 相当于 "rt":读取结果为 str,写入也需要 str,Python 按 encoding 编解码。二进制模式 b 直接读写 bytes,不传 encoding;图片、PDF、文件哈希通常需要原始字节。.txt 后缀不会替你选择模式。

先运行下面的完整例子。mkdir() 创建上层目录;open() 只打开文件,不替你创建目录。此例使用演示文件,w 会覆盖它,不能指向已有订单数据。

from pathlib import Path
 
path = Path("data/note.txt")
path.parent.mkdir(parents=True, exist_ok=True)  # open不会自动创建上层目录
 
with path.open("w", encoding="utf-8", newline="\n") as target:
    target.write("轻羽无线鼠标\n")
 
with path.open("r", encoding="utf-8") as source:
    print(source.read())

source.read() 一次读取剩余文本,小文件便于观察;大文件可以 for line in source 逐行处理,避免一次装入内存。读到末尾再 read() 会得到空字符串;重新打开文件会从头读。

在上例之后运行,观察同一个文件的两种结果:

with path.open("rb") as source:
    raw = source.read()
    print("二进制读取类型:", type(raw))
    print("按 UTF-8 解码:", raw.decode("utf-8"))

文本模式还处理换行:默认读取会把常见的 \r\n、\r 转为 \n;示例写入指定 newline="\n",让演示文件统一使用 LF 换行。这和编码是两件事。

模式行为今天的风险
r读取;文件不存在时报错要区分首次无文件与已有坏文件
w打开时先截断,再写中断可能留下半文件
a追加到末尾不能靠拼接多段文本维护一个完整 JSON 对象
x新建;已存在则报 FileExistsError(文件已存在)适合今天第一次保存,防止覆盖旧数据

w、a 在文件不存在时会新建;r 不会。w 在打开成功时就截断,哪怕后来没有执行 write()。r/w/a/x 决定读写行为,t/b 决定文字还是字节,可以组合,例如 rb、wb。今天不引入同时读写的 + 模式。

4.3 为什么要用 with:中途失败也要归还文件资源

打开文件会取得文件句柄。代码执行到一半报错时,普通的末尾 close() 可能根本走不到。文件缓冲也可能尚未提交给操作系统。不要依赖程序退出或垃圾回收替自己收尾。

# 接着前面的 path 定义运行:finally 无论成功或异常都会尝试清理。
source = path.open("r", encoding="utf-8")
try:
    print(source.read())
finally:
    source.close()

对文件对象,常用写法可以缩短成 with(上下文管理语句):

with path.open("r", encoding="utf-8") as source:
    print("块内关闭了吗:", source.closed)  # False:还可以读取
    print(source.read())
print("块外关闭了吗:", source.closed)      # True:对象变量仍在,文件已关闭

逐段读:path.open(...) 打开文件;as source 给文件对象起名;冒号后的缩进块是使用范围;离开块时自动关闭。with 不会让文件内容自动变成 JSON,也不负责校验订单。

现在故意中途失败。下面接着已有 path 运行,异常由外层 except 处理,文件关闭由 with 处理:

try:
    with path.open("r", encoding="utf-8") as source:
        raise RuntimeError("模拟读取后业务校验失败")
except RuntimeError as error:
    print("捕获原因:", error)
    print("异常后文件已关闭:", source.closed)

记住两条边界:文件 with 不会吞掉异常;关闭文件不等于撤销已经写入的内容。本日使用x模式拒绝覆盖旧快照;临时文件替换留作后续扩展,不能只加一个with就宣称保存安全。

小结:保存中文订单前,先能回答这四问

  1. 文件给我的究竟是 str 还是 bytes?看文本/二进制模式。
  2. 中文按什么规则存、按什么规则读?看来源约定与 encoding。
  3. 当前操作会不会清空旧文件?看 w,不是看是否已经 write()。
  4. 中途报错谁关闭文件、谁处理错误?分别是文件 with 和外层异常处理。

中文订单写入文件时,UTF-8、文本模式和 with 各解决什么问题?

UTF-8 约定字符与字节的转换;文本模式读取和写入字符串;with 管理文件资源,正常结束或异常退出都会执行关闭。编码正确不等于 JSON 结构及业务规则正确。

先认识 JSON、序列化与反序列化

刚才write()接收文字,直接把订单字典交给它会报TypeError(类型错误)。我们需要一种文字格式保存字段和结构,下次才可以恢复。

*** JSON(JavaScript Object Notation,JavaScript对象表示法)是一种数据交换格式***,是一种开放标准的轻量级数据交换格式。对象转成JSON文本叫序列化,JSON文本恢复为对象叫反序列化。

import json
order = {"order_id": "O20260825002", "total_amount_yuan": "258.00"}
text = json.dumps(order, ensure_ascii=False)  # 转成字符串,还没有写文件
restored = json.loads(text)
print(type(text), text)
print(type(restored), restored)

先观察类型,再组合文件操作。下面保存和读取两段应分两次运行,证明新进程也能取回订单:

from pathlib import Path
import json
path = Path("data/d01_orders_demo.json")
path.parent.mkdir(parents=True, exist_ok=True)
order = {"order_id": "O20260825002", "total_amount_yuan": "258.00"}
# 第一段:x拒绝覆盖已有文件;重演换新文件名,保护旧订单。
with path.open("x", encoding="utf-8") as file:
    json.dump([order], file, ensure_ascii=False, indent=2)
# 第二段:在新进程只运行必要导入、path定义和以下读取代码。
with path.open("r", encoding="utf-8") as file:
    print(json.load(file))
函数做什么
dumps / loads对象与JSON字符串互转,不操作文件
dump / load对象与已经打开的文件交互

这一小例只取订单关键字段解释机制,不代替累计订单模型的完整约定。后半课再接回完整商品、客户、订单数据及校验。

Python 类型怎样进入 JSON

JSON 能表达对象、数组、字符串、数字、布尔和 null。Python 的 dict/list/str/int/float/bool/None 有常见对应关系,但 Decimal 不是 JSON 原生类型。

from decimal import Decimal
import json
 
payload = {"unit_price_yuan": Decimal("129.00")}
print(json.dumps(payload, ensure_ascii=False))

先预测,再运行。你会看到:

TypeError: Object of type Decimal is not JSON serializable

金额边界由我们明确转换:

from decimal import Decimal
 
amount = Decimal("129.00")
json_value = format(amount, ".2f")
restored = Decimal(json_value)
 
assert json_value == "129.00"
assert restored == amount

Python 与 JSON 的金额边界

图中有两次人工负责的转换:to_dict() 把 Decimal(十进制数值类型) 变成两位小数字符串,line_from_dict() 把字符串恢复成 Decimal,并再次校验格式。不要使用 float(浮点数类型) 作为中间桥梁。

最容易踩的坑

Decimal(129.00)       # 参数先成为 float,可能已带入二进制近似
Decimal("129.00")    # 从十进制字符串构造

累计商城订单沿用 _yuan 字段;本日最小明细练习沿用 unit_price,单位同为元,迁入累计订单时显式映射。两种边界的JSON金额都必须是类似 "129.00" 的字符串。

明细对象怎样通过 JSON 边界

我给 OrderLine 增加 to_dict 方法,把 unit_price 明确转为两位小数字符串。恢复用模块函数 line_from_dict,先查字段再构造 OrderLine;不把未经校验的 JSON 直接当成对象。

# 放进 OrderLine 类内的方法片段
def to_dict(self):
    return {"product_id": self.product_id,
            "unit_price": format(self.unit_price, ".2f"),
            "quantity": self.quantity}

本日最小练习沿用 unit_price 字段,单位仍为元;接入累计订单时明确映射到订单项 unit_price_yuan,不改现有商城接口。恢复步骤:确认 dict、检查必填和额外键、确认价格为规范字符串、转 Decimal、调用已校验的构造函数。

# 本人实现 line_from_dict 后运行
restored = line_from_dict(line.to_dict())
assert restored == line
assert restored is not line

这条往返检查同时验证字段和类型;只有“JSON 能读出”不能证明模型合法。日包 exercise.py 给出这两个方法/函数的 TODO,完成后合并到本人 src/models.py。

序列化与反序列化不是业务校验

序列化:把内存数据转换为可保存格式。反序列化:从外部格式恢复内存数据。

import json
 
text = '{"quantity": 0, "total_amount_yuan": "1.00"}'
payload = json.loads(text)
print(payload)

JSON 可以成功解析,但数量 0 和错误总金额仍是无效订单。请把有效性分成三层:

层典型失败今天怎样观察
JSON 语法少括号、少引号JSONDecodeError 的行列
Schema 形状多未知字段、金额类型错误恢复函数拒绝
业务对象数量 0、金额不一致、未知状态本课前半段的 OrderLine 重新校验

今天把前半段的模型校验接到文件恢复之后:读得出 JSON,只完成了第一层检查。

无文件、空文件和坏文件不能共用一条处理

先运行三个实验:

实验 A:首次无文件

path = Path("evidence/d01/missing.json")
print(path.exists())

这表示尚未初始化,可以由调用方明确创建空数据集。

实验 B:空文件

path.write_text("", encoding="utf-8")
json.loads(path.read_text(encoding="utf-8"))

实验 C:截断 JSON

path.write_text('{"orders": [', encoding="utf-8")
json.loads(path.read_text(encoding="utf-8"))

空文件和截断文件都属于“已有文件损坏”。如果这样写:

try:
    return json.loads(path.read_text(encoding="utf-8"))
except Exception:
    return {"orders": []}

下一次保存会把故障现场覆盖成空数据。这不是容错,而是数据丢失。

正确的项目语义

  • 无文件:保留 FileNotFoundError,由用户决定是否首次初始化。
  • 空文件、坏 JSON、错误编码、字段不合法:明确报错,原文件不写入。
  • 本日 save 使用 x 模式创建新快照,已有目标拒绝覆盖;再次演示换新文件名。
  • 今天沿用内置异常。明天用自定义 SnapshotError 统一向 CLI 报告,并保留异常链。

分别创建空文件和截断文件,记录读取前后内容,确认失败没有覆盖。新建测试目录要先存在;生产备份、原子替换、并发写入都不在本日实现范围。

基础文件存储的边界

我把“读写一份 JSON”作为数据库之前的过渡。缺少文件、JSON 损坏和业务字段非法必须区分;已有坏文件不能直接当成空订单后覆盖。临时文件安全替换、备份轮转的完整机制保留为扩展阅读,今天只观察一次直接覆盖失败的反例,不要求搭建文件数据库。

JSON 是可交换的文本格式;它不会自动保存 Decimal 或检查订单状态。模型构造负责运行时校验,序列化负责明确转换,存储函数负责路径和读写,CLI 只负责用户输入输出。

从语法小例合并成自己的订单快照

我按“普通类 → 校验 → 显示与比较 → 组合 → 文件”逐段接回 D03 项目。日包 exercise.py 只提供 TODO;先用 python check_model.py,不提前依赖明天才系统讲的 pytest。完成后将模型、保存恢复函数合并至 src/models.py,保留原有业务函数调用。

核心检查:129.00×2得到258.00,商品现价改成199.00后历史行金额不变;新进程读取快照后金额仍相同;损坏文件仍保留;两个 Cart 不共享列表。独立变化为不同商品/数量,说明 len(cart) 与总件数为何不同。

保存的是价格快照,不等于恢复了整笔订单状态。累计项目还需逐字段保存自己的订单编号、客户、状态和所有明细,并在读取后逐项验证;在日验收中分开登记最小模型与累计工程完成情况。

同一套转换扩展到完整订单

单条价格快照验证通过后,我把同一方法放到订单列表:保留order_id、customer_id、items、total_amount_yuan、order_status和created_at;每条items继续保存product_id、product_name_snapshot、unit_price_yuan、quantity、line_total_yuan。

在 src/order_snapshot.py 中分三个职责:restore_orders校验并返回新的订单列表;save_orders转为JSON可表达的数据并创建新文件;load_orders只读文件再交给恢复函数。金额字符串先转Decimal,再用已有模型重新计算行金额与订单总额,与文件声明值比较。字段缺失、未知状态、重复编号和金额不一致都要拒绝。

# 本人完成累计保存恢复后,从项目根运行这一段。
from pathlib import Path
from src.order_snapshot import save_orders, load_orders
path = Path("orders_snapshot.json")
save_orders(path, orders)  # orders为本人已有订单列表;新文件名
restored_orders = load_orders(path)
assert restored_orders == orders

先恢复到新变量,全部校验成功再替换当前内存列表,防止读到一半破坏旧状态。独立变化:手改文件总额为1.00,程序应拒绝;原文件内容与旧内存列表都保持原样。这是单进程文件练习,不作为多人共享数据库。

学生自己动手,AI 只能改你允许改的地方

我先保存当前可运行版本,再完成“修改商品当前价格,证明历史订单金额不变”。让 AI 一次只修改负责该行为的文件;修改前预测正常与失败结果,检查差异,再运行回归。每人需要解释一处接受或拒绝的建议。

错误与边界怎样验收

今天重点检查:把坏文件当空数据、Decimal 转 float、bool 冒充数量。保留输入、预期、实际输出和修复后的结果,不能只贴全绿截图。未验证的外部依赖如实记为待验证。

留给下一阶段

今天的完整链是“构造并校验 → 保存价格快照 → 转换成 JSON → 写文件 → 重新读取并校验”。明天把这条链与此前的订单规则接到命令行入口,并用自动测试保护,不能只留下一个孤立的保存示例。

官方资料