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
+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();
}
}
}
}