拉黑是安全操作,默认值必须是隐藏
拉黑和点赞不一样。点赞失败,用户最多看到计数没变;拉黑失败,用户可能继续看到不想看到的人,甚至收到对方的新消息。这类操作的端上默认值只能是隐藏,不能是「等服务端确认后再隐藏」。
本节只讨论端上必须立即生效的部分:用户点下按钮之后,哪些界面要马上变化、同步失败时怎么办、服务端快照回来时如何合并。它不讨论服务端如何存储拉黑关系,也不讨论举报和审核流程;拉黑与屏蔽在服务端可能有不同语义,但在端上共享同一条原则:先隐藏,再同步。
为什么不能等服务端确认
最直观的实现是:点击拉黑,发请求,等服务端返回成功,再把对方内容从列表里移除。这个顺序在 Wi-Fi 下几乎看不出问题,在弱网下就是事故。
请求可能耗时三秒,也可能超时。这三秒里,对方的内容仍在信息流里,详情页仍能打开,评论区仍能加载,搜索结果仍然命中,私信会话仍然在列表里。用户点完拉黑,看到内容还在,第一反应是「没生效」,于是反复点击;第二反应是「这个功能坏了」。
更严重的是,请求成功但响应丢失。服务端已经拉黑,端上还在等确认,界面继续展示对方内容。用户以为自己没拉黑成功,实际上关系已经变了。端上和服务端各自相信不同的状态,后续每个操作都可能给出矛盾的反馈。
五个界面,一个隐藏集合
拉黑要在五个地方同时生效:
| 界面 | 过滤依据 |
|---|---|
| 信息流 | 内容作者 id |
| 详情 | 内容作者 id,包括深链直接打开 |
| 评论 | 评论作者 id |
| 搜索 | 结果作者 id,包括历史搜索缓存 |
| 私信 | 会话参与者 id,包含对方即整条隐藏 |
五处不能各写一套判断。正确做法是维护一个本地隐藏集合,每个界面在渲染前用同一个集合过滤。信息流、详情、评论、搜索过滤作者;私信过滤参与者。这样新增一个入口时,只要接入过滤函数,就不会漏掉。
深链是容易漏的一处。用户从浏览器直接打开对方的主页或内容,页面没有经过信息流,仍然要读取同一个隐藏集合。搜索历史缓存也是同理:结果可能是几天前缓存的,渲染时仍要重新过滤。
三态:pending、active、failed
一条拉黑记录在端上有三种同步状态:
pending:本地已隐藏,请求已发出或即将发出。active:服务端已确认,关系稳定。failed:同步失败,但本地仍然隐藏,等待重试。
注意 failed 的语义:它不是「拉黑失败」,而是「同步失败」。对用户来说,拉黑已经生效;对系统来说,还需要补一次请求。如果失败后把记录删掉、让内容重新出现,等于把安全操作退化成一次普通请求。
block 在调用瞬间写入 pending 并返回 hidden: true。重复点击时如果已经是 pending 或 active,直接返回当前状态,不再发请求;如果之前是 failed,则转回 pending 并重新入队。
失败不回显,重试不解除
同步失败时只改状态,不改可见性。fail 把 pending 转成 failed,尝试次数加一,条目仍留在隐藏集合里。界面不会因为一次网络错误把对方内容放回来。
重试也只改状态:failed 转回 pending,仍然隐藏。重试可以按指数退避安排,也可以在应用回到前台、网络恢复时触发;无论哪种,都不能在重试前短暂解除隐藏。
这里有一个容易被忽略的边界:如果用户在 failed 状态下再次点击拉黑,应该视为重试,而不是新建一条记录。否则会出现同一个人对应多条待同步记录,服务端收到重复请求,端上还要去重。
服务端快照只增不减
应用启动或重连时,端上会拉取服务端的拉黑列表。合并规则必须偏向隐藏:
- 快照里有、本地没有的 id,补成
active。 - 本地有、快照里没有的 id,保留本地状态,不解除隐藏。
- 本地
failed、快照里有的 id,可以转成active,说明服务端其实已经收到。
为什么快照里没有也不能解除?因为快照可能来自一次较早的请求,或者服务端复制延迟。用户刚拉黑的人,完全可能不在这次快照里。此时解除隐藏,等于用一份旧数据推翻一次更新的本地操作。
这条规则会让本地集合偶尔多出一些服务端已经不存在的 id。它们会在下次显式解除或数据清理时消失,代价远小于「拉黑后内容又出现」。
解除拉黑的方向相反
解除拉黑也是先本地生效,但失败方向相反:解除失败时应该恢复隐藏,而不是保持可见。因为隐藏是安全默认值。
实现上,unblock 先删除本地条目并返回旧记录;请求失败时调用方用 restore 把旧记录放回去。恢复的是隐藏,不是「更可见」。如果解除请求成功但响应丢失,端上会暂时隐藏对方,下次快照合并时再纠正;这个方向对用户更安全。
一个可运行的拉黑存储
下面的 BlockStore 属于 L1:纯内存、注入时钟、没有真实定时器和网络。它把「本地先行」和「快照只增不减」写成可测试的约束。
// file: src/relations/block.ts
// tier: L1
// verify: node --test test/relations/block.test.ts
export type Surface = "feed" | "detail" | "comment" | "search" | "dm";
export type BlockPhase = "active" | "pending" | "failed";
export type Clock = () => number;
export const SURFACES: readonly Surface[] = ["feed", "detail", "comment", "search", "dm"];
export interface BlockedEntry {
userId: string;
phase: BlockPhase;
updatedAt: number;
attempts: number;
}
export interface BlockResult {
hidden: boolean;
phase: BlockPhase;
queued: boolean;
}
export interface SnapshotResult {
added: number;
keptLocal: number;
}
export class BlockStore {
#entries = new Map<string, BlockedEntry>();
#now: Clock;
constructor(now: Clock = () => Date.now()) {
this.#now = now;
}
// 本地先行:调用瞬间就进入 pending 并立即隐藏,网络请求只是后续同步。
block(userId: string): BlockResult {
const existing = this.#entries.get(userId);
if (existing && existing.phase !== "failed") {
return { hidden: true, phase: existing.phase, queued: false };
}
const phase: BlockPhase = "pending";
this.#entries.set(userId, {
userId,
phase,
updatedAt: this.#now(),
attempts: existing?.attempts ?? 0,
});
return { hidden: true, phase, queued: true };
}
confirm(userId: string): boolean {
const entry = this.#entries.get(userId);
if (!entry || entry.phase !== "pending") return false;
entry.phase = "active";
entry.updatedAt = this.#now();
return true;
}
// 同步失败只改变同步状态,不解除隐藏。弱网下宁可不显示,也不能回显。
fail(userId: string): boolean {
const entry = this.#entries.get(userId);
if (!entry || entry.phase !== "pending") return false;
entry.phase = "failed";
entry.attempts += 1;
entry.updatedAt = this.#now();
return true;
}
retry(userId: string): boolean {
const entry = this.#entries.get(userId);
if (!entry || entry.phase !== "failed") return false;
entry.phase = "pending";
entry.updatedAt = this.#now();
return true;
}
// 解除拉黑先本地生效并返回旧条目;服务端失败时调用方用 restore 恢复,
// 恢复的是隐藏,不会出现「失败后更可见」的方向。
unblock(userId: string): BlockedEntry | null {
const entry = this.#entries.get(userId);
if (!entry) return null;
this.#entries.delete(userId);
return { ...entry };
}
restore(entry: BlockedEntry): void {
this.#entries.set(entry.userId, { ...entry });
}
status(userId: string): BlockPhase | null {
return this.#entries.get(userId)?.phase ?? null;
}
isHidden(userId: string): boolean {
return this.#entries.has(userId);
}
hiddenSurfaces(userId: string): Surface[] {
return this.isHidden(userId) ? [...SURFACES] : [];
}
filterAuthored<T extends { authorId: string }>(items: readonly T[]): T[] {
return items.filter((item) => !this.isHidden(item.authorId));
}
filterThreads<T extends { participantIds: readonly string[] }>(threads: readonly T[]): T[] {
return threads.filter((thread) => !thread.participantIds.some((id) => this.isHidden(id)));
}
pending(): string[] {
return [...this.#entries.values()]
.filter((entry) => entry.phase !== "active")
.map((entry) => entry.userId);
}
// 服务端快照只增不减:它可以把未知 id 补成 active,也可以确认本地 pending,
// 但不能因为快照里没有某个 id 就解除本地隐藏。快照本身可能滞后。
applyServerList(ids: readonly string[]): SnapshotResult {
const serverIds = new Set(ids);
let added = 0;
for (const id of serverIds) {
const entry = this.#entries.get(id);
if (!entry) {
this.#entries.set(id, {
userId: id,
phase: "active",
updatedAt: this.#now(),
attempts: 0,
});
added += 1;
} else if (entry.phase === "failed") {
entry.phase = "active";
entry.updatedAt = this.#now();
}
}
let keptLocal = 0;
for (const id of this.#entries.keys()) {
if (!serverIds.has(id)) keptLocal += 1;
}
return { added, keptLocal };
}
}
三个关键决策
隐藏集合与同步状态分开
isHidden 只看条目是否存在,不看 phase。这样 pending 和 failed 都自然处于隐藏状态,不需要在每次读取时枚举「哪些状态算隐藏」。状态只影响同步,不影响可见性。
快照合并偏向隐藏
applyServerList 只补条目、确认条目,不删除条目。它不是「用服务端覆盖本地」,而是「把服务端已知的拉黑并进本地」。在安全操作上,合并策略宁可多隐藏,不可少隐藏。
解除失败恢复隐藏
unblock 返回旧条目,调用方在失败时 restore。这个设计把失败方向固定在「隐藏」,避免解除请求失败后对方内容反而一直可见。
测试与真实输出
本机运行结果如下:
$ node --test test/relations/block.test.ts
✔ 拉黑在请求发出前就隐藏五个界面 (0.951917ms)
✔ 服务端确认后转为 active,仍保持隐藏 (0.106458ms)
✔ 同步失败转为 failed 且不回显 (0.101958ms)
✔ 失败后重试回到 pending,隐藏不中断 (0.084583ms)
✔ 解除拉黑返回旧条目,服务端失败可恢复隐藏 (0.526041ms)
✔ 信息流、详情、评论与搜索共用作者过滤 (0.074459ms)
✔ 私信会话只要包含被拉黑参与者就整条隐藏 (0.087458ms)
✔ 服务端快照只增不减,缺失的本地 pending 不会被解除 (0.100375ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.468292
八条用例覆盖了五个界面的隐藏、三态流转、失败不回显、解除失败恢复、作者过滤、会话过滤和快照合并。它们没有覆盖真实网络下的重试调度,也没有覆盖服务端推送;那两部分需要 L3 环境。
失败复盘:等确认的三秒,内容还在
「回声」最早的拉黑实现是「请求成功后再隐藏」。上线后收到一条反馈:在地铁里拉黑一个人,内容没有消失,反复点了五次,出站后才发现拉黑成功,但列表里还残留着对方的内容。
日志还原了过程。第一次请求在弱网下超时,端上没有收到成功响应,因此没有隐藏;用户又点了四次,服务端实际上收到了三次重复请求。端上每次都在等确认,界面始终没有变化。更糟的是,其中一次响应在页面切走后到达,回调更新了一个已经卸载的列表,既没有隐藏内容,也没有提示失败。
修复按三步走。第一,block 改为本地先行,点击瞬间进入 pending 并隐藏五个界面,请求只负责同步。第二,失败只把状态改成 failed,内容保持隐藏,重试按退避调度,重复点击视为重试。第三,快照合并改为只增不减,避免应用重启后用一份旧列表把刚拉黑的人放出来。
修复后补了两条关键测试:一条断言 failed 状态下作者过滤仍然返回空数组;另一条断言服务端快照缺少本地 pending 时,条目仍然隐藏。这两条测试直接对应事故里的两个错误方向。
还有一个残留问题:对方的内容被第三方转发时,转发卡片的作者是转发者,不是被拉黑者。端上按作者过滤只能挡住直接内容,挡不住引用和截图。这类场景需要服务端在响应里标记「包含被拉黑用户的内容」,端上再按标记过滤;仅靠本地集合无法完成。
小结
拉黑是安全操作,端上必须先隐藏再同步。五个界面共用同一个隐藏集合;三态只描述同步进度,不影响可见性;失败不回显,重试不解除;服务端快照只增不减;解除失败恢复隐藏。深链、搜索缓存、私信会话都要接入同一个过滤入口。
关系、可见性与拉黑处理完之后,下一章转向另一个容易出错的小功能:通知与未读数。多个来源如何合并成一个角标,多端已读以谁为准,没有推送权限时如何补上。
阅读导航:上一节:9.2 可见性规则在端上的表达 · 下一节:10.1 未读数的来源与端上合并 。
常见错误与边界
- 等服务端确认后才隐藏,弱网下内容仍然可见。
- 同步失败后删除本地记录,对方内容重新出现。
- 重试前先解除隐藏,界面出现一次闪回。
- 五个界面各写一套过滤,深链和搜索缓存漏掉。
- 只过滤信息流作者,不过滤评论作者和私信参与者。
- 用服务端快照覆盖本地,旧的空列表解除刚完成的拉黑。
- 解除拉黑失败后保持可见,安全默认值被反转。
- 只按作者过滤,忽略第三方转发卡片里的被拉黑内容。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。