diff --git a/.env.example b/.env.example index a9fe32e..25c2982 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,19 @@ CORS_ORIGINS=http://localhost:5175,http://localhost:5176 LOG_LEVEL=info ADMIN_ACCOUNT=admin ADMIN_PASSWORD=admin-secret-change-me + +# ── P2 双地域仿真(ov 域)── +# 本任务(Task 1)即被读取的键:内部同步 HMAC 密钥与 ov 双库连接。 +SYNC_INTERNAL_KEY=dev-sync-secret-change-me +DATABASE_URL_OV=mysql://root:root@127.0.0.1:3307/photo_ov?connection_limit=10 +REDIS_URL_OV=redis://127.0.0.1:6381 +# 以下为 P2 决策初值(D07/D11)的 env 覆盖键:默认值见 +# packages/contracts/src/constants.ts(HEARTBEAT_MS 见 src/realtime.ts), +# 此处登记供运维可见与调整;代码读取在 Task 3+ 落地(当前为前瞻登记)。 +SYNC_BATCH_SIZE=100 +SYNC_PUSH_INTERVAL_MS=2000 +SYNC_RECONCILE_INTERVAL_MS=300000 +STAT_RETURN_INTERVAL_MS=60000 +VISIT_WINDOW_MINUTES=30 +LIKE_BATCH_WINDOW_HOURS=24 +VISIT_REQUEST_TTL_MINUTES=35 diff --git a/docs/plans/2026-09-18-P2-双地域仿真与互动.md b/docs/plans/2026-09-18-P2-双地域仿真与互动.md index 0d75ca9..e040828 100644 --- a/docs/plans/2026-09-18-P2-双地域仿真与互动.md +++ b/docs/plans/2026-09-18-P2-双地域仿真与互动.md @@ -22,12 +22,12 @@ **Files:** Modify `packages/contracts/openapi.yaml`(+gen)、`src/constants.ts`、`.env.example` -- [ ] 契约新增: +- [x] 契约新增: - **公开互动端点(ov 域,路径前缀不变,部署域名区分)**:`POST /public/albums/{token}/photos/{photoId}/likes`(匿名+限速;body {sessionId, batchId, delta 1..20} → 200 {total, countVersion, batchAccepted};重复 batchId → 幂等返回已确认结果);`POST /public/albums/{token}/visits`(body {visitorId, requestId} → 200 {viewCount, counted});likes 上限/限速语义写入 description - **WS 事件契约**(components 不适用——单独 `packages/contracts/src/realtime.ts`):server→client 消息 `photo.published` / `photo.hidden` / `photo.deleted` / `likes.burst`{photoId, albumId, delta, total, countVersion, burstId, originBatchIds[]} / `stats.updated`{albumId, viewCount};client→server `subscribe{albumToken, sinceVersion?}` / `unsubscribe`;心跳 ping/pong 20s - **内部同步接口**(tag internal,security: internalSyncKey 描述):`POST /internal/sync/content-batches`(数组:{eventId, schemaVersion, entityType: album|category|photo|asset|slot-state(预留), entityId, albumId, entityVersion, tombstone?, priority, payload});`POST /internal/sync/stat-snapshots`(cn←ov 方向:数组 {photoId?, albumId, kind: likes|views, total, statVersion, source, confirmedAt});对账 `POST /internal/sync/reconcile`{entityType, sinceVersion, limit} → 缺失清单;全量快照 `POST /internal/sync/bootstrap`{albumId?}→{snapshotEventIds[]}(经对账通道分批拉取) -- [ ] constants:SYNC 批量/间隔、VISIT_WINDOW_MINUTES、REALTIME 心跳等 env 键名与默认值入 `.env.example` -- [ ] gen + lint + 全回归绿;Commit `feat(contracts): p2 realtime, likes, visits and internal sync contracts` +- [x] constants:SYNC 批量/间隔、VISIT_WINDOW_MINUTES、REALTIME 心跳等 env 键名与默认值入 `.env.example` +- [x] gen + lint + 全回归绿;Commit `feat(contracts): p2 realtime, likes, visits and internal sync contracts` ## Task 2: ov 数据层 diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 77e2b2c..068a256 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -15,6 +15,11 @@ info: `message` 仅供诊断。 - 列表接口统一游标分页,元信息见 `components.schemas.CursorPageMeta`。 - 业务端点一律 Bearer 认证;`/public/**` 匿名访问(`security: []`)。 + - `/internal/**` 为服务间同步接口(P2):HMAC 签名鉴权 + (`X-Sync-Signature`/`X-Sync-Timestamp`,见 [internal] tag 描述), + OpenAPI 无对应标准 scheme 故同样标注 `security: []`,但匿名不可访问。 + - WebSocket 实时消息契约不经 OpenAPI 生成,单独维护于 + `src/realtime.ts`(判别联合 + 心跳常量),与生成类型同属单一事实来源。 - 状态码通用映射:401 → AUTH_TOKEN_EXPIRED、500 → INTERNAL_ERROR; 其余状态码对应的 code 在各响应的 description 中标注。 - 多语言内容按「请求 locale → 相册 defaultLocale」回退解析为单语言对象, @@ -42,6 +47,15 @@ tags: description: 管理辅助查询 - name: public description: 观众匿名只读(相册信息与公开照片) + - name: interactions + description: 观众匿名互动(点赞批次与浏览上报;ov 域部署,路径前缀不变、域名区分) + - name: internal + description: | + 服务间同步接口,HMAC 鉴权,不对匿名开放。共用签名规则: + `X-Sync-Signature` = hex(HMAC-SHA256(原始请求体, SYNC_INTERNAL_KEY)), + `X-Sync-Timestamp` = Unix 秒(与服务端时差 ±300s 内有效,防重放); + 签名缺失/不符或时间戳超窗一律拒绝。OpenAPI 无 HMAC 标准 scheme, + 故下列操作 `security` 标注为 `[]`(无标准认证要求),鉴权由服务端强制执行。 paths: # ─────────────────────────── auth ─────────────────────────── @@ -1102,6 +1116,259 @@ paths: '500': $ref: '#/components/responses/InternalError' + # ─────────────────────────── interactions ─────────────────────────── + + /public/albums/{token}/photos/{photoId}/likes: + parameters: + - $ref: '#/components/parameters/AlbumPublicToken' + - $ref: '#/components/parameters/PhotoId' + post: + operationId: likePhotoBatch + tags: [interactions] + summary: 点赞批次上报 + description: | + 匿名点赞批次(ov 域部署;路径前缀与 cn 相同,按部署域名区分)。 + 客户端将短时间内的多次点击合并为批次提交,delta 为本批新增点赞数。 + + - 幂等:同一 (photoId, sessionId, batchId) 重复提交返回已确认结果 + (batchAccepted=true、total 为权威累计值),不重复加计;失败重试 + 必须复用同一 batchId。 + - 窗口外过期批次(LIKE_BATCH_WINDOW_HOURS,默认 24h,清理任务保留窗口) + 拒绝重放:不加计,返回当前权威 total(batchAccepted=false), + 客户端据此校准本地乐观计数。 + - 基础限速:sessionId+来源 IP 滑窗,超限 429(RATE_LIMITED)。 + - 相册非公开/紧急停用 404(ALBUM_NOT_PUBLIC);照片不存在、未发布、 + 不属于该相册或相册非公开 404(PHOTO_NOT_PUBLIC)。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LikeBatchRequest' + responses: + '200': + description: | + 本批处理结果(含被拒绝的过期批次——拒绝也返回 200 与权威 total, + 语义由 batchAccepted 表达,避免客户端把重放当失败重试)。 + content: + application/json: + schema: + $ref: '#/components/schemas/LikeBatchResponse' + '400': + description: 参数校验失败(VALIDATION_FAILED;delta 越界 1..20 或字段长度非法)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: | + 相册不存在或未公开(ALBUM_NOT_PUBLIC),或照片不存在、未发布、 + 不属于该相册(PHOTO_NOT_PUBLIC)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: 基础限速触发(RATE_LIMITED;sessionId+IP 滑窗)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /public/albums/{token}/visits: + parameters: + - $ref: '#/components/parameters/AlbumPublicToken' + post: + operationId: reportAlbumVisit + tags: [interactions] + summary: 浏览上报 + description: | + 匿名浏览上报(ov 域部署)。仅由客户端显式调用:页面可见且相册信息 + 加载成功后上报一次(列表/图片/WS 等接口调用一律不触发计数)。 + + - requestId 为上报幂等键(建议 UUID),counted 确认前跨刷新/重试持久复用; + 重复 requestId 返回 counted=false 与权威 viewCount,不重复计数。 + - 去重窗口(VISIT_WINDOW_MINUTES,默认 30 分钟,D07)内同一 visitorId + 不重复计数;窗口过后再上报按新访次计数。 + - 过期 requestId(VISIT_REQUEST_TTL_MINUTES,默认 35 分钟)拒绝重放 + (counted=false),不得重新加计。 + - 基础限速:visitorId 滑窗,超限 429(RATE_LIMITED);去重本身为主缓解。 + - 相册非公开/紧急停用 404(ALBUM_NOT_PUBLIC)。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VisitReportRequest' + responses: + '200': + description: 上报结果(窗口内重复/重复 requestId/过期重放均 200 + counted=false)。 + content: + application/json: + schema: + $ref: '#/components/schemas/VisitReportResponse' + '400': + description: 参数校验失败(VALIDATION_FAILED;visitorId/requestId 长度非法)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册不存在或未公开(ALBUM_NOT_PUBLIC)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: 基础限速触发(RATE_LIMITED;visitorId 滑窗)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + # ─────────────────────────── internal ─────────────────────────── + + /internal/sync/content-batches: + post: + operationId: syncContentBatches + tags: [internal] + summary: 内容事件批量推送 + description: | + cn→ov 方向:cn outbox 未投递事件批量推送(默认每 2s 一批、单批 + SYNC_BATCH_SIZE 条;priority=high 的下线/隐藏/删除类事件即时单发)。 + ov 侧语义:eventId 幂等(重复推送忽略)、entityVersion 单调 + (旧版本忽略)、tombstone 墓碑优先(副本删除且不被旧事件复活)、 + 依赖缺失暂存待补。acceptedEventIds 为本批受理(含幂等重复)的事件 ID, + applied 为实际应用(新版本生效)条数。鉴权见 [internal] tag 描述。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContentBatchRequest' + responses: + '200': + description: 本批受理结果。 + content: + application/json: + schema: + $ref: '#/components/schemas/ContentBatchResponse' + '400': + description: 请求体校验失败(VALIDATION_FAILED)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /internal/sync/stat-snapshots: + post: + operationId: syncStatSnapshots + tags: [internal] + summary: 统计快照回传 + description: | + ov→cn 方向:ov 侧聚合的统计快照(likes/views 权威累计值,默认每 60s + 增量回传)。cn 按 statVersion 单调替换副本列(仅更高版本替换、 + 绝不相加),重复回传幂等。kind=likes 时 photoId 必填(照片点赞数); + kind=views 为相册级浏览数(无 photoId)。鉴权见 [internal] tag 描述。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StatSnapshotRequest' + responses: + '200': + description: 回传受理结果。 + content: + application/json: + schema: + $ref: '#/components/schemas/StatSnapshotResponse' + '400': + description: 请求体校验失败(VALIDATION_FAILED)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /internal/sync/reconcile: + post: + operationId: syncReconcile + tags: [internal] + summary: 缺失事件对账 + description: | + ov 侧按 sync_state 水位向 cn 请求指定实体类型在 sinceVersion 之后 + 的事件清单(albumId 可选过滤;中断恢复=水位重放)。cn 返回按版本 + 升序的事件数组,ov 重放补漏;全量快照(bootstrap)的事件本体也经 + 本通道分批拉取。鉴权见 [internal] tag 描述。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReconcileRequest' + responses: + '200': + description: 缺失事件清单(按 entityVersion 升序)。 + content: + application/json: + schema: + $ref: '#/components/schemas/ReconcileResponse' + '400': + description: 请求体校验失败(VALIDATION_FAILED)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /internal/sync/bootstrap: + post: + operationId: syncBootstrap + tags: [internal] + summary: 全量快照引导 + description: | + ov 冷启动或新建副本时请求 cn 生成当前全量快照事件序列 + (album → categories → assets → photos,含各实体当前 entityVersion)。 + 本端点仅返回事件计数 eventCount 与快照水位 watermark;事件本体经 + 对账通道(/internal/sync/reconcile)以 watermark 为起点分批拉取。 + albumId 缺省表示全部相册。鉴权见 [internal] tag 描述。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BootstrapRequest' + responses: + '200': + description: 快照规模与水位。 + content: + application/json: + schema: + $ref: '#/components/schemas/BootstrapResponse' + '400': + description: 请求体校验失败(VALIDATION_FAILED)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + components: securitySchemes: bearerAuth: @@ -1201,6 +1468,7 @@ components: - AUTH_INVALID_CREDENTIALS - AUTH_TOKEN_EXPIRED - AUTH_RATE_LIMITED + - RATE_LIMITED - AUTH_FORBIDDEN - NOT_FOUND - ALBUM_NOT_FOUND @@ -2182,3 +2450,302 @@ components: type: array items: $ref: '#/components/schemas/PublicPhoto' + + # ── 互动(观众端,ov 域) ── + + LikeBatchRequest: + type: object + description: | + 点赞批次上报请求。sessionId 为客户端生成的会话随机标识(会话期间复用); + batchId 为批次幂等键,同一批的失败重试必须复用,跨批次不得复用。 + required: [sessionId, batchId, delta] + properties: + sessionId: + type: string + minLength: 1 + maxLength: 64 + description: 会话标识(客户端随机生成,会话期间复用)。 + batchId: + type: string + minLength: 1 + maxLength: 64 + description: 批次幂等键;同批重试复用,重复提交不加计。 + delta: + type: integer + minimum: 1 + maximum: 20 + description: 本批新增点赞数(客户端短窗合并后的累计点击数)。 + + LikeBatchResponse: + type: object + description: | + 点赞批次处理结果。total 恒为服务端权威累计值;batchAccepted 区分 + 本批是否被计入(幂等重复=true;窗口外过期批次=false 且不加计)。 + required: [total, countVersion, batchAccepted] + properties: + total: + type: integer + format: int64 + readOnly: true + minimum: 0 + description: 照片权威累计点赞数(客户端据此校准乐观计数)。 + countVersion: + type: integer + readOnly: true + minimum: 0 + description: 计数版本(单调递增,实时消息与校准的乱序判别依据)。 + batchAccepted: + type: boolean + readOnly: true + description: | + 本批是否被计入:true=新批次或幂等重放已确认批次; + false=窗口外过期批次(拒绝重放,返回权威 total)。 + + VisitReportRequest: + type: object + description: | + 浏览上报请求。visitorId 跨标签页共享(同一访客);requestId 为本次 + 访次的上报幂等键(建议 UUID),counted 确认前持久复用。 + required: [visitorId, requestId] + properties: + visitorId: + type: string + minLength: 8 + maxLength: 128 + description: 访客标识(客户端持久化并在标签页间共享)。 + requestId: + type: string + minLength: 8 + maxLength: 128 + description: 上报幂等键;重复提交不重复计数。 + + VisitReportResponse: + type: object + description: 浏览上报结果。viewCount 恒为相册权威累计浏览数。 + required: [viewCount, counted] + properties: + viewCount: + type: integer + format: int64 + readOnly: true + minimum: 0 + description: 相册权威累计浏览数。 + counted: + type: boolean + readOnly: true + description: | + 本次是否实际计数:true=新访次;false=去重窗口内重复、重复 + requestId 或过期 requestId 重放(均不重复计数)。 + + # ── 内部同步 ── + + SyncEntityType: + type: string + description: 同步实体类型(slot-state 为预留给后续阶段)。 + enum: + - album + - category + - photo + - asset + - slot-state + + SyncPriority: + type: string + description: | + 推送优先级:high 为相册下线、照片隐藏/删除等需即时生效的事件 + (发送器即发,不等批间隔);normal 走常规批量推送。 + enum: + - normal + - high + + SyncEvent: + type: object + description: | + cn→ov 内容同步事件(线格式)。与 cn outbox 事件(src/events.ts) + 一一对应:eventId 幂等、entityVersion 单调、tombstone 墓碑优先。 + payload 为实体类型相关载荷(album.updated 携带 translations/开关/ + banner 对象键,photo.* 携带对象键与宽高等展示字段)。 + required: + - eventId + - schemaVersion + - entityType + - entityId + - albumId + - entityVersion + - priority + - payload + properties: + eventId: + type: string + description: 事件 ID(全局唯一,接收方幂等键)。 + schemaVersion: + type: integer + minimum: 1 + description: 载荷 schema 版本;当前为 1。 + entityType: + $ref: '#/components/schemas/SyncEntityType' + entityId: + type: string + description: 实体 ID(albumId/categoryId/photoId/assetId 等)。 + albumId: + type: string + description: 归属相册 ID(album 实体时与 entityId 相同)。 + entityVersion: + type: integer + format: int64 + minimum: 1 + description: 实体版本(单调递增;接收方仅应用更高版本)。 + tombstone: + type: boolean + default: false + description: 墓碑标记:true 表示实体删除,副本删除且不被旧事件复活。 + priority: + $ref: '#/components/schemas/SyncPriority' + payload: + type: object + additionalProperties: true + description: 实体类型相关载荷;结构由发送方按 schemaVersion 约定。 + + ContentBatchRequest: + type: object + description: 内容事件批量推送请求体。 + required: [batch] + properties: + batch: + type: array + minItems: 1 + maxItems: 100 + description: 事件批次(单批上限对齐 SYNC_BATCH_SIZE,默认 100)。 + items: + $ref: '#/components/schemas/SyncEvent' + + ContentBatchResponse: + type: object + description: 批量推送受理结果。 + required: [acceptedEventIds, applied] + properties: + acceptedEventIds: + type: array + description: 本批受理的事件 ID(含此前已应用的同 eventId 幂等重复)。 + items: + type: string + applied: + type: integer + description: 本批实际应用(新版本生效)的条数。 + + SyncStatKind: + type: string + description: 统计快照种类。 + enum: + - likes + - views + + StatSnapshot: + type: object + description: | + ov→cn 统计快照:权威累计值的版本化替换单元(cn 侧仅更高 statVersion + 替换副本列,绝不相加)。 + required: [albumId, kind, total, statVersion, source, confirmedAt] + properties: + photoId: + type: string + description: 照片 ID;kind=likes 时必填,kind=views 无此字段。 + albumId: + type: string + description: 归属相册 ID。 + kind: + $ref: '#/components/schemas/SyncStatKind' + total: + type: integer + format: int64 + minimum: 0 + description: 权威累计值(点赞数或浏览数)。 + statVersion: + type: integer + format: int64 + minimum: 1 + description: 统计版本(单调递增;接收方幂等与替换依据)。 + source: + type: string + description: 快照来源标识(如 ov)。 + confirmedAt: + type: string + format: date-time + description: 快照确认时间。 + + StatSnapshotRequest: + type: object + description: 统计快照回传请求体。 + required: [snapshots] + properties: + snapshots: + type: array + minItems: 1 + description: 增量快照批次。 + items: + $ref: '#/components/schemas/StatSnapshot' + + StatSnapshotResponse: + type: object + description: 快照回传受理结果。 + required: [accepted] + properties: + accepted: + type: integer + description: 接受(应用或幂等确认)的快照条数;旧版本被忽略的不计。 + + ReconcileRequest: + type: object + description: 对账拉取请求:某实体类型在 sinceVersion 之后的事件清单。 + required: [entityType, sinceVersion, limit] + properties: + entityType: + $ref: '#/components/schemas/SyncEntityType' + albumId: + type: string + description: 可选:限定单相册;缺省为该实体类型全部。 + sinceVersion: + type: integer + format: int64 + minimum: 0 + description: 起始版本(exclusive;0 表示从头拉取,配合 bootstrap)。 + limit: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + description: 本批拉取上限(分批重放,对齐 SYNC_BATCH_SIZE)。 + + ReconcileResponse: + type: object + description: 对账结果:按 entityVersion 升序的事件(无缺失为空数组)。 + required: [events] + properties: + events: + type: array + description: 缺失事件(升序);请求方按序重放。 + items: + $ref: '#/components/schemas/SyncEvent' + + BootstrapRequest: + type: object + description: 全量快照引导请求。 + properties: + albumId: + type: string + description: 指定相册;缺省为全部相册。 + + BootstrapResponse: + type: object + description: 全量快照规模与水位;事件本体经 reconcile 通道分批拉取。 + required: [eventCount, watermark] + properties: + eventCount: + type: integer + minimum: 0 + description: 快照事件总数(供拉取分批与完成校验)。 + watermark: + type: integer + format: int64 + minimum: 0 + description: 快照水位:后续增量对账的 sinceVersion 起点。 diff --git a/packages/contracts/src/__tests__/realtime.test.ts b/packages/contracts/src/__tests__/realtime.test.ts new file mode 100644 index 0000000..764aa11 --- /dev/null +++ b/packages/contracts/src/__tests__/realtime.test.ts @@ -0,0 +1,166 @@ +// packages/contracts/src/__tests__/realtime.test.ts +// WS 消息契约类型测试(同 events.test.ts 的 @ts-expect-error 手法):判别联合 +// 按 type 收窄、非法 type/缺字段/错类型编译期拒绝、联合成员 1:1 锁定、 +// 心跳常量锁定。ts-jest 为全类型检查模式,编译期断言随测试生效。 +import { + HEARTBEAT_MS, + type ClientMessage, + type ServerMessage, +} from '../realtime.js'; + +// 类型恒等辅助:仅当 X 与 Y 是完全相同的类型(双向兼容,多/少一个成员都为 false)。 +type Equal = + (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; + +const burst: ServerMessage = { + type: 'likes.burst', + photoId: 'photo-1', + albumId: 'album-1', + delta: 3, + total: 128, + countVersion: 42, + burstId: 'burst-1', + originBatchIds: ['batch-a', 'batch-b'], +}; + +const subscribe: ClientMessage = { type: 'subscribe', albumToken: 'tok' }; +const subscribeSince: ClientMessage = { + type: 'subscribe', + albumToken: 'tok', + sinceVersion: 7, +}; + +describe('ServerMessage', () => { + it('合法的 likes.burst 消息通过类型检查且字段完整', () => { + if (burst.type !== 'likes.burst') throw new Error('unreachable'); + expect(burst.originBatchIds).toEqual(['batch-a', 'batch-b']); + expect(burst.total).toBe(128); + }); + + it('判别联合按 type 收窄(photo.published 才有 publishedAt)', () => { + const msg: ServerMessage = { + type: 'photo.published', + photoId: 'photo-1', + albumId: 'album-1', + publishedAt: '2026-09-18T08:00:00.000Z', + entityVersion: 3, + }; + if (msg.type === 'photo.published') { + expect(msg.publishedAt).toContain('T'); + } else { + // publishedAt 仅存在于 photo.published 分支:其它分支访问须编译失败。 + // @ts-expect-error photo.hidden 分支没有 publishedAt + const absent = msg.publishedAt; + expect(absent).toBeUndefined(); + throw new Error('unreachable'); + } + }); + + it('pong 为服务端消息(回显 ping 的 ts)', () => { + const pong: ServerMessage = { type: 'pong', ts: 1758187200000 }; + expect(pong.type).toBe('pong'); + }); + + it('非法 type 在编译期被拒绝', () => { + // @ts-expect-error type 必须严格属于联合成员 + const invalid: ServerMessage = { ...burst, type: 'likes.update' }; + expect(invalid.type).toBe('likes.update'); + }); + + it('缺少必填字段在编译期被拒绝', () => { + // @ts-expect-error likes.burst 缺少 burstId + const missing: ServerMessage = { ...burst, burstId: undefined }; + expect(missing.type).toBe('likes.burst'); + }); + + it('字段类型错误在编译期被拒绝', () => { + // @ts-expect-error originBatchIds 必须为 string[] + const wrongTypes: ServerMessage = { ...burst, originBatchIds: [1, 2] }; + expect(wrongTypes.type).toBe('likes.burst'); + }); + + it('type 联合与 6 个消息成员 1:1(穷尽锁定)', () => { + const exhaustive: Equal< + ServerMessage['type'], + | 'photo.published' + | 'photo.hidden' + | 'photo.deleted' + | 'likes.burst' + | 'stats.updated' + | 'pong' + > = true; + expect(exhaustive).toBe(true); + }); + + it('switch 可穷尽处理全部服务端消息', () => { + const messages: ServerMessage[] = [ + burst, + { type: 'photo.published', photoId: 'p', albumId: 'a', publishedAt: '2026-09-18T08:00:00.000Z', entityVersion: 1 }, + { type: 'photo.hidden', photoId: 'p', albumId: 'a', entityVersion: 2 }, + { type: 'photo.deleted', photoId: 'p', albumId: 'a', entityVersion: 3 }, + { type: 'stats.updated', albumId: 'a', viewCount: 99, countVersion: 9 }, + { type: 'pong', ts: 1758187200000 }, + ]; + const kinds = messages.map((m) => { + switch (m.type) { + case 'photo.published': + case 'photo.hidden': + case 'photo.deleted': + return `photo:${m.entityVersion}`; + case 'likes.burst': + return `likes:${m.delta}`; + case 'stats.updated': + return `stats:${m.viewCount}`; + case 'pong': + return `pong:${m.ts}`; + } + }); + expect(kinds).toEqual([ + 'likes:3', + 'photo:1', + 'photo:2', + 'photo:3', + 'stats:99', + 'pong:1758187200000', + ]); + }); +}); + +describe('ClientMessage', () => { + it('subscribe 的 sinceVersion 可选(缺省不补播历史)', () => { + expect(subscribe.sinceVersion).toBeUndefined(); + expect(subscribeSince.sinceVersion).toBe(7); + }); + + it('unsubscribe / ping 均合法', () => { + const messages: ClientMessage[] = [ + subscribe, + subscribeSince, + { type: 'unsubscribe', albumToken: 'tok' }, + { type: 'ping', ts: 1758187200000 }, + ]; + expect(messages).toHaveLength(4); + }); + + it('ping 缺少 ts 在编译期被拒绝', () => { + // @ts-expect-error ping 必须携带 ts + const invalid: ClientMessage = { type: 'ping' }; + expect(invalid.type).toBe('ping'); + }); + + it('type 联合与 3 个消息成员 1:1(穷尽锁定)', () => { + const exhaustive: Equal< + ClientMessage['type'], + 'subscribe' | 'unsubscribe' | 'ping' + > = true; + expect(exhaustive).toBe(true); + }); +}); + +describe('HEARTBEAT_MS', () => { + it('心跳间隔锁定为 20s(P2 决策初值,改动须回计划登记)', () => { + expect(HEARTBEAT_MS).toBe(20000); + }); +}); diff --git a/packages/contracts/src/constants.ts b/packages/contracts/src/constants.ts index b78b39b..445adb0 100644 --- a/packages/contracts/src/constants.ts +++ b/packages/contracts/src/constants.ts @@ -3,3 +3,22 @@ // complete 后入队)与 worker(Task 20/21 消费者)经同一常量引用,杜绝两处字符串漂移。 /** 照片处理 BullMQ 队列名(api enqueue → worker 消费;补偿扫描 Task 21)。 */ export const QUEUE_PHOTO_PROCESS = 'photo-process'; + +// ── P2 双地域同步与互动(决策初值 D07/D11,验收前可调;env 覆盖键见 .env.example)── +// 运行时可调项的默认值集中在此,服务实现(Task 3+)读 env、缺省回落本表; +// HEARTBEAT_MS 属 WS 消息契约,随 realtime.ts 导出(此处不重复定义避免重名导出)。 + +/** cn→ov 内容同步:content-batches 单批事件数上限(D11)。 */ +export const SYNC_BATCH_SIZE = 100; +/** cn→ov 内容同步:outbox 推送轮询间隔毫秒(D11;高优先级事件不受此间隔约束)。 */ +export const SYNC_PUSH_INTERVAL_MS = 2000; +/** ov 补漏对账轮询间隔毫秒(D11,5 分钟;按 sync_state 水位向 cn 请求 reconcile)。 */ +export const SYNC_RECONCILE_INTERVAL_MS = 300000; +/** ov→cn 统计回传间隔毫秒(D11,60s 增量快照)。 */ +export const STAT_RETURN_INTERVAL_MS = 60000; +/** 浏览去重窗口分钟(D07):窗口内同一 visitorId 不重复计数。 */ +export const VISIT_WINDOW_MINUTES = 30; +/** 点赞批次幂等窗口小时:窗口外 batchId 拒绝重放(返回权威 total,不加计)。 */ +export const LIKE_BATCH_WINDOW_HOURS = 24; +/** 浏览上报 requestId 保留分钟:过期 requestId 拒绝重放(≥ 去重窗口)。 */ +export const VISIT_REQUEST_TTL_MINUTES = 35; diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts index 7c005fa..8f7fa4c 100644 --- a/packages/contracts/src/errors.ts +++ b/packages/contracts/src/errors.ts @@ -6,6 +6,9 @@ export const ErrorCodes = { // 凭据错误的错误码——429 若复用 AUTH_INVALID_CREDENTIALS 会向客户端撒谎, // 复用 VALIDATION_FAILED 则语义错位。与 openapi.yaml Error enum 同步增补。 AUTH_RATE_LIMITED: 'AUTH_RATE_LIMITED', + // P2 Task 1:公开互动端点(likes/visits)的基础限速 429。AUTH_RATE_LIMITED + // 语义绑死登录场景,匿名观众限速需独立通用码;与 openapi.yaml Error enum 同步增补。 + RATE_LIMITED: 'RATE_LIMITED', AUTH_FORBIDDEN: 'AUTH_FORBIDDEN', // P1a 终审修缮①(P1b Task 1 补登):框架级 404(未注册路由等)此前兜底映射 // ALBUM_NOT_FOUND,语义错位。业务 404 一律经 ApiException 显式携带域码; diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index e15370f..e167162 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -500,6 +500,170 @@ export interface paths { patch?: never; trace?: never; }; + "/public/albums/{token}/photos/{photoId}/likes": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册公开令牌(不可枚举,持有即可访问)。 */ + token: components["parameters"]["AlbumPublicToken"]; + /** @description 照片 ID。 */ + photoId: components["parameters"]["PhotoId"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 点赞批次上报 + * @description 匿名点赞批次(ov 域部署;路径前缀与 cn 相同,按部署域名区分)。 + * 客户端将短时间内的多次点击合并为批次提交,delta 为本批新增点赞数。 + * + * - 幂等:同一 (photoId, sessionId, batchId) 重复提交返回已确认结果 + * (batchAccepted=true、total 为权威累计值),不重复加计;失败重试 + * 必须复用同一 batchId。 + * - 窗口外过期批次(LIKE_BATCH_WINDOW_HOURS,默认 24h,清理任务保留窗口) + * 拒绝重放:不加计,返回当前权威 total(batchAccepted=false), + * 客户端据此校准本地乐观计数。 + * - 基础限速:sessionId+来源 IP 滑窗,超限 429(RATE_LIMITED)。 + * - 相册非公开/紧急停用 404(ALBUM_NOT_PUBLIC);照片不存在、未发布、 + * 不属于该相册或相册非公开 404(PHOTO_NOT_PUBLIC)。 + */ + post: operations["likePhotoBatch"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/public/albums/{token}/visits": { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册公开令牌(不可枚举,持有即可访问)。 */ + token: components["parameters"]["AlbumPublicToken"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 浏览上报 + * @description 匿名浏览上报(ov 域部署)。仅由客户端显式调用:页面可见且相册信息 + * 加载成功后上报一次(列表/图片/WS 等接口调用一律不触发计数)。 + * + * - requestId 为上报幂等键(建议 UUID),counted 确认前跨刷新/重试持久复用; + * 重复 requestId 返回 counted=false 与权威 viewCount,不重复计数。 + * - 去重窗口(VISIT_WINDOW_MINUTES,默认 30 分钟,D07)内同一 visitorId + * 不重复计数;窗口过后再上报按新访次计数。 + * - 过期 requestId(VISIT_REQUEST_TTL_MINUTES,默认 35 分钟)拒绝重放 + * (counted=false),不得重新加计。 + * - 基础限速:visitorId 滑窗,超限 429(RATE_LIMITED);去重本身为主缓解。 + * - 相册非公开/紧急停用 404(ALBUM_NOT_PUBLIC)。 + */ + post: operations["reportAlbumVisit"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/internal/sync/content-batches": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 内容事件批量推送 + * @description cn→ov 方向:cn outbox 未投递事件批量推送(默认每 2s 一批、单批 + * SYNC_BATCH_SIZE 条;priority=high 的下线/隐藏/删除类事件即时单发)。 + * ov 侧语义:eventId 幂等(重复推送忽略)、entityVersion 单调 + * (旧版本忽略)、tombstone 墓碑优先(副本删除且不被旧事件复活)、 + * 依赖缺失暂存待补。acceptedEventIds 为本批受理(含幂等重复)的事件 ID, + * applied 为实际应用(新版本生效)条数。鉴权见 [internal] tag 描述。 + */ + post: operations["syncContentBatches"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/internal/sync/stat-snapshots": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 统计快照回传 + * @description ov→cn 方向:ov 侧聚合的统计快照(likes/views 权威累计值,默认每 60s + * 增量回传)。cn 按 statVersion 单调替换副本列(仅更高版本替换、 + * 绝不相加),重复回传幂等。kind=likes 时 photoId 必填(照片点赞数); + * kind=views 为相册级浏览数(无 photoId)。鉴权见 [internal] tag 描述。 + */ + post: operations["syncStatSnapshots"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/internal/sync/reconcile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 缺失事件对账 + * @description ov 侧按 sync_state 水位向 cn 请求指定实体类型在 sinceVersion 之后 + * 的事件清单(albumId 可选过滤;中断恢复=水位重放)。cn 返回按版本 + * 升序的事件数组,ov 重放补漏;全量快照(bootstrap)的事件本体也经 + * 本通道分批拉取。鉴权见 [internal] tag 描述。 + */ + post: operations["syncReconcile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/internal/sync/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 全量快照引导 + * @description ov 冷启动或新建副本时请求 cn 生成当前全量快照事件序列 + * (album → categories → assets → photos,含各实体当前 entityVersion)。 + * 本端点仅返回事件计数 eventCount 与快照水位 watermark;事件本体经 + * 对账通道(/internal/sync/reconcile)以 watermark 为起点分批拉取。 + * albumId 缺省表示全部相册。鉴权见 [internal] tag 描述。 + */ + post: operations["syncBootstrap"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -509,7 +673,7 @@ export interface components { * @description 稳定错误码,与 src/errors.ts 的 ErrorCodes 一一对应。 * @enum {string} */ - readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "INTERNAL_ERROR"; + readonly code: "AUTH_INVALID_CREDENTIALS" | "AUTH_TOKEN_EXPIRED" | "AUTH_RATE_LIMITED" | "RATE_LIMITED" | "AUTH_FORBIDDEN" | "NOT_FOUND" | "ALBUM_NOT_FOUND" | "ALBUM_NOT_PUBLIC" | "ALBUM_UPLOAD_DISABLED" | "MEMBER_CONFLICT" | "UPLOAD_SESSION_CONFLICT" | "UPLOAD_VERIFY_FAILED" | "PHOTO_NOT_FOUND" | "PHOTO_NOT_PUBLIC" | "VALIDATION_FAILED" | "INTERNAL_ERROR"; /** @description 服务器生成的可读消息(诊断用,不作为客户端展示文案依据)。 */ readonly message: string; /** @description 请求追踪 ID。 */ @@ -1045,6 +1209,193 @@ export interface components { PublicPhotoList: components["schemas"]["CursorPageMeta"] & { items: components["schemas"]["PublicPhoto"][]; }; + /** + * @description 点赞批次上报请求。sessionId 为客户端生成的会话随机标识(会话期间复用); + * batchId 为批次幂等键,同一批的失败重试必须复用,跨批次不得复用。 + */ + LikeBatchRequest: { + /** @description 会话标识(客户端随机生成,会话期间复用)。 */ + sessionId: string; + /** @description 批次幂等键;同批重试复用,重复提交不加计。 */ + batchId: string; + /** @description 本批新增点赞数(客户端短窗合并后的累计点击数)。 */ + delta: number; + }; + /** + * @description 点赞批次处理结果。total 恒为服务端权威累计值;batchAccepted 区分 + * 本批是否被计入(幂等重复=true;窗口外过期批次=false 且不加计)。 + */ + LikeBatchResponse: { + /** + * Format: int64 + * @description 照片权威累计点赞数(客户端据此校准乐观计数)。 + */ + readonly total: number; + /** @description 计数版本(单调递增,实时消息与校准的乱序判别依据)。 */ + readonly countVersion: number; + /** + * @description 本批是否被计入:true=新批次或幂等重放已确认批次; + * false=窗口外过期批次(拒绝重放,返回权威 total)。 + */ + readonly batchAccepted: boolean; + }; + /** + * @description 浏览上报请求。visitorId 跨标签页共享(同一访客);requestId 为本次 + * 访次的上报幂等键(建议 UUID),counted 确认前持久复用。 + */ + VisitReportRequest: { + /** @description 访客标识(客户端持久化并在标签页间共享)。 */ + visitorId: string; + /** @description 上报幂等键;重复提交不重复计数。 */ + requestId: string; + }; + /** @description 浏览上报结果。viewCount 恒为相册权威累计浏览数。 */ + VisitReportResponse: { + /** + * Format: int64 + * @description 相册权威累计浏览数。 + */ + readonly viewCount: number; + /** + * @description 本次是否实际计数:true=新访次;false=去重窗口内重复、重复 + * requestId 或过期 requestId 重放(均不重复计数)。 + */ + readonly counted: boolean; + }; + /** + * @description 同步实体类型(slot-state 为预留给后续阶段)。 + * @enum {string} + */ + SyncEntityType: "album" | "category" | "photo" | "asset" | "slot-state"; + /** + * @description 推送优先级:high 为相册下线、照片隐藏/删除等需即时生效的事件 + * (发送器即发,不等批间隔);normal 走常规批量推送。 + * @enum {string} + */ + SyncPriority: "normal" | "high"; + /** + * @description cn→ov 内容同步事件(线格式)。与 cn outbox 事件(src/events.ts) + * 一一对应:eventId 幂等、entityVersion 单调、tombstone 墓碑优先。 + * payload 为实体类型相关载荷(album.updated 携带 translations/开关/ + * banner 对象键,photo.* 携带对象键与宽高等展示字段)。 + */ + SyncEvent: { + /** @description 事件 ID(全局唯一,接收方幂等键)。 */ + eventId: string; + /** @description 载荷 schema 版本;当前为 1。 */ + schemaVersion: number; + entityType: components["schemas"]["SyncEntityType"]; + /** @description 实体 ID(albumId/categoryId/photoId/assetId 等)。 */ + entityId: string; + /** @description 归属相册 ID(album 实体时与 entityId 相同)。 */ + albumId: string; + /** + * Format: int64 + * @description 实体版本(单调递增;接收方仅应用更高版本)。 + */ + entityVersion: number; + /** + * @description 墓碑标记:true 表示实体删除,副本删除且不被旧事件复活。 + * @default false + */ + tombstone: boolean; + priority: components["schemas"]["SyncPriority"]; + /** @description 实体类型相关载荷;结构由发送方按 schemaVersion 约定。 */ + payload: { + [key: string]: unknown; + }; + }; + /** @description 内容事件批量推送请求体。 */ + ContentBatchRequest: { + /** @description 事件批次(单批上限对齐 SYNC_BATCH_SIZE,默认 100)。 */ + batch: components["schemas"]["SyncEvent"][]; + }; + /** @description 批量推送受理结果。 */ + ContentBatchResponse: { + /** @description 本批受理的事件 ID(含此前已应用的同 eventId 幂等重复)。 */ + acceptedEventIds: string[]; + /** @description 本批实际应用(新版本生效)的条数。 */ + applied: number; + }; + /** + * @description 统计快照种类。 + * @enum {string} + */ + SyncStatKind: "likes" | "views"; + /** + * @description ov→cn 统计快照:权威累计值的版本化替换单元(cn 侧仅更高 statVersion + * 替换副本列,绝不相加)。 + */ + StatSnapshot: { + /** @description 照片 ID;kind=likes 时必填,kind=views 无此字段。 */ + photoId?: string; + /** @description 归属相册 ID。 */ + albumId: string; + kind: components["schemas"]["SyncStatKind"]; + /** + * Format: int64 + * @description 权威累计值(点赞数或浏览数)。 + */ + total: number; + /** + * Format: int64 + * @description 统计版本(单调递增;接收方幂等与替换依据)。 + */ + statVersion: number; + /** @description 快照来源标识(如 ov)。 */ + source: string; + /** + * Format: date-time + * @description 快照确认时间。 + */ + confirmedAt: string; + }; + /** @description 统计快照回传请求体。 */ + StatSnapshotRequest: { + /** @description 增量快照批次。 */ + snapshots: components["schemas"]["StatSnapshot"][]; + }; + /** @description 快照回传受理结果。 */ + StatSnapshotResponse: { + /** @description 接受(应用或幂等确认)的快照条数;旧版本被忽略的不计。 */ + accepted: number; + }; + /** @description 对账拉取请求:某实体类型在 sinceVersion 之后的事件清单。 */ + ReconcileRequest: { + entityType: components["schemas"]["SyncEntityType"]; + /** @description 可选:限定单相册;缺省为该实体类型全部。 */ + albumId?: string; + /** + * Format: int64 + * @description 起始版本(exclusive;0 表示从头拉取,配合 bootstrap)。 + */ + sinceVersion: number; + /** + * @description 本批拉取上限(分批重放,对齐 SYNC_BATCH_SIZE)。 + * @default 100 + */ + limit: number; + }; + /** @description 对账结果:按 entityVersion 升序的事件(无缺失为空数组)。 */ + ReconcileResponse: { + /** @description 缺失事件(升序);请求方按序重放。 */ + events: components["schemas"]["SyncEvent"][]; + }; + /** @description 全量快照引导请求。 */ + BootstrapRequest: { + /** @description 指定相册;缺省为全部相册。 */ + albumId?: string; + }; + /** @description 全量快照规模与水位;事件本体经 reconcile 通道分批拉取。 */ + BootstrapResponse: { + /** @description 快照事件总数(供拉取分批与完成校验)。 */ + eventCount: number; + /** + * Format: int64 + * @description 快照水位:后续增量对账的 sinceVersion 起点。 + */ + watermark: number; + }; }; responses: { /** @description 未认证:令牌缺失、无效或已过期(AUTH_TOKEN_EXPIRED)。 */ @@ -2311,4 +2662,258 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + likePhotoBatch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册公开令牌(不可枚举,持有即可访问)。 */ + token: components["parameters"]["AlbumPublicToken"]; + /** @description 照片 ID。 */ + photoId: components["parameters"]["PhotoId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LikeBatchRequest"]; + }; + }; + responses: { + /** + * @description 本批处理结果(含被拒绝的过期批次——拒绝也返回 200 与权威 total, + * 语义由 batchAccepted 表达,避免客户端把重放当失败重试)。 + */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LikeBatchResponse"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;delta 越界 1..20 或字段长度非法)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** + * @description 相册不存在或未公开(ALBUM_NOT_PUBLIC),或照片不存在、未发布、 + * 不属于该相册(PHOTO_NOT_PUBLIC)。 + */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 基础限速触发(RATE_LIMITED;sessionId+IP 滑窗)。 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + reportAlbumVisit: { + parameters: { + query?: never; + header?: never; + path: { + /** @description 相册公开令牌(不可枚举,持有即可访问)。 */ + token: components["parameters"]["AlbumPublicToken"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VisitReportRequest"]; + }; + }; + responses: { + /** @description 上报结果(窗口内重复/重复 requestId/过期重放均 200 + counted=false)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VisitReportResponse"]; + }; + }; + /** @description 参数校验失败(VALIDATION_FAILED;visitorId/requestId 长度非法)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册不存在或未公开(ALBUM_NOT_PUBLIC)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 基础限速触发(RATE_LIMITED;visitorId 滑窗)。 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + syncContentBatches: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ContentBatchRequest"]; + }; + }; + responses: { + /** @description 本批受理结果。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContentBatchResponse"]; + }; + }; + /** @description 请求体校验失败(VALIDATION_FAILED)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + syncStatSnapshots: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StatSnapshotRequest"]; + }; + }; + responses: { + /** @description 回传受理结果。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatSnapshotResponse"]; + }; + }; + /** @description 请求体校验失败(VALIDATION_FAILED)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + syncReconcile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReconcileRequest"]; + }; + }; + responses: { + /** @description 缺失事件清单(按 entityVersion 升序)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReconcileResponse"]; + }; + }; + /** @description 请求体校验失败(VALIDATION_FAILED)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + syncBootstrap: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BootstrapRequest"]; + }; + }; + responses: { + /** @description 快照规模与水位。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BootstrapResponse"]; + }; + }; + /** @description 请求体校验失败(VALIDATION_FAILED)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fd47d6e..e8b6354 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,4 +5,5 @@ export * from './constants.js'; export * from './errors.js'; export * from './events.js'; +export * from './realtime.js'; export type * from './generated/openapi.js'; diff --git a/packages/contracts/src/realtime.ts b/packages/contracts/src/realtime.ts new file mode 100644 index 0000000..9a71131 --- /dev/null +++ b/packages/contracts/src/realtime.ts @@ -0,0 +1,91 @@ +// packages/contracts/src/realtime.ts +// WS 实时消息契约(P2,技术实施方案 §7):OpenAPI 不覆盖 WS 帧,本文件单独 +// 维护,与 openapi.yaml 生成类型同属契约单一事实来源。消息一律为以 type 判别 +// 的联合;新增/修改消息类型须同步 __tests__/realtime.test.ts 的穷尽断言。 + +/** 心跳间隔(毫秒):客户端周期发送 ping、服务端回 pong;超时无心跳的连接由服务端踢除。 */ +export const HEARTBEAT_MS = 20000; + +// ── 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; +} + +export type ServerMessage = + | PhotoPublishedMessage + | PhotoHiddenMessage + | PhotoDeletedMessage + | LikesBurstMessage + | StatsUpdatedMessage + | PongMessage; + +// ── 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;