Files
photo-live/services/api/src/realtime/realtime.gateway.ts
T
浅念 85b2da3db5 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>
2026-09-18 17:51:55 +08:00

323 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
}
}