DDD 实战(六):消息之后的通知、搜索与开放平台
海外 AI 服务代充推荐:BeWild(推广链接)。
上两篇《协同办公 IM 的领域全景》和《账号、组织、关系与群组的边界划分》把上下文一个个划开了,这篇要处理它们之间的接线。
边界划好之后,最容易走的回头路是把所有协作都做成同步调用:发送消息的时候顺手更新索引、顺手推进送、顺手通知机器人。在群里发一条消息,服务端实际要做的事大概是这些:写入消息、更新会话列表、算未读数、多端同步、离线推送、更新搜索索引、触发机器人、投递开放平台事件、记审计日志、跑风控、生成 AI 摘要。
把这些依次写进发送接口,很快就会遇到一个问题:搜索服务慢了,用户为什么也发不出消息?
所以这里要先定清楚「发送成功」承诺了什么。本文采用的约定是:服务端已经接受并可靠保存了这条消息,不代表所有终端都收到了,更不代表接收人已读。这个边界定下来,后面哪些动作能异步、哪些不能,判断起来就有依据了。
飞书、钉钉的公开文档能确认消息、机器人、通讯录、审批、事件订阅这些对外能力,但看不到内部事务和服务是怎么划的。下面是基于这些能力需求推导的一种设计,不是厂商架构复原。飞书发送消息 API、飞书开放平台、钉钉开放平台
发送链路先保护哪些事实
一次发送可以按这个顺序处理:
- 从可信登录态取得操作者,确认租户与成员身份。
- 校验会话访问权、群发言权限,以及必须前置的治理策略——敏感词、频率限制、内容大小。
- 检查客户端消息 ID,识别重复提交。
- 在同一事务里保存消息与待发布事件。
- 返回服务端消息 ID 和受理结果。
第 3 步的幂等范围要写明确,比如「租户 + 发送者 + 客户端消息 ID」这个组合。同一个键再次提交时,还要校验请求内容是否一致——内容不同说明不是重试,而是客户端出了问题,这时返回上一条结果反而是错的。不能只在内存里查一次,然后指望客户端「通常不会重复」。
权限检查还有时间边界。同步调用一次 CanSend 只能证明检查的那一刻允许发送;调用返回之后,这个人可能已经被移出群了。如果业务要求撤权和发送之间有严格顺序,就得引入版本号、栅栏或者可串行化的协调机制,一次 RPC 不是跨服务事务。
图中的事务框是持久化边界,不代表这些规则都归 Messaging 所有。必要的审计记录如果要求与消息同时成立,也应该纳入可靠提交;审计报表则可以事后生成。
事件需要足够的信息,但不该携带所有内容
MessageSent 该带哪些字段,取决于谁消费它,而不是把数据库行直接序列化出去。通常包括事件 ID、发生时间、租户 ID、会话 ID、消息 ID、发送者 ID、内容类型和消息版本。
正文本身一般不放进去,改用 content_digest 表示内容指纹;visibility_scope 则说明这条事件能扩散到多大范围,是只给会话内消费,还是允许开放平台取用。事件设计得越像数据库行复制,边界越容易被破坏。
正文、附件地址和权限信息更敏感。推送可能只需要一句摘要,搜索需要可索引的内容,外部应用却只能看到授权范围内的数据。用一个「全量消息事件」同时喂给这三种消费者,迟早会出事。更稳的做法是发布最少字段,让消费者通过受控接口取自己需要的内容,或者按权限拆成不同契约。
Outbox 负责把业务提交和待发记录放在一起,但后台发布仍然可能重复,消费者要把去重和自己的业务写入放进合适的事务边界。还有一个顺序问题:消息撤回事件先于发送事件到达时,索引端不能再用旧事件把内容补回来——消息版本或者每实体序号可以帮助拒绝过期更新。
通知决定怎么提醒,不决定消息是否成立
Notification 拥有通知偏好、渠道、频控、推送订阅和投递任务。它要覆盖的渠道至少有站内通知、移动端 Push、桌面端提醒、邮件、短信和应用内红点六种,每一种的重试语义和失败后果都不一样。消息受理之后,它根据在线状态、免打扰和提醒规则决定要不要推送。
它消费的也不只是 MessageSent。GroupMemberJoined、ApprovalCompleted、DocumentShared、OrganizationMemberRemoved 这类事件同样要处理,把业务事实转成用户能感知的提醒。
一次推送失败,不应该把消息改成发送失败。通知侧要记录渠道响应、重试次数和最终状态。移动推送服务接受了请求,也不等于终端一定展示了它,更不能据此判断用户已读——「推送成功」「送达」「已读」是三个不同的结论。
已读游标最好还是由消息或会话模型维护。跨应用的红点可以由通知侧聚合,但要说明它只是提醒视图:「清掉一个红点」不能顺手把所有消息的已读状态都改掉。这里值得把三件事分开:会话内的读状态和消息游标属于消息或会话模型,跨应用红点和桌面提醒状态属于通知,页面上那个总未读数则是一个查询侧的组合视图。别为了一个 UI 数字,把三个模型揉成一个。
搜索允许索引延迟,不意味着权限也能随便延迟
Search 保存的是可重建的查询视图。用户希望一次搜到联系人、群、消息、文档、应用、审批和机器人,于是很多系统会让搜索服务直接读各业务库,甚至反写业务状态——这样一来搜索就成了一个隐形中心。更稳的做法是各上下文发布事件、搜索自己建索引,而且索引里的权限过滤必须继承来源上下文的规则。消息新增、编辑、撤回后它按事件更新索引;失败可以重试,必要时从事实源重建。
「新消息晚几秒才能搜到」是这类设计的正常代价。但「离职后仍能搜到原企业消息」「撤回后搜索摘要还显示正文」是内容泄露,不能用「最终一致」四个字带过去。这两件事经常被混为一谈,因为它们在实现上都表现为「索引落后于事实源」。
如果产品要求撤权立即生效,搜索结果就必须在返回正文和摘要之前复核权限、过滤失效内容,或者采用能满足这个要求的索引与授权协调方案。只在点击详情时才鉴权是不够的——摘要本身已经泄露了信息。权限服务不可用时怎么处理,也要提前定好。
重建索引还需要可观测的进度、失败记录和追赶增量的方式。能收 MQ 消息,只是完成了索引维护的一部分。
开放平台要维护自己的对外契约
Open Platform 不只是把内部 API 转发出去。应用注册、API 凭证、OAuth 授权、权限范围、机器人、Webhook、事件订阅、API 配额、开发者审计和回调重试,这十项规则都归它维护。
内部模型会随业务变化,对外协议却要兼容已经接入的应用,这两件事的节奏不一样,所以中间需要一层转换:
图中的 message.created.v1 是示例契约名,不对应某个厂商的真实内部事件。转换层要做的是租户隔离、应用权限、字段脱敏、事件版本、回调签名、失败重试和频率限制,不是简单改个名字。这就是防腐层的价值:既挡住外部 API 形态反向污染内部模型,也挡住内部模型变化直接打碎开发者生态。
对外回调还需要签名、重放防护、重试、幂等标识和失败查询能力。订阅者自己的故障应该留在回调任务里处理,不能无限拖住内部消息消费。另外,重试期间应用可能被撤销授权,投递前要不要重新检查权限,也得有明确规则。
机器人为什么会跨几个上下文
机器人的应用身份、安装与授权属于开放平台;它在群里有没有成员资格、能不能发言,属于群组规则;它发送的内容和消息状态,仍然属于消息模型。落到代码上,Bot、AppCredential、WebhookSubscription 这些概念归开放平台,群里的那层成员关系归群组,消息本身归消息模型。
所以「机器人发送消息」可以复用同一个发送用例,但操作者类型和权限来源不一样。这里有两种偷懒的做法都要避免:为了复用普通用户接口,伪造一个用户身份绕过应用授权;或者觉得模型不同,另建一套完全独立的消息存储。前者是权限漏洞,后者是重复建设。
哪些动作适合异步,哪些不适合
笔者判断的标准是业务承诺和失败后果,不是「这个操作重不重」。同一个动作,用户完全不介意它晚三秒,和用户会立刻刷新页面看结果,处理方式必须不同。
必须在发送成立前满足的约束——权限、余额、配额——不能只发个事件让下游事后检查。下游检查出问题的时候,消息已经存进去了,用户已经看到了。反过来,允许延迟、能够重试、失败也不会推翻发送事实的派生动作,才适合移出主链路。
这个 IM 里必须留在同步路径上的约束至少有五条:同一消息 ID 的幂等写入、群成员发送资格、群主唯一性、撤回权限、账号禁用之后的关键操作拦截。它们要么在发送前校验完,要么在同一事务里保证,指望消费者事后发现就已经晚了。
反过来看,下面四类不适合事件驱动:必须在当前请求内给出确定结果的;必须强一致校验的业务不变量;失败之后既不能重试也不能补偿的;以及只是拿事件绕开一个本该讲清楚的接口的。最后一种最常见——把方法调用改名叫事件,复杂度一点没少,反而更难追踪。
异步化以后,运维上至少要能回答四个问题:哪些事件还没交出去,哪个消费者落后了,失败任务能不能重放,重放会不会重复推送或者覆盖更新的状态。
把这几个问题回答清楚,通知和搜索出故障时才有机会把影响留在各自的范围内。回答不清楚,就只是把「接口超时」换成了「无人发现的后台丢任务」——后者更难排查,因为用户不会报错。