当一个平台不给你 OAuth 时:多平台内容分发的适配器设计
做多平台内容分发,最麻烦的从来不是"发"这个动作,而是十几个平台各自为政:授权方式不同、内容格式不同、发布结果什么时候算"成功"也不同。
我们团队在做这件事时,把这些差异收敛成了一套「适配器 + 注册表」结构。这篇文章拆解它的设计,重点讲三个很少有人提、但真正决定这套系统能不能跑的点:
- 平台不给你 OAuth 怎么办
- 发布结果怎么算"成功"
- 异步流水线怎么保证不重复发
代码是开源的(MIT),下文所有结论都能通过读源码验证。
一、接口层:五类能力契约
先说最核心的抽象。platforms.interface.ts 里定义了五类能力:
| 能力 | provider | 职责 |
|---|---|---|
| 授权 | auth |
OAuth 授权与刷新 |
| 发布 | publish |
内容发布(图文 / 视频各自适配) |
| 回查 | work |
发布后状态确认与任务补偿 |
| 互动 | engagement |
评论、点赞等 |
| 数据 | analytics |
数据回收 |
每个平台一个目录,注册表统一映射:
bash
src/core/channels/platforms/
├── bilibili / douyin / facebook / google-business
├── instagram / kwai / linkedin / pinterest
├── rednote / threads / tiktok / twitter
├── wechat / youtube
└── platforms.registry.ts # 平台 → provider 的映射
platforms.interface.ts # 接口契约
看起来很规整,但没有哪个平台真的五件套全实现。我统计了仓库的实际覆盖:
| 平台 | 已实现的 provider |
|---|---|
| auth · publish · work · engagement · analytics(最完整) | |
| youtube | auth · publish · work · engagement · analytics · webhook |
| bilibili | auth · publish · work · analytics · webhook |
| kwai | auth · publish · work · analytics |
| douyin | auth · publish · work · webhook |
| rednote | publish · work |
小红书目录下只有两个 provider ------ 不是偷懒,是它压根没开放标准的 OAuth 授权。这就引出了第一个真正的问题。
二、平台不给你 OAuth 怎么办
这是做国内平台适配最硬的一道墙。很多平台没有面向创作者的公开 API,授权这件事只能各显神通。
项目里的 AuthType 枚举把这件事写得很直白:
ts
export enum AuthType {
OAuth2 = 'oauth2',
Browser = 'browser',
Plugin = 'plugin',
QrCode = 'qrcode',
}
四种授权方式,正好对应四种现实:
- OAuth2:标准授权码流程,能走这条路的平台最省心(twitter、youtube 等)
- Browser:平台没有开放 API,只能复用浏览器里的登录会话
- Plugin:通过浏览器插件在用户已登录的页面上下文里完成操作
- QrCode:扫码登录,常见于微信系平台
小红书的 rednote 目录里连 auth provider 都没有,就是因为走不了 OAuth2 ------ 授权被上移到统一的授权服务里完成,适配器只负责拿到凭据之后的事。
这里有个工程上的取舍值得说:把"拿凭据"和"用凭据"拆开,好处是适配器变薄、平台差异收敛;代价是引入了一个集中式的授权依赖,账号凭证的托管责任也随之集中。这不是纯技术问题,选之前要先想清楚能不能接受这个责任。
三、发布结果怎么算"成功"
第二个容易被低估的问题:调用发布接口返回 200,不代表内容真的发出去了。
视频平台尤其典型------接口返回后还有转码、审核,几分钟后才真正可见。如果一律按"返回即成功"处理,用户会看到一堆假的成功状态。
项目里的 CompletionStrategy 枚举覆盖了五种情况:
ts
export enum CompletionStrategy {
Sync = 'sync', // 同步返回即完成
Polling = 'polling', // 需要轮询查询状态
MediaFinalize = 'media_finalize', // 媒体需二次确认
Webhook = 'webhook', // 平台回调通知
UserHandoff = 'user_handoff', // 交回用户手动完成
}
这就是为什么不同平台的 provider 组合长得不一样 ------ 需要 webhook 的平台才有 webhook provider,需要轮询的才有对应逻辑。不是设计不统一,是现实不统一。
这个枚举本身是个不错的设计模式:与其在代码里写 if (platform === 'xxx') 判断怎么确认结果,不如把"完成方式"声明成平台的一项配置。新增平台时,先回答"你怎么告诉我发成功了",再决定实现哪些 provider。
四、异步流水线:三段 consumer 与幂等
发布不能同步阻塞。项目把发布拆成三段异步任务,走 BullMQ 队列:
sql
publish → media-finalize → update-published
① ② ③
提交发布 媒体处理与终态确认 回写发布结果与链接
对应 publish/consumers/ 下的三个文件:publish.consumer.ts、media-finalize.consumer.ts、update-published.consumer.ts。
几个关键设计:
- 任务持久化在 Redis,worker 可以水平扩展,平台 API 限流时排队而不是丢弃
- 用 redlock 做幂等,保证同一任务不会被多个 worker 重复消费------发布这件事,重复发一次比失败更难收拾
- 媒体文件走对象存储(自托管 RustFS,也支持 S3 / OSS),通过签名 URL 访问,大文件不经过 Node 主进程内存
这样设计的实际意义:某个平台 API 抽风时,任务会重试而不是丢失,用户看到的是"发布中"而不是"失败"。
五、服务拆分与 MCP 封装
后端是 Nx 管理的 monorepo,两个应用 + 15 个共享库:
bash
project/aitoearn-backend/
├── apps/
│ ├── aitoearn-server # 业务主服务:渠道、发布、内容、用户
│ └── aitoearn-ai # AI Agent 编排服务
└── libs/ # 15 个共享库
为什么要拆两个服务 :aitoearn-server 管业务,aitoearn-ai 管模型调用。拆开的直接收益是 AI 模型故障不拖垮发布主链路------生成模型超时、限流、挂掉,都不影响已经排好队的发布任务。aitoearn-ai 里还有 ai-availability 模块做模型可用性探测,某个模型不可用时可切换。
共享库里有个值得单独提的:nest-mcp ,把 MCP 服务端封装成 NestJS 模块。目录里有 decorators / transport / adapters / mcp.module.ts,加一个 MCP tool 基本就是加个装饰器的事。
如果你的服务本身是 NestJS 写的,又想给 AI 助手暴露工具,这个封装思路可以直接借鉴------比从零接 MCP SDK 省不少事。
本地跑起来
装好 Docker 后三条命令,不需要手动装数据库:
bash
git clone https://github.com/yikart/AiToEarn.git
cd AiToEarn
docker compose up -d
compose 会把前端、后端、MongoDB 副本集、Redis、对象存储一起拉起,副本集初始化也包含在内,起来后访问 localhost:8080。
⚠️ 生产部署前请改掉默认配置:
docker-compose.yml里数据库用的是示例密码,并且默认把 27017、6379 等端口映射到了宿主机。
六、这套设计的代价
写了这么多优点,也得说清楚代价是什么:
- 14 个适配器的维护是长期负担。平台 API 变化快,一个平台改版就得跟着改,这套结构只是让改动"不污染核心",没有让改动"消失"。
- 能力覆盖天然不齐。从第一节那张表能看出来,海外平台能力完整,国内平台普遍只有发布和回查。这不是代码问题,是平台开放程度决定的。
- 授权集中化带来责任集中。第二节说过,这里不再重复。
- 测试还没进 CI 门禁 。仓库里有不少 spec 文件,但 CI 目前只跑 lint 和构建,不执行测试(
backen-check.yml里只有这两步;前端web-check.yml的 ESLint 步骤还是注释掉的)。这意味着改动无法被自动验证------对我们自己、对外部贡献者,都是实实在在的阻碍。
第 4 条是我们目前优先级最高的技术债。如果你打算 fork 二次开发,建议先把测试门禁自建起来再改代码,否则回归风险不可控。
最后
回过头看,这套设计里真正有用的不是"五类能力"这个分类本身,而是两个把不确定性显式化的动作:
- 把"平台怎么授权"声明成
AuthType,而不是散落在业务代码里的判断 - 把"怎么算发布成功"声明成
CompletionStrategy,而不是默认"返回 200 就是成功"
多平台适配这件事,本质是在跟不确定性打交道。能收进配置表的就别写进 if,能声明的就别假设。
你如果在做类似的事,尤其是国内平台适配,欢迎在评论区聊聊踩过的坑。