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
+606 -1
View File
@@ -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"];
};
};
}