社交平台的内容系统设计:统一建模帖子、回复、引用与转发

社交产品最容易被低估的模块,往往是“发一条帖子”。在原型阶段,一张 posts 表似乎已经足够:作者、正文、创建时间,再加一个可空的 parent_id,回复和转发就都有了。但当产品继续生长,问题会成倍出现:

  • 回复的是根帖子,还是某条直接父评论?
  • 转发卡片顶部显示转发者,还是原作者?
  • 引用和纯转发能否共用一套计数与权限?
  • 原内容被删除或转为私密后,历史对话是否还能理解?
  • 同一内容出现在首页、搜索、收藏夹和通知中时,谁负责保证展示一致?
  • 媒体尚未处理完成时,发布动作是失败、等待,还是先发文本?

这些并不是 UI 文案问题,而是领域模型没有把“内容本体、关系语义和展示上下文”分开。本文给出一套可实施的设计:使用统一的 Post 聚合承载可独立展示、可计数、可检索的内容节点;使用明确的关系字段表达回复、引用和转发;以 PostgreSQL 保存权威事实,以事件驱动方式构建 MongoDB 读模型,并在读取时执行最终权限门禁。

本文讨论的是可迁移的设计方法。示例技术栈用于说明边界、约束和恢复能力,不构成对数据库或消息系统的强制选择。

适用范围与规模假设

本文面向支持公开与受限内容、嵌套回复、引用、转发、媒体和动态权限的社交产品。假设写入需要事务一致性,首页和详情属于高频读取,并允许读模型存在秒级最终一致。若产品只有单层留言、没有独立评论详情,也不需要搜索和传播评论,可以采用更轻量的独立 Comment 表,不必完整套用统一 Post 聚合。

一、先确定设计目标与非目标

内容系统至少要满足六个目标。

  1. 语义无歧义:看到一条记录即可判断它是原创、回复、引用还是转发,并知道直接目标和对话根节点。
  2. 作者身份正确:卡片主作者始终是“执行当前动作的人”;被引用或被转发内容作为嵌套来源展示,不能覆盖动作主体。
  3. 关系可恢复:父内容删除、隐藏或权限收紧时,关系仍然存在,并能输出稳定占位,而不是把整条时间线截断。
  4. 写入强约束、读取高吞吐:关系合法性和计数在权威事务中确认,高频卡片读取走专门读模型。
  5. 重复消息安全:事件、重试和网络超时不能制造重复转发、重复计数或旧投影覆盖新投影。
  6. 可演进:以后增加投票、长文、直播卡片或社区帖子时,不需要推翻回复与转发的基础语义。

非目标同样重要:读模型不负责决定最终权限;消息总线不是数据库;“前端拼装正确”不能替代后端关系约束;所有场景也不必共享一个无限膨胀的 DTO。

二、统一 Post,并不等于把所有关系塞进一个 parent_id

推荐把可发布内容统一成四种类型:

type PostKind = "ORIGINAL" | "REPLY" | "QUOTE" | "REPOST";

type PostRelation = {
  replyToPostId: string | null;
  rootPostId: string | null;
  quoteOfPostId: string | null;
  repostOfPostId: string | null;
};

统一模型的价值是:四类内容都可以拥有稳定 ID、作者、生命周期、权限、媒体、话题、提及、计数和事件流。它们可以进入收藏、搜索、通知和个人主页,也可以作为其他关系的来源。

但统一模型必须搭配关系矩阵约束

kindreplyToPostIdrootPostIdquoteOfPostIdrepostOfPostId是否允许正文
ORIGINAL
REPLY必填必填
QUOTE必填是,通常必填
REPOST必填通常否

数据库约束与服务层校验都应落实这张矩阵,并禁止自引用。否则一条记录可能同时“回复 A、引用 B、转发 C”,下游永远无法确定该用哪种卡片、哪个计数和哪条权限规则。

为什么回复需要两个 ID

假设帖子 P1 下有评论 C1,用户又回复了 C1 形成 C2

  • C2.replyToPostId = C1:记录直接回复对象,用于显示“回复了 @handle”和构建局部对话。
  • C2.rootPostId = P1:记录整个对话所在的根帖子,用于详情页、计数、权限和分页。

只有 parent_id 时,查询根节点需要递归;只有 root_id 时,又丢失直接回复语义。两者同时保存是一种有意的冗余,但必须在事务内校验:直接父节点存在、已发布、可回复,且其规范根节点与 rootPostId 一致。

为什么转发者不能被来源作者覆盖

转发卡片实际上包含两层身份:

  • 动作主体:谁执行了转发,决定这条记录出现在谁的个人主页、谁可删除它、顶部显示“谁转发了”。
  • 内容来源:原帖作者、正文与媒体,作为嵌套来源卡片展示。

因此聚合 DTO 应显式区分:

type RepostCard = {
  postId: string;              // 转发记录 ID
  actor: UserCard;             // 转发者
  kind: "REPOST";
  source: {
    postId: string;
    author: UserCard;          // 原作者
    bodyText: string | null;
    media: MediaCard[];
  } | Tombstone;
};

如果适配器用 source.author 覆盖顶层 actor,就会出现“明明是我转发,卡片却像原作者自己发了一遍”的错误。模型层分开后,前端不需要猜。

三、权威写模型与卡片读模型分工

一种通用实现可以把 PostgreSQL 作为 owner truth,把 MongoDB 作为卡片投影,把 Redis 用于锁、幂等和热点缓存:

  • PostgreSQL:Post、关系字段、媒体绑定、提及、话题、生命周期、权限快照和 outbox。
  • Outbox + Kafka:在同一事务记录领域事实,提交后再可靠发布,避免“数据库成功但消息丢失”。
  • 投影 Worker:收到 PostPublishedPostDeleted 等事件后,不直接相信消息中的整份卡片,而是按 postId 回读权威数据。
  • MongoDB:保存适合时间线、搜索结果和详情页首屏读取的文档。
  • Redis:投影重建锁、列表缓存、幂等键及短期状态。

这一设计看似比“双写 PostgreSQL 和 MongoDB”多了一层,但它提供了关键恢复能力:投影可以删除后重建;事件可以重放;某次失败只造成短暂延迟,不会让两个数据库永久分叉。

一个可读的投影文档可以是:

{
  "schemaVersion": 2,
  "postId": "p_01J...",
  "authorUserId": "u_01J...",
  "kind": "REPLY",
  "bodyText": "我更关心失败恢复策略。",
  "replyToPostId": "p_parent",
  "rootPostId": "p_root",
  "quoteOfPostId": null,
  "repostOfPostId": null,
  "media": [],
  "mentions": [{ "userId": "u_target", "handle": "demo_yunshu" }],
  "hashtags": ["系统设计"],
  "visibility": "PUBLIC",
  "status": "PUBLISHED",
  "readVersion": "42",
  "publishedAt": "2026-08-07T10:00:00.000Z"
}

schemaVersion 解决投影结构演进,readVersion 解决乱序事件:MongoDB 只接受不旧于当前版本的更新。即使 PostPublished(v42)PostEdited(v43) 晚到,也不能用 v42 覆盖 v43。

四、发布不是一次 INSERT,而是一条状态机

一个包含媒体、提及和社区权限的帖子,发布流程可分为:

DRAFT
  └─ 请求发布 → VALIDATING
       ├─ 媒体未就绪 → WAITING_MEDIA
       ├─ 权限或关系失败 → PUBLISH_FAILED
       └─ 校验通过 → PUBLISHED
                            └─ 删除 → DELETED

发布服务应完成以下检查:

  1. 请求者是草稿所有者,幂等键未被其他语义占用。
  2. 正文、媒体或链接至少存在一项有效内容。
  3. kind 与关系矩阵一致,来源不是自身。
  4. 回复根帖、直接父回复、引用来源或转发来源仍然存在且当前可操作。
  5. 提及目标可被提及;社区上下文、可见性和评论/引用/转发权限有效。
  6. 所有绑定媒体已进入 READY;未就绪时进入等待发布,而不是生成半成品卡片。
  7. 在事务内写入生命周期变化、关系、必要计数和 outbox。

关键点是最终门禁。预检能尽快向用户返回友好错误,但从预检到事务提交之间,来源帖子可能被删除、可见性可能改变。因此事务内必须再次回读权威简报,核对作者、状态、可见性、社区和版本等稳定字段。预检用于体验,最终门禁用于正确性。

幂等键应该保护“业务动作”

客户端超时后通常会重试。服务端不能以“请求是否重复到达”作为判断,而应绑定:

(actorUserId, operation, idempotencyKey) -> semantic fingerprint + result

同一个 key、同一个语义返回首次结果;同一个 key、不同语义返回冲突。纯转发还可以对 (actorUserId, repostOfPostId, active) 建唯一约束,避免同一用户产生多个有效转发记录。

五、详情 DTO 应表达“可用、不可用和为什么不可用”

关联内容读取存在三种状态:

  1. ACTIVE:目标存在、对当前 viewer 可见,返回完整卡片。
  2. DELETED:目标已删除,返回“原内容已删除”的墓碑占位。
  3. HIDDEN / UNAVAILABLE:目标存在但当前用户无权查看,返回中性占位。

不要把后两种都映射成“用户不存在”。目标用户、目标帖子和直接父评论是三个不同实体,失败原因也完全不同。推荐类型:

type RelationView<T> =
  | { state: "ACTIVE"; card: T }
  | { state: "DELETED"; reason: "SOURCE_DELETED" }
  | { state: "HIDDEN"; reason: "NOT_VISIBLE" | "BLOCKED" };

回复详情应返回两段上下文:根帖卡片和当前回复卡片;如果回复的是评论,再额外返回直接父评论卡片。这样既能让读者知道“讨论发生在哪”,也能知道“这句话直接回应了谁”。顶栏文案应使用已解析的父作者 handle:回复了 @demo_yunshu。只有父对象确实不可用时才显示占位。

六、计数、删除与级联边界

评论数、引用数和转发数不是简单的 COUNT(*) 替代品。它们是高频读取的派生数据,应明确口径:

  • 根帖评论数是否包含所有层级回复?通常包含。
  • 一条评论的回复数是否只包含直接子回复?通常是。
  • 删除后是立即减计数,还是保留审计占位?产品需先定语义。
  • 重复事件和回放是否会重复增减?必须用事件 ID 或关系唯一键幂等。

删除推荐采用“生命周期删除 + 关系保留”:

  • 作者删除正文后,Post 进入 DELETED,正文和敏感媒体不再对外输出。
  • 回复树中的关系节点仍可返回墓碑,让子回复不失去上下文。
  • PostDeleted 事件触发投影删除或墓碑更新、缓存失效、搜索移除和信息流候选失效。
  • 不在同步请求中递归删除所有回复,否则大对话会拖垮事务,也会破坏其他作者的内容所有权。

七、接口与游标设计

接口按用途拆分,比“一个万能帖子接口”更稳定:

POST /api/posts                         创建或发布内容
GET  /api/posts/:postId                 获取详情及关系上下文
GET  /api/posts/:postId/replies         获取根帖回复分页
GET  /api/posts/:postId/quotes          获取引用列表
GET  /api/posts/:postId/reposts         获取转发者/转发列表
DELETE /api/posts/:postId               生命周期删除

回复分页使用不透明游标,至少编码排序时间、稳定 ID 和查询版本。服务端返回:

{
  "items": [],
  "nextCursor": "opaque-base64url-token",
  "hasMore": true,
  "snapshotAt": "2026-08-07T10:00:00.000Z"
}

游标不能只使用时间戳,同一毫秒内多条记录会重复或遗漏;也不应把数据库偏移量直接暴露给客户端,因为内容删除后偏移会漂移。

八、失败恢复与可观测性

内容系统最需要监控的不是“接口平均耗时”一个数字,而是每条链路的业务不变量。

风险保护措施观测指标
outbox 未发布publisher 重试、租约与死信outbox oldest age、retry count
投影乱序readVersion 条件更新stale ignored count
投影缺失按 postId 权威重建、批量回填PG/Mongo 差异率
缓存陈旧投影成功后失效;TTL 兜底cache stale sample
重复转发业务唯一约束、幂等结果缓存conflict/dedupe count
关系断裂事务内 root/parent 一致性校验relation invariant violations
来源不可见读取最终权限门禁hydration filtered count

恢复工具至少应支持:按单个 postId 重建、按时间窗口回填、全量重建新版本投影、比对权威表与读模型、隔离无法解析的坏事件。修复动作本身也要有审计记录,避免“为了回填”绕过正式链路。

九、测试应围绕不变量,而不是只测成功响应

建议建立四层测试:

  • 关系矩阵单元测试:四种 kind 的合法/非法字段组合、自引用、根与父不一致。
  • 事务集成测试:发布、重复请求、媒体等待、最终门禁漂移、计数与 outbox 原子性。
  • 投影契约测试:旧版本忽略、删除后移除、缺失权威记录、重建幂等。
  • 端到端展示测试:转发者与原作者不混淆;回复显示直接 handle;父评论被删时显示占位;卡片可进入正确详情。

尤其要写一个看似简单但极有价值的测试:REPOST 顶层作者必须等于动作执行者,嵌套来源作者必须等于原帖作者。这个断言能阻止适配器在重构时再次把两层身份合并。

十、方案权衡

统一 Post vs 独立 Comment 表:统一 Post 让回复天然获得搜索、收藏、媒体和通知能力,也简化卡片复用;代价是关系约束更严格、表规模更大。若评论永远只是轻量文本且不可独立传播,独立表会更简单;对现代社交产品,统一模型通常更有延展性。

同步组装 vs 异步投影:同步读取权威表一致性直观,但首页一张卡片会触发多表关联,吞吐和尾延迟难以控制;异步投影读取快、可水平扩展,但必须接受最终一致并建设重建工具。

物理删除 vs 墓碑:物理删除节省存储,却会破坏对话结构和审计;墓碑保留关系、保护上下文,但要明确隐私擦除边界。实践中通常是业务层墓碑、敏感字段清除、合规层另行处理。

结语

内容系统的核心不是“存下正文”,而是长期维护一组可验证的语义:谁执行了动作、动作指向什么、对话根在哪里、当前是否可见、来源不可用时如何降级、派生卡片如何从权威事实重建。

只要这些语义在模型和事务中被明确表达,前端就不需要靠文案猜关系,搜索和通知也不必各自发明一套帖子解释。反过来,如果底层只留下一个含糊的 parent_id,再精致的卡片也会在转发、回复和删除场景中不断暴露裂缝。

发表评论