feat(contracts): p2 realtime, likes, visits and internal sync contracts

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 13:34:35 +08:00
parent 06111e1466
commit 24327c5147
9 changed files with 1472 additions and 4 deletions
+567
View File
@@ -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 起点。