feat(ov): windowed dedup visit counting with retention cleanup
Task 8 (P2): POST /public/albums/{token}/visits with visit_sessions
window dedup (VISIT_WINDOW_MINUTES), requestId idempotent replay
(echoes stored outcome; expired beyond VISIT_REQUEST_TTL_MINUTES
rejected), album_stats viewCount/statVersion authority + outbox_stat_events
views snapshots, WS stats.updated broadcast, visitorId+IP fixed-window
rate limit (VISIT_WINDOW_REQUESTS), and ov CleanupModule scheduler
(setInterval, CLEANUP_INTERVAL_MINUTES) deleting like_batches /
visit_requests / visit_sessions past their windows — totals never
touched. Contract prose aligned to replay semantics; constants
VISIT_WINDOW_REQUESTS / CLEANUP_INTERVAL_MINUTES added.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -55,3 +55,8 @@ LIKE_SESSION_WINDOW_DELTA=500
|
||||
LIKE_IP_WINDOW_DELTA=2000
|
||||
LIKE_BURST_MERGE_MS=500
|
||||
VISIT_REQUEST_TTL_MINUTES=35
|
||||
# P2 Task 8:浏览上报基础限速(visitorId+IP 固定窗,窗口=VISIT_WINDOW_MINUTES,
|
||||
# 每窗请求数上限——去重本身为主缓解)与 ov 互动流水清理调度间隔(分钟,0=停用;
|
||||
# 清理 like_batches/visit_requests/visit_sessions 超窗口行,累计总数永不清)。
|
||||
VISIT_WINDOW_REQUESTS=60
|
||||
CLEANUP_INTERVAL_MINUTES=60
|
||||
|
||||
@@ -1202,12 +1202,14 @@ paths:
|
||||
加载成功后上报一次(列表/图片/WS 等接口调用一律不触发计数)。
|
||||
|
||||
- requestId 为上报幂等键(建议 UUID),counted 确认前跨刷新/重试持久复用;
|
||||
重复 requestId 返回 counted=false 与权威 viewCount,不重复计数。
|
||||
重复 requestId 幂等重放:counted 回显该 requestId 首次处理的结局
|
||||
(确认响应丢失后重试仍见 true),viewCount 恒为当前权威值,不重复计数。
|
||||
- 去重窗口(VISIT_WINDOW_MINUTES,默认 30 分钟,D07)内同一 visitorId
|
||||
不重复计数;窗口过后再上报按新访次计数。
|
||||
- 过期 requestId(VISIT_REQUEST_TTL_MINUTES,默认 35 分钟)拒绝重放
|
||||
(counted=false),不得重新加计。
|
||||
- 基础限速:visitorId 滑窗,超限 429(RATE_LIMITED);去重本身为主缓解。
|
||||
(counted=false,无论原结局),不得重新加计。
|
||||
- 基础限速:visitorId+来源 IP 固定窗(VISIT_WINDOW_REQUESTS,默认
|
||||
60/窗),超限 429(RATE_LIMITED);去重本身为主缓解。
|
||||
- 相册非公开/紧急停用 404(ALBUM_NOT_PUBLIC)。
|
||||
security: []
|
||||
requestBody:
|
||||
@@ -2666,8 +2668,9 @@ components:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
description: |
|
||||
本次是否实际计数:true=新访次;false=去重窗口内重复、重复
|
||||
requestId 或过期 requestId 重放(均不重复计数)。
|
||||
本次是否计入:true=新访次,或幂等重放已确认计入的 requestId
|
||||
(回显原结局);false=去重窗口内重复或过期 requestId 重放
|
||||
(均不重复计数)。
|
||||
|
||||
# ── 内部同步 ──
|
||||
|
||||
|
||||
@@ -28,3 +28,7 @@ export const LIKE_IP_WINDOW_DELTA = 2000;
|
||||
export const LIKE_BURST_MERGE_MS = 500;
|
||||
/** 浏览上报 requestId 保留分钟:过期 requestId 拒绝重放(≥ 去重窗口)。 */
|
||||
export const VISIT_REQUEST_TTL_MINUTES = 35;
|
||||
/** 浏览上报基础限速:visitorId+IP 固定窗(=去重窗口)内请求数上限,超限 429。 */
|
||||
export const VISIT_WINDOW_REQUESTS = 60;
|
||||
/** ov 互动流水清理调度间隔分钟(like_batches/visit_requests/visit_sessions)。 */
|
||||
export const CLEANUP_INTERVAL_MINUTES = 60;
|
||||
|
||||
+8
-5
@@ -554,12 +554,14 @@ export interface paths {
|
||||
* 加载成功后上报一次(列表/图片/WS 等接口调用一律不触发计数)。
|
||||
*
|
||||
* - requestId 为上报幂等键(建议 UUID),counted 确认前跨刷新/重试持久复用;
|
||||
* 重复 requestId 返回 counted=false 与权威 viewCount,不重复计数。
|
||||
* 重复 requestId 幂等重放:counted 回显该 requestId 首次处理的结局
|
||||
* (确认响应丢失后重试仍见 true),viewCount 恒为当前权威值,不重复计数。
|
||||
* - 去重窗口(VISIT_WINDOW_MINUTES,默认 30 分钟,D07)内同一 visitorId
|
||||
* 不重复计数;窗口过后再上报按新访次计数。
|
||||
* - 过期 requestId(VISIT_REQUEST_TTL_MINUTES,默认 35 分钟)拒绝重放
|
||||
* (counted=false),不得重新加计。
|
||||
* - 基础限速:visitorId 滑窗,超限 429(RATE_LIMITED);去重本身为主缓解。
|
||||
* (counted=false,无论原结局),不得重新加计。
|
||||
* - 基础限速:visitorId+来源 IP 固定窗(VISIT_WINDOW_REQUESTS,默认
|
||||
* 60/窗),超限 429(RATE_LIMITED);去重本身为主缓解。
|
||||
* - 相册非公开/紧急停用 404(ALBUM_NOT_PUBLIC)。
|
||||
*/
|
||||
post: operations["reportAlbumVisit"];
|
||||
@@ -1334,8 +1336,9 @@ export interface components {
|
||||
*/
|
||||
readonly viewCount: number;
|
||||
/**
|
||||
* @description 本次是否实际计数:true=新访次;false=去重窗口内重复、重复
|
||||
* requestId 或过期 requestId 重放(均不重复计数)。
|
||||
* @description 本次是否计入:true=新访次,或幂等重放已确认计入的 requestId
|
||||
* (回显原结局);false=去重窗口内重复或过期 requestId 重放
|
||||
* (均不重复计数)。
|
||||
*/
|
||||
readonly counted: boolean;
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
// (public-ov:读 photos_ov 副本的同形契约端点 + hot 排序);Task 7 起加入
|
||||
// interactions(点赞批次幂等 + Redis ov 限速)与 realtime(WS 网关:契约帧
|
||||
// 分发见 main-ov 的 ContractWsAdapter),EventEmitter2 全局总线承接「事务提交
|
||||
// → 实时广播」(ov-events.ts,单进程——多实例 P5)。cn 模块保持原样,两入口
|
||||
// 互不影响。
|
||||
// → 实时广播」(ov-events.ts,单进程——多实例 P5);Task 8 起 interactions
|
||||
// 增浏览上报(visits:窗口去重 + requestId 幂等)并加入 cleanup(互动流水
|
||||
// 超窗口行回收调度)。cn 模块保持原样,两入口互不影响。
|
||||
import {
|
||||
type MiddlewareConsumer,
|
||||
Module,
|
||||
@@ -19,6 +20,7 @@ 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 { CleanupModule } from './cleanup/cleanup.module';
|
||||
import { EmergencyModule } from './emergency/emergency.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { InteractionsModule } from './interactions/interactions.module';
|
||||
@@ -44,6 +46,9 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
||||
InteractionsModule,
|
||||
// ov 实时广播(P2 Task 7):WS /v1/realtime(适配器见 main-ov)。
|
||||
RealtimeModule,
|
||||
// ov 互动流水清理调度(P2 Task 8):like_batches/visit_requests/
|
||||
// visit_sessions 超窗口行回收(累计总数永不清理)。
|
||||
CleanupModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// services/api/src/cleanup/ —— ov 互动流水清理模块(P2 Task 8)。仅 ov 入口
|
||||
// 装配(app-ov.module 显式挑选;cn 入口不感知——cn 侧无互动流水表)。
|
||||
// CleanupService 同时导出:调度(setInterval,见 cleanup.service.ts 头注)
|
||||
// 与 runCleanupOnce(e2e 直调断言)。语义边界:仅清 like_batches /
|
||||
// visit_requests / visit_sessions 超窗口行,累计总数(album_stats、
|
||||
// photos_ov.likeTotal)永不触碰。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
|
||||
import { CleanupService } from './cleanup.service';
|
||||
|
||||
@Module({
|
||||
imports: [OvPrismaModule],
|
||||
providers: [CleanupService],
|
||||
exports: [CleanupService],
|
||||
})
|
||||
export class CleanupModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
// services/api/src/cleanup/cleanup.service.ts —— ov 互动流水清理调度
|
||||
// (P2 Task 8,评审缺口 2)。清理对象与边界:
|
||||
//
|
||||
// · like_batches:createdAt 早于 LIKE_BATCH_WINDOW_HOURS(24h)——点赞幂等
|
||||
// 账本。清理即「窗口外重放拒绝点」:行被清后重放同 batchId 会以新批次
|
||||
// 重新加计(无永久幂等账本与无界增长的取舍,likes.service.ts 头注已
|
||||
// 登记偏差;sessionId/IP 滑窗上限钳制重放影响);
|
||||
// · visit_requests:createdAt 早于 VISIT_REQUEST_TTL_MINUTES(35min ≥
|
||||
// 去重窗口 30min)——过期行先于清理即被重放拒绝(counted=false),
|
||||
// 清理只回收空间;TTL ≥ 窗口保证行被清后重放至多以「新 requestId」
|
||||
// 身份走窗口判定,不会绕过 visit_sessions 去重双计;
|
||||
// · visit_sessions:lastCountedAt 早于 VISIT_WINDOW_MINUTES——去重锚点
|
||||
// 只需覆盖窗口内状态,窗外行等价「无行」(重访走新窗计入);
|
||||
// · **累计总数永不清理**:album_stats(viewCount)与 photos_ov.likeTotal
|
||||
// 是权威计数,长期保留(Task 9 回传 cn 的数据源)。
|
||||
//
|
||||
// 调度:setInterval 驱动(CLEANUP_INTERVAL_MINUTES,默认 60,≤0 停用),
|
||||
// 首轮延迟一个间隔(进程刚起无需立即清)。不引入 @nestjs/schedule——
|
||||
// realtime.gateway 心跳 / sync main 循环同款 setInterval 约定,env 可调
|
||||
// 间隔用原生定时器最直白;runCleanupOnce 导出为可注入方法,e2e 直接调用
|
||||
// 断言(不等待真实定时)。窗口阈值惰性读 env(同 visits/likes 服务约定,
|
||||
// 缺省回落契约常量)。
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CLEANUP_INTERVAL_MINUTES,
|
||||
LIKE_BATCH_WINDOW_HOURS,
|
||||
VISIT_REQUEST_TTL_MINUTES,
|
||||
VISIT_WINDOW_MINUTES,
|
||||
} from '@photo/contracts';
|
||||
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
|
||||
|
||||
/** 清理调度间隔分钟(惰性读 env;≤0 = 停用调度,runCleanupOnce 仍可直调)。 */
|
||||
export function cleanupIntervalMinutes(): number {
|
||||
const parsed = Number.parseInt(
|
||||
process.env.CLEANUP_INTERVAL_MINUTES ?? '',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed >= 0
|
||||
? parsed
|
||||
: CLEANUP_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
/** 单轮清理观测口径(日志打点;e2e 断言删除行数)。 */
|
||||
export interface CleanupStats {
|
||||
likeBatches: number;
|
||||
visitRequests: number;
|
||||
visitSessions: number;
|
||||
}
|
||||
|
||||
/** like_batches 保留窗毫秒(LIKE_BATCH_WINDOW_HOURS,惰性 env)。 */
|
||||
function likeBatchWindowMs(): 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 * 3_600_000;
|
||||
}
|
||||
|
||||
/** visit_requests 保留窗毫秒(VISIT_REQUEST_TTL_MINUTES,惰性 env)。 */
|
||||
function visitRequestTtlMs(): number {
|
||||
const parsed = Number.parseInt(
|
||||
process.env.VISIT_REQUEST_TTL_MINUTES ?? '',
|
||||
10,
|
||||
);
|
||||
const minutes =
|
||||
Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: VISIT_REQUEST_TTL_MINUTES;
|
||||
return minutes * 60_000;
|
||||
}
|
||||
|
||||
/** visit_sessions 保留窗毫秒(VISIT_WINDOW_MINUTES,惰性 env)。 */
|
||||
function visitWindowMs(): number {
|
||||
const parsed = Number.parseInt(process.env.VISIT_WINDOW_MINUTES ?? '', 10);
|
||||
const minutes =
|
||||
Number.isFinite(parsed) && parsed > 0 ? parsed : VISIT_WINDOW_MINUTES;
|
||||
return minutes * 60_000;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CleanupService.name);
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly prisma: OvPrismaService) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const minutes = cleanupIntervalMinutes();
|
||||
if (minutes <= 0) {
|
||||
this.logger.log('清理调度停用(CLEANUP_INTERVAL_MINUTES≤0)');
|
||||
return;
|
||||
}
|
||||
this.timer = setInterval(() => {
|
||||
this.runCleanupOnce().catch((err: unknown) => {
|
||||
this.logger.error(
|
||||
`清理轮失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
}, minutes * 60_000);
|
||||
// 不阻塞进程退出(应用关闭经 onModuleDestroy 显式清理定时器)。
|
||||
this.timer.unref?.();
|
||||
this.logger.log(`清理调度就绪:每 ${minutes} 分钟一轮`);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单轮清理:三表超窗口行删除,累计总数(album_stats/photos_ov)不触碰。 */
|
||||
async runCleanupOnce(): Promise<CleanupStats> {
|
||||
const now = Date.now();
|
||||
const [likeBatches, visitRequests, visitSessions] = await Promise.all([
|
||||
this.prisma.like_batches.deleteMany({
|
||||
where: { createdAt: { lt: new Date(now - likeBatchWindowMs()) } },
|
||||
}),
|
||||
this.prisma.visit_requests.deleteMany({
|
||||
where: { createdAt: { lt: new Date(now - visitRequestTtlMs()) } },
|
||||
}),
|
||||
this.prisma.visit_sessions.deleteMany({
|
||||
where: { lastCountedAt: { lt: new Date(now - visitWindowMs()) } },
|
||||
}),
|
||||
]);
|
||||
const stats: CleanupStats = {
|
||||
likeBatches: likeBatches.count,
|
||||
visitRequests: visitRequests.count,
|
||||
visitSessions: visitSessions.count,
|
||||
};
|
||||
this.logger.log(
|
||||
`清理完成:like_batches=${stats.likeBatches} ` +
|
||||
`visit_requests=${stats.visitRequests} ` +
|
||||
`visit_sessions=${stats.visitSessions}`,
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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,字符串数字不静默转换——公开匿名端点从严)。
|
||||
// services/api/src/interactions/dto.ts —— 互动上报请求体(P2 Task 7 点赞批次 /
|
||||
// Task 8 浏览上报)。校验边界与契约 LikeBatchRequest / VisitReportRequest
|
||||
// 逐字段对齐(sessionId/batchId 1..64、delta 1..20 整数;visitorId/requestId
|
||||
// 8..128);越界一律 400 VALIDATION_FAILED(全局 ValidationPipe whitelist
|
||||
// 剥离未声明字段)。delta 必须是 JSON number 整数(管道未开 transform,
|
||||
// 字符串数字不静默转换——公开匿名端点从严)。
|
||||
import { IsInt, IsString, Length, Max, Min } from 'class-validator';
|
||||
|
||||
export class LikeBatchDto {
|
||||
@@ -22,3 +23,16 @@ export class LikeBatchDto {
|
||||
@Max(20)
|
||||
delta!: number;
|
||||
}
|
||||
|
||||
/** 浏览上报请求体(P2 Task 8;契约 VisitReportRequest)。 */
|
||||
export class VisitReportDto {
|
||||
/** 访客标识(客户端持久化并在标签页间共享;去重窗口与限速的键)。 */
|
||||
@IsString()
|
||||
@Length(8, 128)
|
||||
visitorId!: string;
|
||||
|
||||
/** 上报幂等键:counted 确认前跨刷新/重试持久复用(建议 UUID)。 */
|
||||
@IsString()
|
||||
@Length(8, 128)
|
||||
requestId!: string;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
// services/api/src/interactions/ —— ov 公开互动模块(P2 Task 7 起)。
|
||||
// 仅 ov 入口装配(app-ov.module 显式挑选;cn 入口不感知)。Task 7 装配
|
||||
// 点赞批次端点;Task 8 起浏览上报(visits)同域加入。事件广播经
|
||||
// EventEmitter2(app 根模块 EventEmitterModule.forRoot() 全局注册)发往
|
||||
// realtime 网关,无模块间直接依赖。
|
||||
// 点赞批次端点;Task 8 装配浏览上报(visits.service/controller)。
|
||||
// 事件广播经 EventEmitter2(app 根模块 EventEmitterModule.forRoot()
|
||||
// 全局注册)发往 realtime 网关(likes.burst / stats.updated),无模块间
|
||||
// 直接依赖。
|
||||
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';
|
||||
import { VisitsController } from './visits.controller';
|
||||
import { VisitsService } from './visits.service';
|
||||
|
||||
@Module({
|
||||
imports: [OvPrismaModule, OvRedisModule],
|
||||
controllers: [LikesController],
|
||||
providers: [LikesService],
|
||||
controllers: [LikesController, VisitsController],
|
||||
providers: [LikesService, VisitsService],
|
||||
})
|
||||
export class InteractionsModule {}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// services/api/src/interactions/visits.controller.ts —— 浏览上报端点
|
||||
// (P2 Task 8,ov 域)。路由与契约 reportAlbumVisit 一致(tags [interactions],
|
||||
// 路径前缀不变、部署域名区分);匿名(无 SessionGuard)。IP 取 req.ip 并归一
|
||||
// IPv6 映射前缀(::ffff:127.0.0.1 → 127.0.0.1),仅作限速键——不落库、不进
|
||||
// 日志(匿名观众端无 IP 留存需求;同 likes.controller 约定)。真实部署经反代
|
||||
// 时的 trust proxy 处理登记 P5。
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Ip,
|
||||
Param,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { VisitReportDto } from './dto';
|
||||
import { VisitsService, type VisitReportResponse } from './visits.service';
|
||||
|
||||
@Controller('public/albums')
|
||||
export class VisitsController {
|
||||
constructor(private readonly visits: VisitsService) {}
|
||||
|
||||
/** 契约 200(窗口内重复/幂等重放/过期拒绝均 200,语义在 counted)。 */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post(':token/visits')
|
||||
report(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: VisitReportDto,
|
||||
@Ip() ip: string | undefined,
|
||||
): Promise<VisitReportResponse> {
|
||||
const clientIp = ip === undefined ? undefined : ip.replace(/^::ffff:/, '');
|
||||
return this.visits.reportVisit(token, dto, clientIp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
// services/api/src/interactions/visits.service.ts —— 浏览统计(P2 Task 8,
|
||||
// ov 域)。契约 POST /public/albums/{token}/visits(reportAlbumVisit)。
|
||||
// 全流程(A14 仿真级语义):
|
||||
//
|
||||
// ① 校验:相册 publicToken → albums_ov + isAlbumVisibleOv(含紧急停用)
|
||||
// → 统一 404 ALBUM_NOT_PUBLIC;DTO 边界(visitorId/requestId 8..128)
|
||||
// 由全局管道 400。
|
||||
// ② 幂等/过期快速通道(不进事务、不消耗限速预算——重试已确认 requestId
|
||||
// 是正常路径,同 likes ② 约定):visit_requests 命中既有行 →
|
||||
// 行龄 > VISIT_REQUEST_TTL_MINUTES → 拒绝重放 {counted:false}(无论
|
||||
// 原结局);否则回显原结局 counted(确认响应丢失的重试仍见 true——
|
||||
// 客户端「requestId 持久到 counted 确认为止」的闭环依赖此语义)。
|
||||
// viewCount 恒为当前权威值(album_stats,单调不减)。
|
||||
// ③ 基础限速(Redis ov 固定窗,visitorId+IP 键,VISIT_WINDOW_REQUESTS/
|
||||
// 窗,窗长=去重窗口):超限 429 RATE_LIMITED。去重本身是主缓解
|
||||
// (已登记),限速定位是「误客户端缓冲」而非防滥用(真实防护 P5)。
|
||||
// ④ 入账事务:visit_requests 插入(requestId 唯一)→ visit_sessions
|
||||
// (albumId+visitorId 唯一)读窗口态:lastCountedAt 距今 < 窗口 →
|
||||
// counted=false(窗口内重复,事务仅保留 counted=false 的幂等行);
|
||||
// 否则(无行 / 窗口外)→ session lastCountedAt=now + album_stats
|
||||
// 原子 viewCount+1、statVersion+1 + outbox_stat_events {kind views,
|
||||
// total, statVersion}(Task 9 回传队列)+ visit_requests.counted=true。
|
||||
// ⑤ 提交后:发布 OV_VISIT_COUNTED 进程内事件 → WS 网关向相册房间广播
|
||||
// stats.updated{albumId, viewCount, countVersion=statVersion}。
|
||||
//
|
||||
// —— 并发语义(唯一约束 + 冲突后重读,不做盲目重试)——
|
||||
// · 并发同 requestId:事务内插入撞 visit_requests 主键(P2002)→ 冲突后
|
||||
// 重读行存在 → 走 ② 重放语义(胜者已提交,回显其结局)。
|
||||
// · 并发同 visitor 不同 requestId(首访竞态):双方都读到「无 session」,
|
||||
// 败者的 session create 撞 (albumId,visitorId) 唯一(P2002,整个事务
|
||||
// 回滚——含其 visit_requests 行)→ 冲突后重读:requestId 行不存在 ⇒
|
||||
// 是 session 竞态,且胜者刚把 lastCountedAt 置为 now(自己必在窗口内)
|
||||
// → {counted:false, viewCount:权威}。重读而非重试:胜者已计数,重试
|
||||
// 只会得到同样的窗口判定,先答先正确。
|
||||
// · 并发不同 visitor 同册首访:album_stats 首行 create 竞态(albumId 主键
|
||||
// P2002)→ countView 内重走 updateMany 自增(InnoDB 当前读见已提交行)。
|
||||
//
|
||||
// —— 上报排除规则(契约 description 落地)——
|
||||
// 浏览计数**仅**由本端点显式调用触发:公开读(album/列表/单图)、点赞、
|
||||
// WS 帧一概不写 visit_* / album_stats(e2e 断言行数不变)。
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import {
|
||||
VISIT_REQUEST_TTL_MINUTES,
|
||||
VISIT_WINDOW_MINUTES,
|
||||
VISIT_WINDOW_REQUESTS,
|
||||
ErrorCodes,
|
||||
} from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { albumNotPublic } 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_VISIT_COUNTED } from '../realtime/ov-events';
|
||||
import type { VisitCountedEvent } from '../realtime/ov-events';
|
||||
import type { Prisma } from '@photo/db-ov';
|
||||
import type { VisitReportDto } from './dto';
|
||||
|
||||
export type VisitReportResponse = components['schemas']['VisitReportResponse'];
|
||||
|
||||
/** 事务客户端类型别名(incrementViewCount 复用于并发首访自增路径)。 */
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/** 去重窗口分钟(D07;惰性读 env 供测试调整)。 */
|
||||
export function visitWindowMinutes(): number {
|
||||
const parsed = Number.parseInt(process.env.VISIT_WINDOW_MINUTES ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : VISIT_WINDOW_MINUTES;
|
||||
}
|
||||
|
||||
/** requestId 保留分钟(过期拒绝重放;惰性读 env,≥ 去重窗口由初值保证)。 */
|
||||
export function visitRequestTtlMinutes(): number {
|
||||
const parsed = Number.parseInt(
|
||||
process.env.VISIT_REQUEST_TTL_MINUTES ?? '',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(parsed) && parsed > 0
|
||||
? parsed
|
||||
: VISIT_REQUEST_TTL_MINUTES;
|
||||
}
|
||||
|
||||
/** visitorId+IP 固定窗内请求数上限(基础限速;去重为主缓解)。 */
|
||||
export function visitWindowRequests(): number {
|
||||
const parsed = Number.parseInt(process.env.VISIT_WINDOW_REQUESTS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : VISIT_WINDOW_REQUESTS;
|
||||
}
|
||||
|
||||
function rateLimited(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.RATE_LIMITED,
|
||||
'上报过于频繁,请稍后再试',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
function isP2002(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
(err as { code?: unknown }).code === 'P2002'
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VisitsService {
|
||||
constructor(
|
||||
private readonly prisma: OvPrismaService,
|
||||
private readonly redis: OvRedisService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async reportVisit(
|
||||
token: string,
|
||||
dto: VisitReportDto,
|
||||
clientIp: string | undefined,
|
||||
): Promise<VisitReportResponse> {
|
||||
// ① 相册公开校验(同 public-ov 读路径口径)。
|
||||
const album = await this.prisma.albums_ov.findUnique({
|
||||
where: { publicToken: token },
|
||||
});
|
||||
if (album === null || !isAlbumVisibleOv(album)) {
|
||||
throw albumNotPublic();
|
||||
}
|
||||
|
||||
// ② 幂等/过期快速通道(重放不消耗限速预算——同 likes ② 约定)。
|
||||
const existing = await this.prisma.visit_requests.findUnique({
|
||||
where: { requestId: dto.requestId },
|
||||
});
|
||||
if (existing !== null) {
|
||||
return this.replay(album.albumId, existing.createdAt, existing.counted);
|
||||
}
|
||||
|
||||
// ③ 基础限速(通过后才进事务;拒绝也消耗预算——固定窗限速惯例)。
|
||||
await this.assertRateLimit(dto.visitorId, clientIp);
|
||||
|
||||
// ④ 入账事务(两类唯一冲突的语义见文件头「并发语义」)。
|
||||
try {
|
||||
const outcome = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.visit_requests.create({
|
||||
data: {
|
||||
requestId: dto.requestId,
|
||||
albumId: album.albumId,
|
||||
visitorId: dto.visitorId,
|
||||
},
|
||||
});
|
||||
const session = await tx.visit_sessions.findUnique({
|
||||
where: {
|
||||
albumId_visitorId: {
|
||||
albumId: album.albumId,
|
||||
visitorId: dto.visitorId,
|
||||
},
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
const windowMs = visitWindowMinutes() * 60_000;
|
||||
const inWindow =
|
||||
session !== null &&
|
||||
session.lastCountedAt !== null &&
|
||||
now.getTime() - session.lastCountedAt.getTime() < windowMs;
|
||||
if (inWindow) {
|
||||
// 窗口内重复:不计入——事务仅保留 counted=false 的幂等行。
|
||||
return {
|
||||
viewCount: await this.currentViewCount(tx, album.albumId),
|
||||
counted: false as const,
|
||||
statVersion: null as number | null,
|
||||
};
|
||||
}
|
||||
// 计入:session 锚定本窗(新行直接带 lastCountedAt——null 行不存在)。
|
||||
if (session === null) {
|
||||
await tx.visit_sessions.create({
|
||||
data: {
|
||||
albumId: album.albumId,
|
||||
visitorId: dto.visitorId,
|
||||
lastCountedAt: now,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.visit_sessions.update({
|
||||
where: { id: session.id },
|
||||
data: { lastCountedAt: now },
|
||||
});
|
||||
}
|
||||
const { viewCount, statVersion } = await this.incrementViewCount(
|
||||
tx,
|
||||
album.albumId,
|
||||
);
|
||||
// 回传队列记权威快照(Task 9:cn 侧仅更高 statVersion 替换)。
|
||||
await tx.outbox_stat_events.create({
|
||||
data: {
|
||||
kind: 'views',
|
||||
albumId: album.albumId,
|
||||
total: viewCount,
|
||||
statVersion,
|
||||
},
|
||||
});
|
||||
await tx.visit_requests.update({
|
||||
where: { requestId: dto.requestId },
|
||||
data: { counted: true },
|
||||
});
|
||||
return { viewCount, counted: true as const, statVersion };
|
||||
});
|
||||
// ⑤ 提交后广播(仅计入路径;$transaction resolve 即已 COMMIT)。
|
||||
if (outcome.counted && outcome.statVersion !== null) {
|
||||
const event: VisitCountedEvent = {
|
||||
albumId: album.albumId,
|
||||
viewCount: outcome.viewCount,
|
||||
statVersion: outcome.statVersion,
|
||||
};
|
||||
this.events.emit(OV_VISIT_COUNTED, event);
|
||||
}
|
||||
return { viewCount: outcome.viewCount, counted: outcome.counted };
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
// 冲突后重读判型(不 inspect meta.target——判据更稳):
|
||||
// requestId 行已存在 → 并发同 requestId 重试 → ② 重放语义;
|
||||
// 行不存在(整个事务已回滚)→ session 首访竞态,胜者刚计数
|
||||
// (lastCountedAt=now 已提交)→ 本请求必在窗口内 → counted=false。
|
||||
const winner = await this.prisma.visit_requests.findUnique({
|
||||
where: { requestId: dto.requestId },
|
||||
});
|
||||
if (winner !== null) {
|
||||
return this.replay(album.albumId, winner.createdAt, winner.counted);
|
||||
}
|
||||
return {
|
||||
viewCount: await this.currentViewCount(this.prisma, album.albumId),
|
||||
counted: false,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** 重放应答:过期(行龄 > TTL)一律 counted=false;否则回显原结局。 */
|
||||
private async replay(
|
||||
albumId: string,
|
||||
createdAt: Date,
|
||||
counted: boolean,
|
||||
): Promise<VisitReportResponse> {
|
||||
const expired =
|
||||
Date.now() - createdAt.getTime() > visitRequestTtlMinutes() * 60_000;
|
||||
return {
|
||||
viewCount: await this.currentViewCount(this.prisma, albumId),
|
||||
counted: !expired && counted,
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前权威浏览数(album_stats 无行默认 0;tx/根客户端皆可传入)。 */
|
||||
private async currentViewCount(
|
||||
client: OvPrismaService | Tx,
|
||||
albumId: string,
|
||||
): Promise<number> {
|
||||
const stats = await client.album_stats.findUnique({
|
||||
where: { albumId },
|
||||
select: { viewCount: true },
|
||||
});
|
||||
return stats?.viewCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* viewCount+1 / statVersion+1(album_stats 权威自增)。无行先建(首访),
|
||||
* 并发不同 visitor 的首行竞态由 albumId 主键 P2002 兜底 → 重走 updateMany
|
||||
* 自增(InnoDB 当前读可见对方已提交行),最多三轮。
|
||||
*/
|
||||
private async incrementViewCount(
|
||||
tx: Tx,
|
||||
albumId: string,
|
||||
): Promise<{ viewCount: number; statVersion: number }> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const updated = await tx.album_stats.updateMany({
|
||||
where: { albumId },
|
||||
data: {
|
||||
viewCount: { increment: 1 },
|
||||
statVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (updated.count === 1) {
|
||||
const row = await tx.album_stats.findUniqueOrThrow({
|
||||
where: { albumId },
|
||||
select: { viewCount: true, statVersion: true },
|
||||
});
|
||||
return { viewCount: row.viewCount, statVersion: row.statVersion };
|
||||
}
|
||||
try {
|
||||
await tx.album_stats.create({
|
||||
data: { albumId, viewCount: 1, statVersion: 1 },
|
||||
});
|
||||
return { viewCount: 1, statVersion: 1 };
|
||||
} catch (err) {
|
||||
if (isP2002(err) && attempt < 2) {
|
||||
continue; // 对方已建行:回到 updateMany 走自增。
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定窗限速:Redis ov INCR 计请求数,首次写键挂窗口 TTL(窗长=去重窗口,
|
||||
* 键自本窗首次上报起存活一个窗口)。键 = visitorId+IP(同 NAT 出口的访客
|
||||
* 各占各的预算——去重窗口本身已钳制单 visitor 的计数影响,此处定位是
|
||||
* 误客户端缓冲,P5 速率层接真实防护)。
|
||||
*/
|
||||
private async assertRateLimit(
|
||||
visitorId: string,
|
||||
clientIp: string | undefined,
|
||||
): Promise<void> {
|
||||
const key = `visit:window:${visitorId}|${clientIp ?? ''}`;
|
||||
const counted = await this.redis.incr(key);
|
||||
if (counted === 1) {
|
||||
// 新键(本窗首次):挂 TTL;EXPIRE 失败不影响本轮判定。
|
||||
await this.redis
|
||||
.expire(key, visitWindowMinutes() * 60)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (counted > visitWindowRequests()) {
|
||||
throw rateLimited();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// services/api/src/realtime/ov-events.ts —— ov 侧「业务提交 → 实时广播」的
|
||||
// 进程内事件契约(P2 Task 7)。点赞端点(interactions)与收件箱应用
|
||||
// (sync-inbox)在**事务提交后**发布这些事件;WS 网关(realtime.gateway)
|
||||
// 经 @OnEvent 监听并广播给相册房间。事件名/载荷只在本进程内有效。
|
||||
// 进程内事件契约(P2 Task 7;Task 8 增浏览计数)。点赞端点(interactions)、
|
||||
// 浏览计数(visits)与收件箱应用(sync-inbox)在**事务提交后**发布这些事件;
|
||||
// WS 网关(realtime.gateway)经 @OnEvent 监听并广播给相册房间。事件名/载荷
|
||||
// 只在本进程内有效。
|
||||
//
|
||||
// 单进程假设(登记偏差,P5 移交):EventEmitter2 是进程内总线——ov 入口
|
||||
// 当前单实例部署(compose 仿真),多实例横向扩展需换 Redis pub/sub 跨进程
|
||||
@@ -24,6 +25,16 @@ export interface LikeBatchCommittedEvent {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/** 浏览已计入(visits 事务提交后发布;网关原样转 stats.updated)。 */
|
||||
export const OV_VISIT_COUNTED = 'ov.visit.counted';
|
||||
|
||||
export interface VisitCountedEvent {
|
||||
albumId: string;
|
||||
/** 提交事务后的权威累计浏览与统计版本(stats.updated 的 viewCount/countVersion)。 */
|
||||
viewCount: number;
|
||||
statVersion: number;
|
||||
}
|
||||
|
||||
/** 副本照片可见性已应用(sync-inbox 事务提交后发布;网关原样转 photo.*)。 */
|
||||
export const OV_PHOTO_APPLIED = 'ov.photo.applied';
|
||||
|
||||
|
||||
@@ -44,8 +44,10 @@ import { isAlbumVisibleOv } from '../shared/album-visibility';
|
||||
import {
|
||||
OV_LIKE_BATCH_COMMITTED,
|
||||
OV_PHOTO_APPLIED,
|
||||
OV_VISIT_COUNTED,
|
||||
type LikeBatchCommittedEvent,
|
||||
type PhotoAppliedEvent,
|
||||
type VisitCountedEvent,
|
||||
} from './ov-events';
|
||||
|
||||
/** likes.burst 合并窗口毫秒(惰性读 env,0=逐批即时广播;e2e 可调)。 */
|
||||
@@ -227,6 +229,17 @@ export class RealtimeGateway implements OnModuleDestroy {
|
||||
pending.originBatchIds.push(event.batchId);
|
||||
}
|
||||
|
||||
/** 浏览已计入 → 原样转发契约 stats.updated 消息到相册房间(Task 8)。 */
|
||||
@OnEvent(OV_VISIT_COUNTED)
|
||||
onVisitCounted(event: VisitCountedEvent): void {
|
||||
this.sendToRoom(event.albumId, {
|
||||
type: 'stats.updated',
|
||||
albumId: event.albumId,
|
||||
viewCount: event.viewCount,
|
||||
countVersion: event.statVersion,
|
||||
});
|
||||
}
|
||||
|
||||
/** 副本照片可见性应用 → 原样转发契约 photo.* 消息到相册房间。 */
|
||||
@OnEvent(OV_PHOTO_APPLIED)
|
||||
onPhotoApplied(event: PhotoAppliedEvent): void {
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
// services/api/test/visits.e2e-spec.ts —— P2 Task 8 浏览统计 + 去重数据清理
|
||||
// e2e。与 likes-realtime.e2e-spec 同模式:进程内起 cn + ov 两个真实入口,
|
||||
// 业务动作全走 cn API(建相册/上传 complete/直驱发布事务/开关),OutboxSender
|
||||
// (生产实现)指向 ov 入口真实 URL——浏览计数作用在与生产链路同源的数据上。
|
||||
//
|
||||
// 覆盖(A14 仿真级 + Task 8 全部工作项):
|
||||
// ① A14 计数主干:首访 counted=true viewCount 1 → GET album 读到真实值
|
||||
// (Task 6 接线验证);窗口内刷新(新 requestId)counted=false 不加计;
|
||||
// 不同 visitorId counted=true 2;visit_requests/visit_sessions/
|
||||
// album_stats/outbox_stat_events(kind views)行级断言;
|
||||
// ② requestId 幂等重试:重放回显原结局(counted=true 行仍 true / false 行
|
||||
// 仍 false),viewCount 当前权威,不加计;
|
||||
// ③ 新窗口再计:OvPrisma 手工回退 visit_sessions.lastCountedAt 越窗 →
|
||||
// 新 requestId counted=true 3;
|
||||
// ④ 并发首访:Promise.all 同 visitorId 两个 requestId → 恰一个
|
||||
// counted=true(唯一约束 + 冲突后重读),总计数只 +1;
|
||||
// ⑤ 过期 requestId 重放:回退 visit_requests.createdAt 越 TTL →
|
||||
// counted=false(无论原结局),不加计;
|
||||
// ⑥ 404/400:幽灵 token / publicEnabled=false 同步后的真实相册 →
|
||||
// ALBUM_NOT_PUBLIC;visitorId 7 字符 / requestId 129 字符 / 缺字段 →
|
||||
// VALIDATION_FAILED;
|
||||
// ⑦ 基础限速:VISIT_WINDOW_REQUESTS 压小(惰性 env)→ 窗口超额 429
|
||||
// RATE_LIMITED;幂等快速通道不消耗限速预算(重放已确认 requestId 仍 200);
|
||||
// ⑧ 上报排除规则:GET album / 列表 / 单图 / 点赞 / WS 帧(subscribe+ping)
|
||||
// 一律不触碰 visit_* / album_stats(行数与 viewCount 快照不变);
|
||||
// ⑨ WS stats.updated:计入 → 相册房间收 {albumId, viewCount,
|
||||
// countVersion=statVersion};窗口内重复(counted=false)不广播;
|
||||
// ⑩ runCleanupOnce:fabricate 超窗旧行(like_batches 25h / visit_requests
|
||||
// 36min / visit_sessions 31min)→ 全删;窗口内新行全留;累计总数
|
||||
// (album_stats / photos_ov.likeTotal)分毫不动;
|
||||
// ⑪ 过期批次重放(Task 7 已登记偏差的文档化行为):清理删除 like_batches
|
||||
// 旧行后,重放同 batchId 以「新批次」身份重新加计(batchAccepted=true,
|
||||
// within session cap)——无永久幂等账本的取舍边界,见 likes.service.ts
|
||||
// 头注;此处固化该已登记行为(确定性:回退行 + 直调清理,无时间等待)。
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
import request from 'supertest';
|
||||
import sharp from 'sharp';
|
||||
import WebSocket from 'ws';
|
||||
import Redis from 'ioredis';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||||
import {
|
||||
type ClientMessage,
|
||||
type ServerMessage,
|
||||
ErrorCodes,
|
||||
REALTIME_WS_PATH,
|
||||
} from '@photo/contracts';
|
||||
import {
|
||||
createStorageFromEnv,
|
||||
type MinioStorageAdapter,
|
||||
} from '@photo/storage';
|
||||
import { AppCnModule } from '../src/app-cn.module';
|
||||
import { AppOvModule } from '../src/app-ov.module';
|
||||
import { CleanupService } from '../src/cleanup/cleanup.service';
|
||||
import { seedAdmin } from '../src/scripts/seed-admin';
|
||||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||||
import { configureApp } from '../src/shared/configure-app';
|
||||
import { PublishService } from '../../worker/src/publish.service';
|
||||
import { resetDb } from './reset';
|
||||
import { resetOvDb } from './reset-ov';
|
||||
import { OutboxSender } from '../../sync/src/sender';
|
||||
|
||||
jest.setTimeout(180_000);
|
||||
|
||||
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||||
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||||
|
||||
/** subscribe 为异步校验(DB 查询):发送后的就绪等待(本地查询 ~ms 级)。 */
|
||||
const SUBSCRIBE_SETTLE_MS = 300;
|
||||
/** 「无新增广播」断言的观察窗(远大于任何提交路径时延)。 */
|
||||
const QUIET_MS = 1000;
|
||||
|
||||
/** 测试内生成真实 JPEG(同 likes-realtime.e2e-spec;无二进制入库)。 */
|
||||
async function makeJpeg(
|
||||
width: number,
|
||||
height: number,
|
||||
rgb: { r: number; g: number; b: number },
|
||||
): Promise<Buffer> {
|
||||
return sharp({ create: { width, height, channels: 3, background: rgb } })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** 浏览上报 POST(返回 supertest Response,断言交给调用方;body 宽松以覆盖 400 用例)。 */
|
||||
async function visit(
|
||||
ovBase: string,
|
||||
token: string,
|
||||
body: { visitorId: string; requestId?: string },
|
||||
): Promise<request.Response> {
|
||||
return request(ovBase)
|
||||
.post(`/v1/public/albums/${token}/visits`)
|
||||
.send(body);
|
||||
}
|
||||
|
||||
/** 契约帧 WS 测试客户端:收帧队列 + 条件等待(同 likes-realtime spec)。 */
|
||||
class WsClient {
|
||||
private readonly ws: WebSocket;
|
||||
private readonly frames: ServerMessage[] = [];
|
||||
private readonly waiters: Array<{
|
||||
predicate: (frame: ServerMessage) => boolean;
|
||||
resolve: (frame: ServerMessage) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}> = [];
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.on('message', (data) => {
|
||||
let frame: ServerMessage;
|
||||
try {
|
||||
frame = JSON.parse(String(data)) as ServerMessage;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this.frames.push(frame);
|
||||
const index = this.waiters.findIndex((w) => w.predicate(frame));
|
||||
if (index >= 0) {
|
||||
const [waiter] = this.waiters.splice(index, 1);
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.resolve(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async open(url: string): Promise<WsClient> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.once('open', () => resolve());
|
||||
ws.once('error', (err: Error) => reject(err));
|
||||
});
|
||||
return new WsClient(ws);
|
||||
}
|
||||
|
||||
send(frame: ClientMessage): void {
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
/** 等待首条满足谓词的帧(含历史帧;超时拒绝)。 */
|
||||
waitFor(
|
||||
predicate: (frame: ServerMessage) => boolean,
|
||||
timeoutMs: number,
|
||||
label: string,
|
||||
): Promise<ServerMessage> {
|
||||
const existing = this.frames.find(predicate);
|
||||
if (existing !== undefined) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const index = this.waiters.findIndex((w) => w.reject === reject);
|
||||
if (index >= 0) {
|
||||
this.waiters.splice(index, 1);
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`waitFor(${label}) 超时 ${timeoutMs}ms;已收帧: ${JSON.stringify(this.frames)}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.waiters.push({ predicate, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
/** 按类型统计(「不广播」断言用)。 */
|
||||
countOf(type: ServerMessage['type']): number {
|
||||
return this.frames.filter((f) => f.type === type).length;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('visits + cleanup (e2e)', () => {
|
||||
let cnApp: NestExpressApplication;
|
||||
let ovApp: NestExpressApplication;
|
||||
let cnBase: string;
|
||||
let ovBase: string;
|
||||
let prismaCn: PrismaClient;
|
||||
let prismaOv: OvPrismaClient;
|
||||
let storage: MinioStorageAdapter;
|
||||
let sender: OutboxSender;
|
||||
let adminToken: string;
|
||||
|
||||
let tokenA: string;
|
||||
let tokenB: string;
|
||||
let albumAId: string;
|
||||
let photoA1: string;
|
||||
|
||||
/** 排空发送器:循环 runOnce 直至无未投递事件。 */
|
||||
async function drainSender(): Promise<void> {
|
||||
for (let round = 0; round < 20; round++) {
|
||||
const stats = await sender.runOnce();
|
||||
if (stats.picked === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
|
||||
}
|
||||
|
||||
/** cn 建相册 → 返回 {albumId, publicToken}。 */
|
||||
async function createAlbum(title: string): Promise<{
|
||||
albumId: string;
|
||||
publicToken: string;
|
||||
}> {
|
||||
const album = await request(cnBase)
|
||||
.post('/v1/albums')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
translations: [{ locale: 'zh-CN', title }],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: '2026-10-01T10:00:00.000Z',
|
||||
});
|
||||
expect(album.status).toBe(201);
|
||||
return {
|
||||
albumId: album.body.id as string,
|
||||
publicToken: album.body.publicToken as string,
|
||||
};
|
||||
}
|
||||
|
||||
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
|
||||
async function uploadAndPublish(
|
||||
albumId: string,
|
||||
clientTaskId: string,
|
||||
jpeg: Buffer,
|
||||
): Promise<string> {
|
||||
const init = await request(cnBase)
|
||||
.post('/v1/uploads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
albumId,
|
||||
clientTaskId,
|
||||
filename: 'visits.jpg',
|
||||
bytes: jpeg.length,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
expect(init.status).toBe(201);
|
||||
const { uploadId, photoId, credential } = init.body as {
|
||||
uploadId: string;
|
||||
photoId: string;
|
||||
credential: { url: string; method: string; headers: Record<string, string> };
|
||||
};
|
||||
const put = await fetch(credential.url, {
|
||||
method: credential.method,
|
||||
headers: credential.headers,
|
||||
body: new Uint8Array(jpeg),
|
||||
});
|
||||
expect(put.ok).toBe(true);
|
||||
const done = await request(cnBase)
|
||||
.post(`/v1/uploads/${uploadId}/complete`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(done.status).toBe(200);
|
||||
expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe(
|
||||
'published',
|
||||
);
|
||||
return photoId;
|
||||
}
|
||||
|
||||
/** ov 入口 WS 基址(同端口 HTTP+WS)。 */
|
||||
function wsUrl(): string {
|
||||
return ovBase.replace(/^http/, 'ws') + REALTIME_WS_PATH;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(SYNC_KEY).not.toBe('');
|
||||
await resetDb();
|
||||
await resetOvDb();
|
||||
// 限速固定窗键跨 spec 运行残留(TTL=去重窗口 30min):清场保证本 spec
|
||||
// 从零计数(同 likes spec 的 like:window:* 清场约定)。
|
||||
const redis = new Redis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381');
|
||||
let cursor = '0';
|
||||
do {
|
||||
const [next, keys] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'visit:window:*',
|
||||
'COUNT',
|
||||
1000,
|
||||
);
|
||||
cursor = next;
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
await redis.quit();
|
||||
|
||||
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
configureApp(cnApp);
|
||||
await cnApp.listen(0);
|
||||
cnBase = await cnApp.getUrl();
|
||||
|
||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
// main-ov 同形:契约帧适配器(WS 与 HTTP 同端口)。
|
||||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||||
configureApp(ovApp);
|
||||
await ovApp.listen(0);
|
||||
ovBase = await ovApp.getUrl();
|
||||
|
||||
prismaCn = new PrismaClient();
|
||||
prismaOv = new OvPrismaClient();
|
||||
storage = createStorageFromEnv();
|
||||
sender = new OutboxSender({
|
||||
prisma: prismaCn,
|
||||
ovApiUrl: ovBase,
|
||||
internalKey: SYNC_KEY,
|
||||
});
|
||||
|
||||
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||||
const login = await request(cnBase).post('/v1/auth/password').send({
|
||||
account: ADMIN_ACCOUNT,
|
||||
password: ADMIN_PASSWORD,
|
||||
});
|
||||
expect(login.status).toBe(200);
|
||||
adminToken = login.body.sessionToken as string;
|
||||
|
||||
// —— 种数据:相册 A(浏览主目标 + 1 张照片供排除规则/过期批次用例)、
|
||||
// 相册 B(publicEnabled=false → ov 同步后不可见,404 用例)——
|
||||
const albumA = await createAlbum('浏览统计 A');
|
||||
tokenA = albumA.publicToken;
|
||||
albumAId = albumA.albumId;
|
||||
const albumB = await createAlbum('浏览统计 B(未公开)');
|
||||
tokenB = albumB.publicToken;
|
||||
photoA1 = await uploadAndPublish(
|
||||
albumAId,
|
||||
'visits-a1',
|
||||
await makeJpeg(900, 600, { r: 200, g: 100, b: 80 }),
|
||||
);
|
||||
const off = await request(cnBase)
|
||||
.patch(`/v1/albums/${albumB.albumId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ publicEnabled: false });
|
||||
expect(off.status).toBe(200);
|
||||
await drainSender();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.VISIT_WINDOW_REQUESTS;
|
||||
await ovApp.close();
|
||||
await cnApp.close();
|
||||
await prismaCn.$disconnect();
|
||||
await prismaOv.$disconnect();
|
||||
});
|
||||
|
||||
it('① A14 计数主干:首访计入、窗口内去重、跨访客计入、GET album 真实 viewCount', async () => {
|
||||
// 首访:counted=true,viewCount 1。
|
||||
const first = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-a14-first',
|
||||
});
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toEqual({ viewCount: 1, counted: true });
|
||||
|
||||
// Task 6 接线:GET 公开相册读到 album_stats 真实值(非占位)。
|
||||
const album1 = await request(ovBase)
|
||||
.get(`/v1/public/albums/${tokenA}`)
|
||||
.query({ locale: 'zh-CN' });
|
||||
expect(album1.status).toBe(200);
|
||||
expect(album1.body.viewCount).toBe(1);
|
||||
|
||||
// 窗口内刷新(同 visitor 新 requestId):counted=false,不加计。
|
||||
const refresh = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-a14-refresh',
|
||||
});
|
||||
expect(refresh.status).toBe(200);
|
||||
expect(refresh.body).toEqual({ viewCount: 1, counted: false });
|
||||
|
||||
// 不同 visitorId:新访次计入。
|
||||
const second = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-2',
|
||||
requestId: 'req-a14-second',
|
||||
});
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body).toEqual({ viewCount: 2, counted: true });
|
||||
|
||||
const album2 = await request(ovBase)
|
||||
.get(`/v1/public/albums/${tokenA}`)
|
||||
.query({ locale: 'zh-CN' });
|
||||
expect(album2.body.viewCount).toBe(2);
|
||||
|
||||
// 行级断言:幂等行(counted 回映各自结局)、去重锚点、权威位、回传队列。
|
||||
const requests = await prismaOv.visit_requests.findMany({
|
||||
where: { albumId: albumAId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
expect(requests.map((r) => [r.requestId, r.counted])).toEqual([
|
||||
['req-a14-first', true],
|
||||
['req-a14-refresh', false],
|
||||
['req-a14-second', true],
|
||||
]);
|
||||
expect(await prismaOv.visit_sessions.count()).toBe(2);
|
||||
const stats = await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
});
|
||||
expect([stats.viewCount, stats.statVersion]).toEqual([2, 2]);
|
||||
const outbox = await prismaOv.outbox_stat_events.findMany({
|
||||
where: { kind: 'views', albumId: albumAId },
|
||||
orderBy: { statVersion: 'asc' },
|
||||
});
|
||||
expect(outbox.map((r) => [r.total, r.statVersion])).toEqual([
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('② requestId 幂等重试:回显原结局、viewCount 权威、不加计', async () => {
|
||||
// 重放已计入的 requestId:counted 仍 true(确认丢失重试的闭环语义)。
|
||||
const retryCounted = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-a14-first',
|
||||
});
|
||||
expect(retryCounted.status).toBe(200);
|
||||
expect(retryCounted.body).toEqual({ viewCount: 2, counted: true });
|
||||
|
||||
// 重放未计入(窗口内重复)的 requestId:counted 仍 false。
|
||||
const retrySkipped = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-a14-refresh',
|
||||
});
|
||||
expect(retrySkipped.body).toEqual({ viewCount: 2, counted: false });
|
||||
|
||||
// 不加计:幂等行不增、权威位不动。
|
||||
expect(await prismaOv.visit_requests.count()).toBe(3);
|
||||
const stats = await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
});
|
||||
expect([stats.viewCount, stats.statVersion]).toEqual([2, 2]);
|
||||
});
|
||||
|
||||
it('③ 新窗口再计:回退 lastCountedAt 越窗 → 同 visitor 再计入', async () => {
|
||||
// 手工回退 visitor1 的去重锚点至窗口(30min)外。
|
||||
await prismaOv.visit_sessions.updateMany({
|
||||
where: { albumId: albumAId, visitorId: 'visitor-a14-1' },
|
||||
data: { lastCountedAt: new Date(Date.now() - 32 * 60_000) },
|
||||
});
|
||||
const again = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-a14-newwindow',
|
||||
});
|
||||
expect(again.status).toBe(200);
|
||||
expect(again.body).toEqual({ viewCount: 3, counted: true });
|
||||
// 锚点被重新置为当前时刻(窗口重新起算)。
|
||||
const session = await prismaOv.visit_sessions.findUniqueOrThrow({
|
||||
where: {
|
||||
albumId_visitorId: { albumId: albumAId, visitorId: 'visitor-a14-1' },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
Date.now() - session.lastCountedAt!.getTime(),
|
||||
).toBeLessThan(60_000);
|
||||
});
|
||||
|
||||
it('④ 并发首访:同 visitorId 两 requestId → 恰一个 counted=true', async () => {
|
||||
const [r1, r2] = await Promise.all([
|
||||
visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-conc-1',
|
||||
requestId: 'req-conc-1',
|
||||
}),
|
||||
visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-conc-1',
|
||||
requestId: 'req-conc-2',
|
||||
}),
|
||||
]);
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
const outcomes = [r1.body, r2.body].sort(
|
||||
(a: { counted: boolean }, b: { counted: boolean }) =>
|
||||
Number(b.counted) - Number(a.counted),
|
||||
);
|
||||
// 恰一个计入(权威 viewCount=4),另一个窗口内重复(权威值同为 4)。
|
||||
expect(outcomes).toEqual([
|
||||
{ viewCount: 4, counted: true },
|
||||
{ viewCount: 4, counted: false },
|
||||
]);
|
||||
// 去重锚点唯一:并发只落一行 session;权威位只 +1。
|
||||
expect(
|
||||
await prismaOv.visit_sessions.count({
|
||||
where: { visitorId: 'visitor-conc-1' },
|
||||
}),
|
||||
).toBe(1);
|
||||
const stats = await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
});
|
||||
expect([stats.viewCount, stats.statVersion]).toEqual([4, 4]);
|
||||
});
|
||||
|
||||
it('⑤ 过期 requestId 重放:越 TTL → counted=false(无论原结局),不加计', async () => {
|
||||
// 回退首个已计入 requestId 的行龄至 TTL(35min)外。
|
||||
await prismaOv.visit_requests.update({
|
||||
where: { requestId: 'req-a14-first' },
|
||||
data: { createdAt: new Date(Date.now() - 36 * 60_000) },
|
||||
});
|
||||
const replay = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-a14-first',
|
||||
});
|
||||
expect(replay.status).toBe(200);
|
||||
expect(replay.body).toEqual({ viewCount: 4, counted: false });
|
||||
const stats = await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
});
|
||||
expect(stats.viewCount).toBe(4);
|
||||
});
|
||||
|
||||
it('⑥ 404/400:幽灵 token / 未公开相册 / 参数越界', async () => {
|
||||
const ghost = await visit(ovBase, 'g' + '0'.repeat(23), {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-404-ghost',
|
||||
});
|
||||
expect(ghost.status).toBe(404);
|
||||
expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||
|
||||
// 真实相册 publicEnabled=false(cn 已同步):同码 404。
|
||||
const invisible = await visit(ovBase, tokenB, {
|
||||
visitorId: 'visitor-a14-1',
|
||||
requestId: 'req-404-invisible',
|
||||
});
|
||||
expect(invisible.status).toBe(404);
|
||||
expect(invisible.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||
|
||||
for (const bad of [
|
||||
{ visitorId: 'v'.repeat(7), requestId: 'req-bad-1' }, // visitorId < 8
|
||||
{ visitorId: 'visitor-a14-1', requestId: 'r'.repeat(129) }, // requestId > 128
|
||||
{ visitorId: 'visitor-a14-1' }, // 缺 requestId
|
||||
]) {
|
||||
const res = await visit(ovBase, tokenA, bad);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it('⑦ 基础限速:visitorId+IP 固定窗超额 429;幂等快速通道不耗预算', async () => {
|
||||
process.env.VISIT_WINDOW_REQUESTS = '3';
|
||||
try {
|
||||
const visitor = 'visitor-rate-1';
|
||||
// 窗口预算 3:n1 计入(viewCount 5)、n2/n3 窗口内重复(仍消耗预算)。
|
||||
const n1 = await visit(ovBase, tokenA, {
|
||||
visitorId: visitor,
|
||||
requestId: 'req-rate-1',
|
||||
});
|
||||
expect(n1.body).toEqual({ viewCount: 5, counted: true });
|
||||
const n2 = await visit(ovBase, tokenA, {
|
||||
visitorId: visitor,
|
||||
requestId: 'req-rate-2',
|
||||
});
|
||||
expect(n2.body).toEqual({ viewCount: 5, counted: false });
|
||||
const n3 = await visit(ovBase, tokenA, {
|
||||
visitorId: visitor,
|
||||
requestId: 'req-rate-3',
|
||||
});
|
||||
expect(n3.body).toEqual({ viewCount: 5, counted: false });
|
||||
|
||||
// 第 4 次:超额 → 429 RATE_LIMITED。
|
||||
const n4 = await visit(ovBase, tokenA, {
|
||||
visitorId: visitor,
|
||||
requestId: 'req-rate-4',
|
||||
});
|
||||
expect(n4.status).toBe(429);
|
||||
expect(n4.body.code).toBe(ErrorCodes.RATE_LIMITED);
|
||||
|
||||
// 幂等快速通道:重放已确认 requestId 不进限速(客户端重试是正常路径)。
|
||||
const retry = await visit(ovBase, tokenA, {
|
||||
visitorId: visitor,
|
||||
requestId: 'req-rate-1',
|
||||
});
|
||||
expect(retry.status).toBe(200);
|
||||
expect(retry.body).toEqual({ viewCount: 5, counted: true });
|
||||
|
||||
// 被拒请求未入账:visit_requests 无 req-rate-4 行。
|
||||
expect(
|
||||
await prismaOv.visit_requests.count({
|
||||
where: { requestId: 'req-rate-4' },
|
||||
}),
|
||||
).toBe(0);
|
||||
} finally {
|
||||
delete process.env.VISIT_WINDOW_REQUESTS;
|
||||
}
|
||||
});
|
||||
|
||||
it('⑧ 上报排除规则:读接口/点赞/WS 帧一律不触碰浏览数据', async () => {
|
||||
const before = {
|
||||
requests: await prismaOv.visit_requests.count(),
|
||||
sessions: await prismaOv.visit_sessions.count(),
|
||||
statsRows: await prismaOv.album_stats.count(),
|
||||
viewCount: (
|
||||
await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
})
|
||||
).viewCount,
|
||||
};
|
||||
|
||||
// 公开读三端点。
|
||||
expect(
|
||||
(
|
||||
await request(ovBase)
|
||||
.get(`/v1/public/albums/${tokenA}`)
|
||||
.query({ locale: 'zh-CN' })
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await request(ovBase).get(`/v1/public/albums/${tokenA}/photos`)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await request(ovBase)
|
||||
.get(`/v1/public/albums/${tokenA}/photos/${photoA1}`)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
// 点赞(含照片计数写入——但与浏览表无关)。
|
||||
const like = await request(ovBase)
|
||||
.post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`)
|
||||
.send({ sessionId: 'excl-session', batchId: 'excl-1', delta: 1 });
|
||||
expect(like.status).toBe(200);
|
||||
|
||||
// WS 帧:subscribe(相册校验读)+ ping/pong。
|
||||
const client = await WsClient.open(wsUrl());
|
||||
try {
|
||||
client.send({ type: 'subscribe', albumToken: tokenA });
|
||||
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
|
||||
client.send({ type: 'ping', ts: Date.now() });
|
||||
await client.waitFor((f) => f.type === 'pong', 3000, 'pong');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
// 浏览域零变更:行数与权威 viewCount 均不变。
|
||||
expect(await prismaOv.visit_requests.count()).toBe(before.requests);
|
||||
expect(await prismaOv.visit_sessions.count()).toBe(before.sessions);
|
||||
expect(await prismaOv.album_stats.count()).toBe(before.statsRows);
|
||||
expect(
|
||||
(
|
||||
await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
})
|
||||
).viewCount,
|
||||
).toBe(before.viewCount);
|
||||
});
|
||||
|
||||
it('⑨ WS stats.updated:计入即广播(countVersion=statVersion);重复不广播', async () => {
|
||||
const client = await WsClient.open(wsUrl());
|
||||
try {
|
||||
client.send({ type: 'subscribe', albumToken: tokenA });
|
||||
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
|
||||
|
||||
// 新访客计入 → 房间收 stats.updated(viewCount 6 / countVersion 6)。
|
||||
const counted = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-ws-1',
|
||||
requestId: 'req-ws-1',
|
||||
});
|
||||
expect(counted.body).toEqual({ viewCount: 6, counted: true });
|
||||
const frame = await client.waitFor(
|
||||
(f) => f.type === 'stats.updated',
|
||||
5000,
|
||||
'stats.updated',
|
||||
);
|
||||
if (frame.type !== 'stats.updated') {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
expect(frame.albumId).toBe(albumAId);
|
||||
expect(frame.viewCount).toBe(6);
|
||||
expect(frame.countVersion).toBe(6);
|
||||
|
||||
// 窗口内重复(counted=false):不广播。
|
||||
const skip = await visit(ovBase, tokenA, {
|
||||
visitorId: 'visitor-ws-1',
|
||||
requestId: 'req-ws-2',
|
||||
});
|
||||
expect(skip.body).toEqual({ viewCount: 6, counted: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, QUIET_MS));
|
||||
expect(client.countOf('stats.updated')).toBe(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('⑩ runCleanupOnce:超窗行全删、窗口内行全留、累计总数不动', async () => {
|
||||
// fabricate 三类超窗旧行(直接落库,无时间等待)。
|
||||
await prismaOv.like_batches.create({
|
||||
data: {
|
||||
photoId: photoA1,
|
||||
sessionId: 'cleanup-old-session',
|
||||
batchId: 'cleanup-old-batch',
|
||||
delta: 5,
|
||||
createdAt: new Date(Date.now() - 25 * 3_600_000),
|
||||
},
|
||||
});
|
||||
await prismaOv.visit_requests.create({
|
||||
data: {
|
||||
requestId: 'cleanup-old-req',
|
||||
albumId: albumAId,
|
||||
visitorId: 'cleanup-old-visitor',
|
||||
createdAt: new Date(Date.now() - 36 * 60_000),
|
||||
},
|
||||
});
|
||||
await prismaOv.visit_sessions.create({
|
||||
data: {
|
||||
albumId: albumAId,
|
||||
visitorId: 'cleanup-old-visitor',
|
||||
lastCountedAt: new Date(Date.now() - 31 * 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
// 清理前基线(新鲜行数 + 两个权威总数)。
|
||||
const requestsBefore = await prismaOv.visit_requests.count();
|
||||
const sessionsBefore = await prismaOv.visit_sessions.count();
|
||||
const batchesBefore = await prismaOv.like_batches.count();
|
||||
const likeTotalBefore = (
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({ where: { photoId: photoA1 } })
|
||||
).likeTotal;
|
||||
|
||||
const stats = await ovApp.get(CleanupService).runCleanupOnce();
|
||||
|
||||
// 删除量:like_batches 1 行旧批;visit_requests 2 行(fabricate 旧行 +
|
||||
// ⑤ 回退的 req-a14-first,行龄 36min > TTL 35min);visit_sessions 1 行。
|
||||
expect(stats.likeBatches).toBe(1);
|
||||
expect(stats.visitRequests).toBe(2);
|
||||
expect(stats.visitSessions).toBe(1);
|
||||
|
||||
// 旧行确实消失。
|
||||
expect(
|
||||
await prismaOv.like_batches.count({
|
||||
where: { batchId: 'cleanup-old-batch' },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prismaOv.visit_requests.count({
|
||||
where: { requestId: 'cleanup-old-req' },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prismaOv.visit_requests.count({
|
||||
where: { requestId: 'req-a14-first' },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await prismaOv.visit_sessions.count({
|
||||
where: { visitorId: 'cleanup-old-visitor' },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// 窗口内新行全留(各减去被删的旧行数)。
|
||||
expect(await prismaOv.visit_requests.count()).toBe(requestsBefore - 2);
|
||||
expect(await prismaOv.visit_sessions.count()).toBe(sessionsBefore - 1);
|
||||
expect(await prismaOv.like_batches.count()).toBe(batchesBefore - 1);
|
||||
|
||||
// 累计总数分毫不动:album_stats(viewCount/statVersion)与 photos_ov。
|
||||
const albumStats = await prismaOv.album_stats.findUniqueOrThrow({
|
||||
where: { albumId: albumAId },
|
||||
});
|
||||
expect([albumStats.viewCount, albumStats.statVersion]).toEqual([6, 6]);
|
||||
expect(
|
||||
(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photoA1 },
|
||||
})
|
||||
).likeTotal,
|
||||
).toBe(likeTotalBefore);
|
||||
});
|
||||
|
||||
it('⑪ 过期批次重放(已登记偏差):清理后重放旧 batchId 重新加计', async () => {
|
||||
// 提交一批 → 回退行龄越窗 → 清理删除 → 重放同 batchId。
|
||||
const first = await request(ovBase)
|
||||
.post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`)
|
||||
.send({ sessionId: 'dev-old-session', batchId: 'dev-old-batch', delta: 2 });
|
||||
expect(first.status).toBe(200);
|
||||
const totalBefore = first.body.total as number;
|
||||
|
||||
await prismaOv.like_batches.updateMany({
|
||||
where: { batchId: 'dev-old-batch' },
|
||||
data: { createdAt: new Date(Date.now() - 25 * 3_600_000) },
|
||||
});
|
||||
const cleanup = await ovApp.get(CleanupService).runCleanupOnce();
|
||||
expect(cleanup.likeBatches).toBe(1);
|
||||
|
||||
// 已登记偏差(likes.service.ts 头注):行被清后与「从未提交」不可区分,
|
||||
// 重放以新批次身份重新加计(batchAccepted=true)——文档化行为固化;
|
||||
// session 滑窗上限仍钳制其总量(delta 累计远低于默认 500 上限)。
|
||||
const replay = await request(ovBase)
|
||||
.post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`)
|
||||
.send({ sessionId: 'dev-old-session', batchId: 'dev-old-batch', delta: 2 });
|
||||
expect(replay.status).toBe(200);
|
||||
expect(replay.body.batchAccepted).toBe(true);
|
||||
expect(replay.body.total).toBe(totalBefore + 2);
|
||||
expect(
|
||||
(
|
||||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photoA1 },
|
||||
})
|
||||
).likeTotal,
|
||||
).toBe(totalBefore + 2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user