端上能表达的是提示,不是授权
「关注可见」「好友可见」这类规则,最终解释权只能在服务端。客户端拿到的关系数据可能过期,本地缓存的正文可能来自规则变化之前,任何在端上完成的允许/拒绝判断都只是猜测。
但这不意味着端上什么都不做。端上仍然要负责三件事:在发布前告诉用户「这条内容大概会被谁看到」;在打开详情时决定先显示正文还是骨架;在服务端拒绝之后,立即清掉已经缓存的标题、封面和摘要。本节只讨论这三件事,不讨论服务端如何做权限查询,也不讨论审核状态如何影响可见性,后者属于第 11 章。
四种可见性与六种访问关系
先统一口径。可见性是作者在发布时选择的规则:
| 可见性 | 规则 |
|---|---|
public | 所有人可见 |
followers | 关注作者的人可见 |
friends | 与作者互相关注的人可见 |
private | 仅作者自己可见 |
访问关系则是「访问者相对作者」的方向:
| 关系 | 含义 |
|---|---|
self | 访问者就是作者 |
mutual | 互相关注 |
following | 访问者关注了作者 |
follower | 作者关注了访问者,但访问者没有回关 |
none | 没有关系 |
blocked | 访问者被作者拉黑,或反向拉黑 |
最容易写错的是 followers:它要求访问者关注作者,也就是 following 或 mutual。follower 表示作者关注访问者,不代表访问者能看「关注可见」的内容。方向写反,端上会把一批本不可见的内容预判为可见。
预判要带置信度
预判结果不能只有一个布尔值,还要说明这个判断有多确定:
public和private是规则层面确定的:公开内容对所有人可见,仅自己可见对他人不可见。followers和friends依赖关系数据,而关系数据可能过期,所以只能是提示。blocked覆盖一切,即使内容是公开的,拉黑关系也要求在端上隐藏。
因此 Prediction 包含 visible、confidence 和 reason 三个字段。confidence: "certain" 只表示「规则层面确定」,不表示「内容一定存在」;内容是否被删除、是否在审核,仍然由服务端决定。
首屏渲染:确定可见才直接展示
首屏只有三种状态:直接展示、骨架、锁定。
公开内容对任何非拉黑关系都是确定的,可以直接用缓存渲染。关注可见和好友可见即使预判为可见,也只能先给骨架,等服务端确认后再展示正文。原因很直接:如果端上预判过宽,直接渲染就等于把不该看的内容先放出去,之后再撤回也来不及了。
仅自己可见、关系不满足、被拉黑,都直接进入锁定态。锁定态只显示固定文案,不显示标题、作者、封面或摘要。这不是 UI 细节,而是数据边界:锁定态组件不应该接收内容字段,从接口上就没有泄露的机会。
服务端决定回来之后
服务端返回的决定至少包含两个信息:是否允许,以及原因。端上用 reconcile 把预判和服务端决定合并:
| 预判 | 服务端 | 结果 |
|---|---|---|
| 可见 | 允许 | 展示,无需纠正 |
| 可见 | 拒绝 | 锁定或移除,清理缓存预览 |
| 隐藏 | 允许 | 展示,记录预判过严 |
| 隐藏 | 拒绝 | 保持锁定 |
「预判可见、服务端拒绝」是最危险的一格。此时不能只把正文藏起来,还要把已经进入列表的标题、封面、摘要一起清掉。reconcile 返回 cachedPreviewAllowed: false,调用方据此清除缓存;固定文案由 placeholderCopy 生成,函数签名不接收任何内容字段。
「预判隐藏、服务端允许」则相反:以服务端为准,直接展示,同时把 correction 记为 too-pessimistic。这条埋点很有用,它能告诉你关系缓存的 TTL 是不是太长,或者方向判断是不是写反了。
预判错了:先清缓存,再换文案
预判错误有两种方向,处理原则不同。
预判过宽时,安全优先:立即锁定、清理预览、停止任何后台预加载。图片 CDN 可能已经收到请求,端上至少不能再把 URL 交给渲染层。预判过窄时,体验优先:服务端允许就展示,不需要向用户解释「刚才判断错了」。
删除和不存在是另一类结果。它们不应该显示「暂不可见」,而应该显示「内容已删除」,否则用户会反复重试。reconcile 把 deleted 和 not-found 映射为 removed,把 blocked 和 restricted 映射为 locked。文案是固定的,不拼接作者名,也不拼接标题。
发布前的受众提示
作者选择可见性时,端上要给一个范围提示,而不是具体名单。具体名单既可能过期,也会泄露他人的关系信息。
audienceHint 对四种可见性给出不同文案:公开是「所有人可见」,仅自己是「仅自己可见」,关注可见带上关注者数量,好友可见带上互关数量。数量只作为范围参考,不承诺最终名单。关注可见和好友可见还要提示一句:关系变化后,历史内容的可见范围会随之变化。
这句提示很重要。很多用户以为「发布时选了好友可见,以后一直是这些人」,实际规则是按当前关系实时计算的。端上如果不说明,关系变化后内容消失会被当成 bug。
一个可运行的可见性模块
下面的模块属于 L1:全部是纯函数,不依赖网络、时钟或存储。predictVisibility 负责预判,initialSurface 负责首屏,reconcile 负责服务端纠正,audienceHint 负责发布提示。
// file: src/relations/visibility.ts
// tier: L1
// verify: node --test test/relations/visibility.test.ts
export type Visibility = "public" | "followers" | "friends" | "private";
// viewer 是「访问者相对作者」的关系:
// following = 访问者关注了作者;follower = 作者关注了访问者。
export type Viewer = "self" | "mutual" | "following" | "follower" | "none" | "blocked";
export type SurfaceState = "show" | "skeleton" | "locked" | "removed";
export type PredictionReason =
| "public"
| "self"
| "mutual"
| "following"
| "owner-only"
| "blocked"
| "not-mutual"
| "not-following";
export interface Prediction {
visible: boolean;
// certain 只表示「规则层面确定」,内容是否被删除或审核仍由服务端决定。
confidence: "certain" | "hint";
reason: PredictionReason;
}
export type ServerReason = "ok" | "blocked" | "restricted" | "deleted" | "not-found";
export interface ServerDecision {
allowed: boolean;
reason: ServerReason;
}
export interface ReconcileResult {
state: "show" | "locked" | "removed";
copy: string;
cachedPreviewAllowed: boolean;
correction: "none" | "too-optimistic" | "too-pessimistic";
}
export interface AudienceStats {
followerCount: number;
mutualCount: number;
}
export interface AudienceHint {
label: string;
detail: string;
}
export function predictVisibility(visibility: Visibility, viewer: Viewer): Prediction {
if (viewer === "blocked") {
return { visible: false, confidence: "certain", reason: "blocked" };
}
if (viewer === "self") {
return { visible: true, confidence: "certain", reason: "self" };
}
if (visibility === "private") {
return { visible: false, confidence: "certain", reason: "owner-only" };
}
if (visibility === "public") {
return { visible: true, confidence: "certain", reason: "public" };
}
if (visibility === "friends") {
return viewer === "mutual"
? { visible: true, confidence: "hint", reason: "mutual" }
: { visible: false, confidence: "hint", reason: "not-mutual" };
}
return viewer === "following" || viewer === "mutual"
? { visible: true, confidence: "hint", reason: "following" }
: { visible: false, confidence: "hint", reason: "not-following" };
}
// 只有规则层面确定的公开内容可以直接用缓存渲染。关注可见、好友可见
// 都只能先给骨架屏,等 service 确认后再展示正文。
export function initialSurface(prediction: Prediction): SurfaceState {
if (!prediction.visible) return "locked";
return prediction.confidence === "certain" ? "show" : "skeleton";
}
// 服务端拒绝后必须清掉端上缓存的标题、封面和摘要。这里的 copy 是固定文案,
// 不接收任何内容字段,从签名上就避免把不可见内容带进占位提示。
export function reconcile(prediction: Prediction, decision: ServerDecision): ReconcileResult {
if (decision.allowed) {
return {
state: "show",
copy: "",
cachedPreviewAllowed: true,
correction: prediction.visible ? "none" : "too-pessimistic",
};
}
const removed = decision.reason === "deleted" || decision.reason === "not-found";
return {
state: removed ? "removed" : "locked",
copy: placeholderCopy(decision.reason),
cachedPreviewAllowed: false,
correction: prediction.visible ? "too-optimistic" : "none",
};
}
export function placeholderCopy(reason: ServerReason): string {
if (reason === "deleted" || reason === "not-found") return "内容已删除";
if (reason === "blocked") return "该内容暂不可见";
return "内容暂不可见";
}
export function audienceHint(visibility: Visibility, stats: AudienceStats): AudienceHint {
if (visibility === "public") {
return { label: "所有人可见", detail: "公开内容可能出现在推荐与搜索里" };
}
if (visibility === "private") {
return { label: "仅自己可见", detail: "不会进入他人的信息流" };
}
if (visibility === "friends") {
const count = Math.max(0, Math.floor(stats.mutualCount));
return {
label: `互相关注的 ${count} 人可见`,
detail: "互关关系解除后,历史内容会退出对方的可见范围",
};
}
const count = Math.max(0, Math.floor(stats.followerCount));
return {
label: `关注你的 ${count} 人可见`,
detail: "对方取关后,历史内容会退出其可见范围",
};
}
三个关键决策
置信度不是装饰
如果只返回布尔值,调用方无法区分「公开内容确定可见」和「关系数据推测可见」,最后一定会在某个页面直接渲染缓存正文。把 certain 和 hint 写进类型,首屏策略才有依据。
锁定态不接收内容字段
placeholderCopy 只接收服务端原因,不接收标题、作者或摘要。调用方即使想拼接也拼不进去。安全边界最好落实在函数签名上,而不是代码评审时的口头约定。
服务端决定永远优先
预判只影响「先显示什么」,不影响「最终显示什么」。服务端允许时,即使预判是隐藏也展示;服务端拒绝时,即使预判是可见也锁定。这个优先级不能反过来,否则缓存就会变成授权。
测试与真实输出
本机运行结果如下:
$ node --test test/relations/visibility.test.ts
✔ 公开内容对所有关系都给出确定的可见预判 (0.806667ms)
✔ 关注可见只认「访问者关注作者」,被关注不算 (0.084292ms)
✔ 好友可见只认互关 (0.0595ms)
✔ 仅自己可见对他人一律隐藏,作者本人始终可见 (0.05875ms)
✔ 拉黑关系覆盖公开规则 (0.448583ms)
✔ 只有规则确定可见的内容可以直接渲染,其余先给骨架 (0.09625ms)
✔ 服务端决定覆盖预判:拒绝时清缓存,允许时纠正过严预判 (0.106334ms)
✔ 发布前的受众提示只写范围,不承诺具体名单 (0.132959ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 145.811708
八条用例覆盖了四种可见性、方向易错的 follower、拉黑覆盖、首屏策略、服务端纠正和受众文案。它们没有覆盖真实的关系查询延迟,也没有覆盖图片预加载;这两部分需要端到端环境。
失败复盘:预判过宽,缓存封面先露了出来
「回声」的详情页曾经在打开时先用本地缓存渲染,再等服务端权限结果。缓存里保存了标题、封面和第一段正文,详情页判断「好友可见」时只看本地缓存的 mutual,于是直接渲染。
问题出在一次关系变化:B 解除了与 A 的互关,A 的端上缓存还没过期。A 从消息里点开 B 的一条好友可见内容,详情页先显示标题和封面,半秒后服务端返回拒绝,页面切成「内容暂不可见」。标题和封面已经在屏幕上出现过,截图也已经保存。
修复不是把半秒缩短,而是改首屏策略:非公开内容一律先给骨架,服务端允许后才渲染正文;缓存预览只允许用于 public 且 confidence === "certain" 的内容;服务端拒绝时返回 cachedPreviewAllowed: false,调用方清除标题、封面和摘要,并取消尚未完成的图片请求。
这次事故还暴露了一个测试缺口:旧用例只断言「拒绝后状态是 locked」,没有断言「缓存预览不被允许」。修复后补上了 cachedPreviewAllowed 的断言,让「状态正确但内容仍可见」这种半对半错的情况无法通过。
小结
可见性在端上是预判,不是授权。四种规则映射到六种访问关系时,方向不能写反;预判要带置信度,只有规则确定可见的公开内容可以直接渲染;非公开内容先给骨架,服务端决定回来后再展示或锁定;拒绝时清理缓存预览,文案不接收内容字段;发布前提示范围,不承诺名单。
可见性处理的是「内容该不该被看到」,拉黑处理的是「这个人该不该出现在我的界面里」。下一节讨论拉黑为什么必须本地先行,以及同步失败时为什么不能回显。
阅读导航:上一节:9.1 关注、互关与关系缓存 · 下一节:9.3 拉黑与屏蔽:端上必须立即生效的部分 。
常见错误与边界
- 把
follower当成「关注可见」的通行证,方向写反。 - 用本地缓存的关系直接渲染非公开正文,服务端拒绝后再撤回。
- 预判只返回布尔值,调用方无法区分确定与推测。
- 锁定态组件仍然接收标题和封面,占位时顺手泄露内容。
- 服务端拒绝后只隐藏正文,列表里的摘要和封面继续存在。
- 把删除和权限不足都显示成「暂不可见」,用户反复重试。
- 发布提示承诺具体名单,关系变化后被用户当成 bug。
- 让端上预判覆盖服务端决定,缓存实际变成了授权。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。