feat(viewer): likes, bubbles, visit reporting and hot sorting

Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 19:52:27 +08:00
parent 955bea49b5
commit cdead49838
29 changed files with 2581 additions and 29 deletions
+272
View File
@@ -0,0 +1,272 @@
// apps/viewer/src/stats/visitor.ts —— 访客标识与浏览上报(P2 Task 10,A14)。
//
// visitorId 跨标签共享(localStorage 'viewer.visitorId',32 hex):
// · 快路径:已存储 → 直接复用;
// · 冷启动:经 BroadcastChannel 'viewer-visitor' 广播 query 探询既有标签页
// → 有应答 claim 则采纳(写回本地);无应答 → 自生成、写后**读回校验**、
// 广播 claim;短暂窗口内听到更早的竞争 claim(claimedAt 小者胜,平局比
// id)则让位采纳——first-writer-wins;
// · BroadcastChannel 不可用(旧环境/jsdom)→ 纯 localStorage;
// · 存储整体不可用(读回失败/隐私模式极端)→ 页面级随机(不持久,跨刷新
// 变化——浏览计数退化为按页去重,登记为可接受降级)。
//
// 上报时机:相册信息加载成功 **且** 页面可见(隐藏时挂一次性 visibilitychange
// 等首次可见)。requestId(UUID)持久 sessionStorage 直到成功响应——窗口内
// 刷新/重试幂等;网络失败同 requestId 指数退避重试(最多 3 次),确认后以
// viewCount 权威值回调展示。
import { onBeforeUnmount, ref, type Ref } from 'vue';
import {
ApiError,
NETWORK_ERROR_CODE,
postAlbumVisit,
type VisitReportResponse,
} from '../api/client';
import { randomHex, randomUuid } from '../support/ids';
export const VISITOR_STORAGE_KEY = 'viewer.visitorId';
export const VISITOR_BROADCAST_CHANNEL = 'viewer-visitor';
/** 冷启动 query 探询窗(毫秒):等待既有标签页应答 claim。 */
export const VISITOR_QUERY_WINDOW_MS = 150;
/** 上报网络失败重试退避基值(毫秒),指数 1s/2s/4s。 */
export const VISIT_RETRY_BASE_MS = 1000;
export const VISIT_RETRY_MAX_ATTEMPTS = 3;
/** 测试注入面(缺省绑浏览器全局)。 */
export interface VisitorDeps {
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
channelFactory?: ((name: string) => BroadcastChannelLike | null) | null;
queryWindowMs?: number;
}
/** BroadcastChannel 最小使用面(jsdom 无实现,测试注入假件)。 */
export interface BroadcastChannelLike {
postMessage(data: unknown): void;
close(): void;
onmessage: ((event: { data: unknown }) => void) | null;
}
interface ClaimFrame {
type: 'claim';
visitorId: string;
claimedAt: number;
}
function isClaim(data: unknown): data is ClaimFrame {
if (typeof data !== 'object' || data === null) return false;
const frame = data as Record<string, unknown>;
return frame.type === 'claim' && typeof frame.visitorId === 'string' && typeof frame.claimedAt === 'number';
}
/** 解析/生成访客标识(异步:冷启动含跨标签探询窗)。 */
export async function resolveVisitorId(deps: VisitorDeps = {}): Promise<string> {
const storage = deps.storage ?? (typeof localStorage !== 'undefined' ? localStorage : undefined);
const queryWindowMs = deps.queryWindowMs ?? VISITOR_QUERY_WINDOW_MS;
if (storage !== undefined) {
try {
const existing = storage.getItem(VISITOR_STORAGE_KEY);
if (existing !== null && existing !== '') return existing;
} catch {
// 读失败视同无存储
}
}
const channel =
deps.channelFactory === undefined
? defaultChannel()
: deps.channelFactory === null
? null
: (deps.channelFactory(VISITOR_BROADCAST_CHANNEL) ?? null);
// 探询既有标签页(有 claim 应答即采纳)。
if (channel !== null) {
const ch = channel;
const answered = await new Promise<string | null>((resolve) => {
const timer = setTimeout(() => {
resolve(null);
}, queryWindowMs);
ch.onmessage = (event) => {
if (!isClaim(event.data)) return;
clearTimeout(timer);
resolve(event.data.visitorId);
};
ch.postMessage({ type: 'query' });
});
channel.onmessage = null;
if (answered !== null) {
channel.close();
if (storage !== undefined) {
try {
storage.setItem(VISITOR_STORAGE_KEY, answered);
} catch {
// 采纳成功但写回失败:内存态照用(本页一致即可)
}
}
return answered;
}
}
// 无应答:自生成 + 写读回校验 + 宣告(含短暂竞争窗,first-writer-wins)。
const claimedAt = Date.now();
const mine = randomHex(16);
if (storage !== undefined) {
try {
storage.setItem(VISITOR_STORAGE_KEY, mine);
const readBack = storage.getItem(VISITOR_STORAGE_KEY);
if (readBack !== mine) throw new Error('visitor id read-back mismatch');
} catch {
channel?.close();
return randomHex(16); // 存储不可用 → 页面级随机(不持久)
}
}
if (channel !== null) {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, queryWindowMs);
channel.onmessage = (event) => {
// 竞争 claim:更早者胜(claimedAt 平局比 id)——让位改写本地。
if (isClaim(event.data) && event.data.visitorId !== mine) {
const theirs: ClaimFrame = event.data;
const theirsWins =
theirs.claimedAt < claimedAt || (theirs.claimedAt === claimedAt && theirs.visitorId < mine);
if (theirsWins) {
clearTimeout(timer);
try {
storage?.setItem(VISITOR_STORAGE_KEY, theirs.visitorId);
} catch {
// 写失败忽略
}
// 本页仍用本轮 mine(跨页让位自下轮加载生效)——登记简化。
resolve();
}
}
};
channel.postMessage({ type: 'claim', visitorId: mine, claimedAt } satisfies ClaimFrame);
});
channel.onmessage = null;
channel.close();
}
return mine;
}
function defaultChannel(): BroadcastChannelLike | null {
if (typeof BroadcastChannel === 'undefined') return null;
// DOM BroadcastChannel 的 onmessage 事件类型比本模块最小使用面窄——收窄
// 仅为注入面统一(事件实际形状运行时一致)。
return new BroadcastChannel(VISITOR_BROADCAST_CHANNEL) as unknown as BroadcastChannelLike;
}
export interface VisitReportInputs {
token: Ref<string>;
/** 上报成功(任意 counted 结局):以权威 viewCount 更新展示。 */
onCounted: (viewCount: number) => void;
}
/**
* 浏览上报编排(ActivityPage 在相册加载成功后调用 notifyAlbumLoaded)。
* 单相册一次;token 变化(相册间跳转)重置可再报。
*/
export function useVisitReport(inputs: VisitReportInputs) {
const reported = ref(false);
let reporting = false;
let visibleListener: (() => void) | null = null;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
function resetForToken(): void {
reported.value = false;
reporting = false;
if (visibleListener !== null) {
document.removeEventListener('visibilitychange', visibleListener);
visibleListener = null;
}
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
}
/** 相册加载成功:可见即报,不可见挂首次可见。幂等(同 token 只报一次)。 */
function notifyAlbumLoaded(): void {
if (reported.value || reporting) return;
if (typeof document !== 'undefined' && document.hidden) {
if (visibleListener !== null) return;
visibleListener = () => {
if (document.hidden) return;
document.removeEventListener('visibilitychange', visibleListener!);
visibleListener = null;
notifyAlbumLoaded();
};
document.addEventListener('visibilitychange', visibleListener);
return;
}
reporting = true;
void resolveVisitorId().then(
(visitorId) => runReport(visitorId),
() => {
reporting = false; // 标识解析失败(理论不可达):放弃本次
},
);
}
/** requestId 持久 sessionStorage(键随 token),成功响应后清除。 */
function requestKey(): string {
return `viewer.visitRequest:${inputs.token.value}`;
}
function takeRequestId(): string {
try {
const existing = sessionStorage.getItem(requestKey());
if (existing !== null && existing !== '') return existing;
const created = randomUuid();
sessionStorage.setItem(requestKey(), created);
return created;
} catch {
return randomUuid(); // 存储不可用:单次内存幂等键
}
}
function clearRequestId(): void {
try {
sessionStorage.removeItem(requestKey());
} catch {
// 忽略
}
}
function runReport(visitorId: string, attempt = 0): void {
const requestId = takeRequestId();
postAlbumVisit(inputs.token.value, { visitorId, requestId })
.then((response: VisitReportResponse) => {
clearRequestId();
reported.value = true;
reporting = false;
inputs.onCounted(response.viewCount);
})
.catch((error: unknown) => {
// 可重试面:网络失败/5xx(服务端可能仍处理了——同 requestId 重试幂等)。
const retryable =
error instanceof ApiError && (error.code === NETWORK_ERROR_CODE || error.status >= 500);
if (!retryable || attempt + 1 >= VISIT_RETRY_MAX_ATTEMPTS) {
reported.value = true; // 4xx/限速/重试耗尽:静默放弃(不打扰观展)
reporting = false;
if (retryable) {
console.info('[viewer] visit report dropped after retries; viewCount stays as loaded');
}
return;
}
const timer = setTimeout(() => {
retryTimers.delete(timer);
runReport(visitorId, attempt + 1);
}, VISIT_RETRY_BASE_MS * 2 ** attempt);
retryTimers.add(timer);
});
}
onBeforeUnmount(() => {
if (visibleListener !== null) {
document.removeEventListener('visibilitychange', visibleListener);
visibleListener = null;
}
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
});
return { reported, notifyAlbumLoaded, resetForToken };
}