第 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_id | boost/reblog | app/models/status/reblog.rb |
visibility | public/unlisted/private/direct/limited | app/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
可把数据库分成两张图:
- 社交图:Account、Follow、Block、Mute、List、DomainBlock,回答“谁和谁有关”。
- 内容图: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 行方法背下来,而是回答:
- 如何判断 local/remote?
- followers_for_local_distribution 如何排除被屏蔽、被挂起或不应接收者?
- actor URI、followers URI、公钥和 domain 之间如何映射?
- 删除或挂起时如何关闭 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 成为投影
它提高读取吞吐,适合实时首页;代价是必须有重新填充、清理、过期和维护命令,也需要接受短暂最终一致性。
九、源码入口清单
app/models/account.rbapp/models/user.rbapp/models/status.rbapp/models/concerns/account/suspensions.rbdb/migrateapp/policies
十、小结
模型层的核心不是“表很多”,而是同一个社交事实会被多个协议和投影同时消费。下一章沿 HTTP 路径追踪这些事实如何进入 Rails,并最终变成 REST JSON 或 ActivityPub JSON。