feat(ov): idempotent batched likes and realtime broadcast

P2 Task 7(点赞批次端点 + ov 实时网关):
- interactions 模块(仅 ov 装配):POST /public/albums/{token}/photos/
  {photoId}/likes——相册不可见/照片未发布 404、delta 1..20 与
  sessionId/batchId 1..64 校验 400;单事务 like_batches 唯一插入
  (P2002=幂等重试,batchAccepted=true 不重加)→ photos_ov.likeTotal/
  countVersion 原子自增回读 → outbox_stat_events(kind=likes) 兜底;
  Redis ov(REDIS_URL_OV)INCRBY+TTL 会话/IP 双窗口限流(默认
  500/2000,超限 429 RATE_LIMITED),幂等重试先于限流不耗额度
- 过期批次重放边界(24h 清理后的 batchId 重插会再次计入):接受该
  边界——无永久账本可查(保留全量批次行违背清理策略),会话/IP 窗口
  上限即重放天然封顶(同一 sessionId 重放挤占自身额度),偏差已在
  likes.service 头注释登记,Task 8 统计侧按清理后语义处理
- realtime:ContractWsAdapter(契约 {type,...} 帧分发,ws 与 HTTP 同
  端口挂 /v1/realtime)+ RealtimeGateway 相册房间模型——subscribe 逐
  相册校验 isAlbumVisibleOv,失败回 error 帧且连接保持;应用层 ping→
  pong 回显 ts;传输层 HEARTBEAT_MS 周期 ping 踢死连接;点赞提交经
  EventEmitter2(单进程,多实例 Redis pub/sub 登记 P5)触发
  likes.burst 500ms 合并窗(delta 聚合、originBatchIds 供客户端过滤
  自身批次);sync-inbox 应用事务提交后发 OV_PHOTO_APPLIED →
  photo.published/hidden/deleted 按房间广播(stale/duplicate/暂存/
  缺席即撤下不转发)
- 契约:REALTIME_WS_PATH、ServerMessage 增 error 帧(穷尽锁 7 成员
  + 类型测试)、LIKE_SESSION_WINDOW_DELTA/LIKE_IP_WINDOW_DELTA/
  LIKE_BURST_MERGE_MS 常量与 .env.example
- e2e(likes-realtime.e2e-spec.ts,6 用例):A09 三批累加/幂等重试/
  outbox 行、小额度 429、ping/pong+幽灵 token error 帧连接保持、
  A10 双房间隔离(先证非空调订)、合并窗单 burst(delta 求和+双
  originBatchIds)、cn 隐藏→房间收 photo.hidden+隐藏后点赞 404;
  凡进程内起 AppOvModule 的 spec(sync×2/public-ov/emergency)补装
  ContractWsAdapter 与生产 main-ov 一致——api e2e 168(162+6)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 17:51:55 +08:00
parent 0fcf18cc5c
commit 85b2da3db5
23 changed files with 1722 additions and 215 deletions
@@ -23,10 +23,15 @@
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
// 仅推进版本,ov schema 无位);
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行预留)。
// ⑥ 实时转发(Task 7):photo 事件**应用事务提交后**发布 OV_PHOTO_APPLIED
// 进程内事件 → realtime 网关向相册房间广播契约 photo.* 消息(stale/
// duplicate/staged 不转发;从未复制即撤下的行也不转发——观众端无此照片
// 可撤,广播无意义)。
//
// 墓碑优先由 ② 保证:tombstone 事件版本必高于其前的任意旧版本,旧
// photo.published/category.updated 重放被版本比较直接拒绝。
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { components } from '@photo/contracts';
import type {
AlbumUpdatedPayload,
@@ -40,6 +45,7 @@ import type { Prisma } from '@photo/db-ov';
// 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用,
// type-only 导入会让 Nest 侧元数据退化为 Function 而 DI 失败(repo 约定)。
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
import { OV_PHOTO_APPLIED, type PhotoAppliedEvent } from '../realtime/ov-events';
export type ContentBatchResponse =
components['schemas']['ContentBatchResponse'];
@@ -57,7 +63,10 @@ interface CategoryReplicaEntry {
@Injectable()
export class SyncInboxService {
constructor(private readonly prisma: OvPrismaService) {}
constructor(
private readonly prisma: OvPrismaService,
private readonly events: EventEmitter2,
) {}
/**
* 批量接收:批内按序逐事件事务处理;任一事件失败抛错(整批 500)——发送器
@@ -82,10 +91,11 @@ export class SyncInboxService {
return { acceptedEventIds, applied };
}
/** 单事件事务(见文件头 ①-⑤)。P2002 = 并发同 eventId → duplicate。 */
/** 单事件事务(见文件头 ①-⑥)。P2002 = 并发同 eventId → duplicate。 */
async applyOne(event: SyncEventWire): Promise<Outcome> {
let realtime: PhotoAppliedEvent | undefined;
try {
return await this.prisma.$transaction(async (tx) => {
const outcome = await this.prisma.$transaction(async (tx) => {
// ① eventId 幂等锚:先查后插 + 唯一约束兜底(并发窗口)。
const seen = await tx.sync_inbox.findUnique({
where: { eventId: event.eventId },
@@ -106,8 +116,15 @@ export class SyncInboxService {
payload: event.payload as unknown as Prisma.InputJsonValue,
},
});
return this.decideAndApply(tx, event);
const applied = await this.decideAndApply(tx, event);
realtime = applied.realtime;
return applied.outcome;
});
// ⑥ 提交后转发($transaction resolve 即已 COMMIT;回滚路径上面已抛)。
if (realtime !== undefined) {
this.events.emit(OV_PHOTO_APPLIED, realtime);
}
return outcome;
} catch (err) {
// P2002 鸭子类型判别(同 albums/support.ts isUniqueViolationOn 的约定:
// Prisma 命名空间经 @photo/db-ov 仅 type 出口,不做值导入)——本端点
@@ -126,12 +143,13 @@ export class SyncInboxService {
/**
* ②-⑤ 裁决与应用(inbox 行已存在的共用核心):新事件(applyOne 落库后)
* 与暂存回扫(sweepStaged 对既有行重跑)同一出口。回扫**不能**走 applyOne
* 整程——其 eventId 幂等检查会把暂存行判成 duplicate 而永不应用。
* 整程——其 eventId 幂等检查会把暂存行判成 duplicate 而永不应用。返回
* ⑥ 的实时转发事件(photo 事件才有;stale/staged 无)。
*/
private async decideAndApply(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<Outcome> {
): Promise<{ outcome: Outcome; realtime?: PhotoAppliedEvent }> {
// ② 版本单调:**更旧**版本裁决即终(appliedAt 置位,不入积压);**等于**
// 已应用版本 → 重断言应用(同版本=同状态,幂等重投影;digest→bootstrap
// 补漏修复缺失副本行的机制,见文件头②)。
@@ -148,7 +166,7 @@ export class SyncInboxService {
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'stale' as const;
return { outcome: 'stale' };
}
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。
if (event.entityType !== 'album') {
@@ -157,11 +175,11 @@ export class SyncInboxService {
select: { albumId: true },
});
if (album === null) {
return 'staged' as const;
return { outcome: 'staged' };
}
}
// ④ 投影。
await this.project(tx, event);
// ④ 投影(photo 事件带回实时转发载荷)。
const realtime = await this.project(tx, event);
// ⑤ 版本账本。
await tx.sync_state.upsert({
where: {
@@ -185,14 +203,14 @@ export class SyncInboxService {
where: { eventId: event.eventId },
data: { appliedAt: new Date() },
});
return 'applied' as const;
return { outcome: 'applied', realtime };
}
/** ④ 按实体类型投影到副本表(版本已由 ② 保证更高)。 */
private async project(
tx: Prisma.TransactionClient,
event: SyncEventWire,
): Promise<void> {
): Promise<PhotoAppliedEvent | undefined> {
if (event.entityType === 'album') {
const p = event.payload as unknown as AlbumUpdatedPayload;
// categories 全量清单(可选,仅 bootstrap/reconcile 快照事件携带):整体
@@ -237,7 +255,7 @@ export class SyncInboxService {
entityVersion: event.entityVersion,
},
});
return;
return undefined;
}
if (event.entityType === 'category') {
const album = await tx.albums_ov.findUniqueOrThrow({
@@ -258,7 +276,7 @@ export class SyncInboxService {
) as unknown as Prisma.InputJsonValue,
},
});
return;
return undefined;
}
const p = event.payload as unknown as CategoryUpdatedPayload;
const entry: CategoryReplicaEntry = {
@@ -276,7 +294,7 @@ export class SyncInboxService {
where: { albumId: event.albumId },
data: { categories: entries as unknown as Prisma.InputJsonValue },
});
return;
return undefined;
}
if (event.entityType === 'photo') {
const payload = event.payload as unknown as Partial<
@@ -293,7 +311,7 @@ export class SyncInboxService {
if (visibility !== 'published') {
// 墓碑/隐藏先于任何 published 到达且副本行不存在:缺席即撤下语义,
// 无行可改——版本账本照常推进(旧 published 之后到会被版本比较拒绝)。
return;
return undefined;
}
const p = payload as PhotoPublishedPayload;
await tx.photos_ov.create({
@@ -311,7 +329,14 @@ export class SyncInboxService {
entityVersion: event.entityVersion,
},
});
return;
// ⑥ 实时转发载荷(photo.published)。
return {
kind: 'published',
photoId: event.entityId,
albumId: event.albumId,
entityVersion: event.entityVersion,
publishedAt: p.publishedAt,
};
}
// update 分支保留 likeTotal/countVersion(ov 权威计数,Task 7/9)。
const data: Prisma.photos_ovUpdateInput = {
@@ -332,7 +357,25 @@ export class SyncInboxService {
where: { photoId: event.entityId },
data,
});
return;
// ⑥ 实时转发载荷:published 直映;hidden/deleted 按契约消息;其余
// visibility 字符串(unpublished 等防御路径)按 hidden 语义转发——
// 观众端的正确反应都是「即刻移出列表」。
if (visibility === 'published') {
const p = payload as PhotoPublishedPayload;
return {
kind: 'published',
photoId: event.entityId,
albumId: event.albumId,
entityVersion: event.entityVersion,
publishedAt: p.publishedAt,
};
}
return {
kind: visibility === 'deleted' ? 'deleted' : 'hidden',
photoId: event.entityId,
albumId: event.albumId,
entityVersion: event.entityVersion,
};
}
if (event.entityType === 'asset') {
const p = event.payload as unknown as AssetUpdatedPayload;
@@ -343,9 +386,10 @@ export class SyncInboxService {
});
}
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
return;
return undefined;
}
// slot-state 预留:受理即 applied(无投影),版本账本照常推进。
return undefined;
}
/** album 应用后回扫该相册暂存事件(receivedAt 升序),返回补应用数。 */
@@ -367,13 +411,17 @@ export class SyncInboxService {
priority: row.priority as SyncEventWire['priority'],
payload: row.payload as unknown as SyncEventWire['payload'],
};
// 行已存在(eventId 即本行主键),走 ②-⑤ 核心而非 applyOne 整程
// 行已存在(eventId 即本行主键),走 ②-⑥ 核心而非 applyOne 整程
// (幂等检查会把回扫误判 duplicate,见 decideAndApply 注释)。
const outcome = await this.prisma.$transaction((tx) =>
const result = await this.prisma.$transaction((tx) =>
this.decideAndApply(tx, event),
);
if (outcome === 'applied') {
if (result.outcome === 'applied') {
applied += 1;
// ⑥ 暂存补应用同样提交后转发(依赖相册到达的迟到照片事件)。
if (result.realtime !== undefined) {
this.events.emit(OV_PHOTO_APPLIED, result.realtime);
}
}
}
return applied;