Files
photo-live/packages/contracts/src/realtime.ts
T
浅念 85b2da3db5 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>
2026-09-18 17:51:55 +08:00

111 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// packages/contracts/src/realtime.ts
// WS 实时消息契约(P2,技术实施方案 §7):OpenAPI 不覆盖 WS 帧,本文件单独
// 维护,与 openapi.yaml 生成类型同属契约单一事实来源。消息一律为以 type 判别
// 的联合;新增/修改消息类型须同步 __tests__/realtime.test.ts 的穷尽断言。
/** 心跳间隔(毫秒):客户端周期发送 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 侧,下同)。 */
export interface PhotoPublishedMessage {
type: 'photo.published';
photoId: string;
albumId: string;
publishedAt: string; // ISO UTC
entityVersion: number;
}
/** 照片隐藏。 */
export interface PhotoHiddenMessage {
type: 'photo.hidden';
photoId: string;
albumId: string;
entityVersion: number;
}
/** 照片删除(软删除的公开语义等同隐藏,客户端行为一致:即刻移出列表)。 */
export interface PhotoDeletedMessage {
type: 'photo.deleted';
photoId: string;
albumId: string;
entityVersion: number;
}
/** 点赞合并广播:短窗内同照片批次合并为一次 burst;originBatchIds 供客户端过滤自身批次。 */
export interface LikesBurstMessage {
type: 'likes.burst';
photoId: string;
albumId: string;
delta: number;
total: number; // 服务端权威累计
countVersion: number;
burstId: string;
originBatchIds: string[]; // 合并进本 burst 的批次 ID(含己则抑制自身动画)
}
/** 相册浏览数更新。 */
export interface StatsUpdatedMessage {
type: 'stats.updated';
albumId: string;
viewCount: number;
countVersion: number;
}
/** 心跳应答:回显客户端 ping 携带的 ts。 */
export interface PongMessage {
type: 'pong';
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
| RealtimeErrorMessage;
// ── client → server ──
/** 订阅相册房间;服务端校验相册公开后加入。 */
export interface SubscribeMessage {
type: 'subscribe';
albumToken: string;
sinceVersion?: number; // 缺省从订阅时刻起推送,不补播历史
}
/** 退订相册房间。 */
export interface UnsubscribeMessage {
type: 'unsubscribe';
albumToken: string;
}
/** 心跳;服务端原样回 pong。 */
export interface PingMessage {
type: 'ping';
ts: number; // epoch 毫秒
}
export type ClientMessage = SubscribeMessage | UnsubscribeMessage | PingMessage;