好久没动这个项目了前几天把仓库拉下来跑了一遍看到配置目录里静静躺着的一百多个平台描述文件说实话有点恍惚。两年前它还是三个平台、一个 for 循环、加一堆 if-else 的脚本跑在我自己的机器上谁要发稿子就喊我一声。现在它变成了一个能覆盖长文平台、图文社区、短视频平台、问答社区、音频平台等一百多个渠道的一键分发系统从写完稿子复制粘贴一晚上变成了提交一次、跑完看报表。这篇想聊的不是多平台发布有多爽而是这里面真正难的部分当平台数量从 3 涨到 100 多架构上到底发生了什么变化哪些地方是必须重写的哪些地方是可以用配置糊过去的以及我在这个过程里踩过、被坑过、后来写进检查清单的那些细节。如果你正在做一个内容多平台分发的工具或者你手上任何一个系统正在经历接入方从个位数涨到三位数的阶段这里面的经验大概率能直接抄。文章里不会给出某个具体平台的对接细节讲的是那套能被复用的骨架和判断。1. 从三五个平台到一百多个这套东西是被什么逼着重写的1.1 最早的脚本版三个平台、一个 for 循环、一堆 if最开始的需求特别朴素一篇稿子发到三个地方省得手动复制。于是我写了一个函数参数是稿件路径函数体里三个 if 分支每个分支里塞一段专门为那个平台写的拼接逻辑。跑起来没问题改起来也还好因为只有三个。代码大概是这个味道def publish_all(draft): if platform_a in targets: body draft_to_markdown(draft) api_a.post(titledraft.title[:30], contentbody) if platform_b in targets: body draft_to_html(draft) api_b.submit(titledraft.title, htmlbody, tagsdraft.tags[:5]) ...这种写法在前十个平台之内其实是可以忍的因为每次新增只是一段代码虽然丑但直观。它的问题不是不优雅而是新增成本是线性的而维护成本是超线性的。你想想三个平台的时候改一次公共逻辑要改三处还能接受到第十五个平台改一次公共逻辑要改十五处而且每一处的上下文都不一样改完还得逐个回归。1.2 崩溃点出现在第 12 个平台左右我印象很深的转折点是接到第 12 个平台的对接需求。那次的需求是这个平台要支持定时发布但定时时间必须是整点之后 5 分钟而且封面必须是 3:4。听起来就是个特殊逻辑加个 if 就完了。但加上去之后我发现之前有另一个平台是封面必须 16:9 且不能超过 2MB再往前还有个平台标题按字节算不能超过 60 字节也就是说中英文混排的时候字数根本不是固定的。这些规则散落在十几个 if 分支里没有任何地方能一眼看全。更要命的是校验稿子提交上来的时候我得知道这篇稿子在这 12 个平台里哪几个是发不了的、为什么发不了。在老结构里唯一的办法是真的去调一遍接口让对面报错。这就导致一个很蠢的循环用户提交 → 跑到第 7 个平台失败 → 前面 6 个已经发出去了 → 用户改稿 → 重新提交 → 前面 6 个重复发。这就是压垮旧架构的最后一根稻草没有发布前的全局校验能力。一旦有平台是发出去就撤不回的你就必须在提交之前把所有平台的适配性问题全部算出来让用户一次性改完。这个能力在 if-else 结构里几乎不可能做好因为校验逻辑和发布逻辑是缠在一起的。1.3 重写时定下的第一条原则平台是数据不是代码重写的核心决定就一句话把某个平台长什么样从代码里挪出来变成一份可读的描述文件。代码只保留怎么按描述去执行的通用能力。这个决定带来的直接好处是显而易见的新增一个平台理想情况下不需要改代码只加一个配置文件平台的规则变了比如标题上限从 30 改成 40改一行 YAML 提交 PR 就行不需要重新理解那段 if 分支在干什么更重要的是因为所有平台的描述都在同一个结构下我可以写一个通用的校验器把一篇稿子对着全部平台的描述跑一遍输出一份哪些能发、哪些要改、改哪里的报告。当然实际做下来没有这么理想总有一些平台的规则没法用声明式描述表达后面会讲但即使是这样能做到 80% 配置化、20% 代码也比 100% 代码强太多。因为那 20% 的例外是被清楚地隔离出来的你知道它们在哪里也知道新加平台的时候大概率不用碰它们。这条原则其实适用于所有接入方数量会持续增长的系统支付渠道、物流承运商、短信服务商、第三方登录都是同一个套路。判断标准很简单——如果新增一个接入方需要改核心代码那架构就是有问题的。2. 一个平台到底有多少不一样把差异做成能力矩阵2.1 我实际记录下来的 14 个差异维度刚开始我以为差异无非是这个平台支持 Markdown那个不支持。真做下来才发现差异是立体的。我后来整理了一张表把实际遇到过的差异分成四类一共 14 个维度类别维度典型差异字段限制标题长度按字符 / 按字节 / 按 UTF-16 单元字段限制正文字数有上限 / 无上限 / 按图片折算字段限制标签数量上限、单个长度、是否必填字段限制摘要平台不自动截取需显式提供内容能力富文本节点是否支持表格、代码块、脚注、分割线内容能力标题层级有的只支持到三级内容能力外链允许 / 转纯文本 / 直接拒绝媒体能力图片数量3 张 / 20 张 / 无限制媒体能力图片规格大小上限、格式白名单、防盗链媒体能力封面比例、尺寸、是否必填媒体能力视频时长、码率、是否要单独转码发布行为定时支持 / 不支持 / 只支持整点发布行为审核先审后发 / 先发后审 / 无审核发布行为修改发布后能否编辑、编辑是否重新审核这张表的价值不在于它多全而在于它给了我一个检查清单。后来每接一个新平台我就照着这 14 个维度过一遍把答案填进去。填空的过程本身就是对接口文档的一次有效阅读——很多时候文档里没写清楚的地方正是你填不下去的那一格那就得去问、去试。2.2 能力矩阵的落地形式一份 YAML 加一个校验器描述文件最后长这样字段名尽量直白platform: platform_a display_name: 某长文社区 adapter: rest_v2 limits: title_unit: byte # char / byte / utf16 title_max: 60 body_max: 20000 tag_max_count: 10 tag_max_chars: 12 summary_required: true features: scheduled: true scheduled_align_minute: 5 editable_after_publish: false cover_ratio: 3:4 cover_required: true content: support_nodes: [paragraph, heading, image, list, quote, code_block] drop_nodes: [table, footnote, hr] degrade: table: image footnote: endnote_list external_link: forbidden auth: type: oauth2 token_ttl: 7200 refresh_before: 900有了这个结构校验器就变成一个纯函数validate(draft, capability) - list[Issue]。它遍历所有目标平台对每个平台跑一遍检查产出一份按平台分组的报告。用户在提交前就能看到平台 C 标题超了 12 字节、平台 F 不支持表格已自动转图、平台 H 外链会被删除。这里有个实际的取舍值得说校验要严到什么程度我的做法是分成两级blocker和warning。blocker 是那种一定发不出去的标题超长、封面缺失、正文为空必须改warning 是能发但效果会打折的表格被降级、外链被删、标签被截断提示但不拦。一开始我把所有问题都设成 blocker结果用户被拦得死死的体验极差后来改成只有真正会失败的才拦反而没人抱怨了。2.3 矩阵之外那些没法描述的差异必须承认有一部分平台的规则是描述不了的。我遇到过几类一种是跟内容语义相关的审核规则。比如某平台对导流意图的判定你很难写成一条规则只能靠人。这类东西我的处理是不去建模把它归到发布后需要人工回查的流程里。一种是隐式的上下架规则。比如某平台对同一账号连续发布有频率限制但这个限制不写在文档里是运行中撞出来的。这类只能靠观测积累把它变成运行时的限流参数而不是配置里的静态字段。还有一种是接口行为的不一致。比如同样是上传图片有的平台是先申请上传凭证再 PUT 二进制有的是直接 multipart 表单一次搞定有的是先传到一个临时地址再引用。这些差异在适配器代码里消化掉不进配置。所以说平台是数据这条原则做到 80% 就够了剩下 20% 用代码兜住但必须集中放在适配器里不能漏回主流程。3. 适配器接口只留四个方法但每个都有明确的边界3.1 接口设计validate、transform、publish、query抽象层最后收敛成四个方法我把它定义成协议class Adapter(Protocol): capability: Capability def validate(self, draft: Draft) - list[Issue]: ... def transform(self, draft: Draft) - Payload: ... def publish(self, payload: Payload) - RemoteRef: ... def query(self, ref: RemoteRef) - RemoteState: ...为什么是这四个因为它们正好对应发布链路的四个阶段而且每个阶段的失败后果不一样分开之后重试策略也能分开设计。validate是纯计算不产生副作用可以随便重跑transform也是纯函数同样的输入永远产出同样的 payload这个特性后面做幂等键的时候会用到publish是唯一有副作用的必须幂等保护query是用来对抗假成功的后面单独讲。我刻意没有加update和delete。不是不需要而是这两个操作的平台差异太大——有的平台发出去就不能改有的改了要重新审核有的删了还留个已删除的壳。硬把它们塞进统一接口最后一定会变成一堆if capability.support_edit的判断。现在的做法是把它们放成可选的扩展方法用能力位控制调不到就明说这个平台不支持。3.2 transform 的输出必须是中间表示不能是 HTML 字符串这一点我走了弯路。最早transform返回的是 HTML 字符串想着反正大部分平台都吃 HTML。结果很快发现不行有的平台吃 HTML 但会过滤掉table有的平台只吃它自己的一套标记有的平台吃 HTML 但会把img src换成自己的 CDN 地址还有的平台对 HTML 里的空白处理方式完全不一样。一旦输出是 HTML 字符串你就失去了结构信息后面想按平台能力做降级都无从下手——你没法从一个字符串里可靠地判断这里原本有个表格。所以最终transform返回的是一个中间表示IR本质上是 Markdown AST 的一个裁剪版节点类型被我限制在十几个之内。降级逻辑全部在 IR 层做做完之后再序列化成目标平台要的格式。这样表格转图片这类降级就是一次 AST 变换干净可测。3.3 假成功返回 200 但线上什么都没有这是所有多平台发布系统里最恶心的一类问题。接口返回成功你记了个 remote_id任务状态标成已发布结果去线上一看什么都没有。可能的原因有很多进了审核队列但审核层面静默失败了、内容被判定为重复所以没上、接口内部异步处理失败但没回传、甚至就是对面的 bug。纯靠 publish 的返回值是发现不了的所以query这个方法不是可选项是必需品。我的做法是发布成功后不立刻标记终态而是进入一个待确认状态由定时任务按退避策略回查比如 2 分钟、10 分钟、1 小时、6 小时各查一次查到线上确实存在才转成已发布超过 24 小时还查不到转成疑似失败并告警。def confirm_loop(ref): for delay in [120, 600, 3600, 21600]: sleep_until(ref.created_at delay) state adapter.query(ref) if state online: return mark_published(ref) if state rejected: return mark_failed(ref, reasonstate.reason) return mark_suspect(ref)这套机制上线之后莫名其妙没发出去的投诉少了一大半。代价是多了一倍的接口调用量和一套状态机我认为完全值得。4. 授权和账号一百个平台里一半的坑埋在这4.1 令牌刷新要抢在过期之前而且要考虑失败多平台发布绕不开授权。一百多个平台授权方式五花八门但主流就是那几种长期令牌、短期令牌加刷新令牌、带签名的请求。真正麻烦的不是首次授权是长期运行中的令牌续期。我踩过的第一个坑是刷新时机。最开始是请求返回未授权了再去刷新看起来很合理但实际会造成两个问题一是每次刷新都伴随着一次失败请求日志里全是噪音二是如果刷新本身也失败比如刷新令牌也过期了那次发布就彻底失败了而且失败得很晚。后来改成提前刷新配置里记token_ttl和refresh_before后台任务在剩余寿命小于refresh_before的时候就主动刷新。refresh_before我一般设成 ttl 的 10% 到 20%比如 ttl 是 7200 秒就提前 900 秒刷。留这个余量的原因是刷新请求本身可能失败你得有重试窗口。def maybe_refresh(cred): if cred.expires_at - now() cred.refresh_before: try: new refresh(cred) store(encrypt(new)) except TransientError: schedule_retry(cred, backoff60) except FatalError: mark_need_reauth(cred) # 通知用户重新授权mark_need_reauth这个状态很重要。刷新令牌彻底失效是必然会发生的用户改了密码、撤销了授权、平台策略变更系统必须能优雅地告诉用户这个平台的授权掉了去重新连一下而不是让任务一直失败重试。我在后台做了个授权健康度页面把所有快过期和已失效的授权列出来这个页面的使用频率比我预想的高很多。4.2 一个账号下有多个身份别拿账号名当主键这个问题在矩阵运营的场景下特别明显同一个登录账号下可能挂着多个发布身份不同栏目、不同子账号、不同站点。早期我拿平台 账号名当唯一键结果发现同一个账号名在同一个平台下能对应好几个发布目标数据全乱了。正确的主键应该是平台 授权主体 发布目标 ID三元组其中发布目标 ID 是平台返回的那个真正用来发内容的东西。这个 ID 才是稳定的账号名会改昵称会改只有 ID 不变。binding: platform: platform_b auth_subject: user_12345 # 授权主体 target_id: column_678 # 真正的发布目标 target_name: 某栏目 # 仅用于展示随时可能变改了主键之后还有个连带好处同一个授权可以绑定多个发布目标用户授权一次就能管一批账号不用重复授权。这个体验提升是很实在的。4.3 权限是静默变更的别假设授权一次就永远够用最隐蔽的一类问题是权限变更。用户在平台上调整了自己的设置或者平台调整了接口的权限范围你手上的令牌还是有效的但某些操作就是会失败返回的还是个模糊的错误码。这类问题没法预防只能靠失败分类来兜。我在适配器里给每个平台的错误码建了一张映射表把平台返回的错误码翻译成内部的几种语义auth_expired、permission_denied、rate_limited、invalid_param、content_rejected、unknown。其中permission_denied单独拎出来触发重新检查授权范围的流程而不是简单地重试。这张映射表是纯体力活但收益极高。有了它之后所有的失败统计、告警、自动处置都能建立在统一的语义上而不是每个平台一套 if。5. 内容转换成一百份稿件降级策略比转换能力更重要5.1 从 Markdown AST 到各家格式的映射与降级前面说了 transform 输出 IR这里说说降级。核心思路是先按平台能力做一次能力裁剪再做一次节点降级最后序列化。裁剪是去掉平台完全无法理解的东西降级是把不支持的东西替换成能表达同等意思的替代品。我维护了一张降级表实际用下来覆盖了绝大多数情况源节点平台不支持时的处理说明表格渲染成图片保留信息完整性代价是不能复制脚注转成文末编号列表语义基本无损分割线直接删除视觉影响小四级及以下标题转成加粗段落层级感弱化但可读代码块转成引用块并保留缩进语言高亮丢失外链转纯文本并附提示平台禁止外链时的唯一选择内嵌视频转封面图加链接部分平台不支持正文嵌视频这里有个经验降级要做成可解释的。用户看到的不是这篇稿子在某平台少了点东西而是某平台不支持表格已自动渲染为图片。前者会让人怀疑系统后者会让人信任系统。所以每个降级动作都要在报告里留痕我在 Payload 里塞了一个transform_log字段专门记这个虽然平台用不上但给用户看。5.2 图片的三件事转存、压缩、裁切图片处理是另一个重灾区因为几乎每个平台对图片都有自己的要求而且很多平台的图片地址带防盗链你直接把源图地址塞过去用户看到的就是一张裂图。第一件事是转存。我的做法是所有图片统一先落到自己的对象存储生成一个稳定可访问的地址再拿这个地址去对接各平台。有些平台会自己去抓这个地址然后转到它自己的 CDN有些平台只接受上传二进制那就走上传流程。两种情况都要能处理。第二件事是压缩。不同平台的大小限制差异很大从 2MB 到 10MB 都有。我的默认策略是长边不超过 1440 像素、质量 82这个参数在绝大多数场景下肉眼几乎看不出差别体积能压到原来的三分之一左右。如果目标平台限制更严再按需降一档。第三件事是裁切主要是封面。封面比例各家不同16:9、3:4、1:1 都见过。直接拉伸会变形我的做法是居中裁切并且在裁切前先做一次主体检测——如果检测不到明显主体就居中裁检测到就按主体位置偏一点。这一步不是必须的但做了之后封面把人脸裁掉一半的情况基本没有了。5.3 字符计数这种小问题最能磨人这个必须单独拎出来说因为它坑了我最久。标题长度限制看起来简单实际上有三种算法按字符数一个汉字算 1按字节数UTF-8 下一个汉字算 3按 UTF-16 单元数一个 emoji 算 2一个汉字算 1同样的一个标题在三种算法下的长度能差三倍。更坑的是有些平台文档写的是字符实际校验用的却是字节。我最后的处理是配置里显式声明title_unit校验器按声明的单位算同时对每个平台维护一个实测值跑测试的时候用超长标题去试把真实上限记下来跟文档对不上就以实测为准。def count(text, unit): if unit char: return len(text) if unit byte: return len(text.encode(utf-8)) if unit utf16: return len(text.encode(utf-16-le)) // 26. 调度、限流与重试把提交了变成线上真的有6.1 配额到底是账号级还是应用级先搞清楚再设计限流限流这块最容易犯的错是假设所有平台的配额维度一样。实际上至少有三层应用级你整个系统对这个平台的调用总量账号级单个授权账号的调用量接口级具体某个接口单独的限制我有一次就是把配额当成应用级来设计的结果一个矩阵账号批量发了 40 篇把账号级配额打满导致另一个业务线的账号也被牵连因为它们在同一个授权主体下。后来改成三层都要考虑用令牌桶在每一层各放一个桶请求要同时通过三层才放行。buckets [app_bucket(platform), account_bucket(platform, subject), api_bucket(platform, endpoint)] for b in buckets: if not b.try_acquire(): raise RateLimited(retry_aftermin(b.wait_time() for b in buckets))另外一个实用技巧是加抖动。定时发布如果都卡在整点很容易形成瞬时洪峰。我的做法是在目标时间前后加一个随机偏移±90 秒把流量摊平。这个偏移对用户完全无感但对系统的稳定性帮助很大。6.2 幂等键怎么设计才能既不重复发也不漏发重复发布是比失败更严重的问题因为失败还能重试重复发出去的内容要一条条去删。幂等键的设计我用的是这个组合idempotency_key sha256(platform target_id draft_version slot_time)四个要素各有作用platform和target_id定位发布目标draft_version保证内容改了之后算新任务否则改稿重发会被当成重复slot_time保证定时任务在同一个时间槽内只发一次。光有键还不够还得有本地状态表配套状态含义重试行为pending已入队未执行可直接执行inflight正在调用接口不允许并发执行需等待超时published已确认线上存在拒绝重复执行failed终态失败拒绝重试需人工介入suspect疑似成功待确认只允许 query不允许 publishinflight这个状态特别关键。没有它网络超时的时候你不知道请求到底有没有到达对面重试可能造成重复。有了它之后超时的任务先转inflight挂着等query结果出来再决定而不是盲目重试。6.3 失败分三类处理方式完全不同把失败分类是让系统不吵人的关键。我的分类和处置是这样的类型典型场景处置策略瞬时失败超时、5xx、限流指数退避重试最多 3 次间隔 30s/2m/10m终态失败参数非法、权限不足、内容被拒不重试落失败聚合告警需人工风控验证、二次确认、验证码挂起通知人工处理疑似成功返回成功但回查不到转 suspect定时回查这个表看起来简单但实施的时候有一个细节很容易漏重试必须重新走 validate 吗我的答案是瞬时失败不走因为内容没变校验结果也不会变但如果失败原因里包含参数类问题就必须走。为了避免这个判断出错我在重试入口加了断言如果上次失败语义是invalid_param直接拒绝重试。7. 观测、灰度与新增一个平台的真实工时7.1 成功率看板要能回答三个问题多平台系统上线后看板是每天都要看的。我一开始做的看板很花哨各种维度都有但实际没用——因为它回答不了我最关心的问题。后来砍到只剩三个必答问题第一哪个平台今天成功率掉了按平台维度的成功率折线掉超过 5 个点就要查。这个指标能第一时间发现对面接口变更。第二失败原因的 Top N 是什么按内部语义聚合的失败原因排行。如果rate_limited突然涨说明配额策略失效如果permission_denied涨说明授权批量失效了。第三从提交到线上可见的中位耗时是多少这个指标反映的是真实体验。我遇到过一次中位耗时从 3 分钟涨到 40 分钟查下来是某个平台的审核变慢了但我们的回查退避策略太保守一直以为自己没发出去在反复查。这个指标不单独看是发现不了的。看板之外还有一个低成本但极其有效的机制每日探针。用固定的测试账号、固定的测试内容每天对每个平台跑一次端到端发布成功就删掉。这条链路跑在独立环境里不受业务流量影响是发现接口悄悄变了最灵敏的手段。7.2 新平台接入清单照着填就不会漏接入一个新平台我固定走这套清单时间久了就变成肌肉记忆读接口文档把能力矩阵的 14 个维度填满填不出来的标记为待验证用测试账号跑一次手动发布记录真实的字段要求尤其注意必填字段和可选字段的实际行为差异用超长标题、超多标签、含表格的正文做一次过载测试确认实测限制确认授权方式把 ttl、刷新方式、失效表现记录下来确认图片处理方式是接受 URL 还是必须上传是否要求特定尺寸确认返回结构成功返回什么、失败返回什么、错误码有没有文档确认发布后的可见性是立即可见还是延迟审核状态怎么查写适配器跑一遍单元测试和探针内部账号灰度发 20 篇真实内容观察 3 天放开给全部用户加进每日探针第 3 步的过载测试我想多说一句。很多平台文档里写的限制和实测不一致尤其是字节vs字符这种。与其相信文档不如用一篇明显超规的内容去撞一次把报错信息记下来这个信息是最准的。7.3 工时从两天压到三个小时的真正原因最开始接一个新平台要两天现在熟手三个小时能搞定我复盘了一下压缩主要来自三个地方一是模板化的目录结构。每个适配器就是一个目录里面固定有capability.yaml、adapter.py、fixtures/、test_adapter.py。新建的时候直接复制模板改内容就行不用想我该放哪。二是共享的测试夹具。fixtures/里有一组标准稿件纯文本的、带表格的、带多级标题的、带 20 张图的、超长标题的、带 emoji 的。每个新适配器跑一遍这组夹具能自动暴露大量的适配问题。这个投入在第三个适配器的时候就开始回本了。三是错误码映射脚本。之前每个平台都手工写错误码映射后来做了个半自动的东西把平台的错误码文档粘进去生成一份待确认的映射草稿人工只做校对。这一步从两小时压到二十分钟。至于剩下的那三个小时主要花在读文档和实测验证上这部分是没法省的也不该省。最后说一个我个人判断上的变化。做这件事之前我总觉得支持更多平台是个数量问题多一个平台多一份工作量线性增长。真做到一百多个之后才明白它其实是个结构问题平台数量越多通用能力的价值越高因为每次优化都是对全部平台生效的。反过来任何一处这个平台特殊单独写一段的技术债都会被一百倍地放大。所以现在我定新平台时的第一反应不是这个怎么实现而是这个能不能被现有的描述结构表达出来——如果不能那大概率是结构需要扩而不是该写特例。7.4 关于支持 100 多个平台这个数字本身最后聊点稍微不那么技术的。这个数字对外看挺唬人的但实际跑起来真正高频使用的平台可能就十几个剩下的大部分是长尾。不过长尾的价值在于覆盖面用户的需求是分散的你觉得冷门的渠道可能就是某个人的主战场。所以对这些低频平台我的优化目标不是性能而是别出问题、别维护不动。具体做法是低频平台的适配器不做特殊优化共用通用链路探针频率降到每周一次出问题就降级成暂不可用而不是死磕修复。这个取舍我也纠结过后来想通了一个一百多个平台的分发系统最重要的能力不是每个平台都完美支持而是任何时候都知道哪些平台能用、哪些不能用、为什么不能用。透明度比覆盖率更值钱。
阅读完成 · 觉得有帮助?