《互联网产品的客户端架构·内容、社交与实时互动》8.3 状态展示的降级与隐私边界

在线状态一旦涉及隐私,就不再是「显示什么」,而是「允许谁看到什么证据」。本节把可见性、关系、拉黑与连接降级组合成一张展示决策表,明确 unknown 与 offline 的差别,给出可运行实现与测试,并复盘一次隐藏在线后仍能从活跃时间反推的事故。

这一节不讨论状态怎么算

8.1 已经给出 presence 的时间模型:最后活跃时间、衰减窗口、四态与 TTL。8.2 处理了输入中信号的节流与超时。两者回答的是「证据足够时,端上算出什么状态」。

本节回答另一个问题:当证据不该被看到、看不到或不可信时,界面应该显示什么。 它不重复判定算法,也不讨论权限系统的服务端实现;我们只关心客户端拿到的数据与最终渲染之间那层决策,以及哪些信息从一开始就不应该到达客户端。

隐私不是把绿点藏起来

很多实现把「显示在线」理解为一个 UI 开关:状态照常计算、照常下发,客户端在渲染时把绿点隐藏。这个做法有一个致命假设:只要用户看不见,信息就没有泄露。 实际上,状态一旦进入客户端进程,就可能出现在网络面板、日志、埋点、崩溃上报、页面内存快照,甚至被脚本直接读取。隐藏一个 DOM 节点不改变数据的可见性。

正确的分层是:

  1. 服务端裁决:根据可见性设置与关系,决定是否下发 lastActiveAt、是否下发输入状态、是否把用户计入「X 人在线」。
  2. 客户端二次防守:即使服务端因为旧版本、缓存或 bug 多下发了字段,渲染层也不得使用。
  3. 展示层表达:无法确认时显示「状态未知」,而不是「离线」。

三层里任何一层缺失,都会在某个版本、某个入口或某次灰度里泄露。隐私边界不是一次开关,而是一条从数据源到像素的链路。

四类「看不见」

客户端需要区分的拒绝原因至少有四类,它们的 UI 结果相似,但语义与日志完全不同:

原因触发条件端上应该显示是否泄露关系
用户关闭可见性设为 nobody状态未知不泄露
关系不足可见性设为 contacts,但双方不是互关状态未知不泄露
拉黑任一方向存在拉黑状态未知不泄露
无信号从未收到、已过保留期、时钟异常状态未知不泄露

四类都落到 unknown,但内部 reason 必须保留,否则排查「为什么这个用户永远未知」时只能靠猜。reason 只进日志与调试面板,不进用户可见文案。用户看到的原因越具体,越容易变成关系探测工具:如果被拉黑的人看到「对方已把你拉黑」,拉黑就失去了沉默的意义。

还有一个容易忽略的来源:自己的连接降级。当本端 WebSocket 正在重连或已经断开,客户端对任何远端状态都没有实时证据。这时缓存里的 offline 不能当成事实展示,否则一次网络切换会让整个好友列表瞬间「集体离线」,用户以为服务器挂了。unknown 才是诚实的状态。

unknown 与 offline 的差别

这两个状态在数据库里可能只差一个枚举值,在产品语义上却是两种完全不同的断言:

  • offline:我有足够证据认为对方当前不活跃。它可以附带「10 分钟前活跃」,因为时间戳本身也是证据。
  • unknown:我没有足够证据,或者我无权拥有证据。它不应该附带任何时间信息。

把两者混用,会产生两类事故。第一类是把 unknown 显示成 offline:用户以为对方故意不理,实际只是隐私设置或网络问题。第二类是给 unknown 附带最后活跃时间:即使文案写的是「状态未知」,旁边的「3 小时前活跃」仍然泄露了对方的活动规律。

因此展示模型里要让 lastActiveAt 与状态同生共死:只有 visible 且允许展示最后活跃时,才把它交给 UI;其余情况一律为 null。这不是 UI 层的 if,而是数据模型的约束。

拉黑是双向沉默

拉黑有一个特殊要求:被拉黑的一方不能知道自己被拉黑。 如果界面显示「对方已拉黑你」,或者发送消息时立即返回一个只有拉黑才会出现的错误,拉黑就变成了通知。状态展示必须和消息发送的降级保持一致:不能发消息时,统一提示「消息发送失败」或「对方暂时无法接收」,而不是暴露原因。

对拉黑方自己,也没有必要显示「已拉黑」的状态标记。列表里直接不展示对方的状态即可;额外标记只会让截图、录屏和共享屏幕时泄露关系。两端使用同一个中性结果,是成本最低也最不容易出错的做法。

这也意味着展示函数不应该接收「是否被对方拉黑」这种细粒度布尔值。它只需要知道关系类别,并把它归入 blocked / blocked-by 两支;两支输出完全一致。测试里要专门断言两者不可区分,防止未来有人为了「调试方便」加一个不同的文案。

连接降级时不要用缓存冒充

6.3 已经规定:重连期间 presence 能力进入 unknown。本节把这个规定落到展示层:只要本端传输状态不是 live,所有远端状态一律降级为不可用,即使本地缓存里存着一个看起来很新的 away。

这条规则的代价是「重连时状态会闪一下未知」。收益是用户不会在断网时看到一屏错误的「离线」。如果产品确实想展示缓存,正确做法是加明确限定词,例如「上次看到:在线」,并把时间戳一起显示;不能把缓存值当成当前值。本书的实现选择保守策略:降级即未知,缓存只用于列表占位,不参与状态断言。

还有一个边界:降级期间用户可能收到一条「对方上线」的推送,但通道本身不可信。此时应该把推送当作待确认事件,等连接恢复、补拉完成后再更新状态,而不是立刻点亮绿点。8.1 的 stale 标记与这里的 no-transport 是同一套思路的两个出口。

一个可运行的展示决策

下面的模块属于 L1:输入是可见性、关系、传输状态与一个 presence 样本,输出是可直接渲染的展示结果。它把「服务端已经裁决过」与「客户端仍需防守」两件事写在同一处,顺序不能随意调换。

// file: src/presence/privacy.ts
// tier: L1
// verify: node --test test/presence/privacy.test.ts
import {
  classifyPresence,
  formatLastSeen,
  DEFAULT_PRESENCE_POLICY,
  type PresencePolicy,
  type PresenceSample,
  type PresenceState,
  type PresenceView,
} from "./presence.ts";

export type PresenceVisibility = "everyone" | "contacts" | "nobody";

export type Relationship = "self" | "mutual" | "following" | "follower" | "none" | "blocked" | "blocked-by";

export type TransportState = "live" | "degraded" | "offline";

export type DisplayState = "visible" | "unavailable" | "suppressed";

export type DisplayReason =
  | "self"
  | "allowed"
  | "disabled-by-user"
  | "not-contact"
  | "blocked"
  | "no-transport"
  | "no-signal";

export interface PresencePrivacyInput {
  visibility: PresenceVisibility;
  relationship: Relationship;
  transport: TransportState;
  sample: PresenceSample;
  now: number;
  policy?: PresencePolicy;
}

export interface PresenceDisplay {
  state: DisplayState;
  view: PresenceState;
  text: string;
  showsLastSeen: boolean;
  lastActiveAt: number | null;
  reason: DisplayReason;
}

const NEUTRAL = "状态未知";

export function buildPresenceDisplay(input: PresencePrivacyInput): PresenceDisplay {
  const { visibility, relationship, transport, sample, now } = input;
  const policy = input.policy ?? DEFAULT_PRESENCE_POLICY;
  const hide = (state: DisplayState, reason: DisplayReason): PresenceDisplay => ({
    state,
    view: "unknown",
    text: NEUTRAL,
    showsLastSeen: false,
    lastActiveAt: null,
    reason,
  });

  // 拉黑是双向沉默:不显示「已拉黑」,也不因为样本是新数据而泄露活跃时间。
  if (relationship === "blocked" || relationship === "blocked-by") {
    return hide("suppressed", "blocked");
  }
  if (relationship === "self") {
    return displayFrom(classifyPresence(sample, now, policy), true, "self");
  }
  if (visibility === "nobody") return hide("suppressed", "disabled-by-user");
  if (visibility === "contacts" && relationship !== "mutual") {
    return hide("suppressed", "not-contact");
  }
  // 自己没连上实时通道时,任何远端状态都不可确认;不能把缓存的 offline 当成事实。
  if (transport !== "live") return hide("unavailable", "no-transport");

  const view = classifyPresence(sample, now, policy);
  if (view.state === "unknown") return hide("unavailable", "no-signal");
  return displayFrom(view, true, "allowed");
}

function displayFrom(view: PresenceView, allowLastSeen: boolean, reason: DisplayReason): PresenceDisplay {
  const lastSeen = allowLastSeen ? formatLastSeen(view) : null;
  return {
    state: "visible",
    view: view.state,
    text: lastSeen ?? view.label,
    showsLastSeen: lastSeen !== null,
    lastActiveAt: allowLastSeen ? view.lastActiveAt : null,
    reason,
  };
}

决策顺序值得逐条解释:

  • 拉黑最先判断。即使样本是刚刚的心跳、即使可见性是 everyone,拉黑关系都必须压过一切。
  • 自己例外。用户永远可以看到自己的状态,哪怕对外设置为 nobody;这不是隐私漏洞,而是「自己的数据自己可见」。
  • 可见性先于传输状态。对方关闭展示时,无论本端连接好坏,结果都是不可见;先判传输会得到一个误导性的 no-transport。
  • 传输状态先于信号判定。本端没连上时,连「样本过期」都无从确认,统一归为不可用。
  • 只有可见结果才带时间。lastActiveAt 在 displayFrom 里统一裁决,拒绝分支一律返回 null。

测试与真实输出

本机运行结果如下:

$ node --test test/presence/privacy.test.ts
✔ 自己永远能看到自己的状态,即使对外关闭展示 (0.640833ms)
✔ 允许展示且信号新鲜时显示在线 (0.089375ms)
✔ 对方关闭展示后只显示未知,即使服务端误带了活跃时间也不透传 (0.118042ms)
✔ 仅联系人的策略只对互相关注生效,非互关显示未知 (0.081625ms)
✔ 拉黑与被拉黑输出同样的中性结果,不泄露原因 (0.070917ms)
✔ 自身连接降级时把远端状态标为未知,不把缓存离线当事实 (0.068666ms)
✔ 没有样本或样本过期时显示未知,而不是离线 (0.065ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 137.109667

七条用例覆盖了自我可见、正常展示、关闭展示、仅联系人、拉黑双向、连接降级、无信号与过期。它们没有覆盖真实权限服务与多端同步,因为这一层验证的是「给定裁决结果与样本,端上渲染什么、透传什么」。

失败复盘:隐藏在线后仍能被反推

「回声」上线隐私开关时,服务端做了一件看起来合理的事:仍然下发 lastActiveAt,由客户端根据设置决定是否显示绿点。Web 端的实现也确实隐藏了绿点。上线一个月后,一位安全研究员在社交媒体上演示了反推:打开开发者工具,读取接口响应里的 lastActiveAt,就能以分钟级精度画出对方的作息。

更严重的是「仅联系人」策略。服务端只判断了「是否互关」,却没有过滤「X 人在线」的聚合数字。一个 20 人的群里有 5 人关闭了展示,群标题仍然显示「12 人在线」;对比成员列表里可见的 7 个绿点,就能推断出有 5 个隐藏用户当前在线。聚合数字也是状态,而且它比单个绿点更难隐藏。

修复分三层:

  1. 服务端在序列化响应时直接剔除无权字段,lastActiveAt 不进入 payload;不是靠客户端隐藏。
  2. 聚合数字只统计「当前用户有权看到」的成员;人数不足以形成 k-匿名时,直接显示「若干人在线」或省略。
  3. 客户端展示函数对无权场景强制把 lastActiveAt 置空,即使服务端误带也不透传。

第三层看起来冗余,但它是灰度期间唯一能兜住旧服务端的东西。事故之后补的回归用例很直接:可见性为 nobody、样本是刚刚的心跳、服务端故意带上 lastActiveAt,断言输出的 lastActiveAt 为 null、文案为「状态未知」。

第二个事故来自重连。一次机房网络抖动导致大量客户端进入重连,UI 把缓存状态全部渲染成「离线」,客服在十分钟内收到大量「为什么所有人都离线」的工单。修复后,传输状态不是 live 时统一显示「状态未知」,重连完成后再按补拉结果逐条恢复。用户仍然会看到状态变化,但不会再被一个错误的断言吓到。

验收隐私边界的六个问题

隐私逻辑很难靠代码评审发现,因为它通常不是「算错了」,而是「算对了但不该算」。上线前可以用下面六个问题做一次验收,每个问题都对应一条可执行的检查:

  1. 无权字段是否在服务端就被剔除? 抓包看响应体,而不是看页面。如果 lastActiveAt 仍在 payload 里,客户端隐藏得再好也不算通过。
  2. 隐藏用户的聚合数字是否被排除? 构造一个 10 人群,其中 3 人关闭展示,检查群标题的在线人数是否仍把这 3 人算进去。
  3. 拉黑双方看到的结果是否完全一致? 用两个账号互相拉黑,对比接口响应与页面文案;任何差异都是关系泄露。
  4. 连接降级时是否显示未知而不是离线? 断网、切后台、重连各试一次,确认缓存状态没有冒充当前状态。
  5. 旧版本客户端是否会透传无权字段? 服务端灰度期间新旧版本共存,必须有客户端侧兜底,不能只依赖新协议。
  6. 派生展示是否绕过了开关? 检查「刚刚活跃」「最近在线」「X 分钟前回复」这类文案,它们可能来自已读、送达或发言时间,而不是 presence 字段。

六个问题里,前三个是数据源问题,后三个是展示与灰度问题。只修其中一半,隐私边界仍然是漏的。

降级文案的三条规则

状态不可见时,文案比状态码更容易泄露信息。可以坚持三条规则:

  • 统一用「状态未知」,不要为不同原因准备不同措辞。用户无法区分「关闭展示」「非互关」「被拉黑」,关系就不会被文案反推。
  • 不附带时间。任何「X 分钟前」「最近」「刚刚」都会把 unknown 变成一条可推断的时间线,哪怕主状态写的是未知。
  • 不提供可点击的解释入口。帮助文档可以解释隐私设置,但针对单个用户的「为什么看不到」不应该存在;它会把沉默变成一次查询。

文案层往往由产品与运营维护,代码评审覆盖不到。把这三条写进组件库的默认值,比在每一处调用点提醒更可靠。

时间本身也是侧信道

即使不下发 lastActiveAt,活动时间仍可能从别的地方泄露:已读回执的时间、消息送达的时间、共同群里的发言、资料页的「最近活跃」、甚至推送到达的顺序。隐私设计不能只盯着一个字段,而要考虑「哪些事件会暴露活动」。

对客户端来说,能做的是不把无权数据用于推断。例如,对方关闭了在线展示,但已读回执仍然实时到达;如果 UI 根据已读时间生成「刚刚活跃」,就等于绕过了开关。正确做法是让隐私设置覆盖所有派生展示,或者在数据层就不提供这些事件。

另一个边界是离线缓存。用户 A 关闭展示后,用户 B 的本地数据库里可能还留着上次同步的活跃时间。下次打开应用,B 不应该看到这个旧值,更不能根据它推断 A 最近是否上线。隐私状态变化时,客户端需要清理或失效相关缓存,而不是只更新一个开关。

小结

状态展示的核心不是「有数据就画绿点」,而是「没有权限或没有证据时,敢于显示未知」。服务端负责不下发无权字段,客户端负责不渲染、不推断、不缓存越权信息;拉黑双向沉默,连接降级回到未知,聚合数字也要按可见范围统计。

到这里,在线状态与输入中这条线走完了:先有近似判定,再有高频信号的节流与超时,最后是隐私与降级的展示边界。下一节离开「人此刻的状态」,转向人与人之间的关系图:关注、粉丝与关系缓存。

阅读导航:上一节:8.2 输入中状态的节流与超时 · 下一节:9.1 关注、互关与关系缓存 。

常见错误与边界

  • 只在下发后由客户端隐藏绿点。字段仍进入进程、日志与网络面板,等于没有隐私。
  • 把 unknown 显示成 offline。用户会把网络或隐私设置误解为对方故意不理。
  • 给 unknown 附带最后活跃时间。文案说未知,旁边的「3 小时前活跃」仍在泄露作息。
  • 拉黑与被拉黑使用不同文案。被拉黑方因此知道自己被拉黑,沉默失效。
  • 重连时把缓存状态当成当前状态。一次网络抖动让整屏好友「集体离线」。
  • 只过滤单人状态,不过滤「X 人在线」。聚合数字能反推出隐藏用户是否在线。
  • 只在 UI 层做权限判断,服务端仍下发无权字段。旧版本、脚本与埋点都能读到。
  • 隐私开关关闭后仍用已读、送达或发言时间生成「刚刚活跃」,绕过设置。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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