《互联网产品的客户端架构·内容、社交与实时互动》13.1 端到端场景:发内容、互动、实时回复

把发内容、信息流出现、点赞、评论与实时回复串成一条 L2 端到端流程,用内存服务端与真实模块组合,逐步断言信息流、互动关系、评论时间线与未读角标的状态归属,并复盘一次刷新只插新条目、不覆盖旧计数导致的双计事故,说明组合验证能证明什么、不能证明什么。

收口章不引入新概念

13.1 不引入新的协议,也不讨论渲染细节、真机性能或服务端实现。它只回答一个问题:前面十二节各自成立的状态归属,放进同一条用户路径后,是否仍然成立。

路径是:作者发一条内容,信息流出现;当前用户点赞;当前用户评论;另一位用户实时回复到达。每一步都能在进程内断言,因此属于 L2。文中除测试输出外的时间与比例都是示例值。

一条链路上的五个状态所有者

步骤状态所有者事实来源组合时最容易错的地方
发内容服务端内容记录 + 信息流容器服务端分配的 id 与 createdAt用本地临时 id 去重
信息流出现SocialClient分页或刷新快照把刷新当成整表覆盖
点赞ReactionStore服务端确认的 active 与 count直接改信息流里的 likeCount
评论CommentThread服务端评论记录 + clientId占位与确认各生成一个 id
实时回复OrderingGate + UnreadMerger消息 seq 与未读水位事件直接写 UI,绕过顺序门

五步里没有一步需要新的抽象。组合层的职责只有两个:把服务端返回值交给正确的所有者;在所有者之间做显式回写。

这也解释了为什么收口章不另起一套“端到端框架”。一旦组合层开始自己保存计数或自己判断消息新旧,它就会变成第五个事实来源,前面章节建立的归属立刻失效。

内存服务端不是 mock

MemorySocialServer 不是对 HTTP 的模拟。它只保留服务端必须负责的事实:自增 id、绝对计数、评论记录。它刻意不做延迟、失败与乱序,因为这些属于 13.2 的故障注入。

两个接口按前面章节的约定设计:setLike 接收目标状态而不是 toggle,重复发送同一个 active 不改变计数;addComment 返回带服务端 id 的记录;feedPage 返回绝对快照。这样组合层拿到的是事实,不是“请求成功”的暗示。

SocialSession 是组合层。它持有 SocialClient、ReactionStore、CommentThread、OrderingGate 与 UnreadMerger,但每个模块仍然只改自己的状态。组合层不提供“统一状态树”,只提供五个动作:发布、点赞、评论、接收回复、刷新。

可运行代码

// file: src/summary/e2e.ts
// tier: L2
// verify: node --test --test-concurrency=1 test/summary/e2e.test.ts
import type { ContentBlock, ContentItem } from "../core/content-model.ts";
import { SocialClient } from "../core/social-client.ts";
import { ReactionStore } from "../interaction/optimistic.ts";
import { CommentThread } from "../interaction/comments.ts";
import type { CommentRecord, TimelineEntry } from "../interaction/comments.ts";
import { OrderingGate } from "../reliability/ordering.ts";
import type { IngestStatus } from "../reliability/ordering.ts";
import { UnreadMerger } from "../notify/unread.ts";

export type Clock = () => number;

export interface ReplyEvent {
  id: string;
  seq: number;
  itemId: string;
  authorId: string;
  text: string;
}

export interface ReplyResult {
  status: IngestStatus;
  badge: number;
  commentCount: number;
}

const FEED = "home";

function cloneItem(item: ContentItem): ContentItem {
  return { ...item, blocks: item.blocks.map((block) => ({ ...block })) };
}

// 内存服务端只负责“事实”:绝对计数、评论记录、自增 id。
// 它不模拟网络延迟与失败,那些交给 13.2 的可控适配器。
export class MemorySocialServer {
  #items: ContentItem[] = [];
  #liked = new Set<string>();
  #likes = new Map<string, number>();
  #comments = new Map<string, CommentRecord[]>();
  #itemSeq = 0;
  #commentSeq = 0;
  #now: Clock;

  constructor(now: Clock = () => Date.now()) {
    this.#now = now;
  }

  publish(authorId: string, blocks: ContentBlock[], likeCount = 0): { item: ContentItem } {
    if (authorId.trim() === "" || blocks.length === 0) throw new Error("作者与内容块不能为空");
    this.#itemSeq += 1;
    const item: ContentItem = {
      id: `post-${this.#itemSeq}`,
      authorId,
      createdAt: this.#now(),
      state: "published",
      blocks: blocks.map((block) => ({ ...block })),
      likeCount,
      commentCount: 0,
    };
    this.#items.unshift(item);
    this.#likes.set(item.id, likeCount);
    return { item: cloneItem(item) };
  }

  feedPage(limit = 20): { items: ContentItem[]; nextCursor: string | null } {
    const items = [...this.#items]
      .sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id))
      .slice(0, limit)
      .map(cloneItem);
    return { items, nextCursor: null };
  }

  // 发送目标状态而不是 toggle;重复发送同一个 active 不改变计数。
  setLike(itemId: string, active: boolean): { active: boolean; count: number } {
    const item = this.#find(itemId);
    if (this.#liked.has(itemId) !== active) {
      const count = Math.max(0, (this.#likes.get(itemId) ?? 0) + (active ? 1 : -1));
      this.#likes.set(itemId, count);
      item.likeCount = count;
      if (active) this.#liked.add(itemId);
      else this.#liked.delete(itemId);
    }
    return this.reaction(itemId);
  }

  reaction(itemId: string): { active: boolean; count: number } {
    this.#find(itemId);
    return { active: this.#liked.has(itemId), count: this.#likes.get(itemId) ?? 0 };
  }

  // 其他用户的点赞只改绝对计数,不改变当前用户的互动关系。
  receiveLike(itemId: string, delta = 1): { count: number } {
    if (!Number.isInteger(delta) || delta === 0) throw new RangeError("delta 必须是非零整数");
    const item = this.#find(itemId);
    const count = Math.max(0, (this.#likes.get(itemId) ?? 0) + delta);
    this.#likes.set(itemId, count);
    item.likeCount = count;
    return { count };
  }

  addComment(itemId: string, authorId: string, text: string): CommentRecord {
    const item = this.#find(itemId);
    const body = text.trim();
    if (body === "") throw new Error("评论内容不能为空");
    this.#commentSeq += 1;
    const record: CommentRecord = {
      id: `cmt-${this.#commentSeq}`,
      threadId: itemId,
      authorId,
      text: body,
      createdAt: this.#now(),
    };
    this.#comments.set(itemId, [...(this.#comments.get(itemId) ?? []), record]);
    item.commentCount += 1;
    return { ...record };
  }

  comments(itemId: string): CommentRecord[] {
    return (this.#comments.get(itemId) ?? []).map((comment) => ({ ...comment }));
  }

  #find(itemId: string): ContentItem {
    const item = this.#items.find((entry) => entry.id === itemId);
    if (!item) throw new Error(`未知内容 ${itemId}`);
    return item;
  }
}

// 组合层只做显式回写:信息流、互动关系、评论时间线、实时顺序门各管各的状态。
export class SocialSession {
  readonly client = new SocialClient();
  #server: MemorySocialServer;
  #reactions = new ReactionStore();
  #threads: CommentThread;
  #gate = new OrderingGate({ initialSeq: 0 });
  #unread = new UnreadMerger();
  #replies = new Map<string, ReplyEvent>();
  #seeded = new Set<string>();
  #replySeq = 0;

  constructor(server: MemorySocialServer, options: { now?: Clock; idFactory?: () => string } = {}) {
    this.#server = server;
    let localId = 0;
    const idFactory = options.idFactory ?? (() => `local-${++localId}`);
    this.#threads = new CommentThread({ now: options.now, idFactory, authorId: "me" });
  }

  get feed(): ContentItem[] {
    return this.client.items(FEED);
  }

  get cursor(): string | null {
    return this.client.cursor(FEED);
  }

  get badge(): number {
    return this.#unread.badge().total;
  }

  publish(authorId: string, blocks: ContentBlock[]): ContentItem {
    const { item } = this.#server.publish(authorId, blocks);
    this.client.replacePage(FEED, this.#server.feedPage());
    return item;
  }

  like(itemId: string): { active: boolean; count: number } {
    const before = this.#feedItem(itemId).likeCount;
    if (!this.#seeded.has(itemId)) {
      this.#reactions.seed(itemId, { like: this.#server.reaction(itemId) });
      this.#seeded.add(itemId);
    }
    const request = this.#reactions.toggle(itemId, "like");
    this.#reactions.settle(request, this.#server.setLike(itemId, request.active));
    const view = this.#reactions.view(itemId).like;
    this.client.updateInteraction(FEED, itemId, { like: view.count - before });
    return view;
  }

  comment(itemId: string, text: string): TimelineEntry[] {
    this.#feedItem(itemId);
    this.#threads.seed(itemId, this.#server.comments(itemId));
    const request = this.#threads.submit(itemId, text);
    const record = this.#server.addComment(itemId, "me", request.text);
    if (this.#threads.markSent(request.clientId, record).inserted) {
      this.client.updateInteraction(FEED, itemId, { comment: 1 });
    }
    return this.#threads.timeline(itemId);
  }

  receiveReply(itemId: string, authorId: string, text: string): ReplyResult {
    this.#feedItem(itemId);
    this.#replySeq += 1;
    return this.deliver({ id: `reply-${this.#replySeq}`, seq: this.#replySeq, itemId, authorId, text });
  }

  // 实时回复也走顺序门:重复、迟到与超前都先判定,再决定是否更新角标与计数。
  deliver(event: ReplyEvent): ReplyResult {
    this.#replies.set(event.id, event);
    const result = this.#gate.ingest({ id: event.id, seq: event.seq });
    if (result.status === "applied") {
      for (const applied of result.applied) {
        const payload = this.#replies.get(applied.id);
        if (!payload) throw new Error(`缺少实时事件 ${applied.id}`);
        this.#unread.applyEvent({ id: payload.id, kind: "comment", seq: payload.seq });
        this.client.updateInteraction(FEED, payload.itemId, { comment: 1 });
        this.#replies.delete(payload.id);
      }
    }
    return {
      status: result.status,
      badge: this.badge,
      commentCount: this.#feedItem(event.itemId).commentCount,
    };
  }

  // replacePage 只插新条目,已有条目的计数不会被覆盖;刷新必须显式对账。
  refresh(): ContentItem[] {
    const page = this.#server.feedPage();
    const before = new Map(
      this.client.items(FEED).map((item) => [
        item.id,
        { like: item.likeCount, comment: item.commentCount },
      ]),
    );
    this.client.replacePage(FEED, page);
    for (const item of page.items) {
      const old = before.get(item.id);
      if (old) {
        this.client.updateInteraction(FEED, item.id, {
          like: item.likeCount - old.like,
          comment: item.commentCount - old.comment,
        });
      }
      if (this.#seeded.has(item.id)) {
        this.#reactions.applySnapshot(item.id, { like: this.#server.reaction(item.id) });
      }
    }
    return this.feed;
  }

  appliedReplySeqs(): number[] {
    return this.#gate.appliedSeqs();
  }

  reactionView(itemId: string): { active: boolean; count: number } {
    return this.#reactions.view(itemId).like;
  }

  pendingLike(itemId: string): boolean {
    return this.#reactions.pending(itemId, "like");
  }

  #feedItem(itemId: string): ContentItem {
    const item = this.client.items(FEED).find((entry) => entry.id === itemId);
    if (!item) throw new Error(`信息流里没有 ${itemId}`);
    return item;
  }
}

代码里有两个组合细节值得先看。like 先把 ReactionStore 的展示值算出来,再把差值回写到信息流;refresh 先记下旧计数,再用服务端快照的差值对账。前者避免互动状态和列表计数各说各话,后者避免 replacePage 只插新条目、不更新旧计数。

组合后仍然成立的四条约束

列表与关系分开

信息流里的 likeCount 是列表快照,ReactionStore 里的 active 是当前用户与服务端的关系。两者可以短暂不一致,但确认后必须显式对齐。like 只改关系,回写列表是单独一步,失败时可以只重做这一步。

刷新不是覆盖

SocialClient.replacePage 的语义是“把新条目插到顶部”,不是“用新快照替换整个列表”。已有条目的计数不会自动更新,必须由组合层对账。这是本节测试真正抓到的契约缺口,后面的失败复盘会展开。

占位与确认共用一个 clientId

评论提交时生成 clientId,服务端确认后 markSent 用同一个 clientId 去掉占位。如果确认时另生成 id,时间线会出现两条一样的评论:一条占位、一条确认。

实时回复先过顺序门

回复到达时先 ingest,只有 applied 才更新未读角标和评论计数。duplicate、late、buffered 都不改 UI。这样重复投递不会双计,超前到达也会等缺口补齐后再应用。

测试与真实输出

七条用例覆盖发布、点赞、取消、评论、实时回复、重复投递与刷新对账。本机 Node 26.5.0 输出如下:

$ node --test --test-concurrency=1 test/summary/e2e.test.ts
✔ 发内容后信息流出现同一条,状态与计数都来自服务端快照 (0.752ms)
✔ 点赞先改互动意图,确认后信息流计数显式回写 (0.70675ms)
✔ 取消点赞回到零,服务端重复确认不会把计数减成负数 (0.133625ms)
✔ 评论先有本地占位,服务端确认后只保留一条已确认记录 (0.272292ms)
✔ 实时回复按序应用,角标与评论计数同步增加 (0.306334ms)
✔ 同一条实时回复重复投递被顺序门判为 duplicate,不重复计数 (0.107958ms)
✔ 刷新用绝对快照覆盖计数,但不覆盖当前用户已确认的互动关系 (0.19925ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 154.855416

这些用例没有一条依赖真实网络或浏览器。它们证明的是组合后的状态收敛,不是渲染是否正确。比如“评论只保留一条已确认记录”断言的是时间线数据,不是屏幕上有没有闪一下占位。

失败复盘:刷新只插新条目,旧计数没人改

第一版 e2e 在刷新时直接调用 replacePage,断言信息流计数会变成服务端的最新值。测试当场失败:服务端已经 2,信息流还是 1。

原因不是 SocialClient 写错了,而是它的语义本来如此:replacePage 只把新 id 插到顶部,同 id 的旧对象保持原位。刷新快照里的计数没有人应用。

更隐蔽的是后续操作。当前用户取消点赞时,ReactionStore 按确认基数 2 算出 1,组合层却按信息流旧值 1 做差值,信息流被写成 0;下一次刷新又跳回 1。用户看到计数来回跳,日志里却每个请求都成功。

修复分两步:刷新前记录旧计数,用服务端快照的差值调用 updateInteraction;同时把同一份快照 applySnapshot 给 ReactionStore。两条路径都来自同一个绝对事实,差值只用于落地,不作为新的事实。

这条事故说明,端到端测试抓到的往往不是某个模块的 bug,而是模块之间没写下来的契约。单测各自绿,组合起来仍然可能让用户看到错的数字。

L2 端到端证明了什么

它证明了五步的状态归属可以组合:发布后信息流有条目,点赞后关系与计数对齐,评论占位能被确认替换,实时回复重复投递只应用一次,刷新用绝对快照对账。

它没有证明首屏耗时、滚动稳定性、真机内存、真实断线重连与多端一致。这些要么需要浏览器或真机,要么需要真实端口与多端环境,留给 13.2 与 13.3 说明边界。

小结

端到端不是把所有模块塞进一个类,而是让每个模块继续只改自己的状态,组合层只做显式回写。发布、信息流、点赞、评论、实时回复五步,各自的所有者不变,状态归属在组合后仍然成立。

但这条链路假设网络是可用的。下一节把网络拿掉:断网、慢网、乱序与断线重连,看看同一套状态在故障下会退成什么样。

阅读导航:上一节:12.3 发现页的推荐位与内容流的差异 · 下一节:13.2 故障注入:断网、慢网与断线重连 。

常见错误与边界

  • 组合层直接改 ReactionStore 的内部状态,绕过 toggle 与 settle 的序号规则。
  • 用 replacePage 当整表刷新,已有条目的计数永远停在第一次加载的值。
  • 点赞确认后不把差值回写信息流,列表和详情各显示一个数字。
  • 评论确认时重新生成 id,占位与确认在时间线里各占一条。
  • 实时回复直接更新 UI,重复投递或超前到达时计数被加两次。
  • 把内存服务端当成网络模拟,用它得出的结论去描述断网行为。
  • 用 L2 结果宣称真机首屏或内存表现,越过 1.3 定义的验证边界。
  • 在端到端用例里断言渲染细节,让测试依赖浏览器而不再能进程内复现。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查