openapi: 3.0.3 info: title: Patbond API — Community / Media 域契约草案(T3-10 起草态,非冻结稿) version: 1.3.0-draft.1 description: | M3 社区迭代 community + media 域契约**草案**(T3-10 第一波起草态)。 本文件独立可解析,仅供评审与实现对照;**不是** `docs/api/openapi.yaml` 的一部分, 冻结合入须待 T3-03/04/05 定型回填后按 M2 迭代式冻结流程升版 v1.3.0。 ## 沿用正典约定(openapi.yaml v1.2.0 零偏差) - 统一前缀 `/api/v1`;JSON 一律 camelCase;ID 为 UUID 字符串。 - 时间字段 ISO 8601 带时区偏移(timestamptz)。 - 统一信封 `{"code": 0, "message": "success", "data": …}`;错误同时带正确 HTTP 状态码与稳定业务码,业务码永不复用或改号。 - cursor 分页正典:`data: {items, nextCursor, hasMore}`;`limit` 1~100 缺省 20; 游标不透明,客户端不得解析;禁 OFFSET。 - 创建操作返回 201(pets 域先例);全部端点强制 Bearer 鉴权,无匿名端点。 ## 本域新增约定(草案) - **幂等按域(ADR-019)**:二元互动(点赞/收藏/关注)用 PUT/DELETE 语义幂等, 重复调用返回同一权威终态(200,非 409),无键管理;创建型写入(发帖/评论/ 媒体登记)`Idempotency-Key` 头**必带**,落表内幂等列并比对 request_hash (请求体规范化 SHA-256),同键不同 payload 返回 409/40905。 与 pets 域「可选键、不比对请求体」并存,pets 不回改。 - **媒体两步上传(ADR-016)**:创建 upload(登记 asset + 签发 MinIO 预签名 PUT 凭据)→ 客户端直传 → complete 确认(服务端 HEAD 校验后 uploading→ready)。 业务引用只接受本人所有且 `status='ready'` 的 asset,否则 422/42203。 - **防枚举 404**:帖子不存在 / 已软删 / hidden / 他人 draft 或 private 响应完全一致 (40403);评论、asset、用户同理(40404/40405/40406)。 - **写响应携带权威终态**:like/bookmark/follow 响应回 `{…State, …Count}`, 客户端乐观更新以响应对账回滚。 ## community/media 域错误码段(草案,待 T3-04 定型冻结) | 业务码 | HTTP | 场景 | | --- | --- | --- | | 40301 | 403 | POST_ACCESS_DENIED:帖子/评论可见但无权操作(如改删他人帖) | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已删、hidden 或对调用者不可见(防枚举合并) | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见 | | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在或非本人所有(防枚举合并) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注/计数端点) | | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(request_hash 不符) | | 42203 | 422 | MEDIA_NOT_READY:引用了非 ready 状态或超挂接窗口的 asset | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(库层 ck_user_follows_self 兜底) | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading 态(幂等重复 ready 除外) | 复用既有码:40000 参数校验、40101 token、40902 VERSION_CONFLICT、50000/50300。 servers: - url: http://127.0.0.1:8082 description: patbond-user(media 上传流程,ADR-017) - url: http://127.0.0.1:8084 description: patbond-community(社区域全部端点,ADR-017) security: - bearerAuth: [] tags: - name: media description: 媒体上传两步流程(patbond-user,ADR-016 MinIO 预签名直传) - name: posts description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) - name: feed description: 公共 Feed 游标分页(patbond-community) - name: comments description: 单层平铺评论 + @ 回复(patbond-community,ADR-018) - name: interactions description: 点赞/收藏 PUT+DELETE 幂等与收藏列表(patbond-community,ADR-019) - name: follows description: 关注最小数据接口:follow/unfollow + 计数(patbond-community,ADR-018) paths: /api/v1/media/uploads: post: tags: [media] summary: 创建上传(登记 asset 并签发预签名直传凭据) description: | 两步上传第一步:校验 mime 白名单与大小上限 → 写 `media.assets` 行 (status=uploading,bucket/objectKey 服务端生成、不含用户输入)→ 返回预签名 PUT 凭据(短 TTL)。客户端凭凭据直传 MinIO,不经应用服务器。 M3 仅 `kind=image`(ADR-018 视频后置)。 # TODO-FREEZE: 等待 T3-03 —— purpose 白名单、mime 白名单、单文件大小上限数值定型 operationId: createMediaUpload requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateMediaUploadRequest' responses: '201': description: asset 已登记(uploading),返回直传凭据 content: application/json: schema: $ref: '#/components/schemas/MediaUploadEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' /api/v1/media/uploads/{assetId}/complete: post: tags: [media] summary: 确认上传完成(uploading → ready) description: | 两步上传第二步:服务端对对象 HEAD 校验存在性与 byteSize(有 sha256 则一并核) → status=ready、写 readyAt,返回可引用的 asset。校验失败置 failed。 对已 ready 的 asset 重复 complete 幂等返回同一 asset(200 语义下仍走 201?—— 草案取 200 返回既有 ready asset);对 failed/deleted 态返回 422/42205。 operationId: completeMediaUpload parameters: - $ref: '#/components/parameters/AssetIdParam' responses: '200': description: 确认成功(或幂等重复确认),asset 为 ready content: application/json: schema: $ref: '#/components/schemas/MediaAssetEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/MediaNotFound' '422': description: asset 非 uploading 态,或对象校验失败已置 failed(code 42205) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: stateInvalid: value: { code: 42205, message: 上传状态不允许确认, data: null } /api/v1/posts: post: tags: [posts] summary: 创建帖子(草稿或直接发布) description: | `Idempotency-Key` **必带**(开发计划 6.1 强制名单含帖子):落 `uq_posts_author_idempotency`,同键重试返回首次创建的帖子(同样 201); 同键不同 payload 返回 409/40905(request_hash 比对,ADR-019)。 `status` 可 draft(缺省)或 published(直接发布,服务端写 publishedAt)。 media 挂接只接受本人所有且 ready 的 asset(否则 422/42203),每帖 ≤9 图 (D3-4),position 0 起连续,isCover 至多一个(缺省取 position 0)。 纯文字帖合法(media 可空数组或缺席)。 operationId: createPost parameters: - $ref: '#/components/parameters/IdempotencyKeyRequiredHeader' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreatePostRequest' responses: '201': description: 创建成功(或同键幂等重试返回首次结果) content: application/json: schema: $ref: '#/components/schemas/PostEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' '422': $ref: '#/components/responses/MediaNotReady' /api/v1/posts/{postId}: get: tags: [posts] summary: 帖子详情 description: | published 对可见者开放;draft/hidden 仅作者可见,他人 404/40403 防枚举。 响应含 likedByMe/bookmarkedByMe 与作者公开摘要。 # TODO-FREEZE: 等待 T3-04 —— 权限矩阵与 403/404 边界语义随实现定型 operationId: getPost parameters: - $ref: '#/components/parameters/PostIdParam' responses: '200': description: 帖子详情 content: application/json: schema: $ref: '#/components/schemas/PostEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' patch: tags: [posts] summary: 编辑帖子 / 发布草稿(部分更新 + version 乐观锁) description: | 仅作者(非作者对可见帖 403/40301,不可见帖 404/40403)。PATCH 部分更新惯例: 缺席字段不变,不支持清空回 null(M2 先例)。`version` 必带(缺失 400/40000, 过期 409/40902)。发布 = `status: published` 的状态迁移(draft→published, 服务端写 publishedAt,校验 ck_posts_publish_state);published→draft 不支持; hidden/archived 不开放任何端点(D3-7 运营位保留)。 operationId: updatePost parameters: - $ref: '#/components/parameters/PostIdParam' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdatePostRequest' responses: '200': description: 更新成功,返回新 version 的完整帖子 content: application/json: schema: $ref: '#/components/schemas/PostEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' '403': $ref: '#/components/responses/PostAccessDenied' '404': $ref: '#/components/responses/PostNotFound' '409': $ref: '#/components/responses/VersionConflict' '422': $ref: '#/components/responses/MediaNotReady' delete: tags: [posts] summary: 删除帖子(软删,仅作者) description: | 软删(deleted_at),删除后详情/Feed/列表一律不可见(M3 验收标准四)。 重复删除返回 404/40403(已删与不存在合并防枚举)。 operationId: deletePost parameters: - $ref: '#/components/parameters/PostIdParam' responses: '200': description: 删除成功 content: application/json: schema: $ref: '#/components/schemas/VoidEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '403': $ref: '#/components/responses/PostAccessDenied' '404': $ref: '#/components/responses/PostNotFound' /api/v1/me/posts: get: tags: [posts] summary: 我的帖子列表(含草稿) description: | 作者视角:含 draft 与 published(软删不含)。排序 `(created_at DESC, id DESC)` 走 `ix_posts_author_created` 游标。`status` 过滤可选(draft|published)。 operationId: listMyPosts parameters: - $ref: '#/components/parameters/PageLimitParam' - $ref: '#/components/parameters/PageCursorParam' - name: status in: query required: false schema: type: string enum: [draft, published] description: 按状态过滤;缺省返回全部(不含已删) responses: '200': description: cursor 分页帖子列表(完整 Post 形态) content: application/json: schema: $ref: '#/components/schemas/PostListEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' /api/v1/feed: get: tags: [feed] summary: 公共 Feed(游标分页) description: | 谓词恒为 `status='published' AND visibility='public' AND deleted_at IS NULL`, 与 `ix_posts_feed` 部分索引一致;复合游标 `(published_at DESC, id DESC)`, keyset 翻页不丢不重,禁 OFFSET。删除/hidden 帖子下一次请求即不可见。 likedByMe/bookmarkedByMe 批量查询(避免 N+1)。 # TODO-FREEZE: 等待 T3-05 —— Feed 卡片字段定型:content 摘要截断规则、 # 封面选取规则(isCover 优先→position 0 兜底)、计数口径逐项写死 operationId: getFeed parameters: - $ref: '#/components/parameters/PageLimitParam' - $ref: '#/components/parameters/PageCursorParam' responses: '200': description: cursor 分页 Feed 卡片列表 content: application/json: schema: $ref: '#/components/schemas/FeedListEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' /api/v1/posts/{postId}/comments: get: tags: [comments] summary: 评论列表(单层平铺,游标分页) description: | 排序 `(created_at DESC, id DESC)` 走 `ix_comments_post_created`; 仅 status=visible;帖子不可见则 404/40403。 operationId: listComments parameters: - $ref: '#/components/parameters/PostIdParam' - $ref: '#/components/parameters/PageLimitParam' - $ref: '#/components/parameters/PageCursorParam' responses: '200': description: cursor 分页评论列表 content: application/json: schema: $ref: '#/components/schemas/CommentListEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' post: tags: [comments] summary: 创建评论(幂等 + 可选 @ 回复) description: | `Idempotency-Key` **必带**,落 `client_request_id + request_hash` (uq author×client_request_id),同键重试返回首条评论(同样 201), payload 不符 409/40905。`replyToUserId` 可选 @ 回复(单层,无楼中楼, ADR-018)。帖子不可见则 404/40403。comment_count 同事务 +1。 operationId: createComment parameters: - $ref: '#/components/parameters/PostIdParam' - $ref: '#/components/parameters/IdempotencyKeyRequiredHeader' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateCommentRequest' responses: '201': description: 创建成功(或同键幂等重试返回首次结果) content: application/json: schema: $ref: '#/components/schemas/CommentEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' /api/v1/comments/{commentId}: delete: tags: [comments] summary: 删除评论(仅评论作者;顶层短路径) description: | 顶层短路径先例(pets 域子资源 PATCH 同理):commentId 全局唯一。 仅评论作者可删(status→deleted,comment_count 同事务 -1);帖主删他人评论 首版不做(D3-7)。不存在/已删/所属帖不可见合并 404/40404。 operationId: deleteComment parameters: - name: commentId in: path required: true schema: type: string format: uuid description: 评论 ID responses: '200': description: 删除成功 content: application/json: schema: $ref: '#/components/schemas/VoidEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '403': $ref: '#/components/responses/PostAccessDenied' '404': $ref: '#/components/responses/CommentNotFound' /api/v1/posts/{postId}/like: put: tags: [interactions] summary: 点赞(PUT 语义幂等) description: | 主键 (post_id, user_id) 即幂等键:`ON CONFLICT DO NOTHING`,仅实际插入才 like_count 同事务 +1。重复 PUT 返回 200 同一权威态(非 409),并发 N 次 计数恰为 1(M3 验收标准二)。帖子不可见 404/40403。 operationId: likePost parameters: - $ref: '#/components/parameters/PostIdParam' responses: '200': description: 权威终态(liked 恒 true) content: application/json: schema: $ref: '#/components/schemas/LikeStateEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' delete: tags: [interactions] summary: 取消点赞(DELETE 语义幂等) description: 取消不存在的点赞不报错不减计数,返回 200 权威态(liked 恒 false)。 operationId: unlikePost parameters: - $ref: '#/components/parameters/PostIdParam' responses: '200': description: 权威终态(liked 恒 false) content: application/json: schema: $ref: '#/components/schemas/LikeStateEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' /api/v1/posts/{postId}/bookmark: put: tags: [interactions] summary: 收藏(PUT 语义幂等,与点赞同构) operationId: bookmarkPost parameters: - $ref: '#/components/parameters/PostIdParam' responses: '200': description: 权威终态(bookmarked 恒 true) content: application/json: schema: $ref: '#/components/schemas/BookmarkStateEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' delete: tags: [interactions] summary: 取消收藏(DELETE 语义幂等) operationId: unbookmarkPost parameters: - $ref: '#/components/parameters/PostIdParam' responses: '200': description: 权威终态(bookmarked 恒 false) content: application/json: schema: $ref: '#/components/schemas/BookmarkStateEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/PostNotFound' /api/v1/me/bookmarks: get: tags: [interactions] summary: 我的收藏列表(游标分页) description: | 排序 `(bookmarks.created_at DESC, post_id DESC)` 走 `ix_post_bookmarks_user_created`。被收藏帖子已删/hidden 时该项不返回 (草案取「静默剔除」,是否保留占位随 T3-05 联动定型)。项形态复用 Feed 卡片。 operationId: listMyBookmarks parameters: - $ref: '#/components/parameters/PageLimitParam' - $ref: '#/components/parameters/PageCursorParam' responses: '200': description: cursor 分页收藏卡片列表 content: application/json: schema: $ref: '#/components/schemas/FeedListEnvelope' '400': $ref: '#/components/responses/ValidationError' '401': $ref: '#/components/responses/AccessTokenInvalid' /api/v1/users/{userId}/follow: put: tags: [follows] summary: 关注(PUT 语义幂等) description: | 主键 (follower, followee) 幂等,重复 PUT 返回 200 权威态;自关注 422/42204 (库层 ck_user_follows_self 兜底);目标用户不存在/已注销 404/40406。 关注 Feed 与 followers 可见性不在 M3(ADR-018 最小数据接口)。 operationId: followUser parameters: - $ref: '#/components/parameters/UserIdParam' responses: '200': description: 权威终态(following 恒 true) content: application/json: schema: $ref: '#/components/schemas/FollowStateEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/UserNotFound' '422': description: 自关注(code 42204) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: selfFollow: value: { code: 42204, message: 不能关注自己, data: null } delete: tags: [follows] summary: 取消关注(DELETE 语义幂等) operationId: unfollowUser parameters: - $ref: '#/components/parameters/UserIdParam' responses: '200': description: 权威终态(following 恒 false) content: application/json: schema: $ref: '#/components/schemas/FollowStateEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/UserNotFound' /api/v1/users/{userId}/follow-stats: get: tags: [follows] summary: 关注计数(关注数/粉丝数/我是否已关注) description: | ADR-018 最小接口的「数量」端点:followerCount/followingCount 实时 COUNT (user_follows 双向索引支撑),followedByMe 为调用者视角。 查自己时 followedByMe 恒 false。关注/粉丝**列表**端点不在 M3 草案 (最小接口裁剪,需时按纯增量补入)。 operationId: getFollowStats parameters: - $ref: '#/components/parameters/UserIdParam' responses: '200': description: 计数与关注状态 content: application/json: schema: $ref: '#/components/schemas/FollowStatsEnvelope' '401': $ref: '#/components/responses/AccessTokenInvalid' '404': $ref: '#/components/responses/UserNotFound' components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: 'Authorization: Bearer (RS256 JWT)' parameters: PostIdParam: name: postId in: path required: true schema: type: string format: uuid description: 帖子 ID AssetIdParam: name: assetId in: path required: true schema: type: string format: uuid description: 媒体 asset ID UserIdParam: name: userId in: path required: true schema: type: string format: uuid description: 目标用户 ID PageLimitParam: name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 20 description: 每页条数(1~100,缺省 20);越界 400/40000 PageCursorParam: name: cursor in: query required: false schema: type: string description: 上一页返回的 nextCursor(不透明字符串,客户端不得解析),首页不传;无效 400/40000 IdempotencyKeyRequiredHeader: name: Idempotency-Key in: header required: true schema: type: string maxLength: 128 description: | **必带**幂等键(1~128 字符,缺失/超长 400/40000;与 pets 域可选 255 字符 不同——本域按 ADR-019 落表内幂等列,列宽 128)。键按作者隔离;同键重试 返回首次创建的资源(同样 201);**比对 request_hash**:同键不同 payload 返回 409/40905。客户端每次逻辑提交换新键(建议 UUID),重试间保持不变。 responses: ValidationError: description: 参数校验失败(code 40000) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: validation: value: { code: 40000, message: 参数校验失败, data: null } AccessTokenInvalid: description: access token 缺失、无效或过期(code 40101) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: tokenInvalid: value: { code: 40101, message: token 无效或过期, data: null } PostNotFound: description: | 帖子不存在、已软删、hidden,或对调用者不可见(他人 draft/private)—— 防枚举语义:全部情况响应完全一致(code 40403)。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: postNotFound: value: { code: 40403, message: 帖子不存在, data: null } CommentNotFound: description: 评论不存在、已删或所属帖子不可见(code 40404,防枚举合并) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: commentNotFound: value: { code: 40404, message: 评论不存在, data: null } MediaNotFound: description: asset 不存在或非本人所有(code 40405,防枚举合并) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mediaNotFound: value: { code: 40405, message: 媒体不存在, data: null } UserNotFound: description: 目标用户不存在或已注销(code 40406) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: userNotFound: value: { code: 40406, message: 用户不存在, data: null } PostAccessDenied: description: | 对可见帖子/评论无权操作(code 40301):如编辑/删除他人已发布帖子。 仅发给对资源「可见」的调用者,不泄露新信息。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: accessDenied: value: { code: 40301, message: 无权限执行该操作, data: null } IdempotencyPayloadMismatch: description: 同 Idempotency-Key 不同 payload,request_hash 不符(code 40905) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mismatch: value: { code: 40905, message: 幂等键已用于不同请求, data: null } MediaNotReady: description: 引用了非 ready 状态、非本人或不存在的 asset(存在性合并 40405,状态问题 code 42203) content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: notReady: value: { code: 42203, message: 媒体尚未就绪, data: null } VersionConflict: description: 乐观锁版本冲突(code 40902):提交的 version 已过期,刷新后重试 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: versionConflict: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } schemas: # ---------- 通用 ---------- VoidEnvelope: type: object required: [code, message] properties: code: type: integer enum: [0] message: type: string example: success data: nullable: true example: null ErrorEnvelope: type: object required: [code, message] properties: code: type: integer description: 稳定业务错误码(见顶部错误码段草案) example: 40403 message: type: string example: 帖子不存在 data: nullable: true example: null AuthorSummary: type: object description: | 作者公开摘要(D3-9 方案 B 预设字段:昵称 + 头像,community 跨 schema 只读 identity 取数,ADR-017)。nickname 为空时回退 username 由**服务端** 完成(客户端不做回退拼装);avatarUrl 为 ready 头像 asset 的访问 URL, 无头像为 null(客户端出占位)。 # TODO-FREEZE: 等待 T3-05 —— 公开资料形态定型(是否补 bio/是否露 username、 # 注销用户的墓碑形态) required: [userId, nickname] properties: userId: type: string format: uuid nickname: type: string maxLength: 32 description: 昵称(空昵称已由服务端回退为 username) example: 毛毛的铲屎官 avatarUrl: type: string nullable: true description: 头像访问 URL;无头像为 null example: http://127.0.0.1:9000/patbond-media/user_avatar/2026/09/018f...jpg # ---------- media ---------- CreateMediaUploadRequest: type: object required: [kind, purpose, mimeType, byteSize] properties: kind: type: string enum: [image] description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) purpose: type: string enum: [post_image] description: | 用途白名单,决定 objectKey 前缀。 # TODO-FREEZE: 等待 T3-03 —— 是否随 P6 扩 user_avatar/pet_avatar mimeType: type: string enum: [image/jpeg, image/png, image/webp] description: | 白名单外 400/40000。 # TODO-FREEZE: 等待 T3-03 —— 白名单与 HEIC 支持定型 byteSize: type: integer format: int64 minimum: 1 maximum: 10485760 description: | 声明的文件字节数,complete 时与对象实测比对。 # TODO-FREEZE: 等待 T3-03 —— 单文件上限数值(草案 10 MiB)定型 sha256: type: string pattern: '^[0-9a-f]{64}$' description: 可选,小写 hex;提供则 complete 时一并核对 MediaUploadCredentials: type: object description: | 预签名直传凭据(MinIO PUT,短 TTL,ADR-016)。 # TODO-FREEZE: 等待 T3-03 —— 凭据形态整体定型:uploadUrl 是否含 query 签名、 # requiredHeaders 具体键集、TTL 数值、读取侧 URL 形态(公共读桶稳定 URL vs 签名读) required: [assetId, uploadUrl, method, expiresAt] properties: assetId: type: string format: uuid description: 已登记的 asset ID(status=uploading) uploadUrl: type: string description: 预签名 PUT 目标(客户端直传,不经应用服务器) method: type: string enum: [PUT] requiredHeaders: type: object additionalProperties: type: string description: 直传请求必须原样携带的头(如 Content-Type),键集草案态 expiresAt: type: string format: date-time description: 凭据过期时刻(草案 TTL 10 分钟);过期后须重新创建上传 MediaAsset: type: object required: [id, kind, purpose, mimeType, status, createdAt] properties: id: type: string format: uuid kind: type: string enum: [image] purpose: type: string example: post_image mimeType: type: string example: image/jpeg byteSize: type: integer format: int64 widthPx: type: integer nullable: true heightPx: type: integer nullable: true status: type: string enum: [uploading, ready, failed] description: deleted 态对外恒 404/40405,不出现在响应 url: type: string nullable: true description: | 访问 URL,仅 ready 态非空。 # TODO-FREEZE: 等待 T3-03 —— 公共读稳定 URL vs 签名读(随凭据形态一并定型) readyAt: type: string format: date-time nullable: true createdAt: type: string format: date-time MediaUploadEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/MediaUploadCredentials' MediaAssetEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/MediaAsset' # ---------- posts ---------- PostMediaItem: type: object description: 帖子挂接的一张图(响应形态) required: [assetId, position, isCover, url] properties: assetId: type: string format: uuid position: type: integer minimum: 0 maximum: 8 isCover: type: boolean url: type: string description: 图片访问 URL(形态随 T3-03 凭据侧一并定型) widthPx: type: integer nullable: true heightPx: type: integer nullable: true caption: type: string nullable: true maxLength: 300 PostMediaAttachRequest: type: object description: 帖子挂接的一张图(请求形态);asset 须本人所有且 ready,否则 422/42203 required: [assetId] properties: assetId: type: string format: uuid position: type: integer minimum: 0 maximum: 8 description: 0 起连续;缺省按数组序 isCover: type: boolean default: false description: 至多一个 true(uq_post_media_cover);全 false 则服务端取 position 0 caption: type: string maxLength: 300 CreatePostRequest: type: object required: [content] properties: title: type: string minLength: 1 maxLength: 120 description: 可选标题(ck_posts_title) content: type: string minLength: 1 maxLength: 10000 description: 正文,必填(ck_posts_content;纯文字帖合法,D3-4) category: type: string enum: [general, help] default: general description: ai_creation 为 M4 预留值,M3 不开放写入 status: type: string enum: [draft, published] default: draft description: published = 创建即发布(服务端写 publishedAt) petId: type: string format: uuid description: 可选关联宠物;须为调用者可见宠物,否则 404/40401 语义沿 pets 域 media: type: array maxItems: 9 description: ≤9 图(D3-4);空数组或缺席 = 纯文字帖 items: $ref: '#/components/schemas/PostMediaAttachRequest' UpdatePostRequest: type: object required: [version] description: | 部分更新:缺席字段不变;不支持清空回 null(M2 惯例)。media 若出现则**整组替换** (position 全量重排,草案态,随 T3-04 定型)。 properties: version: type: integer minimum: 0 description: 乐观锁,必带;过期 409/40902 title: type: string minLength: 1 maxLength: 120 content: type: string minLength: 1 maxLength: 10000 category: type: string enum: [general, help] petId: type: string format: uuid status: type: string enum: [published] description: 唯一开放的状态迁移 draft→published(发布动作);其余迁移 400/40000 media: type: array maxItems: 9 items: $ref: '#/components/schemas/PostMediaAttachRequest' Post: type: object description: | 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。region/generationJob/topics 等裁剪字段整体不出现(ADR-018 + ADR-010 先例),后续按新增可选字段纯增量补入。 required: - id - author - category - content - status - visibility - media - likeCount - commentCount - bookmarkCount - likedByMe - bookmarkedByMe - createdAt - updatedAt - version properties: id: type: string format: uuid author: $ref: '#/components/schemas/AuthorSummary' petId: type: string format: uuid nullable: true category: type: string enum: [general, help, ai_creation] description: ai_creation 仅读侧预留(M3 无法写入) title: type: string nullable: true maxLength: 120 content: type: string maxLength: 10000 status: type: string enum: [draft, published] description: hidden/archived 对非作者恒 404;对作者是否露出运营态待 T3-04(草案不露) visibility: type: string enum: [public] description: M3 恒 public(ADR-018:followers/private 语义后置,字段保留) media: type: array items: $ref: '#/components/schemas/PostMediaItem' likeCount: type: integer format: int64 commentCount: type: integer format: int64 bookmarkCount: type: integer format: int64 likedByMe: type: boolean bookmarkedByMe: type: boolean createdAt: type: string format: date-time updatedAt: type: string format: date-time publishedAt: type: string format: date-time nullable: true description: 仅 published 非空 version: type: integer FeedCard: type: object description: | Feed / 收藏列表卡片形态(较 Post 裁剪)。 # TODO-FREEZE: 等待 T3-05 —— 卡片字段整体定型:contentPreview 截断长度与规则、 # coverImage 选取规则(isCover→position 0)、是否带 mediaCount 之外的图列表 required: - id - author - category - contentPreview - mediaCount - likeCount - commentCount - bookmarkCount - likedByMe - bookmarkedByMe - publishedAt properties: id: type: string format: uuid author: $ref: '#/components/schemas/AuthorSummary' category: type: string enum: [general, help, ai_creation] title: type: string nullable: true contentPreview: type: string description: 正文摘要(服务端截断,规则待 T3-05 定型;草案 200 字符 + 完整边界截断) coverImage: nullable: true allOf: - $ref: '#/components/schemas/PostMediaItem' description: 封面图;纯文字帖为 null mediaCount: type: integer description: 帖子图片总数(卡片角标「1/9」类展示) likeCount: type: integer format: int64 commentCount: type: integer format: int64 bookmarkCount: type: integer format: int64 likedByMe: type: boolean bookmarkedByMe: type: boolean publishedAt: type: string format: date-time PostEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/Post' PostListEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: type: object description: cursor 分页正典信封;排序 created_at DESC, id DESC required: [items, hasMore] properties: items: type: array items: $ref: '#/components/schemas/Post' nextCursor: type: string nullable: true description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null hasMore: type: boolean FeedListEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: type: object description: | cursor 分页正典信封;公共 Feed 排序 published_at DESC, id DESC; 收藏列表排序 bookmarks.created_at DESC, post_id DESC required: [items, hasMore] properties: items: type: array items: $ref: '#/components/schemas/FeedCard' nextCursor: type: string nullable: true description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null hasMore: type: boolean # ---------- comments ---------- CreateCommentRequest: type: object required: [content] properties: content: type: string minLength: 1 maxLength: 2000 description: ck_comments_content 同宽 replyToUserId: type: string format: uuid description: 可选 @ 回复目标(单层平铺,无 parentCommentId,ADR-018) Comment: type: object required: [id, postId, author, content, createdAt] properties: id: type: string format: uuid postId: type: string format: uuid author: $ref: '#/components/schemas/AuthorSummary' replyToUser: nullable: true allOf: - $ref: '#/components/schemas/AuthorSummary' description: '@ 回复目标的公开摘要;非回复为 null' content: type: string maxLength: 2000 createdAt: type: string format: date-time CommentEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/Comment' CommentListEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: type: object description: cursor 分页正典信封;排序 created_at DESC, id DESC required: [items, hasMore] properties: items: type: array items: $ref: '#/components/schemas/Comment' nextCursor: type: string nullable: true description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null hasMore: type: boolean # ---------- interactions / follows ---------- LikeState: type: object description: 点赞权威终态(乐观更新以此对账回滚) required: [liked, likeCount] properties: liked: type: boolean likeCount: type: integer format: int64 BookmarkState: type: object description: 收藏权威终态(与点赞同构) required: [bookmarked, bookmarkCount] properties: bookmarked: type: boolean bookmarkCount: type: integer format: int64 FollowState: type: object description: 关注权威终态;followerCount 为目标用户的粉丝数 required: [following, followerCount] properties: following: type: boolean followerCount: type: integer format: int64 FollowStats: type: object required: [followerCount, followingCount, followedByMe] properties: followerCount: type: integer format: int64 description: 目标用户的粉丝数 followingCount: type: integer format: int64 description: 目标用户关注的人数 followedByMe: type: boolean description: 调用者是否已关注目标用户;查自己恒 false LikeStateEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/LikeState' BookmarkStateEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/BookmarkState' FollowStateEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/FollowState' FollowStatsEnvelope: type: object required: [code, message, data] properties: code: { type: integer, enum: [0] } message: { type: string, example: success } data: $ref: '#/components/schemas/FollowStats'