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
@@ -0,0 +1,40 @@
// services/api/src/realtime/contract-ws.adapter.ts —— 契约帧协议的 WS 适配器
// (P2 Task 7)。@nestjs/platform-ws 的 WsAdapter 只分发 {event,data} 信封
// 帧,而 P2 契约(@photo/contracts realtime.ts)的客户端消息是 {type,...}
// 判别联合——本子类仅覆写 bindMessageHandler:按 frame.type 查处理映射,
// 整帧作为载荷传入网关方法(@SubscribeMessage(type)),其余(同端口 HTTP
// 挂载、upgrade 路由、关闭语义)全部继承 WsAdapter。非法 JSON / 非对象帧 /
// 未知 type 静默丢弃(EMPTY)——协议层 error 帧只对「可理解但被拒绝」的
// 消息(如 subscribe 校验失败)由网关显式返回。
import { WsAdapter } from '@nestjs/platform-ws';
import type { MessageMappingProperties } from '@nestjs/websockets/gateway-metadata-explorer';
import { EMPTY, type Observable } from 'rxjs';
export class ContractWsAdapter extends WsAdapter {
override bindMessageHandler(
buffer: { data?: unknown },
handlersMap: Map<string, MessageMappingProperties>,
transform: (data: unknown) => Observable<unknown>,
): Observable<unknown> {
let frame: unknown;
try {
frame = JSON.parse(String(buffer.data));
} catch {
return EMPTY;
}
if (typeof frame !== 'object' || frame === null) {
return EMPTY;
}
const { type } = frame as { type?: unknown };
if (typeof type !== 'string') {
return EMPTY;
}
const handler = handlersMap.get(type);
if (handler === undefined) {
return EMPTY;
}
// 整帧作为载荷(网关方法按契约类型解构);返回值经 transform 送回客户端
// (返回 undefined/void 时上层 filter 掉,不发送——无应答语义的消息用此)。
return transform(handler.callback(frame, type));
}
}
+38
View File
@@ -0,0 +1,38 @@
// services/api/src/realtime/ov-events.ts —— ov 侧「业务提交 → 实时广播」的
// 进程内事件契约(P2 Task 7)。点赞端点(interactions)与收件箱应用
// (sync-inbox)在**事务提交后**发布这些事件;WS 网关(realtime.gateway)
// 经 @OnEvent 监听并广播给相册房间。事件名/载荷只在本进程内有效。
//
// 单进程假设(登记偏差,P5 移交):EventEmitter2 是进程内总线——ov 入口
// 当前单实例部署(compose 仿真),多实例横向扩展需换 Redis pub/sub 跨进程
// 扇出(REDIS_URL_OV 已就位,P5 部署项);届时本文件的事件名/载荷可原样
// 迁移为频道名/消息体。
/** 点赞批次已提交入账(事务提交后发布;网关合并为 likes.burst)。 */
export const OV_LIKE_BATCH_COMMITTED = 'ov.like.batch.committed';
export interface LikeBatchCommittedEvent {
photoId: string;
albumId: string;
/** 本批 delta(burst 合并前的单批值)。 */
delta: number;
/** 提交事务后的权威累计与计数版本(burst 取合并窗内最新值)。 */
total: number;
countVersion: number;
/** 幂等批次键(burst 的 originBatchIds 元素,客户端据此过滤自身批次)。 */
batchId: string;
sessionId: string;
}
/** 副本照片可见性已应用(sync-inbox 事务提交后发布;网关原样转 photo.*)。 */
export const OV_PHOTO_APPLIED = 'ov.photo.applied';
export interface PhotoAppliedEvent {
kind: 'published' | 'hidden' | 'deleted';
photoId: string;
albumId: string;
/** cn 权威版本(photo.* 消息的 entityVersion)。 */
entityVersion: number;
/** kind=published 时必填(photo.published 消息字段)。 */
publishedAt?: string;
}
@@ -0,0 +1,322 @@
// services/api/src/realtime/realtime.gateway.ts —— ov 实时网关(P2 Task 7)。
// 与 HTTP 同端口(ov 8081)挂载 /v1/realtime(ContractWsAdapter 按契约帧
// {type,...} 分发)。模型:**公开订阅、按相册房间广播**——
// · 连接无鉴权(观众匿名域):subscribe 时逐相册校验 isAlbumVisibleOv
// (含紧急停用),失败回 {type:'error', code:ALBUM_NOT_PUBLIC},连接保持;
// · 房间 = album:{albumId}:likes.burst(500ms 合并窗,originBatchIds 供
// 客户端过滤自身批次)、photo.*(sync-inbox 应用后转发)按房间扇出,
// 广播不排除发送者(无服务端「发送者」概念——originBatchIds 是过滤依据);
// · 心跳:应用层 ping→pong(回显 ts)+ 传输层 HEARTBEAT_MS 周期 ping、
// 无 pong 的死连接 terminate(ws 客户端/浏览器按 RFC 自动回 pong)。
// 进程内事件源(ov-events.ts):EventEmitter2 单进程扇出——多实例部署需
// Redis pub/sub(P5,登记偏差)。
import { randomUUID } from 'node:crypto';
import {
type OnModuleDestroy,
Injectable,
Logger,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import {
ErrorCodes,
HEARTBEAT_MS,
LIKE_BURST_MERGE_MS,
REALTIME_WS_PATH,
} from '@photo/contracts';
// 契约帧类型与 Nest 装饰器重名(SubscribeMessage/WebSocketServer),以别名
// 区分:帧类型加 Frame 后缀,ws 原生服务器类型用 WsServer。
import type {
PongMessage,
RealtimeErrorMessage,
ServerMessage,
SubscribeMessage as SubscribeFrame,
UnsubscribeMessage as UnsubscribeFrame,
} from '@photo/contracts';
import { WebSocket } from 'ws';
import type { WebSocketServer as WsServer } from 'ws';
import {
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
import { isAlbumVisibleOv } from '../shared/album-visibility';
import {
OV_LIKE_BATCH_COMMITTED,
OV_PHOTO_APPLIED,
type LikeBatchCommittedEvent,
type PhotoAppliedEvent,
} from './ov-events';
/** likes.burst 合并窗口毫秒(惰性读 env,0=逐批即时广播;e2e 可调)。 */
export function likeBurstMergeMs(): number {
const parsed = Number.parseInt(process.env.LIKE_BURST_MERGE_MS ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : LIKE_BURST_MERGE_MS;
}
/** 房间键:album:{albumId}。 */
function roomKey(albumId: string): string {
return `album:${albumId}`;
}
/** 合并窗内的待发 burst(同照片聚合;flush 时一次广播)。 */
interface PendingBurst {
albumId: string;
delta: number;
total: number;
countVersion: number;
originBatchIds: string[];
timer: NodeJS.Timeout;
}
@Injectable()
@WebSocketGateway({ path: REALTIME_WS_PATH })
export class RealtimeGateway implements OnModuleDestroy {
private readonly logger = new Logger(RealtimeGateway.name);
@WebSocketServer()
private readonly server!: WsServer;
/** album:{albumId} → 订阅连接集(房间)。 */
private readonly rooms = new Map<string, Set<WebSocket>>();
/** 连接 → 已订 {publicToken → albumId}(退订/断开清理用)。 */
private readonly subscriptions = new Map<WebSocket, Map<string, string>>();
/** 传输层心跳存活标记(pong 置回 true;轮询时仍 false → terminate)。 */
private readonly alive = new Map<WebSocket, boolean>();
private heartbeatTimer: NodeJS.Timeout | null = null;
/** photoId → 合并窗待发 burst。 */
private readonly bursts = new Map<string, PendingBurst>();
constructor(private readonly prisma: OvPrismaService) {}
/** 传输层心跳:周期 ping,两个周期无 pong 的死连接踢除。 */
afterInit(): void {
this.heartbeatTimer = setInterval(() => {
for (const client of this.server.clients) {
const isAlive = this.alive.get(client) === true;
if (!isAlive) {
client.terminate();
continue;
}
this.alive.set(client, false);
client.ping();
}
}, HEARTBEAT_MS);
}
/** 应用生命周期结束:停心跳与合并窗定时器(房间随连接关闭清空)。 */
onModuleDestroy(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
for (const pending of this.bursts.values()) {
clearTimeout(pending.timer);
}
this.bursts.clear();
}
handleConnection(client: WebSocket): void {
this.alive.set(client, true);
this.subscriptions.set(client, new Map());
// ws 客户端/浏览器按 RFC 自动回传输层 pong——这里只记录存活。
client.on('pong', () => this.alive.set(client, true));
}
handleDisconnect(client: WebSocket): void {
const subs = this.subscriptions.get(client);
if (subs !== undefined) {
for (const albumId of subs.values()) {
this.leaveRoom(client, albumId);
}
this.subscriptions.delete(client);
}
this.alive.delete(client);
}
/** 订阅相册房间(逐相册校验公开性;失败回 error 帧,连接保持)。 */
@SubscribeMessage('subscribe')
async onSubscribe(
client: WebSocket,
payload: SubscribeFrame,
): Promise<RealtimeErrorMessage | void> {
const token = (payload as SubscribeFrame | undefined)?.albumToken;
if (typeof token !== 'string' || token === '') {
return {
type: 'error',
code: ErrorCodes.VALIDATION_FAILED,
message: 'albumToken 缺失',
};
}
try {
const album = await this.prisma.albums_ov.findUnique({
where: { publicToken: token },
});
if (album === null || !isAlbumVisibleOv(album)) {
return { type: 'error', code: ErrorCodes.ALBUM_NOT_PUBLIC };
}
this.joinRoom(client, album.albumId, token);
} catch (err) {
this.logger.error(
`subscribe 校验失败 token=${token}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { type: 'error', code: ErrorCodes.INTERNAL_ERROR };
}
}
/** 退订(幂等:未订阅的 token 静默忽略)。 */
@SubscribeMessage('unsubscribe')
onUnsubscribe(
client: WebSocket,
payload: UnsubscribeFrame,
): void {
const token = (payload as UnsubscribeFrame | undefined)?.albumToken;
if (typeof token !== 'string') {
return;
}
const albumId = this.subscriptions.get(client)?.get(token);
if (albumId !== undefined) {
this.subscriptions.get(client)?.delete(token);
this.leaveRoom(client, albumId);
}
}
/** 应用层心跳:回显 ts。 */
@SubscribeMessage('ping')
onPing(
client: WebSocket,
payload: { ts?: unknown },
): PongMessage {
void client;
return { type: 'pong', ts: typeof payload?.ts === 'number' ? payload.ts : Date.now() };
}
/** 点赞批次提交 → 合并窗聚合(LIKE_BURST_MERGE_MS,惰性 env)。 */
@OnEvent(OV_LIKE_BATCH_COMMITTED)
onLikeBatchCommitted(event: LikeBatchCommittedEvent): void {
const mergeMs = likeBurstMergeMs();
if (mergeMs <= 0) {
this.broadcastBurst(
event.albumId,
event.photoId,
event.delta,
event.total,
event.countVersion,
[event.batchId],
);
return;
}
let pending = this.bursts.get(event.photoId);
if (pending === undefined) {
pending = {
albumId: event.albumId,
delta: 0,
total: event.total,
countVersion: event.countVersion,
originBatchIds: [],
timer: setTimeout(() => this.flushBurst(event.photoId), mergeMs),
};
this.bursts.set(event.photoId, pending);
}
pending.delta += event.delta;
// 权威值取合并窗内最新提交(后到覆盖先到——countVersion 单调)。
pending.total = event.total;
pending.countVersion = event.countVersion;
pending.originBatchIds.push(event.batchId);
}
/** 副本照片可见性应用 → 原样转发契约 photo.* 消息到相册房间。 */
@OnEvent(OV_PHOTO_APPLIED)
onPhotoApplied(event: PhotoAppliedEvent): void {
const base = { photoId: event.photoId, albumId: event.albumId };
let message: ServerMessage;
if (event.kind === 'published') {
message = {
type: 'photo.published',
...base,
publishedAt: event.publishedAt ?? new Date(0).toISOString(),
entityVersion: event.entityVersion,
};
} else if (event.kind === 'hidden') {
message = { type: 'photo.hidden', ...base, entityVersion: event.entityVersion };
} else {
message = { type: 'photo.deleted', ...base, entityVersion: event.entityVersion };
}
this.sendToRoom(event.albumId, message);
}
private flushBurst(photoId: string): void {
const pending = this.bursts.get(photoId);
if (pending === undefined) {
return;
}
this.bursts.delete(photoId);
this.broadcastBurst(
pending.albumId,
photoId,
pending.delta,
pending.total,
pending.countVersion,
pending.originBatchIds,
);
}
private broadcastBurst(
albumId: string,
photoId: string,
delta: number,
total: number,
countVersion: number,
originBatchIds: string[],
): void {
this.sendToRoom(albumId, {
type: 'likes.burst',
photoId,
albumId,
delta,
total,
countVersion,
burstId: randomUUID(),
originBatchIds,
});
}
/** 房间扇出(跳过中途断开的连接;无房间 = 无人订阅,自然 no-op)。 */
private sendToRoom(albumId: string, message: ServerMessage): void {
const room = this.rooms.get(roomKey(albumId));
if (room === undefined) {
return;
}
const raw = JSON.stringify(message);
for (const client of room) {
if (client.readyState === WebSocket.OPEN) {
client.send(raw);
}
}
}
private joinRoom(client: WebSocket, albumId: string, token: string): void {
const key = roomKey(albumId);
let room = this.rooms.get(key);
if (room === undefined) {
room = new Set();
this.rooms.set(key, room);
}
room.add(client);
this.subscriptions.get(client)?.set(token, albumId);
}
private leaveRoom(client: WebSocket, albumId: string): void {
const key = roomKey(albumId);
const room = this.rooms.get(key);
if (room === undefined) {
return;
}
room.delete(client);
if (room.size === 0) {
this.rooms.delete(key);
}
}
}
@@ -0,0 +1,15 @@
// services/api/src/realtime/ —— ov 实时广播模块(P2 Task 7)。仅 ov 入口装配:
// 网关挂载由 main-ov 的 ContractWsAdapter 承担(同端口 /v1/realtime),本模块
// 注册网关 provider(订阅校验读 ov 副本 → OvPrismaModule)。广播事件源为
// 进程内 EventEmitter2(app 根 EventEmitterModule.forRoot() 全局注册;
// interactions/sync-inbox 发布,网关 @OnEvent 消费——无模块间直接依赖)。
// stats.updated(Task 8 浏览计数)将经同一模式接入。
import { Module } from '@nestjs/common';
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
import { RealtimeGateway } from './realtime.gateway';
@Module({
imports: [OvPrismaModule],
providers: [RealtimeGateway],
})
export class RealtimeModule {}