第 2 章:领域模型与数据边界

一、Account 不等于 User

这是阅读 Mastodon 模型时最重要的第一道门槛:

  • Account 是联邦身份和社交对象,拥有用户名、域名、公钥、头像、简介、状态和 followers/following 关系。
  • User 是本实例的登录主体,承载密码、邮箱、偏好、角色、确认状态、登录安全和 OAuth 关联。

本地用户通常同时拥有一个 User 和一个本地 Account;远端账户只存在 Account,没有本实例可登录的 User。因此,任何“这个账户能不能登录”的问题都不能只看 Account

User (local authentication)
  └─ has_one Account (federated actor)

Account (local or remote actor)
  ├─ has_many Status
  ├─ has_many Follow / FollowRequest
  ├─ has_many Notification
  └─ has_one/has_many ActivityPub delivery state

二、Status 是社交事实,不是简单帖子表

Status 代表一个状态或转发,但它还连接了很多语义:

关系/字段含义阅读入口
account作者app/models/status.rb
in_reply_to_id回复树app/models/status/threading.rb
reblog_of_idboost/reblogapp/models/status/reblog.rb
visibilitypublic/unlisted/private/direct/limitedapp/models/status/visibility.rb
media_attachments图片、视频、音频app/models/media_attachment.rb
mentions提及关系与通知收件人app/models/mention.rb
tags标签索引与标签流app/models/tag.rb
poll投票选项、投票记录、过期任务app/models/poll.rb
quote引用状态的访问策略app/models/quote.rb

这解释了为什么“发帖”不是 Status.create! 一行代码:创建状态会同时建立内容解析、权限校验、媒体绑定、提及、标签、缓存、时间线、通知和联邦副作用。

三、关系模型:社交图与内容图

erDiagram
  USER ||--|| ACCOUNT : authenticates
  ACCOUNT ||--o{ STATUS : authors
  ACCOUNT ||--o{ FOLLOW : follows
  ACCOUNT ||--o{ NOTIFICATION : receives
  STATUS ||--o{ MENTION : contains
  ACCOUNT ||--o{ MENTION : is_mentioned
  STATUS ||--o{ MEDIA_ATTACHMENT : attaches
  STATUS }o--o{ TAG : indexes
  STATUS ||--o| POLL : has
  POLL ||--o{ POLL_VOTE : receives
  ACCOUNT ||--o{ POLL_VOTE : casts

可把数据库分成两张图:

  1. 社交图:Account、Follow、Block、Mute、List、DomainBlock,回答“谁和谁有关”。
  2. 内容图:Status、MediaAttachment、Mention、Tag、Poll、Quote,回答“发布了什么以及如何被引用”。

Feed 和通知是这两张图的投影:它们不是新的社交事实,而是为了读性能和用户体验生成的派生结果。

四、可见性是跨层不变量

可见性不能只在 Controller 里判断,因为同一个状态会被:

  • 网页详情页渲染;
  • REST serializer 输出;
  • ActivityPub serializer 投递;
  • 本地 Feed 插入;
  • 搜索与趋势索引;
  • 通知和 streaming payload 使用。

因此要同时观察:

  • Status 的 visibility scope 和 predicate;
  • StatusPolicy / account policy;
  • serializer 的字段裁剪;
  • FeedManager 的收件人筛选;
  • ActivityPub 的 audience 与 to / cc
  • 远端对象是否能被 dereference。

一个常见的错误推理是“数据库里查到了,就可以返回给当前用户”。正确的顺序是:先确定状态存在,再基于 viewer、作者关系、阻断/静音、状态可见性和联邦来源做 policy/filter。

五、迁移文件是第二份领域文档

模型文件描述现在的关系,db/migrate 描述这些关系如何随产品演进。531 个迁移文件看起来很多,但可以用几个主题分组:

1. 身份与权限

用户确认、角色、OAuth application/access token、登录安全、二次验证和账户锁定。

2. 社交关系

关注请求、关注状态、静音、屏蔽、列表、域名屏蔽、标签关注和推荐关系。

3. 内容与媒体

状态线程、reblog、编辑版本、媒体附件、预览卡、poll、emoji、引用状态和内容警告。

4. 联邦与搜索

远端 URI、活动对象、delivery 状态、collection、全文搜索字段、Chewy index 需要的关联字段。

读迁移时重点找四类信号:唯一索引、部分索引、外键/删除策略、字段默认值。它们往往比注释更明确地表达“系统允许什么”。

六、缓存与数据库一致性

Mastodon 经常采用“先写事实,再异步投影”的模式:

事务提交
  ├─ PostgreSQL: Status / Mention / Media 事实
  └─ after-commit 或 service 后续动作
       ├─ Redis feed
       ├─ streaming event
       ├─ notification
       └─ ActivityPub delivery

这带来两个可接受的中间态:

  • 状态页面已经能打开,但 follower 首页还没出现;
  • 本地状态已发送,但远端实例稍后才看到。

不能接受的中间态则由事务、幂等键或权限再次检查保护,例如媒体未处理完成不能直接发布、私密状态不能因为 fan-out race 被陌生人看到。

七、两个非常值得精读的模型

Account

精读目标不是把 300 行方法背下来,而是回答:

  1. 如何判断 local/remote?
  2. followers_for_local_distribution 如何排除被屏蔽、被挂起或不应接收者?
  3. actor URI、followers URI、公钥和 domain 之间如何映射?
  4. 删除或挂起时如何关闭 streaming session 并清理联邦关系?

Status

精读目标是建立“内容生命周期”:

build → validate → save → attach relations → render/cache
  → local feed → notification → remote ActivityPub
  → edit/delete → cleanup/vacuum

Status 的 concerns 值得按主题拆读:可见性、线程、转发、编辑、媒体、投票、引用、索引与清理。不要只顺着文件从上读到下。

八、设计取舍

把远端用户放进 Account 而不是 User

好处是本地和远端社交图可以共用 follower、status、notification 关系;登录只作为本实例的附加能力。代价是开发者必须始终区分“社交身份”和“本地身份”。

用关系表表达 Mention / Follow

关系表让撤销关注、屏蔽、批量查询和审计都更清晰,也支持唯一约束和索引;代价是发帖和删除会触发很多关联写入,必须依赖 service 和 worker 编排。

让 Feed 成为投影

它提高读取吞吐,适合实时首页;代价是必须有重新填充、清理、过期和维护命令,也需要接受短暂最终一致性。

九、源码入口清单

十、小结

模型层的核心不是“表很多”,而是同一个社交事实会被多个协议和投影同时消费。下一章沿 HTTP 路径追踪这些事实如何进入 Rails,并最终变成 REST JSON 或 ActivityPub JSON。