
审批等三天怎么办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服务端时间早于截止APPROVEAPPROVED有效审批先完成本轮审核WAITING_REVIEW服务端时间早于截止WITHDRAWWITHDRAWN申请人先撤回后续审批失效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:0008:00程序转换成2026-10-01T10:00:0000: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)priordb.execute(SELECT outcome FROM history WHERE application_id? AND command_id?,(application_id,command_id),).fetchone()ifprior:db.execute(COMMIT)returnprior[outcome]rowdb.execute(SELECT * FROM applications WHERE application_id? AND tenant_id?,(application_id,xinghe-demo),).fetchone()ifrow[state]WAITING_REVIEWandserver_nowrow[deadline_at]:afterEXPIRED# 后续在同一事务里写申请状态与 history再提交。完整可运行代码见 code/demo.py。实验把数据库路径放在临时目录运行结束自动清理保存状态跨越的是一个明确的关闭并重新连接操作。这能验证“等待记录不靠进程内存”但不能等价于验证生产中数据库断电恢复、多个 Worker 抢占或分布式锁。为了让篇幅集中于时间语义本篇没有构造 HTTP 接口也没有把代码直接迁移进第 03 篇的 PostgreSQL 服务。还有一个细节需要单独说审批已经成功以后销售再次请求“撤回”本篇返回冲突。这不是说已经批准的申请永远不能取消而是说取消已批准业务与撤回尚未完成审核的申请是两种命令。批准以后可能已生成合同、已收到款项甚至 ERP 已建档此时要启动独立的取消或退款流程验证后续事实并决定是否补偿。把这种复杂动作藏在“把状态改回撤回”里会抹掉真正发生过的审批和外部行为。五、Worker 停机之后期限怎样继续生效一个常见实现是在请求处理时创建内存定时器三天以后调用expire(application_id)。演示效果往往很好因为进程没有重启、部署没有滚动升级、队列也没有积压。可企业流程最需要回答的正是这些事件发生之后等待怎么恢复。持久化的方式是先存review_deadline_atWorker 周期性查找达到期限且仍在等待的申请再通过同一命令入口尝试迁移。即使 Worker 停机期限这条数据不会消失恢复后扫描能重新发现它。图 4Worker 能晚处理不能晚定义截止。实验 08-D 用停机后的补扫验证这个区别。查询到一条到期记录之后仍需再次检查状态在查询与执行之间运营可能完成审批销售可能撤回。单纯SELECT出任务 ID 然后无条件UPDATE stateEXPIRED会覆盖合法结果。实验把最终判断放进BEGIN IMMEDIATE事务中。接入 PostgreSQL 时可以使用条件更新、行锁或工作领取机制让多个 Worker 竞争同一条记录但“领到扫描任务”与“最终允许过期”仍是两件事后者必须依据当前实例状态与期限重新决定。补扫至少有三个运行指标。第一是到期积压量衡量有多少已到期申请尚未处理第二是处理延迟即从业务期限到写入到期记录间隔多久第三是失败次数与重试原因用于发现数据库忙、权限不足或代码错误。把“流程总等待三天”误报成“HTTP 响应耗时三天”会导致错误告警。反过来仅监测 API 延迟也发现不了补扫停了半天。对于扫描频率可以从业务容忍度倒推。若业务规定“到期后一小时内完成关闭”每分钟扫描已足够提供宽裕的调度空间若要求秒级关闭则要评估处理规模与精度。无论扫描多频繁裁决规则仍应在命令入口不应依靠扫描的准时性来阻止到期后审批。后续迁到 Temporal 时持久 Timer 可以承担唤醒但恢复、重放和活动副作用的边界仍需理解不能把它当作撤销远端业务效果的工具。参考Temporal Workflow Execution六、迟到消息值得记录但不应让终态复活合同回执、支付通知、邮件确认都可能在原申请关闭以后到达。把这些消息直接丢弃会让运营找不到客户实际做过什么把它们无条件应用到旧流程则可能复活已撤回或过期的申请。我们的中间做法是先验证来源和关联再记录观察结果最后依据当前申请和材料版本判断是否需要对账、退款或发起新流程。“收到了消息”与“允许它推进状态”是两个判断。图 5示意合同回执在 EXPIRED 之后到达。原申请不自动回到 APPROVED责任人可根据签署的真实时间与合同版本处理争议。本篇实验用SIGN_CALLBACK展示最简单的守卫待审阶段仅登记观察不充当审批过期以后返回LATE_OR_CONFLICT状态不改变。真正系统还需检查签名、来源身份、租户、合同编号、申请 ID、合同版本和材料版本。若该回执确实代表客户在截止前完成了签署也不能直接绕过“审核已过期”的独立事实应按照合同业务政策打开异常处理单或新流程。这是权责安排而非一个if条件能自动给出的法律或运营结论。这里特别容易出现“事件发生时间”和“事件收到时间”被混用。外部系统的时间若可伪造只靠一个occurred_at字段决定业务截止会被绕过。若用接收时间又可能使延迟传输伤害本来按时完成的客户。对于真正受合同约束的期限业务方要明确可信证据来源、容错窗口和争议处理办法开发者要让记录足够完整以便能够执行这套政策。本篇选择的审批截止规则更简单正是为了先教会读者把规则写清楚。终态不复活也不是不允许“重开”。如果业务允许主管延期或客户重新提交应创建有权限、理由、版本和新截止时刻的显式命令记录它与原流程的关联。管理员在数据库里执行一次UPDATE stateSUBMITTED看似快捷却无法解释哪些原任务恢复、哪些旧审批重新有效、已发出的通知怎么办。恢复业务能力可以有很多种形式但应由命令承载而不是删除历史。七、从第 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(days3)。这取决于企业所在地区、工作日历版本、申请提交的截单时刻、节假日调整和时区。如果同一客户跨地区申请需要先约定使用哪个地区的业务日历。合理的实现是把计算后的绝对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 已经知道任务何时不再有效也能在进程重启后重新发现超时。下一篇会把“运营审批”从一个简单按钮扩展成真正的人工作业谁可以领取转派以后谁有权完成两位审核人怎样会签以及资料改版后旧批准为什么必须失效。