DDD 实战(五):账号、组织、关系与群组的边界划分
海外 AI 服务代充推荐:BeWild(推广链接)。
上一篇《协同办公 IM 的领域全景》画的是整张地图,这一篇钻进最容易吵架的那一格。
账号、组织、联系人、群成员都和“人”有关,于是很容易被放进一个用户中心。早期这样做未必有问题——一张 user 表加几个字段,登录、通讯录、好友、群成员全都能查,改起来也快。真正需要重新划边界的时刻,通常是一次看似简单的修改开始牵动不相干的规则。
比如成员离职:要取消他在企业里的身份,却不一定要注销账号;要回收企业群访问权,却未必解除私人好友关系。若数据库里只有一个 user.status,这些不同的后果就只能靠各处的条件判断补出来。
这一篇比较三种组织方式,也顺便说明边界是怎么一步步收紧的。它们都是设计选项,不是飞书或钉钉内部实现的描述。
先把几个身份分清
| 概念 | 它记录的事实 | 典型变化 |
|---|---|---|
| Account | 登录主体、凭证及账号状态 | 更换凭证、冻结登录、注销 |
| Member | 账号在某个组织里的身份 | 入职、调岗、离职 |
| Contact / Friend | 某一方的联系人记录或双方关系 | 备注、申请、确认、解除、拉黑 |
| ExternalContact | 产品定义的外部联系人身份与归属 | 客户分配、资料更新、跟进权限变化 |
| GroupMember | 某个主体在某个群里的成员资格 | 入群、退群、角色调整、禁言 |
一个人可以同时对应这些模型。它们通过 ID 关联,不表示必须共用一个对象。
这三组概念两两之间都不能划等号:Account 不等于 Member,Member 不等于 Contact,Contact 不等于 GroupMember。每一条“不等于”背后都有具体场景撑着——一个人可以是某家企业的成员却不在任何一个群里,可以是某人的好友却不属于同一家企业,也可以是群成员却根本没有企业成员身份(比如外部协作方)。
其中 ExternalContact 尤其要先问清产品定义。有的系统把它当作外部账号的联系人视图,有的系统允许录入没有账号的客户。后者不能强行依赖 Account 才能存在。
方案一:先留在用户中心
产品只有登录、基础通讯录和简单好友关系时,可以在同一模块下组织代码:
internal/user/
├── account.go
├── member.go
├── contact.go
├── friendship.go
└── group_member.go
它的好处很直接:一次查询容易组合,本地事务容易使用,也不需要维护跨服务协议。团队可以把时间花在尚未稳定的业务上。
但“留在同一模块”不等于“所有字段放进同一个 User”。即使暂时不拆包,也可以让账号、组织成员和好友关系有不同的修改入口。否则,导入通讯录的人可能顺手改了账号资料,处理好友请求的人又得了解企业成员状态。
是否继续保留用户中心,笔者会看两件事:这些规则是否经常一起变,修改入口是否仍然能解释清楚。如果已经出现独立的成员生命周期和复杂关系权限,再把它们塞进一个 Service,省下的只是目录数量。
方案二:按事实所有权划分上下文
对于前文讨论的企业协作 IM,可以先采用下面的边界。它们可以部署在同一个进程里。
| 上下文 | 拥有的事实 | 需要引用、但不负责修改的事实 |
|---|---|---|
| Identity & Access | 账号、凭证、设备、登录会话 | 企业成员身份 |
| Tenant & Organization | 租户、部门、成员、组织角色 | 登录凭证、私人好友关系 |
| Contact & Relationship | 申请、好友、拉黑、备注、关系权限 | 账号与组织成员主数据 |
| Group & Conversation | 群资料、成员资格、群角色、群治理 | 消息正文、组织架构 |
以“修改姓名”为例,先要区分账号昵称、企业通讯录姓名和群昵称。三者可以有默认继承关系,但覆盖规则和修改权限未必相同。直接同步成一个字段,反而会丢失这种差异。
拆开之后,组合查询会变多。一个联系人页面可能同时需要组织资料、个人备注和在线状态。可以由查询接口组合,也可以维护专门的读模型;不需要为了让页面一次取完数据,就把事实的写入口重新合并。
方案三:抽象成关系图
另一种思路是用统一关系模型表达“主体之间的关系”:
Subject --[relation_type]--> Subject
belongs_to_department reports_to friend_with
blocks member_of_group
admin_of_group external_contact_of
同一个存储结构可以记录好友、关注、拉黑、组织隶属或群成员关系——上面这七种关系在这个模型里长得一模一样。对于多种关系的遍历查询,这种表达有价值。
问题是,边的形状相似,不代表建立和删除它们的规则相同。好友可能要双方确认,拉黑通常是单向操作,部门隶属受组织管理员控制,群主转让还要保护群内不变量。把它们全部交给一个通用 AddRelation,调用方仍得在外面补齐业务判断。
所以笔者更愿意把关系图当作存储或查询手段,而不是默认的业务边界。Organization 和 Group 可以继续拥有各自的规则,再把允许传播的关系投影到图里。只有产品本身就围绕统一关系规则运行时,才值得进一步抽象。
用生命周期检查 Account 和 Member
假设一个账号加入企业 A 和 B。它从 A 离职后:
- Account 仍然存在,能否继续登录由账号规则决定。
- A 下的 Member 失效,企业访问资格随之收回。
- B 下的 Member 不应因此改变。
- A 的历史消息、审计记录是否保留,要按留存与访问规则处理,不能直接级联删除。
这说明账号和组织成员有各自的生命周期。离职只是其中一种变化,另外四种同样值得并排看:
- 成员被禁用,但账号仍能登录个人空间。
- 转岗,只改组织上下文里的部门和角色。
- 账号注销,组织里的成员记录要按留存规则单独处理。
- 设备异常,只影响身份上下文里的登录会话和安全策略。
放在一起看,判据就清楚了:每种变化都只应该搅动一个上下文。如果任何一条都要同时改四五处,说明模型还没真正分开。
但拆成两个模型以后,还需要在请求中明确当前租户和当前成员身份。认证成功只能证明“是谁”,不能证明“在这家企业有权做什么”。
组织撤权也不能只依赖一条不受时限约束的异步事件。关键入口需要约定缓存有效期、复核方式和失败策略。校验与写入之间仍可能发生并发撤权,是否允许已经受理的请求继续完成,也应写进规则。
通讯录和好友列表可以一起展示,不能混成一份事实
组织通讯录来自企业管理关系。好友列表来自个人或双方建立的联系关系。前者可能受部门可见性约束,后者可能涉及备注、隐私和跨企业联系。
通讯录里的成员事实通常有三条来源:管理员手工维护、HR 系统同步、组织架构同步流程。来源一多,规则就得先写清楚——同步过来的部门被人手工改过之后,下一次同步要不要覆盖回去?这类问题既不属于账号,也不属于群组,只能由组织上下文回答。部门可见性和字段脱敏规则同理,它们决定的是“谁能看到这条成员事实”,而不是“谁拥有它”。
“把同事加入联系人”因此可以只是建立一个个人视图,不必复制组织成员。展示时组合组织资料与个人备注;成员离职后,再按规则决定隐藏资料、保留联系入口还是解除关系。
这里最容易遗漏的是关系两端的身份。跨企业好友可能关联 Account,企业客户关系可能关联 Member 或 ExternalContact。两个没有解释的 user_id,会把这个决定推迟到每次业务判断里。
群组和消息也要分别问规则
群组决定谁能加入、谁能管理、是否允许发言。消息记录发送内容、撤回状态和投递相关事实。群里消息再多,也不该因此让每次修改群公告都加载消息集合。
会话是另一层概念:它为通信提供标识和入口。置顶、草稿、免打扰通常属于每个参与者的会话设置,不是所有群成员共享的群状态。这里同样有两条“不等于”:Group 不等于 Conversation,一个群可以对应不同的会话形态,单聊也有会话;Conversation 也不等于 Message,前者是入口和标识,后者才是事实。
早期可以把 Group 与 Conversation 放在同一个模块。等会话列表、多端同步和个人设置明显独立变化,再考虑拆分。模块名称不是重点,重要的是群成员资格、个人设置和消息内容分别由谁修改。
什么时候值得拆出去
笔者会把下面这些现象当作拆分信号,而不是等代码行数达到某个阈值:
- 好友申请、外部联系人和关系权限已有独立的状态变化。
- 组织规则的修改频繁影响登录或聊天发布。
- 多个入口在直接改同一份数据,规则难以集中验证。
- 某块能力确实需要独立扩容、发布或故障隔离。
- 某些字段只服务少数几个场景,却被所有入口读写。
- 团队职责和代码边界对不上,改一处要找两个组。
- 一个模块出故障,影响范围远超它的业务职责。
后三条不像前四条那么显眼,但往往来得更早。
回看这一篇讨论的两个边界,都是这样一步步收紧的。好友关系最初只是账号上的一个双向字段,直到好友申请、外部联系人、客户关系、黑名单和跨企业协作陆续出现,才独立成关系上下文。群组最初只是消息的投递目标,直到群治理、群机器人、超大群、群权限、群文件和群公告一件件加上来,才从消息里拆出去。
这两次调整都发生在部署变化之前:先有包边界和写入口,再谈要不要拆服务。模型边界要比部署边界先出现。反过来做,通常只是把一团乱麻切成几团,中间还得靠网络调用重新连回去。
如果目前只有简单登录和通讯录,先保留一个部署单元即可。若已经有前面这些压力,可以先建立 Identity & Access、Tenant & Organization、Contact & Relationship、Group & Conversation、Messaging 的包边界与写入口,再决定哪些值得承担跨服务成本。
接下来打算沿着一次消息发送,看通知、搜索和开放平台怎样参与,又不把所有后续动作塞进发送接口。