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
+231
View File
@@ -0,0 +1,231 @@
// apps/viewer/src/likes/useLikes.ts —— 点赞互动状态机(P2 Task 10)。
//
// 批次生命周期(技术方案 §7「批次收集」):
// 点击 → 乐观 +1(display = base + pending,含心跳动画 nonce)→ 计入待冲
// 队列;500ms 防抖到期或单照片累计 5 次 → 冲一批(batchId=UUID,delta=累计)
// → POST likes。响应 {total, countVersion} 权威校准:base=total(可能因他人
// 点赞跳变)、pending-=delta;countVersion 单调防乱序回退。
// 失败 → 同 batchId 退避重试(1s/2s/4s,3 次后丢弃:pending-=delta + console
// 备注——批次可能已落服务端(响应丢失),计数在下次交互/校准自然对齐)。
//
// 自身气泡过滤的数据面:每次成功提交登记 myBatches: batchId → delta(容量
// 上限裁剪);WS likes.burst.originBatchIds 与之求交得「己方贡献」,供 router
// 决定抑制动画但仍校准 total(见 realtime/router.ts)。
//
// 校准入口(三源殊途同归):
// · 批次响应 calibrate(total, countVersion)(版本化,可回退修正);
// · burst/stats 推送 calibrate(total, countVersion)(版本化);
// · 列表重取 recalibrateFromList(items)(无版本号 → base 单调不减,防快照
// 滞后让 display 回跳)。
import { onBeforeUnmount, reactive, watch, type Ref } from 'vue';
import {
postLikeBatch,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { randomUuid } from '../support/ids';
/** 冲批防抖窗(毫秒)。 */
export const LIKE_FLUSH_DEBOUNCE_MS = 500;
/** 单照片累计满额即冲(不等防抖窗)。 */
export const LIKE_FLUSH_CAP = 5;
/** 失败重试退避基值(毫秒),指数 1s/2s/4s。 */
export const LIKE_RETRY_BASE_MS = 1000;
/** 同 batchId 最大尝试次数(含首发),超出丢弃。 */
export const LIKE_RETRY_MAX_ATTEMPTS = 3;
/** 自身批次登记容量(burst 回显在其后到达,超限裁最旧)。 */
const MY_BATCHES_CAP = 100;
/** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */
const SESSION_KEY = 'viewer.likeSessionId';
/** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */
interface PhotoLikeState {
base: number;
pending: number;
version: number;
pop: number;
}
/** 在途/重试批次。 */
interface InflightBatch {
photoId: string;
batchId: string;
delta: number;
attempts: number;
}
export interface LikesInputs {
token: Ref<string>;
/** 当前已加载照片序列(首页到达/重取时同步权威 base)。 */
photos: () => PublicPhoto[];
}
function readSessionId(): string {
try {
const existing = sessionStorage.getItem(SESSION_KEY);
if (existing !== null && existing !== '') return existing;
const created = randomUuid();
sessionStorage.setItem(SESSION_KEY, created);
return created;
} catch {
// sessionStorage 不可用(隐私模式极端策略):每页内存随机即可——幂等性
// 依赖 batchId 而非 sessionId,仅损失「会话期间复用」的限速聚合粒度。
return randomUuid();
}
}
export function useLikes(inputs: LikesInputs) {
const sessionId = readSessionId();
/** photoId → 状态(响应式:display/pop 供模板直读)。 */
const states = reactive(new Map<string, PhotoLikeState>());
/** 待冲队列:photoId → 未提交点击数。 */
const queued = new Map<string, number>();
/** 成功批次登记:batchId → delta(自身气泡过滤)。 */
const myBatches = new Map<string, number>();
const inflight = new Set<InflightBatch>();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
function stateOf(photoId: string, seedCount?: number): PhotoLikeState {
let state = states.get(photoId);
if (state === undefined) {
state = { base: seedCount ?? 0, pending: 0, version: 0, pop: 0 };
states.set(photoId, state);
}
return state;
}
/** 乐观展示计数(模板用):base + pending。 */
function displayFor(photo: Pick<PublicPhoto, 'photoId' | 'likeCount'>): number {
const state = stateOf(photo.photoId, photo.likeCount);
return state.base + state.pending;
}
/** 点击:立即 +1(乐观)并入队;首个点击启动防抖窗。 */
function like(photoId: string): void {
const state = stateOf(photoId);
state.pending += 1;
state.pop += 1;
queued.set(photoId, (queued.get(photoId) ?? 0) + 1);
if ((queued.get(photoId) ?? 0) >= LIKE_FLUSH_CAP) {
flushQueue();
return;
}
if (flushTimer === null) {
flushTimer = setTimeout(flushQueue, LIKE_FLUSH_DEBOUNCE_MS);
}
}
/** 冲队:每照片一批(新 batchId);未满防抖窗的部分留给下一窗。 */
function flushQueue(): void {
if (flushTimer !== null) {
clearTimeout(flushTimer);
flushTimer = null;
}
for (const [photoId, delta] of queued) {
queued.delete(photoId);
sendBatch({ photoId, batchId: randomUuid(), delta, attempts: 0 });
}
}
function sendBatch(batch: InflightBatch): void {
inflight.add(batch);
postLikeBatch(inputs.token.value, batch.photoId, {
sessionId,
batchId: batch.batchId,
delta: batch.delta,
})
.then((response) => settleBatch(batch, response))
.catch(() => retryBatch(batch));
}
function settleBatch(
batch: InflightBatch,
response: { total: number; countVersion: number },
): void {
inflight.delete(batch);
myBatches.set(batch.batchId, batch.delta);
if (myBatches.size > MY_BATCHES_CAP) {
const oldest = myBatches.keys().next().value;
if (oldest !== undefined) myBatches.delete(oldest);
}
calibrate(batch.photoId, response.total, response.countVersion);
const state = states.get(batch.photoId);
if (state !== undefined) state.pending -= batch.delta;
}
function retryBatch(batch: InflightBatch): void {
inflight.delete(batch);
batch.attempts += 1;
if (batch.attempts >= LIKE_RETRY_MAX_ATTEMPTS) {
// 丢弃:撤销乐观量(服务端可能已计入——响应丢失情形由下次校准收敛)。
const state = states.get(batch.photoId);
if (state !== undefined) state.pending -= batch.delta;
console.info(
`[viewer] like batch dropped after ${batch.attempts} attempts (batchId=${batch.batchId}); count recalibrates on next interaction`,
);
return;
}
const delay = LIKE_RETRY_BASE_MS * 2 ** (batch.attempts - 1);
const timer = setTimeout(() => {
retryTimers.delete(timer);
sendBatch(batch);
}, delay);
retryTimers.add(timer);
}
/** 权威校准(批次响应/burst/stats 推送):版本单调,旧版本忽略。 */
function calibrate(photoId: string, total: number, countVersion: number): void {
const state = stateOf(photoId);
if (countVersion < state.version) return;
state.base = total;
state.version = countVersion;
}
/** 己方批次在 burst.originBatchIds 中的贡献总量(自身气泡过滤)。 */
function ownDeltaIn(batchIds: string[]): number {
let own = 0;
for (const id of batchIds) {
const delta = myBatches.get(id);
if (delta !== undefined) own += delta;
}
return own;
}
/** 列表重取校准(无版本号):base 单调不减(防滞后快照回跳);在途乐观量保留。 */
function recalibrateFromList(items: PublicPhoto[] | PublicPhotoList['items']): void {
for (const photo of items) {
const state = stateOf(photo.photoId, photo.likeCount);
if (photo.likeCount > state.base) state.base = photo.likeCount;
}
}
// 序列到达/重取(首页、排序切换、刷新)→ 同步服务端计数。
watch(
() => inputs.photos(),
(photos) => recalibrateFromList(photos),
);
onBeforeUnmount(() => {
if (flushTimer !== null) clearTimeout(flushTimer);
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
});
return reactive({
/** 乐观展示计数。 */
displayFor,
/** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */
popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0,
like,
calibrate,
ownDeltaIn,
recalibrateFromList,
states,
});
}
export type LikesController = ReturnType<typeof useLikes>;