做多平台自动发布,最贵的 bug 不是「发不出去」,而是「同一条内容发出去两遍」。
发不出去只是慢一点,重跑一次就行;重复发布是已经造成的事实------平台侧可能限流,粉丝侧直接看到两条一模一样的东西,而且你没法撤回。我们在跑「不可能片场」AI 短片的全网分发流水线(9 个平台 + 掘金日更),这篇文章记录我们在防重复上踩过的坑和最终定下来的三道防线。
一、重复发布是怎么发生的
先说清楚敌人长什么样。自动发布链路里,重复几乎不来自「手抖点了两次」,而来自这几种情况:
- HTTP 超时但服务端其实已经成功。我们的发布 HTTP 客户端默认 540 秒超时,头条那次就是 540 秒超时返回失败,服务端其实已经传完了。调用方看到失败 → 重跑 → 第二条。
- 进程被沙箱杀掉,输出为空。Electron 应用跑 CLI 会真的开浏览器窗口,我所在的宿主环境会把它当 GUI 进程 SIGTERM 掉,表现是「无输出 + 退出码 1」。状态完全未知,你只能重跑。
- 文件被改名归档后,防重复主键漂移。视频发完要归档重命名,如果主键用了当前文件名,改名之后同一条内容就成了「新内容」。
这三类有个共同点:发起方无法判断上一次到底成没成。所以防重复不能靠调用方自觉,必须在写入侧做幂等。
二、第一道防线:写入侧的幂等判定
矩阵应用内部有个 changeData({ fileName, type, item }),所有发布记录都经它落盘。它在 type === "add" 且 fileName === "pushData" 时先查重,命中就不新增、改更新。判定函数是这个:
js
function recordValue(value) {
return String(value || "");
}
function isSamePushDataRecord(existingItem, newItem) {
if (
existingItem.textOtherName !== newItem.textOtherName ||
existingItem.pt !== newItem.pt ||
existingItem.textType !== newItem.textType
) {
return false;
}
if (newItem.textType === "article") {
return (
recordValue(existingItem.partition) === recordValue(newItem.partition) &&
recordValue(existingItem.articleFilePath) === recordValue(newItem.articleFilePath) &&
recordValue(existingItem.content) === recordValue(newItem.content) &&
recordValue(existingItem.coverPath) === recordValue(newItem.coverPath) &&
recordValue(existingItem.category) === recordValue(newItem.category) &&
articleTagsValue(existingItem) === articleTagsValue(newItem) &&
recordValue(existingItem.summary) === recordValue(newItem.summary)
);
}
return existingItem.selectedFile === newItem.selectedFile;
}
几个值得抄的细节:
recordValue 统一归一化。 String(value || "") 让 undefined、null、"" 三种写法全部等价。不做这步,今天传了 summary: ""、明天传了 summary: undefined,判定就不相等,去重直接失效。
主键里带 partition,不带账号名。 partition 是 Electron 的会话分区(我们这边形如 persist:不可能片场掘金),一个分区对应一套独立的 cookie。同平台换账号就是换分区,天然区分开;而账号名可能有别名、空格、大小写问题,不适合做主键。
视频和文章用不同宽度的主键。 视频只要四元组(textOtherName + pt + textType + selectedFile);文章因为文件体积小、内容可能被改,主键扩到了九项,把 content 也算进去。
查重命中之后不是简单丢弃,而是原地更新计数:
js
const duplicateIndex = list.findIndex(
existingItem =>
!existingItem.scheduledTask &&
!newItem.scheduledTask &&
isSamePushDataRecord(existingItem, newItem)
);
if (duplicateIndex === -1) {
list.push(newItem);
} else {
const oldItem = list[duplicateIndex] || {};
const attemptCount = (Number(oldItem.publishAttemptCount) || 1) + 1;
list[duplicateIndex] = {
...oldItem,
publishAttemptCount: attemptCount,
republishCount: Math.max(0, attemptCount - 1),
publishStatus: "publishing",
lastPublishMessage: "重新发布中",
lastPublishAt: Date.now(),
};
}
...oldItem 在前,所以历史成功/失败计数被保留,只把 attempt +1、状态重置为 publishing。这样一条内容在记录里永远只有一行,重试次数一目了然,不会攒出一堆重复行。
注意 !existingItem.scheduledTask && !newItem.scheduledTask 这个条件:定时任务是允许并存的(同一个视频定时发两次是合法需求),所以定时任务不参与去重。这个取舍是对的,但要知道它存在。
三、第一道防线的两个漏洞
漏洞一:去重是按天文件做的,跨天无效。 落盘路径是:
js
const targetDate = item.date || today; // YYYY-MM-DD
const filePath = path.join(folderPath, `${targetDate}.json`);
const list = readDayData(filePath); // 只读这一天
也就是说,今天发过的东西,明天再发一遍,应用内部不会拦。这对「每天换内容的日更」没问题,但对「隔天重发同一支视频」完全失效。
漏洞二:文章主键包含 content,改一个字就失效。 如果昨天发布失败,今天改了 Markdown 里一个错别字再发,content 变了,判定为「新内容」,直接新增一条。而服务端那边,昨天的那次可能已经成功了------于是重复。
这两个漏洞都必须靠应用之外的台账来补。
四、第二道防线:外部台账
我们在流水线目录维护了一个独立的 ledger.json,结构是 publishes 对象,key 格式:
css
juejin-article|2026-08-30|掘金
下载 (2).mp4|视频号|persist:不可能片场视频号
即 内容标识 | 平台中文名 | 分区 ,其中「内容标识」对视频用发布时的原始文件名、对文章用当天日期(同一天第二篇用 日期-2)。
三条铁律:
- 发布前必查,status 为 success 的绝不重发。 台账是防重复的唯一依据,不查台账就发布等于裸奔。
- 键里存原始路径,不存当前路径。 视频归档改名后,台账另记
archivedTo/currentPath两个字段,但防重复键保持发布那一刻的原始路径不动。否则改名一次,去重就断一次。 - 改台账要留证据链。 下面单独说。
五、失败分类:不是所有失败都该重试
重跑之前先要判断「上一次到底属于哪种失败」。CLI 用退出码区分:
| 退出码 | 含义 | 处置 |
|---|---|---|
| 0 | 成功 | 记 success,结束 |
| 3 | 登录态异常或未登录 | 先补登录,别重跑(重跑必然再失败) |
| 4 | needsAttention,例如 outcome: "draft_saved" |
内容已落到草稿箱,不是失败,不该重发 |
| 其他 | 具体错误,如 点击确认后发布弹窗仍未关闭 |
可重试,但要限制次数 |
outcome: "draft_saved" 这一类最容易被误判。它意味着平台侧已经把内容收进草稿了,此时重发一定会多出一条。看到 needsAttention: true 就应该停下来人工确认,而不是自动重试。
我们对「发布弹窗仍未关闭」这类偶发抖动的策略是:等 60 秒,最多重试 1 次。它本质是确认按钮点下去之后弹窗没及时消失,重试大概率能过,但不能无限重试------每次重试都在赌「上一次真的没成功」。
六、超时不等于失败:以服务端的记录为准
这是整套流程里最反直觉、也最重要的一条。
发布任务有个总超时,超时后应用会往记录里写 failed:
js
const timer = setTimeout(() => {
const message = `发布超时(${min} 分钟),请检查网络或登录态`;
updateRecord("failed", message);
finish({ exitCode: 1, status: "failed", message, id: recordId });
}, CLI_PUBLISH_TIMEOUT_MS);
但这个 failed 是「发起方没等到结果」,不是「服务端失败了」。判定真实结果必须查应用自己写的记录:
python
import json, datetime
rec = json.load(open(
r"C:\Users\molan\Documents\MatrixMedia\data\pushData\2026-08-30.json",
encoding="utf-8"))
for x in rec:
if x["pt"] == "视频号":
print(x["publishStatus"],
"ok=", x["publishSuccessCount"],
"fail=", x["publishFailCount"],
"attempt=", x["publishAttemptCount"])
print(datetime.datetime.fromtimestamp(x["lastPublishAt"] / 1000))
注意 pt 字段存的是中文平台名 (视频号),不是平台代码(sph);lastPublishAt 是毫秒时间戳,要除以 1000。再配合主进程日志里成功时打的 ✅ <平台>视频上传成功,两边时间戳对得上才算确认。
七、台账会过期:改判要留证据链
最后一点,也是最容易被忽略的:台账本身会失真。
我们接手这套流水线时就遇到一条:下载 (2).mp4 → 视频号 台账记的是 failed(当时理由写的是登录失效),实际后来重试成功了,只是没人回头更新台账。如果照着台账重发,就是一条重复视频。
改判不能靠感觉,凑齐三份证据才动手:
pushData/2026-08-30.json里该条publishStatus=success、ok=1 / fail=1 / attempt=2、lastPublishAt=1788063519662→ 换算 12:18:39;- 日志
2026-08-30.log对应行[04:18:34.650Z] ✅ 视频号视频上传成功(UTC,即 12:18:34); - 同日志更早处
[03:31:39.885Z] ERROR 视频号登录状态已失效(11:31:39,即被记为 failed 的那一次)。
三份证据互相印证,才能把状态从 failed 改成 success。而且改的时候要把完整证据链写进 reconcileNote 字段------只改 status 的话,下次接手的人还得重新查一遍。
顺带一提,应用内部对计数的处理并不完全一致:查重分支用 ...oldItem 保留了历史成功/失败计数,而 updateRecord 是按当次 status 重新算 1 / 0。读记录时看到 attempt=12 / ok=2 这种数字,要知道它背后是多条路径共同写出来的,别拿单一字段直接下结论。
小结
防重复不是一个开关,是分层的结果:
- 写入侧幂等 (
isSamePushDataRecord)拦住绝大多数「同一天同内容重跑」,成本最低; - 外部台账补上跨天和「内容被改动过」的漏洞,代价是要手工维护;
- 失败分类 + 以服务端记录为准保证「该重试的重试、不该重试的别乱试」。
以及一条贯穿始终的原则:任何一层的失败结论,都要能被更靠近服务端的一层推翻。调用方的超时、沙箱的 SIGTERM、退出码,都只是「没观测到成功」,不等于「失败了」。
本文来自「不可能片场」AI 短片生产线的实践记录。