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
+15 -2
View File
@@ -4,8 +4,11 @@
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);Task 5
// 加入 emergency(海外紧急停用通道,独立于同步);公开读自 Task 6 起装配
// (public-ov:读 photos_ov 副本的同形契约端点 + hot 排序);likes/visits、
// WS 网关自 Task 7+ 起逐个加入。cn 模块保持原样,两入口互不影响。
// (public-ov:读 photos_ov 副本的同形契约端点 + hot 排序);Task 7 起加入
// interactions(点赞批次幂等 + Redis ov 限速)与 realtime(WS 网关:契约帧
// 分发见 main-ov 的 ContractWsAdapter),EventEmitter2 全局总线承接「事务提交
// → 实时广播」(ov-events.ts,单进程——多实例 P5)。cn 模块保持原样,两入口
// 互不影响。
import {
type MiddlewareConsumer,
Module,
@@ -13,11 +16,14 @@ import {
ValidationPipe,
} from '@nestjs/common';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { LoggerModule } from 'nestjs-pino';
import { AllExceptionsFilter } from './all-exceptions.filter';
import { EmergencyModule } from './emergency/emergency.module';
import { HealthController } from './health.controller';
import { InteractionsModule } from './interactions/interactions.module';
import { PublicOvModule } from './public-ov/public-ov.module';
import { RealtimeModule } from './realtime/realtime.module';
import { RequestIdMiddleware } from './request-id.middleware';
import { createLoggerOptions } from './shared/logger-options';
import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
@@ -25,12 +31,19 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
@Module({
imports: [
LoggerModule.forRoot(createLoggerOptions()),
// 进程内事件总线(Task 7):interactions/sync-inbox 发布 → realtime 网关
// 消费。forRoot 缺省 global——子模块直接注入 EventEmitter2。
EventEmitterModule.forRoot(),
// ov 内容事件收件箱(P2 Task 3):POST /v1/internal/sync/content-batches。
SyncInboxModule,
// 海外紧急停用通道(P2 Task 5):POST /v1/internal/emergency/disable|enable。
EmergencyModule,
// ov 公开读(P2 Task 6):/public/albums/* 读 ov 副本(含 sort=hot 快照)。
PublicOvModule,
// ov 公开互动(P2 Task 7):POST /public/albums/*/photos/*/likes。
InteractionsModule,
// ov 实时广播(P2 Task 7):WS /v1/realtime(适配器见 main-ov)。
RealtimeModule,
],
controllers: [HealthController],
providers: [
+24
View File
@@ -0,0 +1,24 @@
// services/api/src/interactions/dto.ts —— 点赞批次上报请求体(P2 Task 7)。
// 校验边界与契约 LikeBatchRequest 逐字段对齐(sessionId/batchId 1..64、
// delta 1..20 整数);越界一律 400 VALIDATION_FAILED(全局 ValidationPipe
// whitelist 剥离未声明字段)。delta 必须是 JSON number 整数(管道未开
// transform,字符串数字不静默转换——公开匿名端点从严)。
import { IsInt, IsString, Length, Max, Min } from 'class-validator';
export class LikeBatchDto {
/** 会话标识(客户端随机生成、会话期间复用;限速滑窗的键之一)。 */
@IsString()
@Length(1, 64)
sessionId!: string;
/** 批次幂等键:同批失败重试必须复用,跨批次不得复用。 */
@IsString()
@Length(1, 64)
batchId!: string;
/** 本批新增点赞数(客户端短窗合并后的累计点击数)。 */
@IsInt()
@Min(1)
@Max(20)
delta!: number;
}
@@ -0,0 +1,17 @@
// services/api/src/interactions/ —— ov 公开互动模块(P2 Task 7 起)。
// 仅 ov 入口装配(app-ov.module 显式挑选;cn 入口不感知)。Task 7 装配
// 点赞批次端点;Task 8 起浏览上报(visits)同域加入。事件广播经
// EventEmitter2(app 根模块 EventEmitterModule.forRoot() 全局注册)发往
// realtime 网关,无模块间直接依赖。
import { Module } from '@nestjs/common';
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
import { OvRedisModule } from '../redis-ov/ov-redis.module';
import { LikesController } from './likes.controller';
import { LikesService } from './likes.service';
@Module({
imports: [OvPrismaModule, OvRedisModule],
controllers: [LikesController],
providers: [LikesService],
})
export class InteractionsModule {}
@@ -0,0 +1,35 @@
// services/api/src/interactions/likes.controller.ts —— 点赞批次端点
// (P2 Task 7,ov 域)。路由与契约 likePhotoBatch 一致(tags [interactions],
// 路径前缀不变、部署域名区分);匿名(无 SessionGuard)。IP 取 req.ip 并
// 归一 IPv6 映射前缀(::ffff:127.0.0.1 → 127.0.0.1),仅作限速键——不落库、
// 不进日志(匿名观众端无 IP 留存需求)。真实部署经反代时应配置 trust proxy
// 系按部署环境处理,登记 P5。
import {
Body,
Controller,
HttpCode,
HttpStatus,
Ip,
Param,
Post,
} from '@nestjs/common';
import { LikeBatchDto } from './dto';
import { LikesService, type LikeBatchResponse } from './likes.service';
@Controller('public/albums')
export class LikesController {
constructor(private readonly likes: LikesService) {}
/** 契约 200(含幂等重试与过期拒绝——拒绝也 200,语义在 batchAccepted)。 */
@HttpCode(HttpStatus.OK)
@Post(':token/photos/:photoId/likes')
likeBatch(
@Param('token') token: string,
@Param('photoId') photoId: string,
@Body() dto: LikeBatchDto,
@Ip() ip: string | undefined,
): Promise<LikeBatchResponse> {
const clientIp = ip === undefined ? undefined : ip.replace(/^::ffff:/, '');
return this.likes.likeBatch(token, photoId, dto, clientIp);
}
}
@@ -0,0 +1,248 @@
// services/api/src/interactions/likes.service.ts —— 匿名点赞批次(P2 Task 7,
// ov 域)。契约 POST /public/albums/{token}/photos/{photoId}/likes。全流程:
//
// ① 校验:相册 publicToken → albums_ov + isAlbumVisibleOv(含紧急停用)
// → 统一 404 ALBUM_NOT_PUBLIC;照片不存在/不属于该册/非 published →
// 404 PHOTO_NOT_PUBLIC;DTO 边界(1..64/1..20)由全局管道 400。
// ② 幂等快速通道:like_batches 唯一 [photoId,sessionId,batchId] 命中既有行
// → 返回当前权威 {total, countVersion, batchAccepted:true},不进事务、
// 不消耗限速预算(客户端重试同 batchId 是正常路径,不因限速误伤)。
// ③ 基础限速(Redis ov 滑窗,见 assertRateLimit):超限 429 RATE_LIMITED。
// ④ 入账事务:like_batches 插入(唯一约束兜底并发重试 → P2002 走 ② 语义)
// → photos_ov.updateMany 原子 likeTotal+=delta、countVersion+=1(行锁
// 串行化并发批次,随后同事务读回新值)→ outbox_stat_events 记权威快照
// {total, statVersion}(Task 9 回传 cn 的「仅更高版本替换」队列)。
// ⑤ 提交后:发布 OV_LIKE_BATCH_COMMITTED 进程内事件 → WS 网关合并广播
// likes.burst(realtime.gateway)。
//
// —— 过期批次重放的窗口边界(登记偏差,Task 8 清理落地后生效)——
// like_batches 行被清理调度按 LIKE_BATCH_WINDOW_HOURS(24h)删除后,同一
// (photoId,sessionId,batchId) 的重放会以「新批次」身份重新插入并再次加计。
// 真正的窗口外拒绝需要**永久保留幂等账本**——与清理调度存在的理由(无界
// 增长)直接冲突,故本实现接受该 24h 边界,缓解措施:
// · sessionId 滑窗上限 LIKE_SESSION_WINDOW_DELTA(默认 500/窗口):重放
// 消耗同一预算,单会话每窗口的重复加计被钳制;
// · 匿名端点本就无强身份,伪造新 sessionId 的增量滥用与重放滥用同级,
// 基础限速的定位是「误客户端缓冲」而非防滥用(真实防护归 P5 速率层)。
// 契约中「窗口外拒绝重放」语义由 Task 8 的清理端补全:清理即拒绝点——清理
// 后行不存在与「从未提交」在数据上不可区分(batchId 无客户端时间戳可信),
// 这是无永久账本下的理论边界,登记偏差供验收评审裁决。
import { HttpStatus, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LIKE_BATCH_WINDOW_HOURS,
LIKE_IP_WINDOW_DELTA,
LIKE_SESSION_WINDOW_DELTA,
ErrorCodes,
} from '@photo/contracts';
import type { components } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { albumNotPublic, photoNotPublic } from '../public/support';
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
import { isAlbumVisibleOv } from '../shared/album-visibility';
import { OvRedisService } from '../redis-ov/ov-redis.module';
import {
OV_LIKE_BATCH_COMMITTED,
type LikeBatchCommittedEvent,
} from '../realtime/ov-events';
import type { LikeBatchDto } from './dto';
export type LikeBatchResponse = components['schemas']['LikeBatchResponse'];
/** 限速滑窗秒数(= 幂等窗口,惰性读 env 供测试调整)。 */
export function likeWindowSeconds(): number {
const parsed = Number.parseInt(
process.env.LIKE_BATCH_WINDOW_HOURS ?? '',
10,
);
const hours =
Number.isFinite(parsed) && parsed > 0 ? parsed : LIKE_BATCH_WINDOW_HOURS;
return hours * 3600;
}
/** 单 sessionId 窗口累计 delta 上限(惰性读 env,同 hotSortTtlMs 模式)。 */
export function likeSessionWindowDelta(): number {
const parsed = Number.parseInt(
process.env.LIKE_SESSION_WINDOW_DELTA ?? '',
10,
);
return Number.isFinite(parsed) && parsed >= 0
? parsed
: LIKE_SESSION_WINDOW_DELTA;
}
/** 来源 IP 窗口累计 delta 上限(NAT 出口共享,宽于单会话)。 */
export function likeIpWindowDelta(): number {
const parsed = Number.parseInt(process.env.LIKE_IP_WINDOW_DELTA ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0
? parsed
: LIKE_IP_WINDOW_DELTA;
}
function rateLimited(): ApiException {
return new ApiException(
ErrorCodes.RATE_LIMITED,
'点赞过于频繁,请稍后再试',
HttpStatus.TOO_MANY_REQUESTS,
);
}
@Injectable()
export class LikesService {
constructor(
private readonly prisma: OvPrismaService,
private readonly redis: OvRedisService,
private readonly events: EventEmitter2,
) {}
async likeBatch(
token: string,
photoId: string,
dto: LikeBatchDto,
clientIp: string | undefined,
): Promise<LikeBatchResponse> {
// ① 相册/照片公开校验(同 public-ov 读路径口径)。
const album = await this.prisma.albums_ov.findUnique({
where: { publicToken: token },
});
if (album === null || !isAlbumVisibleOv(album)) {
throw albumNotPublic();
}
const photo = await this.prisma.photos_ov.findUnique({
where: { photoId },
});
if (
photo === null ||
photo.albumId !== album.albumId ||
photo.visibility !== 'published'
) {
throw photoNotPublic();
}
// ② 幂等快速通道:既有批次 = 客户端重试已确认批次 → 权威值 + true。
const existing = await this.prisma.like_batches.findUnique({
where: {
photoId_sessionId_batchId: {
photoId,
sessionId: dto.sessionId,
batchId: dto.batchId,
},
},
select: { id: true },
});
if (existing !== null) {
return this.authoritative(photoId, true);
}
// ③ 基础限速(通过后才进事务;拒绝也消耗预算——固定窗限速惯例)。
await this.assertRateLimit(dto.sessionId, clientIp, dto.delta);
// ④ 入账事务(并发同批由唯一约束兜底 → ② 语义)。
try {
const committed = await this.prisma.$transaction(async (tx) => {
await tx.like_batches.create({
data: {
photoId,
sessionId: dto.sessionId,
batchId: dto.batchId,
delta: dto.delta,
},
});
// 原子自增(行锁串行并发批次)+ 同事务读回新值(own-write 可见)。
await tx.photos_ov.updateMany({
where: { photoId },
data: {
likeTotal: { increment: dto.delta },
countVersion: { increment: 1 },
},
});
const row = await tx.photos_ov.findUniqueOrThrow({
where: { photoId },
select: { likeTotal: true, countVersion: true },
});
// 回传队列记权威快照(Task 9:cn 侧仅更高 statVersion 替换)。
await tx.outbox_stat_events.create({
data: {
kind: 'likes',
albumId: album.albumId,
photoId,
total: row.likeTotal,
statVersion: row.countVersion,
},
});
return { total: row.likeTotal, countVersion: row.countVersion };
});
// ⑤ 提交后广播事件($transaction resolve 即已 COMMIT;失败时上面已抛)。
const event: LikeBatchCommittedEvent = {
photoId,
albumId: album.albumId,
delta: dto.delta,
total: committed.total,
countVersion: committed.countVersion,
batchId: dto.batchId,
sessionId: dto.sessionId,
};
this.events.emit(OV_LIKE_BATCH_COMMITTED, event);
return { ...committed, batchAccepted: true };
} catch (err) {
if (
typeof err === 'object' &&
err !== null &&
(err as { code?: unknown }).code === 'P2002'
) {
// 并发窗口的同批重试:胜者已提交(唯一冲突在对方 COMMIT 后才可见),
// 返回权威值 + batchAccepted:true(幂等语义,不加计、不重发事件)。
return this.authoritative(photoId, true);
}
throw err;
}
}
/** 当前权威计数(幂等/冲突路径共用的只读应答)。 */
private async authoritative(
photoId: string,
batchAccepted: boolean,
): Promise<LikeBatchResponse> {
const row = await this.prisma.photos_ov.findUniqueOrThrow({
where: { photoId },
select: { likeTotal: true, countVersion: true },
});
return {
total: row.likeTotal,
countVersion: row.countVersion,
batchAccepted,
};
}
/**
* 滑窗限速:Redis ov INCRBY 累计 delta,首次写键挂窗口 TTL(固定窗近似
* 滑动窗——键自首次点赞起存活一个窗口)。sessionId 键先查(拒绝不消耗
* IP 预算);IP 键兜底伪造新 sessionId 的批量滥用(NAT 共享场景上限放宽)。
*/
private async assertRateLimit(
sessionId: string,
clientIp: string | undefined,
delta: number,
): Promise<void> {
const windowSeconds = likeWindowSeconds();
const windows: Array<{ key: string; cap: number }> = [
{ key: `like:window:sess:${sessionId}`, cap: likeSessionWindowDelta() },
];
if (clientIp !== undefined && clientIp !== '') {
windows.push({
key: `like:window:ip:${clientIp}`,
cap: likeIpWindowDelta(),
});
}
for (const { key, cap } of windows) {
const counted = await this.redis.incrby(key, delta);
if (counted === delta) {
// 新键(本窗口首次):挂 TTL;EXPIRE 失败不影响本轮判定。
await this.redis.expire(key, windowSeconds).catch(() => undefined);
}
if (counted > cap) {
throw rateLimited();
}
}
}
}
+7
View File
@@ -4,11 +4,16 @@
// 不变,部署域名区分」,ov 观众端(viewer :5175,P2 Task 10 切换 VITE_API_BASE
// 指向本入口)与 cn viewer 共用 CORS_ORIGINS env,无需 ov 专属配置。env 一律
// 来自进程环境(根 dotenv-cli 注入)。
// Task 7 起:WS 网关与 HTTP **同端口**(契约执行注意:WS 与 HTTP 同端口
// 8081)——ContractWsAdapter 按 realtime.ts 契约帧 {type,...} 分发,网关挂载
// /v1/realtime(REALTIME_WS_PATH),upgrade 请求经适配器路由到 noServer 的
// ws Server,普通 HTTP 路由不受影响。
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import { Logger } from 'nestjs-pino';
import { AppOvModule } from './app-ov.module';
import { ContractWsAdapter } from './realtime/contract-ws.adapter';
import { configureApp } from './shared/configure-app';
async function bootstrap(): Promise<void> {
@@ -20,6 +25,8 @@ async function bootstrap(): Promise<void> {
rawBody: true,
});
app.useLogger(app.get(Logger));
// WS 与 HTTP 同端口:网关(@WebSocketGateway 无 port)挂到本应用 HTTP 服务。
app.useWebSocketAdapter(new ContractWsAdapter(app));
configureApp(app);
const port = Number.parseInt(process.env.PORT_OV ?? '', 10) || 8081;
await app.listen(port);
@@ -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 {}
@@ -0,0 +1,42 @@
// services/api/src/redis-ov/ —— OV 侧 Redis(redis-ov 6381)的可注入封装
// (P2 Task 7)。与 OvPrismaService 同款约定:非 @Global、独立类名,仅 ov
// 角色模块(interactions 等)显式 import,防止 cn 角色模块误注入。首个消费方
// 是点赞基础限速(sessionId/IP 滑窗 INCRBY+EXPIRE);P5 多实例部署时本连接
// 亦是跨进程实时扇出(pub/sub)的就位基础(见 realtime/ov-events.ts 头注)。
//
// 生命周期:构造即连接(ioredis 缺省急连 + 自动重连),onModuleDestroy 断开
// ——e2e 的 app.close() 不残留句柄。error 事件挂 no-op 监听(同 queue.provider
// 约定:未监听的 error 会崩进程,重连风暴交给命令失败语义)。Redis 不可用时
// 依赖它的端点 5xx(限速是 ov 互动域的组成环节,不做 fail-open——开放匿名
// 写入端点在基础设施故障时的正确姿势是拒绝而非放行)。
import {
Injectable,
Module,
type OnModuleDestroy,
} from '@nestjs/common';
import IORedis from 'ioredis';
@Injectable()
export class OvRedisService
extends IORedis
implements OnModuleDestroy
{
constructor() {
super(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381', {
// 滑窗限速命令的失败要快速上抛(5xx),不做长时间排队重试。
maxRetriesPerRequest: 2,
});
// no-op 监听:吞掉重连期 error 事件(命令层失败已向上传播)。
this.on('error', () => undefined);
}
async onModuleDestroy(): Promise<void> {
await this.quit().catch(() => undefined);
}
}
@Module({
providers: [OvRedisService],
exports: [OvRedisService],
})
export class OvRedisModule {}
@@ -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;