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:
@@ -48,4 +48,10 @@ HOT_SORT_TTL_MS=30000
|
||||
STAT_RETURN_INTERVAL_MS=60000
|
||||
VISIT_WINDOW_MINUTES=30
|
||||
LIKE_BATCH_WINDOW_HOURS=24
|
||||
# P2 Task 7:点赞基础限速(Redis ov 滑窗,窗口=LIKE_BATCH_WINDOW_HOURS)与
|
||||
# likes.burst 实时合并窗口——sessionId 窗口累计 delta 上限 / 来源 IP 上限 /
|
||||
# 同照片批次合并毫秒数(0=不合并)。默认值见 packages/contracts/src/constants.ts。
|
||||
LIKE_SESSION_WINDOW_DELTA=500
|
||||
LIKE_IP_WINDOW_DELTA=2000
|
||||
LIKE_BURST_MERGE_MS=500
|
||||
VISIT_REQUEST_TTL_MINUTES=35
|
||||
|
||||
@@ -63,6 +63,16 @@ describe('ServerMessage', () => {
|
||||
expect(pong.type).toBe('pong');
|
||||
});
|
||||
|
||||
it('error 帧携带域码(subscribe 校验失败等场景)', () => {
|
||||
const error: ServerMessage = { type: 'error', code: 'ALBUM_NOT_PUBLIC' };
|
||||
if (error.type !== 'error') throw new Error('unreachable');
|
||||
expect(error.code).toBe('ALBUM_NOT_PUBLIC');
|
||||
// message 可选:缺省合法(客户端只应消费 code)。
|
||||
// @ts-expect-error 非 error 分支没有 code
|
||||
const absent = (burst as ServerMessage).code;
|
||||
expect(absent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('非法 type 在编译期被拒绝', () => {
|
||||
// @ts-expect-error type 必须严格属于联合成员
|
||||
const invalid: ServerMessage = { ...burst, type: 'likes.update' };
|
||||
@@ -81,7 +91,7 @@ describe('ServerMessage', () => {
|
||||
expect(wrongTypes.type).toBe('likes.burst');
|
||||
});
|
||||
|
||||
it('type 联合与 6 个消息成员 1:1(穷尽锁定)', () => {
|
||||
it('type 联合与 7 个消息成员 1:1(穷尽锁定)', () => {
|
||||
const exhaustive: Equal<
|
||||
ServerMessage['type'],
|
||||
| 'photo.published'
|
||||
@@ -90,6 +100,7 @@ describe('ServerMessage', () => {
|
||||
| 'likes.burst'
|
||||
| 'stats.updated'
|
||||
| 'pong'
|
||||
| 'error'
|
||||
> = true;
|
||||
expect(exhaustive).toBe(true);
|
||||
});
|
||||
@@ -102,6 +113,7 @@ describe('ServerMessage', () => {
|
||||
{ type: 'photo.deleted', photoId: 'p', albumId: 'a', entityVersion: 3 },
|
||||
{ type: 'stats.updated', albumId: 'a', viewCount: 99, countVersion: 9 },
|
||||
{ type: 'pong', ts: 1758187200000 },
|
||||
{ type: 'error', code: 'ALBUM_NOT_PUBLIC', message: '相册不存在或未公开' },
|
||||
];
|
||||
const kinds = messages.map((m) => {
|
||||
switch (m.type) {
|
||||
@@ -115,6 +127,8 @@ describe('ServerMessage', () => {
|
||||
return `stats:${m.viewCount}`;
|
||||
case 'pong':
|
||||
return `pong:${m.ts}`;
|
||||
case 'error':
|
||||
return `error:${m.code}`;
|
||||
}
|
||||
});
|
||||
expect(kinds).toEqual([
|
||||
@@ -124,6 +138,7 @@ describe('ServerMessage', () => {
|
||||
'photo:3',
|
||||
'stats:99',
|
||||
'pong:1758187200000',
|
||||
'error:ALBUM_NOT_PUBLIC',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,5 +20,11 @@ export const STAT_RETURN_INTERVAL_MS = 60000;
|
||||
export const VISIT_WINDOW_MINUTES = 30;
|
||||
/** 点赞批次幂等窗口小时:窗口外 batchId 拒绝重放(返回权威 total,不加计)。 */
|
||||
export const LIKE_BATCH_WINDOW_HOURS = 24;
|
||||
/** 点赞基础限速:单 sessionId 滑窗(=幂等窗口)累计 delta 上限,超限 429。 */
|
||||
export const LIKE_SESSION_WINDOW_DELTA = 500;
|
||||
/** 点赞基础限速:来源 IP 滑窗累计 delta 上限(NAT 出口共享,宽于单会话)。 */
|
||||
export const LIKE_IP_WINDOW_DELTA = 2000;
|
||||
/** likes.burst 合并窗口毫秒(技术方案 §7 短窗合并;0=不合并逐批即时广播)。 */
|
||||
export const LIKE_BURST_MERGE_MS = 500;
|
||||
/** 浏览上报 requestId 保留分钟:过期 requestId 拒绝重放(≥ 去重窗口)。 */
|
||||
export const VISIT_REQUEST_TTL_MINUTES = 35;
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
/** 心跳间隔(毫秒):客户端周期发送 ping、服务端回 pong;超时无心跳的连接由服务端踢除。 */
|
||||
export const HEARTBEAT_MS = 20000;
|
||||
|
||||
/**
|
||||
* ov 入口 WS 网关挂载路径:与 HTTP 同端口(ov 8081),升级请求按本路径路由
|
||||
* (services/api realtime 网关装饰器与 e2e/viewer 客户端共用,单一来源)。
|
||||
*/
|
||||
export const REALTIME_WS_PATH = '/v1/realtime';
|
||||
|
||||
// ── server → client ──
|
||||
|
||||
/** 照片发布:副本应用后向相册房间广播(ov 侧,下同)。 */
|
||||
@@ -59,13 +65,26 @@ export interface PongMessage {
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误帧:客户端消息无法处理时的服务端应答(如 subscribe 的相册不存在/
|
||||
* 不公开)。连接保持打开——客户端可修正后重发(如改订其它相册或等待恢复)。
|
||||
*/
|
||||
export interface RealtimeErrorMessage {
|
||||
type: 'error';
|
||||
/** 契约 ErrorCodes 域码(subscribe 校验失败 → ALBUM_NOT_PUBLIC 等)。 */
|
||||
code: string;
|
||||
/** 可读说明(可选,不承诺稳定性,客户端只应消费 code)。 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type ServerMessage =
|
||||
| PhotoPublishedMessage
|
||||
| PhotoHiddenMessage
|
||||
| PhotoDeletedMessage
|
||||
| LikesBurstMessage
|
||||
| StatsUpdatedMessage
|
||||
| PongMessage;
|
||||
| PongMessage
|
||||
| RealtimeErrorMessage;
|
||||
|
||||
// ── client → server ──
|
||||
|
||||
|
||||
Generated
+126
-188
@@ -312,10 +312,19 @@ importers:
|
||||
version: 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^10.4.6
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/event-emitter':
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^10.4.6
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
'@nestjs/platform-ws':
|
||||
specifier: ^10.4.6
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)
|
||||
'@nestjs/websockets':
|
||||
specifier: ^10.4.6
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@node-rs/argon2':
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
@@ -358,6 +367,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
ws:
|
||||
specifier: ^8.18.0
|
||||
version: 8.21.3
|
||||
devDependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.700.0
|
||||
@@ -374,6 +386,9 @@ importers:
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
'@types/ws':
|
||||
specifier: ^8.5.12
|
||||
version: 8.18.1
|
||||
dotenv:
|
||||
specifier: ^16.4.5
|
||||
version: 16.6.1
|
||||
@@ -404,6 +419,9 @@ importers:
|
||||
'@photo/db-cn':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db-cn
|
||||
'@photo/db-ov':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db-ov
|
||||
pino:
|
||||
specifier: ^10.0.0
|
||||
version: 10.3.1
|
||||
@@ -413,13 +431,10 @@ importers:
|
||||
version: 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^10.4.6
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^10.4.6
|
||||
version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
'@photo/db-ov':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db-ov
|
||||
'@photo/storage':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/storage
|
||||
@@ -437,7 +452,7 @@ importers:
|
||||
version: 16.6.1
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))
|
||||
sharp:
|
||||
specifier: ^0.35.0
|
||||
version: 0.35.4(@types/node@22.20.3)
|
||||
@@ -446,7 +461,7 @@ importers:
|
||||
version: 7.2.2
|
||||
ts-jest:
|
||||
specifier: ^29.2.5
|
||||
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)))(typescript@5.6.3)
|
||||
version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.6.3)
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.3
|
||||
@@ -1568,12 +1583,25 @@ packages:
|
||||
'@nestjs/websockets':
|
||||
optional: true
|
||||
|
||||
'@nestjs/event-emitter@3.1.0':
|
||||
resolution: {integrity: sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||
|
||||
'@nestjs/platform-express@10.4.22':
|
||||
resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0
|
||||
'@nestjs/core': ^10.0.0
|
||||
|
||||
'@nestjs/platform-ws@10.4.22':
|
||||
resolution: {integrity: sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0
|
||||
'@nestjs/websockets': ^10.0.0
|
||||
rxjs: ^7.1.0
|
||||
|
||||
'@nestjs/testing@10.4.22':
|
||||
resolution: {integrity: sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==}
|
||||
peerDependencies:
|
||||
@@ -1587,6 +1615,18 @@ packages:
|
||||
'@nestjs/platform-express':
|
||||
optional: true
|
||||
|
||||
'@nestjs/websockets@10.4.22':
|
||||
resolution: {integrity: sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^10.0.0
|
||||
'@nestjs/core': ^10.0.0
|
||||
'@nestjs/platform-socket.io': ^10.0.0
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
rxjs: ^7.1.0
|
||||
peerDependenciesMeta:
|
||||
'@nestjs/platform-socket.io':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
@@ -2306,6 +2346,9 @@ packages:
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
@@ -3386,6 +3429,9 @@ packages:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter2@6.4.9:
|
||||
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
@@ -4443,6 +4489,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-hash@3.0.0:
|
||||
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5787,6 +5837,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.0:
|
||||
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -6721,41 +6783,6 @@ snapshots:
|
||||
jest-util: 29.7.0
|
||||
slash: 3.0.0
|
||||
|
||||
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))':
|
||||
dependencies:
|
||||
'@jest/console': 29.7.0
|
||||
'@jest/reporters': 29.7.0
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
'@types/node': 24.13.5
|
||||
ansi-escapes: 4.3.2
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
exit: 0.1.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-changed-files: 29.7.0
|
||||
jest-config: 29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
jest-haste-map: 29.7.0
|
||||
jest-message-util: 29.7.0
|
||||
jest-regex-util: 29.6.3
|
||||
jest-resolve: 29.7.0
|
||||
jest-resolve-dependencies: 29.7.0
|
||||
jest-runner: 29.7.0
|
||||
jest-runtime: 29.7.0
|
||||
jest-snapshot: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
jest-watcher: 29.7.0
|
||||
micromatch: 4.0.8
|
||||
pretty-format: 29.7.0
|
||||
slash: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@jest/console': 29.7.0
|
||||
@@ -6983,7 +7010,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nuxtjs/opencollective': 0.3.2
|
||||
@@ -6996,13 +7023,20 @@ snapshots:
|
||||
uid: 2.0.2
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
'@nestjs/websockets': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@nestjs/event-emitter@3.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
eventemitter2: 6.4.9
|
||||
|
||||
'@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
body-parser: 1.20.4
|
||||
cors: 2.8.5
|
||||
express: 4.22.1
|
||||
@@ -7011,14 +7045,35 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/platform-ws@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@10.4.22)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/websockets': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
ws: 8.18.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@nestjs/testing@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
|
||||
'@nestjs/websockets@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(@nestjs/websockets@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
iterare: 1.2.1
|
||||
object-hash: 3.0.0
|
||||
reflect-metadata: 0.2.2
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodable/entities@3.0.0': {}
|
||||
@@ -7688,6 +7743,10 @@ snapshots:
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.13.5
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@17.0.35':
|
||||
@@ -8672,21 +8731,6 @@ snapshots:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
create-jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
chalk: 4.1.2
|
||||
exit: 0.1.2
|
||||
graceful-fs: 4.2.11
|
||||
jest-config: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
jest-util: 29.7.0
|
||||
prompts: 2.4.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
create-jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/types': 29.6.3
|
||||
@@ -9072,6 +9116,8 @@ snapshots:
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter2@6.4.9: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
execa@5.1.1:
|
||||
@@ -9578,25 +9624,6 @@ snapshots:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-cli@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
'@jest/test-result': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
chalk: 4.1.2
|
||||
create-jest: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
exit: 0.1.2
|
||||
import-local: 3.2.0
|
||||
jest-config: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
yargs: 17.7.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-cli@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))
|
||||
@@ -9616,37 +9643,6 @@ snapshots:
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest-config@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/test-sequencer': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
deepmerge: 4.3.1
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
jest-circus: 29.7.0
|
||||
jest-environment-node: 29.7.0
|
||||
jest-get-type: 29.6.3
|
||||
jest-regex-util: 29.6.3
|
||||
jest-resolve: 29.7.0
|
||||
jest-runner: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
micromatch: 4.0.8
|
||||
parse-json: 5.2.0
|
||||
pretty-format: 29.7.0
|
||||
slash: 3.0.0
|
||||
strip-json-comments: 3.1.1
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.3
|
||||
ts-node: 10.9.2(@types/node@22.20.3)(typescript@5.6.3)
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-config@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -9678,37 +9674,6 @@ snapshots:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-config@29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/test-sequencer': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
chalk: 4.1.2
|
||||
ci-info: 3.9.0
|
||||
deepmerge: 4.3.1
|
||||
glob: 7.2.3
|
||||
graceful-fs: 4.2.11
|
||||
jest-circus: 29.7.0
|
||||
jest-environment-node: 29.7.0
|
||||
jest-get-type: 29.6.3
|
||||
jest-regex-util: 29.6.3
|
||||
jest-resolve: 29.7.0
|
||||
jest-runner: 29.7.0
|
||||
jest-util: 29.7.0
|
||||
jest-validate: 29.7.0
|
||||
micromatch: 4.0.8
|
||||
parse-json: 5.2.0
|
||||
pretty-format: 29.7.0
|
||||
slash: 3.0.0
|
||||
strip-json-comments: 3.1.1
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.5
|
||||
ts-node: 10.9.2(@types/node@22.20.3)(typescript@5.6.3)
|
||||
transitivePeerDependencies:
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
|
||||
jest-config@29.7.0(@types/node@24.13.5)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -9955,18 +9920,6 @@ snapshots:
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
'@jest/types': 29.6.3
|
||||
import-local: 3.2.0
|
||||
jest-cli: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- babel-plugin-macros
|
||||
- supports-color
|
||||
- ts-node
|
||||
|
||||
jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))
|
||||
@@ -10415,6 +10368,8 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-hash@3.0.0: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
obug@2.2.1: {}
|
||||
@@ -11269,26 +11224,6 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3)))(typescript@5.6.3):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
handlebars: 4.7.9
|
||||
jest: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3))
|
||||
json5: 2.2.3
|
||||
lodash.memoize: 4.1.2
|
||||
make-error: 1.3.6
|
||||
semver: 7.8.5
|
||||
type-fest: 4.41.0
|
||||
typescript: 5.6.3
|
||||
yargs-parser: 21.1.1
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
jest-util: 29.7.0
|
||||
|
||||
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
@@ -11309,24 +11244,25 @@ snapshots:
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
jest-util: 29.7.0
|
||||
|
||||
ts-node@10.9.2(@types/node@22.20.3)(typescript@5.6.3):
|
||||
ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.6.3):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
'@tsconfig/node10': 1.0.13
|
||||
'@tsconfig/node12': 1.0.11
|
||||
'@tsconfig/node14': 1.0.3
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/node': 22.20.3
|
||||
acorn: 8.18.0
|
||||
acorn-walk: 8.3.5
|
||||
arg: 4.1.3
|
||||
create-require: 1.1.1
|
||||
diff: 4.0.4
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
handlebars: 4.7.9
|
||||
jest: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3))
|
||||
json5: 2.2.3
|
||||
lodash.memoize: 4.1.2
|
||||
make-error: 1.3.6
|
||||
semver: 7.8.5
|
||||
type-fest: 4.41.0
|
||||
typescript: 5.6.3
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
optional: true
|
||||
yargs-parser: 21.1.1
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/transform': 29.7.0
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
jest-util: 29.7.0
|
||||
|
||||
ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3):
|
||||
dependencies:
|
||||
@@ -11838,6 +11774,8 @@ snapshots:
|
||||
|
||||
ws@7.5.13: {}
|
||||
|
||||
ws@8.18.0: {}
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.6",
|
||||
"@nestjs/core": "^10.4.6",
|
||||
"@nestjs/event-emitter": "^3.1.0",
|
||||
"@nestjs/platform-express": "^10.4.6",
|
||||
"@nestjs/platform-ws": "^10.4.6",
|
||||
"@nestjs/websockets": "^10.4.6",
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"@photo/contracts": "workspace:*",
|
||||
"@photo/db-cn": "workspace:*",
|
||||
@@ -31,7 +34,8 @@
|
||||
"pino": "^10.0.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
@@ -39,6 +43,7 @@
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.12",
|
||||
"dotenv": "^16.4.5",
|
||||
"jest": "^29.7.0",
|
||||
"sharp": "^0.35.0",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,9 @@ import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||||
import { ErrorCodes, signSyncBody } from '@photo/contracts';
|
||||
import { AppOvModule } from '../src/app-ov.module';
|
||||
import { configureApp } from '../src/shared/configure-app';
|
||||
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
|
||||
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
|
||||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||||
import { EMERGENCY_AUDIT_LOGGER } from '../src/emergency/emergency.provider';
|
||||
import { resetOvDb } from './reset-ov';
|
||||
|
||||
@@ -80,6 +83,7 @@ describe('ov emergency disable channel (e2e)', () => {
|
||||
rawBody: true,
|
||||
logger: false,
|
||||
});
|
||||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||||
configureApp(ovApp);
|
||||
await ovApp.listen(0);
|
||||
ovBase = await ovApp.getUrl();
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
// services/api/test/likes-realtime.e2e-spec.ts —— P2 Task 7 点赞与实时 e2e。
|
||||
// 与 public-ov.e2e-spec 同模式:进程内起 cn + ov 两个真实入口,业务动作全走
|
||||
// cn API(建相册/上传 complete/直驱发布事务),OutboxSender(生产实现)指向
|
||||
// ov 入口真实 URL——点赞与 WS 广播作用在与生产链路同源的数据上。ov 入口按
|
||||
// main-ov 同形装配 ContractWsAdapter(WS 与 HTTP 同端口 /v1/realtime)。
|
||||
//
|
||||
// 覆盖(任务验收 A09/A10/A21 部分):
|
||||
// ① A09 点赞批次:连点 3 批累计 1+2+3(total/countVersion 单调)、同 batchId
|
||||
// 重试幂等不加计(batchAccepted=true、like_batches 行数不增)、outbox_
|
||||
// stat_events 逐批落权威快照;delta 0/21、sessionId/batchId 65 字符 →
|
||||
// 400;幽灵 token → 404 ALBUM_NOT_PUBLIC;幽灵照片 → 404 PHOTO_NOT_PUBLIC;
|
||||
// ② 基础限速:LIKE_SESSION_WINDOW_DELTA 压小(惰性 env)→ 窗口超额 429
|
||||
// RATE_LIMITED(代码语义验证;窗口本身 24h,不做时间旅行);
|
||||
// ③ WS 协议:ping→pong 回显 ts;幽灵 token subscribe → error 帧
|
||||
// (ALBUM_NOT_PUBLIC)且连接保持(后续 ping 仍应答);
|
||||
// ④ A10 房间隔离:两相册两客户端各自订阅(先以一次本册点赞证明双方订阅
|
||||
// 均生效——非空转通过),跨册点赞只达本册房间,另一房间零消息;
|
||||
// ⑤ A21 合并广播:合并窗内两批同照片 → 恰一条 likes.burst(delta 求和、
|
||||
// originBatchIds 双元素、total 权威、burstId 存在),窗外无第二条;
|
||||
// ⑥ photo.hidden 实时:cn 隐藏 → 高优先级同步 → ov 应用后相册房间收到
|
||||
// photo.hidden(entityVersion 与副本行一致);隐藏后点赞 → 404。
|
||||
//
|
||||
// —— 登记偏差(过期批次重放,不可 e2e)——
|
||||
// 24h 窗口外重放已清理 batchId 的「再次加计」边界在 likes.service.ts 头注
|
||||
// 分析(无永久幂等账本下的理论边界,由 sessionId/IP 滑窗上限钳制);窗口
|
||||
// 时间不可旅行,e2e 无法覆盖,随 Task 8 清理调度落地时以单元语义评审。
|
||||
//
|
||||
// —— 不在本 spec 的语义(对应实现位)——
|
||||
// · 传输层心跳踢除(HEARTBEAT_MS=20s 周期 ping/terminate):真实 20s 等待
|
||||
// 不值 e2e 时长,realtime.gateway.afterInit 直读契约常量(类型锁定见
|
||||
// contracts __tests__/realtime.test.ts);P5 压测域验证。
|
||||
// · stats.updated:Task 8 浏览计数接线后由该任务测试。
|
||||
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 { 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';
|
||||
|
||||
/** 合并窗加大到 1s:两批 HTTP 提交的完成时间远小于窗(防慢机抖动)。 */
|
||||
const BURST_MERGE_TEST_MS = 1000;
|
||||
/** subscribe 为异步校验(DB 查询):发送后的就绪等待(本地查询 ~ms 级)。 */
|
||||
const SUBSCRIBE_SETTLE_MS = 300;
|
||||
|
||||
/** 测试内生成真实 JPEG(同 public-ov.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,断言交给调用方)。 */
|
||||
async function like(
|
||||
ovBase: string,
|
||||
token: string,
|
||||
photoId: string,
|
||||
body: { sessionId: string; batchId: string; delta: number },
|
||||
): Promise<request.Response> {
|
||||
return request(ovBase)
|
||||
.post(`/v1/public/albums/${token}/photos/${photoId}/likes`)
|
||||
.send(body);
|
||||
}
|
||||
|
||||
/** 契约帧 WS 测试客户端:收帧队列 + 条件等待。 */
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
/** 已收帧快照(隔离断言用:跨房间不应新增)。 */
|
||||
received(): readonly ServerMessage[] {
|
||||
return this.frames;
|
||||
}
|
||||
|
||||
/** 按类型统计(合并广播「恰一条」断言用)。 */
|
||||
countOf(type: ServerMessage['type']): number {
|
||||
return this.frames.filter((f) => f.type === type).length;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('likes + realtime (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;
|
||||
let photoA2: string;
|
||||
let photoB1: 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: 'likes-rt.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();
|
||||
// 合并窗与限速阈值均为惰性 env(同 HOT_SORT_TTL_MS 模式):测试内压/放宽。
|
||||
process.env.LIKE_BURST_MERGE_MS = String(BURST_MERGE_TEST_MS);
|
||||
// 限速滑窗键跨 spec 运行残留(TTL 24h):清场保证本 spec 从零计数。
|
||||
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',
|
||||
'like: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(两张照片:A1 点赞主目标 / A2 隐藏目标)+ 相册 B ——
|
||||
const albumA = await createAlbum('点赞实时 A');
|
||||
tokenA = albumA.publicToken;
|
||||
albumAId = albumA.albumId;
|
||||
const albumB = await createAlbum('点赞实时 B');
|
||||
tokenB = albumB.publicToken;
|
||||
|
||||
photoA1 = await uploadAndPublish(
|
||||
albumAId,
|
||||
'likes-rt-a1',
|
||||
await makeJpeg(900, 600, { r: 180, g: 120, b: 60 }),
|
||||
);
|
||||
photoA2 = await uploadAndPublish(
|
||||
albumAId,
|
||||
'likes-rt-a2',
|
||||
await makeJpeg(800, 500, { r: 60, g: 130, b: 180 }),
|
||||
);
|
||||
photoB1 = await uploadAndPublish(
|
||||
albumB.albumId,
|
||||
'likes-rt-b1',
|
||||
await makeJpeg(700, 450, { r: 120, g: 180, b: 90 }),
|
||||
);
|
||||
await drainSender();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.LIKE_BURST_MERGE_MS;
|
||||
delete process.env.LIKE_SESSION_WINDOW_DELTA;
|
||||
await ovApp.close();
|
||||
await cnApp.close();
|
||||
await prismaCn.$disconnect();
|
||||
await prismaOv.$disconnect();
|
||||
});
|
||||
|
||||
it('① A09 点赞批次:连点累计、幂等重试不加计、outbox 快照、参数/404 校验', async () => {
|
||||
const session = 'e2e-session-a09';
|
||||
// 连点 3 批:total 1 → 3 → 6,countVersion 逐批 +1。
|
||||
const b1 = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'batch-1',
|
||||
delta: 1,
|
||||
});
|
||||
expect(b1.status).toBe(200);
|
||||
expect(b1.body).toEqual({ total: 1, countVersion: 1, batchAccepted: true });
|
||||
const b2 = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'batch-2',
|
||||
delta: 2,
|
||||
});
|
||||
expect(b2.body).toEqual({ total: 3, countVersion: 2, batchAccepted: true });
|
||||
const b3 = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'batch-3',
|
||||
delta: 3,
|
||||
});
|
||||
expect(b3.body).toEqual({ total: 6, countVersion: 3, batchAccepted: true });
|
||||
|
||||
// 幂等重试:同 batchId 再提交 → 权威 total 不变、batchAccepted 仍 true。
|
||||
const retry = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'batch-2',
|
||||
delta: 2,
|
||||
});
|
||||
expect(retry.status).toBe(200);
|
||||
expect(retry.body).toEqual({ total: 6, countVersion: 3, batchAccepted: true });
|
||||
// like_batches 不因重试增行(3 批 3 行)。
|
||||
expect(await prismaOv.like_batches.count({ where: { photoId: photoA1 } })).toBe(
|
||||
3,
|
||||
);
|
||||
|
||||
// outbox_stat_events 逐批落权威快照(total 单调 1/3/6,statVersion 对齐)。
|
||||
const outbox = await prismaOv.outbox_stat_events.findMany({
|
||||
where: { photoId: photoA1 },
|
||||
orderBy: { statVersion: 'asc' },
|
||||
});
|
||||
expect(outbox.map((r) => [r.total, r.statVersion, r.kind])).toEqual([
|
||||
[1, 1, 'likes'],
|
||||
[3, 2, 'likes'],
|
||||
[6, 3, 'likes'],
|
||||
]);
|
||||
|
||||
// 参数校验:delta 越界 / 字段长度 65 → 400 VALIDATION_FAILED。
|
||||
for (const bad of [
|
||||
{ sessionId: session, batchId: 'bad-0', delta: 0 },
|
||||
{ sessionId: session, batchId: 'bad-21', delta: 21 },
|
||||
{ sessionId: 'x'.repeat(65), batchId: 'bad-len', delta: 1 },
|
||||
{ sessionId: session, batchId: 'y'.repeat(65), delta: 1 },
|
||||
]) {
|
||||
const res = await like(ovBase, tokenA, photoA1, bad);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
}
|
||||
|
||||
// 404:幽灵相册 token / 幽灵照片(合法相册下)。
|
||||
const ghostToken = await like(ovBase, 'g' + '0'.repeat(23), photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'bad-token',
|
||||
delta: 1,
|
||||
});
|
||||
expect(ghostToken.status).toBe(404);
|
||||
expect(ghostToken.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||
const ghostPhoto = await like(ovBase, tokenA, 'p-ghost', {
|
||||
sessionId: session,
|
||||
batchId: 'bad-photo',
|
||||
delta: 1,
|
||||
});
|
||||
expect(ghostPhoto.status).toBe(404);
|
||||
expect(ghostPhoto.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||
});
|
||||
|
||||
it('② 基础限速:sessionId 滑窗超额 → 429 RATE_LIMITED', async () => {
|
||||
process.env.LIKE_SESSION_WINDOW_DELTA = '5';
|
||||
try {
|
||||
const session = 'e2e-session-rate';
|
||||
const ok = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'rl-1',
|
||||
delta: 3,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
// 累计 3+3=6 > 5 → 拒绝(且本批不加计)。
|
||||
const limited = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: session,
|
||||
batchId: 'rl-2',
|
||||
delta: 3,
|
||||
});
|
||||
expect(limited.status).toBe(429);
|
||||
expect(limited.body.code).toBe(ErrorCodes.RATE_LIMITED);
|
||||
// 未入账:批次表无 rl-2 行,权威 total = ① 的 6 + 本测试已受理的 rl-1=3
|
||||
// (若被拒批次加计则为 12)。
|
||||
expect(
|
||||
await prismaOv.like_batches.count({
|
||||
where: { sessionId: session },
|
||||
}),
|
||||
).toBe(1);
|
||||
const row = await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photoA1 },
|
||||
select: { likeTotal: true },
|
||||
});
|
||||
expect(row.likeTotal).toBe(9);
|
||||
} finally {
|
||||
delete process.env.LIKE_SESSION_WINDOW_DELTA;
|
||||
}
|
||||
});
|
||||
|
||||
it('③ WS 协议:ping→pong 回显、幽灵 token error 帧、错误后连接保持', async () => {
|
||||
const client = await WsClient.open(wsUrl());
|
||||
try {
|
||||
const ts = 1758187200123;
|
||||
client.send({ type: 'ping', ts });
|
||||
const pong = await client.waitFor(
|
||||
(f) => f.type === 'pong',
|
||||
3000,
|
||||
'pong',
|
||||
);
|
||||
expect(pong).toEqual({ type: 'pong', ts });
|
||||
|
||||
// 幽灵 token:error 帧(ALBUM_NOT_PUBLIC)。
|
||||
client.send({
|
||||
type: 'subscribe',
|
||||
albumToken: 'g' + '0'.repeat(23),
|
||||
});
|
||||
const error = await client.waitFor(
|
||||
(f) => f.type === 'error',
|
||||
3000,
|
||||
'error',
|
||||
);
|
||||
expect(error).toEqual({
|
||||
type: 'error',
|
||||
code: ErrorCodes.ALBUM_NOT_PUBLIC,
|
||||
});
|
||||
|
||||
// 连接保持:错误后仍可 ping→pong(可修正后重订其它相册)。
|
||||
client.send({ type: 'ping', ts: 1758187200456 });
|
||||
const pong2 = await client.waitFor(
|
||||
(f) => f.type === 'pong' && f.ts === 1758187200456,
|
||||
3000,
|
||||
'pong2',
|
||||
);
|
||||
if (pong2.type !== 'pong') {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
expect(pong2.ts).toBe(1758187200456);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('④ A10 房间隔离:跨册点赞只达本册房间,另一房间零消息', async () => {
|
||||
const clientA = await WsClient.open(wsUrl());
|
||||
const clientB = await WsClient.open(wsUrl());
|
||||
try {
|
||||
clientA.send({ type: 'subscribe', albumToken: tokenA });
|
||||
clientB.send({ type: 'subscribe', albumToken: tokenB });
|
||||
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
|
||||
|
||||
// 非空转证明:先各以一次本册点赞证明两侧订阅均已生效。
|
||||
await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: 'e2e-session-iso-a',
|
||||
batchId: 'iso-a1',
|
||||
delta: 1,
|
||||
});
|
||||
await clientA.waitFor((f) => f.type === 'likes.burst', 5000, 'A 首个 burst');
|
||||
await like(ovBase, tokenB, photoB1, {
|
||||
sessionId: 'e2e-session-iso-b',
|
||||
batchId: 'iso-b1',
|
||||
delta: 1,
|
||||
});
|
||||
await clientB.waitFor((f) => f.type === 'likes.burst', 5000, 'B 首个 burst');
|
||||
|
||||
// 隔离断言:再赞 B,A 房间在「B 收到 burst + 合并窗外一拍」后仍零新增。
|
||||
const aBefore = clientA.received().length;
|
||||
await like(ovBase, tokenB, photoB1, {
|
||||
sessionId: 'e2e-session-iso-b',
|
||||
batchId: 'iso-b2',
|
||||
delta: 2,
|
||||
});
|
||||
await clientB.waitFor(
|
||||
(f) => f.type === 'likes.burst' && f.originBatchIds.includes('iso-b2'),
|
||||
5000,
|
||||
'B 第二个 burst',
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, BURST_MERGE_TEST_MS + 300),
|
||||
);
|
||||
expect(clientA.received().length).toBe(aBefore); // A 房间零新增
|
||||
} finally {
|
||||
clientA.close();
|
||||
clientB.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('⑤ A21 合并广播:合并窗内两批 → 恰一条 likes.burst(delta 求和 + 双 originBatchIds)', async () => {
|
||||
const client = await WsClient.open(wsUrl());
|
||||
try {
|
||||
client.send({ type: 'subscribe', albumToken: tokenA });
|
||||
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
|
||||
|
||||
const before = await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photoA1 },
|
||||
select: { likeTotal: true, countVersion: true },
|
||||
});
|
||||
|
||||
// 两批快速提交(远小于合并窗):同照片不同 batchId。
|
||||
const first = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: 'e2e-session-merge',
|
||||
batchId: 'merge-1',
|
||||
delta: 1,
|
||||
});
|
||||
const second = await like(ovBase, tokenA, photoA1, {
|
||||
sessionId: 'e2e-session-merge',
|
||||
batchId: 'merge-2',
|
||||
delta: 2,
|
||||
});
|
||||
expect(first.body.total).toBe(before.likeTotal + 1);
|
||||
expect(second.body.total).toBe(before.likeTotal + 3);
|
||||
|
||||
// 恰一条 burst:delta=1+2、originBatchIds 双元素、total 权威、版本对齐。
|
||||
const burst = await client.waitFor(
|
||||
(f) => f.type === 'likes.burst' && f.originBatchIds.includes('merge-2'),
|
||||
5000,
|
||||
'合并 burst',
|
||||
);
|
||||
if (burst.type !== 'likes.burst') {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
expect(burst.photoId).toBe(photoA1);
|
||||
expect(burst.albumId).toBe(albumAId);
|
||||
expect(burst.delta).toBe(3);
|
||||
expect([...burst.originBatchIds].sort()).toEqual(['merge-1', 'merge-2']);
|
||||
expect(burst.total).toBe(before.likeTotal + 3);
|
||||
expect(burst.countVersion).toBe(before.countVersion + 2);
|
||||
expect(typeof burst.burstId).toBe('string');
|
||||
|
||||
// 窗外不再补发第二条(等待一个完整合并窗 + 余量)。
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, BURST_MERGE_TEST_MS + 300),
|
||||
);
|
||||
expect(client.countOf('likes.burst')).toBe(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('⑥ photo.hidden 实时:cn 隐藏 → 同步应用 → 房间收 photo.hidden;隐藏后点赞 404', async () => {
|
||||
const client = await WsClient.open(wsUrl());
|
||||
try {
|
||||
client.send({ type: 'subscribe', albumToken: tokenA });
|
||||
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
|
||||
|
||||
const hide = await request(cnBase)
|
||||
.patch(`/v1/photos/${photoA2}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ visibility: 'hidden' });
|
||||
expect(hide.status).toBe(200);
|
||||
await drainSender();
|
||||
|
||||
const hidden = await client.waitFor(
|
||||
(f) => f.type === 'photo.hidden' && f.photoId === photoA2,
|
||||
5000,
|
||||
'photo.hidden',
|
||||
);
|
||||
if (hidden.type !== 'photo.hidden') {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
expect(hidden.albumId).toBe(albumAId);
|
||||
const replica = await prismaOv.photos_ov.findUniqueOrThrow({
|
||||
where: { photoId: photoA2 },
|
||||
select: { entityVersion: true, visibility: true },
|
||||
});
|
||||
expect(hidden.entityVersion).toBe(replica.entityVersion);
|
||||
expect(replica.visibility).toBe('hidden');
|
||||
|
||||
// 隐藏后点赞:404 PHOTO_NOT_PUBLIC(与公开读同口径)。
|
||||
const denied = await like(ovBase, tokenA, photoA2, {
|
||||
sessionId: 'e2e-session-hidden',
|
||||
batchId: 'hidden-1',
|
||||
delta: 1,
|
||||
});
|
||||
expect(denied.status).toBe(404);
|
||||
expect(denied.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,9 @@ import { AppCnModule } from '../src/app-cn.module';
|
||||
import { AppOvModule } from '../src/app-ov.module';
|
||||
import { seedAdmin } from '../src/scripts/seed-admin';
|
||||
import { configureApp } from '../src/shared/configure-app';
|
||||
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
|
||||
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
|
||||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||||
import { PublishService } from '../../worker/src/publish.service';
|
||||
import { resetDb } from './reset';
|
||||
import { resetOvDb } from './reset-ov';
|
||||
@@ -194,6 +197,7 @@ describe('public-ov (e2e)', () => {
|
||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||||
configureApp(ovApp);
|
||||
await ovApp.listen(0);
|
||||
ovBase = await ovApp.getUrl();
|
||||
|
||||
@@ -27,6 +27,9 @@ import { AppCnModule } from '../../api/src/app-cn.module';
|
||||
import { AppOvModule } from '../../api/src/app-ov.module';
|
||||
import { seedAdmin } from '../../api/src/scripts/seed-admin';
|
||||
import { configureApp } from '../../api/src/shared/configure-app';
|
||||
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
|
||||
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
|
||||
import { ContractWsAdapter } from '../../api/src/realtime/contract-ws.adapter';
|
||||
import { PublishService } from '../../worker/src/publish.service';
|
||||
import { resetDb } from '../../api/test/reset';
|
||||
import { resetOvDb } from '../../api/test/reset-ov';
|
||||
@@ -144,6 +147,7 @@ describe('sync reconcile + digest healing (e2e)', () => {
|
||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||||
configureApp(ovApp);
|
||||
await ovApp.listen(0);
|
||||
ovBase = await ovApp.getUrl();
|
||||
|
||||
@@ -37,6 +37,9 @@ import { AppCnModule } from '../../api/src/app-cn.module';
|
||||
import { AppOvModule } from '../../api/src/app-ov.module';
|
||||
import { seedAdmin } from '../../api/src/scripts/seed-admin';
|
||||
import { configureApp } from '../../api/src/shared/configure-app';
|
||||
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
|
||||
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
|
||||
import { ContractWsAdapter } from '../../api/src/realtime/contract-ws.adapter';
|
||||
import { PublishService } from '../../worker/src/publish.service';
|
||||
import { resetDb } from '../../api/test/reset';
|
||||
import { resetOvDb } from '../../api/test/reset-ov';
|
||||
@@ -221,6 +224,7 @@ describe('sync cn→ov (e2e)', () => {
|
||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
rawBody: true,
|
||||
});
|
||||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||||
configureApp(ovApp);
|
||||
await ovApp.listen(0);
|
||||
ovBase = await ovApp.getUrl();
|
||||
|
||||
Reference in New Issue
Block a user