GoWind CMS 拆解:社交分发引擎——10 平台适配器、状态闸门与自愈定时任务
本文回答一个问题:“把这篇文章一键发到 10 个平台、明早 9 点准时发、失败自动重试、发布后还能回拉互动数据”——这句话拆成工程问题,一共有几层?
GoWind CMS 的社交分发功能连接 10 个平台(Bluesky / Mastodon / DEV.to / Telegram / WordPress / 微信公众号 / Nostr / 微博 / LinkedIn / 博客园),支持内容一键分发、定时发布、改期重排、失败重试、互动统计回拉,并把这些能力以 MCP 工具的形式暴露给 AI 代理。这篇文章拆它的五层:平台适配器、凭据生命周期、发布状态机、定时自愈、统计回拉——每一层都有可以直接迁移的工程决策。
一、适配器架构:三个方法、一个接口、一种错误哲学
所有平台适配器实现同一个接口(pkg/social/provider.go):
typeProviderinterface{ValidateCredentials(ctx context.Context,creds Credentials)errorPublish(ctx context.Context,account Account,content Content)(*PublishResult,error)FetchStats(ctx context.Context,account Account,externalIDstring)(*Stats,error)}typeCredentialsmap[string]string接口面刻意收窄到三方法,配套一个语义化哨兵错误集合:
var(ErrInvalidCredentials=errors.New(...)ErrRateLimited=errors.New(...)ErrContentTooLong=errors.New(...)ErrNotFound=errors.New(...)ErrStatsUnsupported=errors.New(...)// 统计回拉链路据此静默跳过而非报错)ErrStatsUnsupported的注释点明了这套错误哲学:适配器用类型化错误表达"能力差异",上层据此分流——Telegram 没有互动统计 API,回拉 worker 遇到这个哨兵就推进节流时间戳、静默跳过,而不是每周期报一次错。错误即协议。
适配器通过注册表解耦:各平台子包在init()里Register("PLATFORM_BLUESKY", p)自注册,core 侧按 proto 枚举名取用——新增平台 = 新增一个子包,主链路零改动。
安全模型有一条清晰的边界(注释原文):「凭据只在 core service 层解密后以 Credentials 传入,适配器不接触存储;与 pkg/ai 的’密钥运行时注入’同款安全模型」。适配器是纯函数式的平台客户端,密钥从哪里来、怎么加密,它一概不知。
二、MetaWeblog:一个适配器吃掉一类协议
10 个平台不是 10 份等量代码。博客园、Typecho 及一切声明支持 MetaWeblog API 的博客系统共用一个通用 XML-RPC 适配器(pkg/social/metaweblog),包头注释就是它的设计宣言:
覆盖对象:博客园、Typecho 及一切声明支持 MetaWeblog API 的博客系统——RPC 端点由用户凭据给出,本适配器不绑定具体站点。
整个适配器只用三个 XML-RPC 方法(blogger.getUsersBlogs、metaWeblog.newPost),blog_id留空时自动调getUsersBlogs取第一个博客。两个工程细节:
- ctx 透传缝:底层 XML-RPC 库不接受 context,
ctxTransport自定义 Transport 把调用级 ctx 塞进去——超时与取消不因库的老接口而失效; - 错误归类:
classifyXMLError按中英文认证关键词(“密码”/“auth”/“403”……)把各家实现不一的 faultString 归类为ErrInvalidCredentials——把混乱归一在适配器层,上层只认哨兵错误。
其他平台的对接要点各有味道:Bluesky 用 App Password 现签现用 JWT(不持久化会话,300 rune 长度预检);Nostr 是"账户即密钥"——NIP-01 私钥直接对 kind=1 事件做 Schnorr 签名广播,任一 relay 接受即成功;微信公众号走草稿箱→freepublish 两段式,thumb_media_id必填、受 IP 白名单约束,未认证号 freepublish 报权限时保留草稿降级。收件箱回拉则是可选能力接口InboxFetcher+AsInboxFetcher类型断言——能力是可选接口而非全家桶,未实现的平台周期任务静默跳过。
三、凭据生命周期:从"保存即验证"到"增量合并不丢密钥"
凭据的安全链路五步,每步都有明确的威胁对位:
- 保存即验证:
CreateSocialAccount先调provider.ValidateCredentials,通过才加密落库——坏凭据在入口就挡住,不等发布时才炸; - AES-256-GCM 落库:
enc:前缀 + 随机 nonce,密钥由ENCRYPTION_KEY经 SHA-256 派生(core/admin 双服务同值)。这里有一个真实的修复故事(CHANGELOG 1.1.0 原话):「AI 模型提供商 API Key 此前明文落库:全局存储加密器(pkg/crypto)从未初始化,现已在服务启动接线;未配置时降级明文并告警」——加密代码写了、初始化漏了,是这类安全设施最典型的失效方式,wiring.go里那段InitGlobalEncryptor+ 未配置告警就是为它补的; - 读取恒空:List/Get/Create/Update 四个出口统一
item.Credentials = nil,只回CredentialHint(如"app_password ***xxxx"末 4 位脱敏)——管理页永远拿不到密文; - 增量合并:编辑账号时前端动态表单只回传用户填写的键,core 取旧密文解密、
if v != "" { merged[k] = v }合并、重新验证加密——部分更新不会把没填的密钥清掉; - 代改密文:边角场景——用户只想改 Bluesky 的 handle 不想重输密码,handle 恰好是凭据的一部分(密文里),core 代为解密改写这一键再加密。「凭据密文无法在 BFF 侧改写,必须由 core 完成」——谁持有密钥谁负责密文变换,边界不含糊。
四、发布是状态机,不是一次 HTTP 调用
"一键发布"的直译是provider.Publish(content)——这个实现把它建成了状态机:DRAFT → SCHEDULED → PUBLISHING → PUBLISHED / FAILED(可重试)。两个关键决策:
状态流转收敛在仓储层(social_distribution_repo.go结构体注释):「状态流转由本仓储的专用方法收敛,service/worker 不直接散写 status,避免竞态下状态机被绕过」——通用的Update根本不开放 status 字段写入,改状态只有走专用方法一条路。
进入 PUBLISHING 走条件更新抢占——全文的高光代码:
// TryLockPublishing:n>0 表示抢到发布权;n==0 表示并发点击/重复发布n,err:=r.entClient.Client().SocialDistribution.Update().Where(socialdistribution.IDEQ(id),socialdistribution.StatusIn(DRAFT,SCHEDULED,FAILED),// 只有这些状态可进发布).SetStatus(PUBLISHING).Save(ctx)returnn>0,nil用户双击发布按钮、worker 与手动触发并发、MCP 工具与页面同时下发——所有路径都汇到这条条件 UPDATE 上,恰一方进入 PUBLISHING。这与运维平台的"PENDING-only 条件回填"是同一种原语的镜像用法:那边防重复终态化,这边防重复起始化。
失败重试的语义也收在仓储:MarkFailed返回累计publish_attempts(原子 +1),worker 据此判定 1+MaxRetry=4 次穷尽才算最终失败——计数在库不在内存,跨进程重启依然准确。看板拖拽流转(MoveStatus)同样走StatusIn(froms...)条件更新,拖晚了并发变化返回 false 让前端刷新。
五、定时发布的两层防线
定时发布用 asynq DELAY 实现:publish_time在未来 → 按时刻入队(ProcessAt),为空或已过 → 零延迟立即执行,MaxRetry(3)。这一层有三个细节:
- 调度器未注入时返回错误——与重索引类 best-effort 任务刻意区分(注释:“否则 SCHEDULED 记录会被无声搁置”)。同样是入队,有的任务失败可以静默降级,有的必须硬失败,判据是"失败是否造成用户可见的承诺落空";
- 改期自动重新排队 + 旧任务自弃:改期落库后重新入队新时刻的 DELAY 任务,而旧的已排期任务到点时发现排期已推迟超过 2 分钟窗口(
socialStaleTaskSkipWindow)则自行丢弃——新旧任务不需要互相互杀,靠数据库里的当前状态裁决; - 永久性/暂时性失败分叉:worker 里
ErrContentTooLong这类重试必然还错的返回nil(不烧重试额度),网络类错误返回err交给 asynq 退避重试。错误分类决定重试策略,哨兵错误体系在这里兑现价值。
第二层防线是兜底扫描(pkg/task/publish_sweep.go):DELAY 任务在 Redis 数据丢失、服务崩溃窗口内可能静默丢失,两个每分钟一轮的 sweep 任务扫描"已到期但仍是 SCHEDULED"的记录重新入队。这个文件的头注释里藏着一个精彩的踩坑故事,值得整段引用:
任务类型命名注意:asynq ServeMux 在精确匹配失败后按【前缀】兜底路由(strings.HasPrefix),类型名不得是其他已注册类型的前缀——曾用名 “post.publish.sweep” 会命中 “post.publish” 的 handler,payload 解码失败被吞。现改为互不为前缀的 “post.scheduled.sweep” / “social.distribution.sweep”。
“payload 解码失败被吞”——这个 bug 的恶劣在于三层叠加:前缀路由命中错误的 handler、解码失败、错误被吞。三个独立的小缺陷叠出一个"定时任务偶尔静默不执行"的幽灵故障。sweep 的幂等性也写明了:“重复入队无害:worker 会跳过已发布/改期记录”——又回到状态闸门,重试的底气始终是那个条件更新。
通知语义对齐"落库即达":成功即时站内信通知操作者,失败只在重试穷尽后通知一次——不打扰,但绝不让失败静默。
六、统计回拉:与平台 API 共处的克制
发布出去只是前半程,互动数据(赞/评/转)要回拉。这条链路的三个常量就是全部的克制哲学:
socialStatsSyncWindow=30天// 只回拉近 30 天发布的内容socialStatsSyncThrottle=2小时// 单条记录最小回拉间隔socialStatsSyncBatchLimit=50// 单批上限,防长事务/平台限流候选查询按PUBLISHED + external_id 非空 + 30 天内 + (从未回拉 或 节流已过)筛选,Limit 50。遇ErrStatsUnsupported走TouchStatsSyncedAt仅推进节流时间戳——用节流替代报错,不支持统计的平台不会产生周期性告警噪音。回拉成功追加快照到social_stats_history(只增不改的表),批次尾部顺手按 90 天保留裁剪(限批 1000 防长事务)——趋势图的数据积累与容量治理在同一个写路径里完成,不需要独立的清理任务。
七、MCP 服务器:把分发能力暴露给 AI 代理
分发的第五层出口是 MCP(Model Context Protocol)服务器(mcp_server.go,290 行):基于mark3labs/mcp-go+ StreamableHTTP 传输,把 CMS 能力包装成四个工具——list_recent_posts/get_social_summary/list_social_distributions/publish_distribution。
鉴权模型有两处讲究:MCP_TOKEN只走环境变量,缺失时拒绝启动 MCP 服务器(不存在"无鉴权的降级模式");tools/list的元数据(名称与描述)对未鉴权调用方可见,但每个工具调用必须带 Token——工具目录是发现协议的一部分,能力执行才是要保护的面。
最有分量的是publish_distribution这个写工具的实现路径:它内部走PublishSocialDistribution→TryLockPublishing——与页面按钮完全同一条安全路径。AI 代理触发的发布与人类触发的发布共享状态闸门、重试语义与审计,不存在"AI 专用旁路"。给 AI 开放写能力时,正确的姿势不是为它新写一套,而是确认它只能走到人类已有的那条路上。
八、结语
拆完五层,这个引擎的架构观可以收敛成三句话:
- 平台异构性收口在适配器层——哨兵错误即协议,能力差异(InboxFetcher、ErrStatsUnsupported)用可选接口和类型化错误表达,上层永远只见统一语义;
- 发布是状态机——状态流转收敛在仓储、进入 PUBLISHING 走条件更新、重试计数在库不在内存,所有并发问题在状态闸门前终止;
- 定时承诺用两层防线兑现——asynq DELAY 管正常路径,每分钟 sweep 管灾难路径,幂等 worker 让重复入队无害;而前缀路由的事故故事提醒我们:任务系统的路由语义要像对待 SQL 注入一样认真读文档。
仓库(分发引擎在backend/pkg/social与backend/app/core/service/internal/service/social_service.go):
- GitHub:github.com/tx7do/go-wind-cms
- Gitee:gitee.com/tx7do/go-wind-cms