审批等三天怎么办?Timer、Deadline、撤回与迟到消息实战
《企业级 Workflow 实战:从审批流到 AI Agent》· 第 08 篇 / 共 24 篇
贯穿项目:星河设备的 AcmeFlow 客户开通中心。
本篇交付:持久截止时间、到期补扫、审批与撤回的竞态规则、迟到回执处置,以及可运行的 SQLite 机制实验。
运行范围:实验使用独立 SQLite 文件验证本篇机制;文末说明如何把新增字段和命令接到第 03 篇的 FastAPI + PostgreSQL 应用。它尚未连接真实合同系统,也未宣称完成跨系统投递。
周一早上,运营收到一条客户开通申请。规则说运营应在三个工作日内完成审核。她领取了任务,但客户资料有一处需要核对,任务一直停在“待审核”。周四下午,销售看到客户着急,提交了撤回请求;运营刚好也点下“审核通过”。同一秒钟,定时任务发现申请过期,合同系统还送来一条延迟的签署回执。
如果后台仅有一个status字段,随后四段代码依次写入数据库,最后一次更新也许会把申请显示为“已通过”。但这个结果没有说明:撤回与审批谁先发生?截止时间有没有真正生效?合同回执是对当前材料的签署,还是上一版本的消息?客户看到“已通过”后,能否安全地继续开通服务?
这篇文章把“等三天”变成一个明确的工程问题:系统必须保存等待的截止时刻,在任意进程重启后仍能发现它,并且让所有竞争命令使用同一套裁决规则。因而,Timer 不只是“过一会儿调用一个函数”,Deadline 更不是界面上的红字。它们参与决定业务命令是否有效。
图 1:本篇示例把北京时间 2026-10-01 18:00 +08:00 统一存成 UTC 10:00。Worker 停机可以让处理变晚,却不改变业务约定的截止时刻。
一、先把“等”拆成三个不同的问题
同样显示为“等待”,含义可能完全不同。申请在等运营审批,是等待一位有权限的人形成决定;合同在等客户签署,是等待外部系统形成可核对的事实;服务在等定时补扫,是等待系统在一个指定时刻执行政策。若把它们都实现成sleep(3 * 24 * 3600),程序至少会遇到三个麻烦:进程重启丢失等待,系统无法解释等待原因,也无法把新来的撤回或合同消息与这个等待可靠地关联。
本案例把时间相关信息拆成三层。截止时刻是业务规则计算出的一个绝对时点,例如运营审核最迟到北京时间周四 18:00。扫描时刻是 Worker 某次发现已到期任务并尝试执行的时间。事件时间是外部系统声明动作发生的时间。三者可以相等,也可能相差很远。数据库里只存一个含糊的timeout_at,往往不足以解释一次争议。
例如合同系统在 17:58 完成签署,却因为队列积压到 18:05 才送来回执。业务究竟接受“签署完成时间在期限内”,还是要求“回执必须在期限内抵达”?这不是数据库能自行决定的问题。我们在本篇实验里选择一条简单、审计友好的规则:审批和撤回以 AcmeFlow 服务端受理并提交时的可信时间为准;达到截止时刻后,待审申请进入 EXPIRED。外部合同回执只形成观察记录,不自动充当审核命令。实际合同期限可以另行规定,以合同系统签名时间和可验证来源为证据;不能把这两种政策混成一条。
还要区分提醒时间与真正截止时间。提前一天给审批人发提醒,只是通知策略;提醒失败不应延长或缩短审核权限。错过服务承诺时间以后,是自动关闭、交给主管延期,还是继续审批同时记录超时,也要由业务确定。本篇采用“到期关闭本轮审核窗口”的教学规则,方便检验竞态。其他企业若采用“超时升级但不关闭”,应修改状态迁移表,而不是悄悄让 Worker 的行为决定政策。
二、用一个确定的规则处理四种输入
第 03 篇有applications、workflow_instances、tasks和transition_history,最初只实现提交与人工审核。第 05 篇再加入条件迁移和并发控制。本篇不是另建一个不相干的开通应用,而是在同一申请 ID、同一材料版本上增加时间语义:待审核实例保存review_deadline_at,所有审批、撤回、到期扫描共享一条迁移入口,迁移记录保存命令 ID、服务端时间和结果。
教学实验把该阶段称为WAITING_REVIEW;接入第 03 篇数据库时,可以把它映射为原来的SUBMITTED加待办仍打开的条件,也可以在升级时显式增加状态。无论采用哪一种,不能让两个字段分别成为“是否可审”的权威答案。实验在独立 SQLite 文件里添加applications.state和history,只是把冲突规则单独放大,方便读者看到它;正式主线仍由一个流程实例决定下一步。
图 2:四类输入的作用不同。合同回执可以被登记,但不会把待审申请直接改成审核通过。
先列出本篇的状态迁移表,比直接写if更容易评审:
| 当前状态与时间 | 输入 | 结果 | 为什么 |
|---|---|---|---|
WAITING_REVIEW,服务端时间早于截止 | APPROVE | APPROVED | 有效审批先完成本轮审核 |
WAITING_REVIEW,服务端时间早于截止 | WITHDRAW | WITHDRAWN | 申请人先撤回,后续审批失效 |
WAITING_REVIEW,服务端时间早于截止 | SWEEP_DEADLINE | 仍等待 | 定时扫描提前运行,不得提前关闭 |
WAITING_REVIEW,服务端时间达到或超过截止 | 任一命令 | EXPIRED | 截止边界统一采用>= |
APPROVED、WITHDRAWN或EXPIRED | 再次审批、撤回或回执 | 状态不变,记录冲突或迟到 | 已有决定不能被迟到输入覆盖 |
这里有一个容易被忽略的细节:到达截止时刻的第一个命令,不一定是定时扫描。如果 Worker 停了两小时,审批请求在超时后的第一秒抵达,审批入口也必须检查截止时刻,并把实例转为EXPIRED。否则系统只在“扫描已运行”后才禁止审批,业务期限实际上取决于后台任务是否准时。扫描负责发现遗漏,命令入口负责维护规则;两者共享一套判定。
本篇把截止边界定为“时间等于截止时刻也算过期”。这是明确选取的业务约定,而非所有流程的普遍规则。若业务要求在 18:00:00 整仍允许审批,就要把比较符号、测试样例、界面文案和审计口径一起改掉,不能只改一行代码。实际系统还要决定是否以数据库时间为准、时钟是否同步、并发事务等待期间怎样取时。本实验将可信服务端时间作为注入参数,使相同输入可以稳定复现,不接收浏览器自报时间作为裁决依据。
三、时间必须能表达同一个“瞬间”
“2026-10-01 18:00”看似清楚,但缺少时区:在上海是一个时点,在伦敦可能是另一个时点。部署机器迁往别的时区,或者日志查看者位于其他地区,无时区时间都容易被解释错。本篇输入必须带 UTC 偏移,例如2026-10-01T18:00:00+08:00;程序转换成2026-10-01T10:00:00+00:00再保存。Python 官方文档也区分了能明确定位时刻的 aware datetime 与含义由程序约定的 naive datetime,并建议在表示 UTC 时使用带时区信息的对象。参考:Pythondatetime文档
为什么不直接存“还有三天”?因为“剩余多久”是从某个起点计算出的相对值,应用停机后重新启动,剩余时间不能凭内存里的计数继续准确解释。持久化绝对截止时刻以后,系统可以在任何一次扫描时重新计算是否到期,也可以向用户展示本地时间。对于“工作日”规则,计算过程还需要节假日日历与规则版本。本篇为了聚焦机制,直接提供已确定的截止时刻;生产系统应保存期限的计算依据,避免节假日规则调整后无法解释当初为何到期。
另一个区别是“业务时钟”与“机器调度时钟”。Worker 在 10:00:03 才醒来,不意味着期限变成 10:00:03。扫描可能受机器负载影响,也可能因为维护窗口延迟。如果业务要求严格在时刻零点禁止审批,审批入口本身就必须判定截止,而不依赖一个定时器恰好准时触发。这样即使补扫晚了,正确性仍有边界,剩下的是处理延迟和告警问题。
在代码里,instant()拒绝没有时区的字符串,然后归一化成 UTC。对于 SQLite 教学样例,规范化后的 ISO 字符串具有同样格式,可以作词典顺序比较;接入 PostgreSQL 后应使用timestamptz和数据库日期时间比较,不应把字符串排序规则当作跨数据库设计。SQLite 官方说明了显式事务和BEGIN IMMEDIATE的行为:它会在开始时争取写事务,而另一个写事务存在时可能返回忙碌错误。本例利用它演示单文件数据库内的串行决定;在并发量更高的服务中,还要处理锁等待、重试预算和数据库级约束。参考:SQLite Transaction
四、审批与撤回同时到达,谁说了算?
假设运营和销售在截止前几秒各自发送请求,两人手机上的时钟无法证明谁先提交;网关、应用服务器和数据库也可能分别看到不同先后。AcmeFlow 必须定义一个可以实现、可以审计的“先”:谁先在同一申请的事务中完成合法状态迁移,谁取得本轮决定;后来的命令得到冲突结果。这个规则把不可观察的“真实先点击”转为可观察的提交顺序。业务若要求更复杂的优先级,例如撤回永远优先于尚未对外生效的审批,需要专门的预留窗口或补偿规则,不能依赖偶然的线程调度。
图 3:路径 A 和 B 都合法,但每个实例最终只保留一个生效决定。输掉的命令进入历史,而不是覆盖前一个结果。
实验核心只有一段事务。先按申请 ID 与命令 ID 查找,确认重放请求是否已经产生结果;再读取申请状态与截止时刻;最后在同一事务里更新申请并插入历史。(application_id, command_id)的唯一约束让同一申请的同一请求再次投递时返回原结果,不会把另一申请碰巧同名的命令误认成重试。接入多租户主库后,租户身份也必须参与查找与授权边界。这个约束没有神奇地保证外部业务动作只发生一次,因为本篇还没有外部调用。第 06 篇讨论消息重复投递;这里关心的是本地决策不能被重复请求改写。
db.execute("BEGIN IMMEDIATE")prior=db.execute("SELECT outcome FROM history WHERE application_id=? AND command_id=?",(application_id,command_id),).fetchone()ifprior:db.execute("COMMIT")returnprior["outcome"]row=db.execute("SELECT * FROM applications WHERE application_id=? AND tenant_id=?",(application_id,"xinghe-demo"),).fetchone()ifrow["state"]=="WAITING_REVIEW"andserver_now>=row["deadline_at"]:after="EXPIRED"# 后续在同一事务里写申请状态与 history,再提交。完整可运行代码见 code/demo.py。实验把数据库路径放在临时目录,运行结束自动清理;保存状态跨越的是一个明确的关闭并重新连接操作。这能验证“等待记录不靠进程内存”,但不能等价于验证生产中数据库断电恢复、多个 Worker 抢占或分布式锁。为了让篇幅集中于时间语义,本篇没有构造 HTTP 接口,也没有把代码直接迁移进第 03 篇的 PostgreSQL 服务。
还有一个细节需要单独说:审批已经成功以后,销售再次请求“撤回”,本篇返回冲突。这不是说已经批准的申请永远不能取消,而是说取消已批准业务与撤回尚未完成审核的申请是两种命令。批准以后可能已生成合同、已收到款项,甚至 ERP 已建档;此时要启动独立的取消或退款流程,验证后续事实并决定是否补偿。把这种复杂动作藏在“把状态改回撤回”里,会抹掉真正发生过的审批和外部行为。
五、Worker 停机之后,期限怎样继续生效?
一个常见实现是在请求处理时创建内存定时器:三天以后调用expire(application_id)。演示效果往往很好,因为进程没有重启、部署没有滚动升级、队列也没有积压。可企业流程最需要回答的,正是这些事件发生之后等待怎么恢复。持久化的方式是先存review_deadline_at,Worker 周期性查找达到期限且仍在等待的申请,再通过同一命令入口尝试迁移。即使 Worker 停机,期限这条数据不会消失;恢复后扫描能重新发现它。
图 4:Worker 能晚处理,不能晚定义截止。实验 08-D 用停机后的补扫验证这个区别。
查询到一条到期记录之后仍需再次检查状态:在查询与执行之间,运营可能完成审批,销售可能撤回。单纯SELECT出任务 ID 然后无条件UPDATE state='EXPIRED'会覆盖合法结果。实验把最终判断放进BEGIN IMMEDIATE事务中。接入 PostgreSQL 时,可以使用条件更新、行锁或工作领取机制让多个 Worker 竞争同一条记录,但“领到扫描任务”与“最终允许过期”仍是两件事,后者必须依据当前实例状态与期限重新决定。
补扫至少有三个运行指标。第一是到期积压量,衡量有多少已到期申请尚未处理;第二是处理延迟,即从业务期限到写入到期记录间隔多久;第三是失败次数与重试原因,用于发现数据库忙、权限不足或代码错误。把“流程总等待三天”误报成“HTTP 响应耗时三天”会导致错误告警。反过来,仅监测 API 延迟也发现不了补扫停了半天。
对于扫描频率,可以从业务容忍度倒推。若业务规定“到期后一小时内完成关闭”,每分钟扫描已足够提供宽裕的调度空间;若要求秒级关闭,则要评估处理规模与精度。无论扫描多频繁,裁决规则仍应在命令入口,不应依靠扫描的准时性来阻止到期后审批。后续迁到 Temporal 时,持久 Timer 可以承担唤醒,但恢复、重放和活动副作用的边界仍需理解,不能把它当作撤销远端业务效果的工具。参考:Temporal Workflow Execution
六、迟到消息值得记录,但不应让终态复活
合同回执、支付通知、邮件确认都可能在原申请关闭以后到达。把这些消息直接丢弃,会让运营找不到客户实际做过什么;把它们无条件应用到旧流程,则可能复活已撤回或过期的申请。我们的中间做法是:先验证来源和关联,再记录观察结果,最后依据当前申请和材料版本判断是否需要对账、退款或发起新流程。“收到了消息”与“允许它推进状态”是两个判断。
图 5:示意合同回执在 EXPIRED 之后到达。原申请不自动回到 APPROVED,责任人可根据签署的真实时间与合同版本处理争议。
本篇实验用SIGN_CALLBACK展示最简单的守卫:待审阶段仅登记观察,不充当审批;过期以后返回LATE_OR_CONFLICT,状态不改变。真正系统还需检查签名、来源身份、租户、合同编号、申请 ID、合同版本和材料版本。若该回执确实代表客户在截止前完成了签署,也不能直接绕过“审核已过期”的独立事实;应按照合同业务政策打开异常处理单或新流程。这是权责安排,而非一个if条件能自动给出的法律或运营结论。
这里特别容易出现“事件发生时间”和“事件收到时间”被混用。外部系统的时间若可伪造,只靠一个occurred_at字段决定业务截止会被绕过。若用接收时间,又可能使延迟传输伤害本来按时完成的客户。对于真正受合同约束的期限,业务方要明确可信证据来源、容错窗口和争议处理办法;开发者要让记录足够完整,以便能够执行这套政策。本篇选择的审批截止规则更简单,正是为了先教会读者把规则写清楚。
终态不复活也不是不允许“重开”。如果业务允许主管延期或客户重新提交,应创建有权限、理由、版本和新截止时刻的显式命令,记录它与原流程的关联。管理员在数据库里执行一次UPDATE state='SUBMITTED',看似快捷,却无法解释哪些原任务恢复、哪些旧审批重新有效、已发出的通知怎么办。恢复业务能力可以有很多种形式,但应由命令承载,而不是删除历史。
七、从第 03 篇应用接入时,具体要改什么?
读者走到第 03 篇时,已经有applications(application_id, tenant_id, material_version, ...)、workflow_instances(instance_id, application_id, state, revision, rule_version, ...)、tasks和transition_history。本篇的实验另建 SQLite,只验证时间规则;合回主线时可以按以下顺序实现:
- 在实例或审核任务上保存
review_deadline_at timestamptz,并保存期限政策版本。不要只在前端计算倒计时。期限如果只约束一次任务,优先放在任务上;如果约束整个申请阶段,则实例必须能找到当前期限。 - 增加撤回与到期命令入口。二者和现有审核决定共享申请 ID、租户、预期材料版本与修订号的校验,不能让三个 HTTP 路由各自直接写状态。
- 在同一数据库事务里提交状态变化、任务关闭和迁移历史。历史增加命令 ID、服务端受理时间、结果与拒绝原因;重试同一命令 ID 返回原结果。
- 增加扫描器,查询到期且仍待审的实例,通过命令入口补扫。部署多个 Worker 时确保同一申请的决定可串行化,并观察积压与处理延迟。
- 回调入口先验证来源,再用租户、申请 ID、合同 ID 与材料版本做关联;若原实例终态,进入迟到观察或人工处置,不直接重写状态。
这五步中的数据库字段名可以随着第 03 篇实际模型微调,语义约束不能删。尤其是材料版本:运营批准的是当前版本的资料,不能让一个迟到的 v1 审批推进 v2 申请。第 09 篇会进一步把人工作业建模成可领取、可转派、可会签的任务;本篇只关注“什么时间、什么状态下,命令还有效”。
生产代码还要考虑应用时钟与数据库时钟偏差。最稳妥的做法是规定哪个时钟为权威,并在日志中同时记录服务端受理时间、数据库提交时间与外部事件自报时间。若多个应用节点直接各自用本地时间做期限裁决,需要有同步与偏差监测;若由数据库判定,也要把检查与状态更新置于同一事务。我们不把“毫秒级精确同时发生”当作现实保证,业务上需要的是一种明确、公平且可核对的顺序规则。
截止、提醒、升级和延期各有自己的权限
实际客户开通中心常会要求“还剩一天提醒审核员”“超过三天升级给主管”“客户申请延期”“异常款项暂停时钟”。这些需求听起来都与时间有关,但不能塞进同一个到期回调。提醒是发送通知,它的失败只影响沟通;升级是更换责任人或优先级,未必关闭原任务;延期是修改业务截止,需要有授权、理由和新旧期限记录;暂停时钟则涉及如何计算被暂停区间。若一段代码在发送提醒失败后顺手把截止日期加一天,就等于让邮件系统改变了审批政策。
本篇代码只做“到期关闭本轮审核”,是为了让核心规则可检验。将来加入延期时,应引入显式EXTEND_DEADLINE命令,验证操作者是否有权限、原申请是否还允许延期、新截止是否符合上限,并在历史中保存旧值、新值、理由和规则版本。若已经到期才允许补救,需要另一个“重开或新建申请”的政策,不能把原截止直接覆盖成未来时间,让此前的迟到审批在记录中看起来像是准时。监管或客户争议时,时间线必须能还原每次改期发生在什么时刻。
“三个工作日”也不是简单的timedelta(days=3)。这取决于企业所在地区、工作日历版本、申请提交的截单时刻、节假日调整和时区。如果同一客户跨地区申请,需要先约定使用哪个地区的业务日历。合理的实现是把计算后的绝对deadline_at保存下来,同时保留计算所用规则版本和地区标识;后续业务日历更新不会悄悄移动已经生效的期限。对本篇固定截止时刻实验而言,我们跳过日历计算,只验证期限形成之后系统怎样执行。
最后要考虑“到期处理失败”的运维出口。扫描器读到到期申请,数据库暂时不可写,它应记录可重试错误并继续补扫,而不能向客户报告已经关闭。扫描器处理了状态变化,通知服务暂时失败,申请可以保持 EXPIRED,再单独重试通知。把状态迁移与提醒捆成一个必须同时成功的外部调用,会让通知服务成为截止规则的故障点。最终验收应分别检查“到期状态是否落库”和“相关人员是否收到消息”,两者都重要,责任却不同。
时间测试也不该只覆盖“截止前一秒”和“截止后一秒”。还要覆盖刚好等于截止、带不同 UTC 偏移但代表同一瞬间、服务重启后首次扫描、扫描任务重复执行、终态后的迟到输入,以及数据库忙时事务未提交的情况。尤其在跨地区部署中,夏令时切换可能使某个当地时间出现两次或根本不存在。若业务规则以当地工作日计算,先由明确的地区时区与日历规则把它换算成绝对时刻;后续裁决统一使用这个结果。一个可复现的时间测试不应等真的过三天,而应像本篇脚本一样注入可信时钟,逐项验证边界。
最后,时间线上的“先后”还需要区分受理顺序与业务发生顺序。审核人可能先点击、请求却晚到数据库;外部合同可能早签、回执却晚到。AcmeFlow 本例把审批和撤回的受理提交顺序定为裁决依据,避免用无法验证的客户端点击时间裁决。这项选择应该在业务验收文档里讲给运营与客服听。若客户提出争议,系统能展示真实保存的命令、服务端时间和失败原因,而不是只回答“系统认为你来晚了”。
八、亲自运行五组故障实验
在本篇目录运行:
python code/demo.py脚本只需要 Python 标准库。完整输出保存在 code/expected-output.txt,其中五组具名场景都带断言。实验使用固定的2026-10-01T10:00:00Z截止时刻和注入时钟,因此不会因为读者实际运行日期不同而变动。第一组创建申请后关闭 SQLite 连接,再重新打开同一文件,证明期限仍在;然后审批先提交,撤回得到冲突。第二组反转提交顺序。第三组在截止的准确时刻尝试审批并接收迟到合同回执。第四组模拟 Worker 停机后补扫,第五组拒绝无时区输入。
图 6:实验验收矩阵。每一行都对应demo.py中可运行的断言,不是虚构的线上运行截图。
请特别观察 08-A 与 08-B:两种结果不一致,但规则一致。在两个命令都合法、都早于截止的前提下,数据库事务中的提交顺序决定谁生效。08-C 展示另一种规则:一旦达到截止时刻,审批不再与撤回竞争,截止政策优先。08-D 说明扫描不必恰好在到期瞬间运行,补扫后仍能记录正确终态。08-E 是输入边界测试;如果把无时区时间默默当成部署机器本地时间,跨环境迁移会制造难以追溯的误差。
还可以自己加三组实验。第一组把deadline改成2026-10-01T10:00:00Z,它与原来的北京时间输入应落在同一个瞬间。第二组把approve-1的命令 ID 改成新值,在申请已批准后再次提交,预期得到冲突而非第二次有效批准。第三组在EXPIRED后发送另一条合同回执,预期审计数量增加但状态不改变。若任一实验导致状态重新变为待审,就说明终态保护没有放在统一入口。
九、验收标准与 Workflow Thinking
团队评审时,我会要求下面四件事都能被证明。第一,申请在进程重启以后仍能解释“截止时刻是什么、如何计算的”。第二,同一申请的审批、撤回与到期命令只有一个有效迁移,失败命令有可读原因。第三,到期后收到外部回执,系统保留事实并通知合适的人处理,但不会自动复活旧实例。第四,运维可以看见到期积压与延迟,而不会只靠客户投诉发现 Worker 已停机。
Workflow Thinking:为什么到期命令不能只由后台定时任务负责?因为后台任务是一个可能延迟的执行者,业务截止是所有入口共同遵守的规则。审核接口若不检查期限,即使扫描器工作良好,在它两次运行之间仍可能接受过期审批。把规则放进共享的命令处理路径,让定时扫描和人工请求接受同一裁决,系统才有一致的解释。
到这里,AcmeFlow 已经知道任务何时不再有效,也能在进程重启后重新发现超时。下一篇会把“运营审批”从一个简单按钮扩展成真正的人工作业:谁可以领取,转派以后谁有权完成,两位审核人怎样会签,以及资料改版后旧批准为什么必须失效。