开始前,会这些就够了
- 能读懂 Python 数据类、字典与断言
- 理解旧值覆盖与版本检查
- 理解部分更新的缺席与空值,本篇只处理已经发生的本地修改
助手把标题改好后,另一位编辑更新了营业时间。你说“撤销刚才的修改”,如果助手直接恢复整份旧稿,标题确实回来了,新的营业时间却也没了。撤销需要知道自己改过哪些地方,以及这些地方后来又发生了什么。
这次做一个只处理平面文字字段的离线撤销器。它记录一次修改的前后状态,撤销前检查每个字段的修订号,任何冲突都会让整批撤销停止。程序操作内存中的虚构资料,不调用模型或远程服务;这里的可逆性来自明确的数据约定。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- conditional_undo.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B conditional_undo.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。单进程、顺序执行的字段日志与版本条件;不提供分布式事务、崩溃恢复或真实服务回滚。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
现在撤销,会不会覆盖后来发生的修改?
助手先把 title 改为 Tea final,删除 phone,再增加 note。每个字段的修订号从 0 开始,每次写入加 1,删除后也保留编号。接着选择后来发生了什么。
尚未执行撤销。三个待撤销字段都符合日志中的条件。
| 字段 | 日志要求的内容 | 本次检查前的内容 | 修订号(要求 → 实际) | 通过吗 |
|---|---|---|---|---|
| title | "Tea final" | "Tea final" | 1 → 1 | 通过 |
| phone | 字段不存在 | 字段不存在 | 1 → 1 | 通过 |
| note | "upstairs" | "upstairs" | 1 → 1 | 通过 |
本次检查前
{
"title": "Tea final",
"hours": "10:00-19:00",
"note": "upstairs"
}当前资料(未撤销)
{
"title": "Tea final",
"hours": "10:00-19:00",
"note": "upstairs"
}重点试“改走又改回”:文字虽然一样,修订号已从 1 变成 3。再试成功撤销后重复使用旧日志,检查结果为什么又不同。
这四个案例对应正文的单线程、平面字符串字段实验,所有修改都经过同一计数入口。面板只在当前页面计算,没有写入文件、商户或服务;真实并发需要在服务端原子检查并提交,不能靠页面预览保证。
先找出这次操作留下的变化
主例一次修改三个字段:改标题、删电话、加便条。每项保存 before 和 after,里面包含字段是否存在、文字值与修订号。撤销时,改标题需要写回旧标题,删电话需要重新放回旧号码,加便条则需要删除新添的字段,三个逆动作并不相同。
整份旧稿可以帮助解释当时情况,却不能直接充当撤销命令。Azure 的补偿事务说明指出恢复旧状态可能覆盖并发工作,逆向处理要结合业务。本例把范围缩到三个文字字段,便于完整检查,不把它当成通用分布式补偿框架。
同一处被改过,就先停下来
same_field_conflict 让另一位编辑再次改标题。当前标题与原账本的 after 不同,撤销器报告冲突,并保留现状。它不会猜谁的改动更重要,也不会把新标题强行换回最早的标题。报告同时保存预期状态与当前状态,供后续人工或业务规则决定下一步。
RFC 6902 的 test 操作提供了“先检验目标值,再继续操作”的规范例子。本例借用这一思路,但账本不是 JSON Patch 文档,也没有实现 JSON Pointer、数组路径或该规范全部操作;实际传给工具之前仍要按工具支持的格式转换。
值又变回来了,也不等于没人碰过
changed_away_and_back 先把标题改成另一个值,随后改回 Tea final。只比较文字,会误以为当前状态仍是助手刚写完的状态。本例每次写入都递增对应字段的 revision;账本期待 1,当前已经是 3,所以仍然拒绝旧撤销。这个实验把“现在看起来一样”和“期间没有新写入”分开。
删除字段后也保留修订号,称为删除记录。电话被别人重新创建时,旧删除操作不能借“字段曾经消失”来覆盖新号码。即使把相同文字再写一次,本题也算一次新写入。修订号不倒退,而且所有写入都必须经过 Store;绕过它直接改字典会破坏这个前提。
这些编号只在一个字段的生命周期内比较,不是全系统时间,也不能用于给不同字段排序。标题修订号三并不意味着它比电话修订号二晚一秒。初始字段从零开始,新增字段首次写入后变为一;撤销后继续递增,因为撤销也是一次新写入,不能把历史计数器一起拨回过去。
三处一起撤销,先检查完再动手
undo 的第一轮只检查,不修改;发现任意冲突就返回。全部通过后,才按账本逆序恢复。冲突案例里,虽然便条和电话仍符合要求,也都不回退。这是本题选择的整批策略,避免把一次三处修改撤掉一半后,用户误以为已全部恢复。
这种先检查再执行只在本脚本的单线程内存环境成立。真实数据库或远程接口里,检查与写入之间可能插入别人的操作,需要服务端事务或条件写入保证原子性。JSON Patch 在 HTTP PATCH 中也有整体成功要求,但这不能让普通 Python 两段循环自动获得跨进程原子性。
本地入口还把值限定为文字或删除标记,并在编辑前检查整批参数。若最后一项是整数,前面合法的标题也不会先写进去。这里故意不接收任意外部账本:撤销只消费本程序 apply 产生的记录。若未来允许读入外部文件,还要验证账本结构、归属与字段关系,不能把它当作可信命令直接执行。
运行四个场景,核对实际保留下来的资料
保存完整代码为 conditional_undo.py,在独立目录运行。脚本生成 conditional-undo-report.json,包含原稿、修改账本、撤销前后资料、冲突详情和第二次撤销状态。样本全为虚构,不连接数据库,也不执行实际编辑操作。
第一组成功恢复三处,同时保留新的营业时间;后三组分别因同字段新值、值改走再改回、已删除字段重建而冲突。断言检查冲突时整份内容未变化,也检查非法编辑批次在任何字段写入前被拒绝。重复使用旧账本会冲突,不会被描述成第二次撤销成功。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Flat string fields with per-field revision checks; single-process simulation."""
from copy import deepcopy
from dataclasses import dataclass, field
import json
from pathlib import Path
@dataclass
class Store:
values: dict[str, str]
revisions: dict[str, int] = field(default_factory=dict)
def __post_init__(self):
self.values = deepcopy(self.values)
self.revisions = {key: 0 for key in self.values}
def state(self, key):
return {"present": key in self.values, "value": self.values.get(key),
"revision": self.revisions.get(key, 0)}
def put(self, key, value):
if not isinstance(key, str) or not key:
raise ValueError("nonempty_string_key_required")
if value is not None and not isinstance(value, str):
raise ValueError("string_or_delete_required")
if value is None:
self.values.pop(key, None)
else:
self.values[key] = value
# A tombstone revision remains even after deletion.
self.revisions[key] = self.revisions.get(key, 0) + 1
def apply(store, edits):
# Validate the complete request before mutating this local store.
if any(not isinstance(k, str) or not k or
(v is not None and not isinstance(v, str)) for k, v in edits.items()):
raise ValueError("invalid_edit")
journal = []
for key, value in edits.items():
before = store.state(key)
store.put(key, value)
journal.append({"field": key, "before": before, "after": store.state(key)})
return journal
def undo(store, journal):
# All guards checked first: one conflict means no fields are reverted.
conflicts = []
for entry in journal:
current = store.state(entry["field"])
if current != entry["after"]:
conflicts.append({"field": entry["field"], "expected": entry["after"],
"current": current})
if conflicts:
return {"status": "conflict", "reverted": [], "conflicts": conflicts}
for entry in reversed(journal):
old = entry["before"]
store.put(entry["field"], old["value"] if old["present"] else None)
return {"status": "reverted", "reverted": [e["field"] for e in journal], "conflicts": []}
def scenario(name, later_edits):
store = Store({"title": "Tea draft", "phone": "010-1000", "hours": "09:00-18:00"})
original = deepcopy(store.values)
journal = apply(store, {"title": "Tea final", "phone": None, "note": "upstairs"})
for edits in later_edits:
apply(store, edits)
before_undo = deepcopy(store.values)
result = undo(store, journal)
after_undo = deepcopy(store.values)
repeat = undo(store, journal)
assert repeat["status"] == "conflict" # stale journal is never silently reused
assert store.values == after_undo
if result["status"] == "conflict":
assert before_undo == after_undo
return {"case": name, "original": original, "journal": journal,
"before_undo": before_undo, "result": result, "after_undo": after_undo,
"repeat_status": repeat["status"]}
def main():
cases = [
scenario("unrelated_edit", [{"hours": "10:00-19:00"}]),
scenario("same_field_conflict", [{"title": "Someone else's title"}]),
scenario("changed_away_and_back", [{"title": "Other title"}, {"title": "Tea final"}]),
scenario("deleted_field_recreated", [{"phone": "010-3000"}]),
]
safe, conflict, aba, recreated = cases
assert safe["result"]["status"] == "reverted"
assert safe["after_undo"] == {"title": "Tea draft", "phone": "010-1000", "hours": "10:00-19:00"}
assert conflict["result"]["status"] == aba["result"]["status"] == recreated["result"]["status"] == "conflict"
assert aba["before_undo"]["title"] == "Tea final"
assert aba["result"]["conflicts"][0]["current"]["revision"] == 3
assert aba["result"]["conflicts"][0]["expected"]["revision"] == 1
assert conflict["after_undo"]["note"] == "upstairs" and "phone" not in conflict["after_undo"]
assert recreated["after_undo"]["phone"] == "010-3000"
probe = Store({"title": "untouched"})
try:
apply(probe, {"title": "would change", "bad": 123})
except ValueError:
pass
else:
raise AssertionError("invalid batch accepted")
assert probe.values == {"title": "untouched"} and probe.revisions == {"title": 0}
report = {"scope": "single-thread, flat string fields; all writes pass through Store",
"policy": "all-or-nothing guards per undo batch; revisions never roll back",
"cases": cases, "assertions": "passed"}
Path("conditional-undo-report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
for row in cases:
print(f"{row['case']}: {row['result']['status']}; repeat={row['repeat_status']}")
print("PASS: unrelated edit preserved, conflicts atomic, ABA detected, deletion tracked")
if __name__ == "__main__":
main()
怎样运行:Python 3.10 或更新版本,在独立练习目录执行:python conditional_undo.py。仅标准库。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。单进程、顺序执行的字段日志与版本条件;不提供分布式事务、崩溃恢复或真实服务回滚。
运行后对照结果
unrelated_edit: reverted; repeat=conflict
same_field_conflict: conflict; repeat=conflict
changed_away_and_back: conflict; repeat=conflict
deleted_field_recreated: conflict; repeat=conflict
PASS: unrelated edit preserved, conflicts atomic, ABA detected, deletion tracked账本要能独立回看,不能跟着当前值一起变
程序用 deepcopy保存展示快照,字段本身则限定为不可变的文字值。这样 later_edits 修改 Store 时,报告里的 original 和 before_undo 不会跟着变化。若将来支持嵌套对象,不能只放宽类型检查;要同时决定路径、容器删除和副本的规则。
第二次撤销故意返回冲突,因为第一次撤销本身也产生了新的修订号。若产品希望提示“此前已撤销”,还需要独立保存操作记录并核对其结果。这个扩展不在本题实现内,不能只因当前文字碰巧等于原稿,就推断某次撤销已经成功执行。
能撤回文字,不等于能撤回所有后果
2026-10-15 已在独立临时目录运行四组场景,全部内置断言通过;完整文章代码与执行文件一致。交付时除了 PASS,还应打开报告,逐项确认保留的营业时间、冲突字段以及没有被部分回退的电话和便条。
范围只含一次进程内、所有写入都受控的平面文字资料。已经发出的通知、对方已读取的内容或真实付款,不能靠恢复字段消除后果;这些动作需要各自业务补偿。本例也没有持久化、崩溃恢复、权限或多进程竞争验证,不能据此宣称一个真实 Agent 已具备可靠撤销能力。
记住这三点
- 撤销应依据本次修改账本恢复受影响字段,保留其他人的无关修改。
- 同时检查字段值和单调修订号,能在受控写入路径中识别值改回原样的情形。
- 整批检查与整批回退需要明确原子性边界,离线单线程代码不能替代服务端保障。
别把例子用过头
- 把整份旧快照直接盖回去,顺手撤掉别人的工作。
- 只比较文字相同就认定没有后续写入,或删除字段时把修订号一同丢掉。
- 发现一项冲突后仍回退其他字段,最终却报告整批撤销成功。
自己试一小步
给虚构资料增加两项互相关联的字段后,你会怎样设计一份不会覆盖后来工作的撤销规则?
- 另选业务场景,自行定义一组必须共同成立的字段条件,先说明允许哪些字段独立改变。
- 构造至少五个撤销案例,涵盖新增、删除、无关变化、同字段变化与值改回原样;运行前写预期。
- 设计一种冲突后的报告格式,让用户看清本次改过什么、现在是什么,以及哪些地方完全未动。
- 说明若迁移到数据库,检查和写入应在哪个边界内完成;不使用模拟结果冒充并发验证。
做到这些,就算完成
- 账本能否区分新增、修改和删除,历史记录是否保持独立?
- 每次写入是否更新修订号,所有修改路径是否遵守同一约定?
- 一项冲突时整批是否保持原样,是否清楚声明真实原子性尚未验证?
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Azure Architecture Center:补偿事务 (新标签页)↗
核读 Context and problem、Solution 和 Problems and considerations;支持逆操作需考虑并发变化、业务规则及补偿可能失败的边界。
- RFC 6902:test 与错误处理 (新标签页)↗
核读第 4.6、5 节;支持先验证目标值以及失败时不能宣称整个 Patch 成功的思路。
- Python 3.10:深复制 (新标签页)↗
核读赋值绑定及深浅复制说明;支持保存不随当前对象变化的展示快照。