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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user