收口章不引入新概念
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 定义的验证边界。
- 在端到端用例里断言渲染细节,让测试依赖浏览器而不再能进程内复现。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。