feat(contracts): p2 realtime, likes, visits and internal sync contracts
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 数据层
|
||||
|
||||
|
||||
@@ -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 起点。
|
||||
|
||||
@@ -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<X, Y> =
|
||||
(<T>() => T extends X ? 1 : 2) extends <T>() => 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 显式携带域码;
|
||||
|
||||
+606
-1
@@ -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<string, never>;
|
||||
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"];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user