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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user