点了 3 次发布,3 次都成功了:我把发布流程改成了状态机

先讲事故。

一条视频,我在发布页点了「发布」。页面没变化,没有 toast,没有跳转,按钮也没变灰。等了几秒,我又点了一次。还是没反应。第三次。

结果:三条一模一样的视频,全都发布成功了。 我手动删掉两条,花了十几分钟,还得担心账号被判重复内容。

失败的不是脚本,是模型。我当时脑子里的模型是「点一下 = 一个布尔值:成功 / 失败」。真实的发布是另一个东西:一个状态机,中间有一段时间你根本不知道发生了什么。

一、"没反馈"不等于"没发生"

这类事故的共同点,不是接口报错,而是正反馈缺失:

  • 单页应用(SPA)里,点击后请求发出去了,但 UI 没更新;
  • 请求成功了,但响应慢,前端 toast 还没弹出来;
  • 平台做了异步审核,提交后状态是「审核中」,页面上看不出"已提交"。

任何一种情况,人(或者脚本)都会做同一个动作:重试。而重试的代价,是造第二条重复内容。

把发布拆开看,其实是四个状态:

复制代码
待发 → 执行中 → 已提交(结果未知)→ 已确认 / 已失败

大部分翻车都发生在第三个状态:已提交但没确认。这个状态被当成"失败",于是进入重试分支,重复就来了。

所以我做的第一件事,不是加重试,而是把这个中间状态显式命名,并给它一个独立的结果码。

二、三道闸门:查重、回读、重试门

现在每次发布都走三步,任何一步不通过就停手。

第一道:发布前查重

先把平台上「已经发过的内容」拉成一份列表,发布前比对标题。

标题对比不能直接字符串相等。平台会动你的标题:全角改半角、去掉多余空格、加 emoji、截断。归一化我做了三件事:

python 复制代码
import re, unicodedata

def norm_title(t: str) -> str:
    s = unicodedata.normalize("NFKC", t or "")   # 全角/半角统一
    s = re.sub(r"\s+", "", s)                     # 去掉所有空白
    return s.casefold()                           # 忽略大小写

命中就拒绝发布,退出码固定为 5。用固定退出码,是为了让上层编排(cron、脚本、Agent)不用解析文案,只看数字。

这里有个比代码更重要的坑:空列表不能证明你没发过。

有一次列表接口返回了限频错误,脚本拿到的是一份空列表。如果查重逻辑把"空"当成"没有重复",闸门就等于不存在,而且它会给你一个虚假的安全感。所以现在的规则是:列表抓取失败 = 状态未知 = 允许发布但必须由后面的回读兜底,绝不把"抓取失败"当成"零条"。

第二道:发布后回读

发布接口返回什么都别全信。发完去线上地址抓一次:

  • HTTP 200,且正文里能找到归一化后的标题 → 已确认;
  • 打不开、404、或者正文里没有标题 → 未确认 (退出码 6),不是失败。

「未确认」和「失败」必须分开,因为它们的下一步动作完全相反:

  • 失败 → 可以排查后重试;
  • 未确认 → 去人工看一眼,禁止重试。

只这一个区分,就挡住了绝大部分重复发布。

顺便说一句,别拿 HTTP 200 当成功。SPA 的路由、登录墙、错误页,都可能返回 200。判断发布是否真的生效,只能看"目标页面上有没有你要的东西"。

第三道:重试门

如果真要重试,先查状态,再决定:

ini 复制代码
平台列表里已经有这个标题        → 已发过,停手(rc=5)
线上 URL 200 且正文含标题       → 实际已成功,停手(rc=5)
两者都没有                      → 允许重试一次(rc=0)

关键在顺序:先查状态,再重试 。不是 while True: try_publish()。

这条规则听起来像常识,但在一个"页面没反应"的场景里,人是很难忍住不点的。所以必须把它写成机器规则,而不是写在文档里靠自觉。

三、跑起来之后,真正花时间的部分

三个函数本身不到一百行。上线之后,大部分时间花在了"让列表可信"上。

  • 每个平台都得单独适配。 没有统一 API,有的要登录态,有的列表在接口里,有的只能抓页面。目前接了四个平台:掘金、小红书、雪球、B 站。
  • 列表只读,且定时刷新。 每天固定时间抓一次,写成本地 JSON;发布动作本身不自动跑。只读抓取失败了不影响业务,写操作失败才是事故。
  • 限频要退避,不要硬刷。 某个平台的列表接口会返回 -799(会话级限频)。我的处理是 45 秒退避、最多三次;三次之后仍然是空,就标记为"未知",而不是"没有"。
  • 每次操作落审计日志。 一行 JSON:时间、动作、平台、标题、URL、退出码、备注。出事之后复盘,靠的是这份日志,不是记忆。

回归测试只做一件事:拿真实标题跑一遍,必须被拦下(rc=5);拿一个假标题跑,必须放行(rc=0)。加上各种归一化边界,一共十几个用例。这组测试的意义不是覆盖率,而是保证闸门不会在重构时悄悄失效------一个永远返回 0 的闸门,比没有闸门更危险。

四、三条可以搬走的结论

这套东西不只适用于发内容。任何"对外部世界产生写操作"的场景都成立:下单、发消息、提交表单、调第三方 API、给客户开通账号。

  1. 写操作必须由一次独立的读来确认。 不要相信返回值,更不要相信 toast。写完去读,读到才算数。
  2. 把"不确定"命名成一个状态。 只要它被塞进布尔值,早晚会被当成失败,然后触发重试。
  3. 重试要有前置条件。 重试之前先查状态;查不到状态,就不该重试。

那个点三次发布的中午,我损失的是两条重复内容。但如果这是给客户开通付费账号、或者重复扣款,代价就不是删两条视频了。

慢一点,确认一次,比快一点、发三遍便宜得多。


这篇是我在维护几个内容站点时分发流程的一段复盘。更多这类工程记录在 cosmocat.dev。

相关推荐
MoRanzhi12033 天前
第6篇(下)· 使用 PyQt5 实现 2048 高性能计算版:统一运行控制与跨模式安全交接
python·pyqt·进程管理·状态机·软件架构·并发控制·数据一致性
isyoungboy14 天前
事件驱动状态机与循环扫描状态机:两种计算模型的比较研究
算法·状态机
贾伟康15 天前
HarmonyOS ArkUI 日历范围选择不歧义:本地日期键、闭区间与闰日校验
harmonyos·状态机·arkui·闰年·日期范围
红红谈说25 天前
论坛帖子审核链路怎么设计?内容安全与状态机的一次复盘
安全·状态机·内容审核·敏感词·异步审核
FungLeo1 个月前
成为全栈·React 管理后台篇·评论审核工作流:把状态下拉改成可理解的动作
react·内容安全·状态机·交互设计·成为全栈·评论审核
EleganceJiaBao2 个月前
【嵌入式】STM32 状态机设计:枚举、位掩码、结构体、变量与宏定义的合理选择
stm32·单片机·嵌入式硬件·枚举·状态机·位掩码
FungLeo2 个月前
成为全栈·Node 后端篇·文章 CRUD 与投稿状态机
node.js·状态机·crud·成为全栈
AIGC大时代2 个月前
LangGraph 生产级笔记:Checkpointer + interrupt(),把人审做成可恢复状态机
python·agent·状态机·langgraph·hitl
东莞市云毅网络有限公司2 个月前
多平台内容分发管线的工程化设计:队列、幂等与断点续传
状态机·工程化·内容分发