85b2da3db5
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>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
// 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);
|
||
}
|
||
}
|
||
}
|