diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java index 33a8f5d..e33d29e 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java @@ -39,8 +39,8 @@ import static org.assertj.core.api.Assertions.assertThat; /** * T3-19(D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与 - * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.2.0(快照 - * {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓 + * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照 + * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 * {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator} * 严格校验响应结构,最后以全响应矩阵门禁兜底。 * @@ -314,10 +314,10 @@ class AuthContractConformanceTest { @Test @Order(98) void frozenSnapshotIsTheExpectedContractVersion() { - assertThat(CONTRACT.version()).isEqualTo("1.2.0"); - assertThat(CONTRACT.paths()).hasSize(18); - assertThat(CONTRACT.operations()).hasSize(24); - assertThat(CONTRACT.schemas()).hasSize(45); + assertThat(CONTRACT.version()).isEqualTo("1.3.0"); + assertThat(CONTRACT.paths()).hasSize(31); + assertThat(CONTRACT.operations()).hasSize(43); + assertThat(CONTRACT.schemas()).hasSize(72); assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics"))) .containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS); } diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/ContractValidator.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/ContractValidator.java index 2dc0cb4..f65459b 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/ContractValidator.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/ContractValidator.java @@ -10,6 +10,7 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -81,7 +82,7 @@ final class ContractValidator { } private void validate(Map rawSchema, JsonNode node, String loc, List errors) { - Map schema = contract.resolve(rawSchema); + Map schema = effectiveSchema(rawSchema); if (node == null || node.isMissingNode()) { errors.add(loc + ": 字段缺失"); return; @@ -130,6 +131,32 @@ final class ContractValidator { } } + /** + * Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]} + * pattern into one plain schema (branch keys first, sibling keys — e.g. + * the outer {@code nullable} — win). The frozen contract only ever uses + * single-branch allOf, so a shallow merge is exact; overlapping + * {@code properties} across branches would need a deep merge and are not + * supported. + */ + private Map effectiveSchema(Map rawSchema) { + Map schema = contract.resolve(rawSchema); + List allOf = list(schema, "allOf"); + if (allOf == null) { + return schema; + } + Map merged = new LinkedHashMap<>(); + for (Object branch : allOf) { + merged.putAll(effectiveSchema(cast(branch))); + } + schema.forEach((key, value) -> { + if (!"allOf".equals(key)) { + merged.put(key, value); + } + }); + return merged; + } + private void validateObject(Map schema, JsonNode node, String loc, List errors) { if (!node.isObject()) { errors.add(loc + ": 应为 object,实际 " + node.getNodeType()); diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java index e9317c5..a86ba66 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java @@ -13,27 +13,30 @@ import java.util.Objects; import java.util.Set; /** - * The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot - * {@code /contract/openapi-v1.2.0.yaml}. + * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.3.0.yaml}. * *

Sync discipline (T2-09, extended by T3-19): the canonical * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this * snapshot is a byte-identical copy taken at freeze time, and this class is * the module-local copy of the pet module's contract framework (same * per-module duplication discipline as BearerAuthFilter). Whenever the - * canonical contract changes, copy it here AND in patbond-pet under the new - * version's file name and update both conformance tests (expected version + - * snapshot counts). The guard test on {@code info.version} makes a forgotten + * canonical contract changes, copy it into every framework-carrying module + * (patbond-pet / patbond-auth / patbond-community / patbond-user) under the + * new version's file name and update each conformance test (expected version + * + snapshot counts). The guard test on {@code info.version} makes a forgotten * sync fail loudly in CI instead of silently testing against a stale * contract. * *

Only the subset of OpenAPI 3.0 this contract actually uses is supported: * local {@code #/} refs, plain types, {@code nullable}, {@code enum}, - * {@code required}, {@code properties}, {@code items} — no allOf/oneOf. + * {@code required}, {@code properties}, {@code items}, and the v1.3.0 + * single-branch {@code nullable + allOf: [$ref]} pattern (merged in + * {@link ContractValidator}) — no oneOf/anyOf. */ final class OpenApiContract { - static final String RESOURCE = "/contract/openapi-v1.2.0.yaml"; + static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; private static final Set HTTP_METHODS = Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); diff --git a/patbond-auth/src/test/resources/contract/openapi-v1.2.0.yaml b/patbond-auth/src/test/resources/contract/openapi-v1.3.0.yaml similarity index 60% rename from patbond-auth/src/test/resources/contract/openapi-v1.2.0.yaml rename to patbond-auth/src/test/resources/contract/openapi-v1.3.0.yaml index 1ecb5a9..c9d3072 100644 --- a/patbond-auth/src/test/resources/contract/openapi-v1.2.0.yaml +++ b/patbond-auth/src/test/resources/contract/openapi-v1.3.0.yaml @@ -1,13 +1,16 @@ openapi: 3.0.3 info: - title: Patbond API — Auth / Me / Events / Pets(公开契约) - version: 1.2.0 + title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) + version: 1.3.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 **1.2.0 M2 契约冻结:pets 域 12 路径**(宠物 CRUD、品种/疫苗目录、体重记录、疫苗记录、 健康事件、照护提醒、档案聚合摘要)按第二波已定型实现合入 (iteration-2 报告 13/16/17/18 定型表;冻结报告见 iteration-2/19)。 + **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 + 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 + (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -27,16 +30,25 @@ info: | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40400 | 404 | 资源不存在 | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | + | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | + | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | | 40902 | 409 | VERSION_CONFLICT:乐观锁版本冲突(PATCH 提交的 version 过期);照护提醒流转的状态守卫落空复用此码 | | 40903 | 409 | MICROCHIP_EXISTS:芯片号已被登记(uq_pets_microchip,跨用户唯一) | | 40904 | 409 | VACCINATION_DOSE_EXISTS:同宠物同疫苗同系列同剂次已有非 cancelled 记录(uq_pet_vaccination_dose) | + | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | + | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | | 50000 | 500 | 服务器内部错误 | | 50300 | 503 | 依赖服务暂不可用 | @@ -84,13 +96,45 @@ info: 「新增可选字段」纯增量补入。软删除端点不在 M2 契约(D2-7:首版仅归档 `status=archived`);`DELETE /api/v1/pets/{petId}` 未收录。 + ## Community / Media 域约定(M3 冻结,iteration-3 报告 13/15/16/17 定型) + - **鉴权**:全部端点强制 Bearer 鉴权,无匿名端点。帖子/Feed/评论/互动/关注在 + patbond-community(:8084),媒体上传两步流程在 patbond-user(:8082)。 + - **幂等按域(ADR-019,与 pets 域刻意不同,两域并存、pets 不回改)**:创建型写入 + (发帖/评论)`Idempotency-Key` **必带**(1~128 字符,trim 后计;缺失/空白/超长 + 400/40000),键按作者隔离,落表内幂等列并**比对规范化 request_hash**——hash 对象 + 是规范化后的创建命令(trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源; + 同键同 payload 返回首次创建的资源(同样 201);同键不同 payload 409/40905; + 同键重试撞已删除的首个资源 404(帖子 40403 / 评论 40404)。 + - **二元互动语义幂等**:点赞/收藏/关注用 PUT/DELETE,复合主键即幂等键(无键管理), + 重复调用返回 200 同一**权威终态**(`{liked, likeCount}` 族);客户端乐观更新以 + 响应对账回滚(回滚基准取响应值而非本地推算)。 + - **媒体两步上传(ADR-016)**:创建上传(登记 asset + 签发预签名 PUT 直传凭据, + TTL 10 分钟,配置项)→ 客户端直传(原样携带 requiredHeaders)→ complete 确认 + (服务端 HEAD 校验后 uploading→ready)。桶保持私有:**一切媒体读取 URL + (asset/帖图/头像)均为时效性预签名 GET URL**(TTL 默认 1 小时,配置项),由 + 服务端每次响应现签;客户端不得持久化 URL,过期即重取。 + - **防枚举 404**:一切不可见情形按资源合并给码(帖子 40403、评论 40404、asset + 40405、用户 40406),同码各情形响应完全一致;403/40301 只发给对资源「可见但 + 无权」的调用者,不泄露新信息。 + - **互动面 = 帖子公开面**:评论(读写删)与点赞/收藏只对 published 且未删的帖子 + 开放,**作者本人的草稿在互动路径同样 404/40403**——可见性回答「能不能看」, + 互动门禁回答「能不能社交」。 + - **列表分页**:全部列表复用 cursor 分页正典 `{items, nextCursor, hasMore}` + (`limit` 1~100 缺省 20),各列表排序键在端点描述中写死。 + - **ADR-018 裁剪**:话题全部端点、关注/粉丝**列表**(最小接口仅 follow/unfollow + + 计数)、作者主页帖子列表、`region`/`generationJob`/`visibility=followers|private` + 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 + (如作者公开资料批量接口)不属于本公开契约。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) - url: http://127.0.0.1:8082 - description: patbond-user(本地开发,/api/v1/me、/api/v1/events) + description: patbond-user(本地开发,/api/v1/me、/api/v1/events、/api/v1/media/**) - url: http://127.0.0.1:8083 description: patbond-pet(本地开发,pets 域全部端点) + - url: http://127.0.0.1:8084 + description: patbond-community(本地开发,帖子/Feed/评论/互动/关注全部端点) tags: - name: auth @@ -105,6 +149,18 @@ tags: description: 品种与疫苗目录(只读字典,patbond-pet) - name: health-records description: 体重、疫苗、健康事件、照护提醒、档案摘要(patbond-pet) + - name: media + description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) + - 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/auth/register: @@ -1016,6 +1072,617 @@ paths: '404': $ref: '#/components/responses/PetNotFound' + # ====================================================================== + # Community / Media 域(M3 冻结,13 路径;定型依据:iteration-3 报告 13/15/16/17) + # ====================================================================== + + /api/v1/media/uploads: + post: + tags: [media] + summary: 创建上传(登记 asset 并签发预签名直传凭据) + description: | + 两步上传第一步:校验白名单与上限(`purpose` 仅 post_image、`mimeType` 仅 + image/jpeg|png|webp、`byteSize` ≤ 10485760,均为服务端配置项,后续扩展为 + 向后兼容的枚举追加)→ 写 `media.assets` 行(status=uploading,bucket/objectKey + 服务端生成、不含任何用户输入)→ 返回预签名 PUT 直传凭据(TTL 10 分钟,配置项)。 + 客户端凭凭据直传对象存储,不经应用服务器;直传必须**原样携带 requiredHeaders** + (Content-Type 已签进签名,改动即被存储侧拒绝)。M3 仅 `kind=image` + (ADR-018 视频后置;video/document 为向后新增枚举预留)。 + operationId: createMediaUpload + security: + - bearerAuth: [] + 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/Content-Type → + uploading→ready、写 readyAt,返回可引用的 asset(含现签预签名 GET URL)。 + + - **幂等**:对已 ready 的 asset 重复 complete 返回 200 同一 asset(现签新 GET URL)。 + - 对象尚不存在(直传完成前确认)→ 422/42205,asset **保持 uploading 可重试** + (补传后再确认即恢复,凭据未过期时无须重新创建上传)。 + - 对象存在但大小/类型与登记不符 → 置 failed(终态),422/42205,须重新创建上传。 + - failed 态再确认 → 422/42205(终态);不存在/非本人/已删 → 404/40405(防枚举合并)。 + - `sha256` 照收照存,M3 不做内容核验(存储侧 HEAD 不返回内容散列;后续经 + 存储侧 checksum 特性补齐,不改契约形态)。 + operationId: completeMediaUpload + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/AssetIdParam' + responses: + '200': + description: 确认成功(或幂等重复确认),asset 为 ready + content: + application/json: + schema: + $ref: '#/components/schemas/MediaAssetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/MediaNotFound' + '422': + description: | + asset 非 uploading 态或对象校验未通过(code 42205):对象未上传保持可重试、 + 大小/类型不符置 failed 终态、failed 态再确认(已 ready 幂等 200 除外) + 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` **必带**(语义见该头参数描述与 info「Community / Media 域 + 约定」)。`status` 可 draft(缺省)或 published(直接发布,服务端写 + publishedAt)。纯文字帖合法(media 空数组或缺席,D3-4)。 + + media 挂接(每帖 ≤9 图):只接受本人所有且 ready 的 asset(uploading/failed + 422/42203;不存在/非本人/已删 404/40405);`position` **全给或全不给**——全给 + 须恰为 0..n-1 连续不重复,全不给按数组序,混合 400/40000;`isCover` 至多一个 + true,全 false 时服务端将 position 0 行落库置为封面(库内恒有唯一封面行); + 同帖 assetId 不重复;caption trim 后 ≤300。 + `petId` 须为调用者可见宠物,否则 404/40401(沿 pets 域防枚举语义)。 + operationId: createPost + security: + - bearerAuth: [] + 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' + '404': + description: petId 引用不可见宠物(code 40401)或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + '422': + $ref: '#/components/responses/MediaNotReady' + + /api/v1/posts/{postId}: + get: + tags: [posts] + summary: 帖子详情 + description: | + 权限矩阵(iteration-3 报告 15 定型):published 对全部登录用户开放;draft 仅 + 作者可见;hidden/archived(运营态)**对作者同样 404/40403**——M3 无端点能产生 + 或解除运营态,status 枚举保持两值。一切不可见情形响应完全一致(防枚举)。 + 响应含 likedByMe/bookmarkedByMe 与作者公开摘要(AuthorSummary)。 + operationId: getPost + security: + - bearerAuth: [] + 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;一切不可见情形——含他人 draft——404/40403)。 + PATCH 部分更新惯例:缺席字段不变,不支持清空回 null(M2 先例)。`version` + 必带(缺失 400/40000,过期 409/40902)。 + + - **发布** = `status: published` 的状态迁移(draft→published 是唯一开放迁移, + 服务端写 publishedAt,恰写一次);**对已发布帖重复提交 `status: published` + 为幂等 no-op(200,version 照常 +1)**——同态提交不是迁移,弱网重发不报错; + draft/hidden/archived 目标值由请求枚举拒为 400/40000(published→draft 不支持)。 + - media 出现即**整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动), + 校验规则同创建。 + operationId: updatePost + security: + - bearerAuth: [] + 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': + description: | + 帖子不可见(code 40403,防枚举合并);或 petId 引用不可见宠物(code 40401); + 或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/VersionConflict' + '422': + $ref: '#/components/responses/MediaNotReady' + delete: + tags: [posts] + summary: 删除帖子(软删,仅作者) + description: | + 软删(deleted_at 为全域唯一删除判定基准),删除后详情/Feed/列表/互动一切路径 + 404/40403。重复删除与删不存在的帖同响应 404/40403(防枚举合并)。 + 不提供恢复端点(M3 无回收站)。非作者对已发布帖 403/40301。 + operationId: deletePost + security: + - bearerAuth: [] + 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(软删不含,hidden/archived 不含)。排序 + `(created_at DESC, id DESC)` 走 `ix_posts_author_created`,keyset 游标。 + `status` 过滤可选(draft|published)。 + operationId: listMyPosts + security: + - bearerAuth: [] + 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 帖子下一次请求即不可见。 + 卡片形态见 FeedCard(iteration-3 报告 16 定型);likedByMe/bookmarkedByMe + 为当前用户视角。 + operationId: getFeed + security: + - bearerAuth: [] + 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`,keyset 游标; + 仅 visible 评论。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。作者与 @ 目标均为 AuthorSummary。 + operationId: listComments + security: + - bearerAuth: [] + 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`(键按作者隔离、天然全局跨帖)。`replyToUserId` 可选 @ 回复 + (单层平铺,无楼中楼,ADR-018);目标须为存活用户,不存在/已注销 404/40406 + (合并不泄露成因)。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。content trim 后 1~2000。comment_count 同事务 +1。 + operationId: createComment + security: + - bearerAuth: [] + 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': + description: 帖子不可见——含作者本人草稿(code 40403);或 replyToUserId 目标用户不存在/已注销(code 40406) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + userNotFound: + value: { code: 40406, message: 用户不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + + /api/v1/comments/{commentId}: + delete: + tags: [comments] + summary: 删除评论(仅评论作者;顶层短路径) + description: | + 顶层短路径先例(pets 域子资源同理):commentId 全局唯一。**仅评论作者可删—— + 帖主不可删除他人评论(D3-7 首版不做)**:对可见评论的非作者(含帖主) + 403/40301;不存在/已删/所属帖不可见合并 404/40404(防枚举)。 + 软删(status→deleted),comment_count 同事务 -1。 + operationId: deleteComment + security: + - bearerAuth: [] + 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) 即幂等键:重复 PUT 返回 200 同一权威终态(非 409), + 仅实际插入才 like_count 同事务 +1,并发 N 次计数恰为 1(M3 验收标准二)。 + 互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**)404/40403。 + operationId: likePost + security: + - bearerAuth: [] + 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)。 + 帖子不可见(含作者本人草稿)404/40403。 + operationId: unlikePost + security: + - bearerAuth: [] + 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 语义幂等,与点赞同构) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: bookmarkPost + security: + - bearerAuth: [] + 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 语义幂等) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: unbookmarkPost + security: + - bearerAuth: [] + 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`,游标键在收藏关系行上。项形态 = FeedCard, + 谓词与公共 Feed 恒等:被收藏帖软删/hidden/archived 后**静默剔除**(剔除在页 + 查询内完成,不破坏翻页不丢不重;publishedAt 恒非空不变式对本列表继续成立)。 + operationId: listMyBookmarks + security: + - bearerAuth: [] + 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 与关注/粉丝列表不在 M3(ADR-018 最小数据接口)。 + operationId: followUser + security: + - bearerAuth: [] + 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;仅 PUT——自取关走 DELETE 的 200 幂等 no-op) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + selfFollow: + value: { code: 42204, message: 不能关注自己, data: null } + delete: + tags: [follows] + summary: 取消关注(DELETE 语义幂等) + description: | + 取消不存在的关注不报错,返回 200 权威终态(following 恒 false)。 + **自取关同样 200 幂等 no-op**(关系行不可能存在,权威 false 即事实;42204 + 只在 PUT)。目标用户不存在/已注销 404/40406。 + operationId: unfollowUser + security: + - bearerAuth: [] + 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 为调用者视角, + 查自己时恒 false。目标用户不存在/已注销 404/40406。关注/粉丝**列表**端点 + 不在 M3(需时按纯增量补入)。 + operationId: getFollowStats + security: + - bearerAuth: [] + 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: @@ -1061,6 +1728,46 @@ components: 可选幂等键(≤255 字符,超长 400/40000)。键按「调用者 × 宠物 × 资源」隔离; 同键重试返回首次创建的记录(同样 201);不比对请求体(每次逻辑提交应换新键, 建议 UUID);键永久幂等(无 TTL)。不带键则无幂等语义。 + 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 + IdempotencyKeyRequiredHeader: + name: Idempotency-Key + in: header + required: true + schema: + type: string + maxLength: 128 + description: | + **必带**幂等键(1~128 字符,trim 后计;缺失/空白/超长 400/40000。与 pets 域 + 「可选、≤255、不比对请求体」刻意不同——community 域按 ADR-019 落表内幂等列, + 列宽 128)。键按作者隔离(跨用户同键互不干扰);同键重试返回首次创建的资源 + (同样 201);**比对规范化 request_hash**——hash 对象是规范化后的创建命令 + (trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源;同键不同 payload + 返回 409/40905;同键重试撞已删除的首个资源返回 404(帖子 40403 / 评论 40404, + 资源已消亡,不复活不另建)。客户端每次逻辑提交换新键(建议 UUID), + 重试间保持不变。 responses: ValidationError: @@ -1125,6 +1832,76 @@ components: examples: versionConflict: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + PostNotFound: + description: | + 帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(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(uploading/failed)状态的 asset(code 42203)。 + asset 不存在/非本人/已删则合并为 404/40405。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + notReady: + value: { code: 42203, message: 媒体尚未就绪, data: null } schemas: RegisterRequest: @@ -2383,3 +3160,700 @@ components: example: success data: $ref: '#/components/schemas/PetSummary' + + # ================================================================== + # Community / Media 域 schemas(M3 冻结;定型依据:iteration-3 报告 13/15/16/17) + # ================================================================== + + AuthorSummary: + type: object + description: | + 作者公开摘要(D3-9 方案 B,iteration-3 报告 16 定型;community 跨 schema + 只读 identity 取数,ADR-017)。正常路径 nickname 恒非空——空昵称由服务端 + 回退为 username(客户端不做回退拼装,回退后的展示名不标注来源); + nickname 与 avatarUrl 同为 null 即「降级/墓碑」形态(作者资料暂不可得, + 或用户已注销)——两种情形同一形态,客户端只需一种占位逻辑。 + 不露 bio、不露 username。 + required: [userId] + properties: + userId: + type: string + format: uuid + description: 恒非空,任何情形都在 + nickname: + type: string + nullable: true + maxLength: 32 + description: 昵称(空昵称已由服务端回退为 username);null 仅出现在降级/注销墓碑形态 + example: 毛毛的铲屎官 + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取;无头像 / 头像 asset 非 ready / 降级 → null + (客户端出占位) + + # ---------- 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: | + 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 + user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + mimeType: + type: string + enum: [image/jpeg, image/png, image/webp] + description: 白名单外 400/40000;不收 HEIC(客户端压缩管线统一转码 jpeg) + byteSize: + type: integer + format: int64 + minimum: 1 + maximum: 10485760 + description: 声明的文件字节数,complete 时与对象实测比对;上限 10485760(10 MiB,服务端配置项) + sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: 可选,64 位小写 hex;照收照存,M3 不做内容核验(后续经存储侧 checksum 特性补齐,不改契约形态) + + MediaUploadCredentials: + type: object + description: 预签名直传凭据(ADR-016,iteration-3 报告 13 定型) + required: [assetId, uploadUrl, method, requiredHeaders, expiresAt] + properties: + assetId: + type: string + format: uuid + description: 已登记的 asset ID(status=uploading) + uploadUrl: + type: string + description: | + 预签名 PUT 完整 URL——签名以 query 参数携带(X-Amz-Algorithm/-Credential/ + -Signature 族),指向客户端可达的对象存储端点;客户端直传,不经应用服务器 + method: + type: string + enum: [PUT] + requiredHeaders: + type: object + additionalProperties: + type: string + description: | + 直传请求必须**原样携带**的头。键集定型为恒且仅一键: + `{"Content-Type": <声明的 mimeType>}`——Content-Type 已签进签名, + 改动即被存储侧拒绝 + expiresAt: + type: string + format: date-time + description: | + 凭据过期时刻 = 签发时刻 + TTL(默认 10 分钟,配置项);过期后重新创建 + 上传(原 asset 在补传后仍可确认) + + 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 + description: complete 后回填,可空 + heightPx: + type: integer + nullable: true + status: + type: string + enum: [uploading, ready, failed] + description: deleted 态对外恒 404/40405,不出现在响应 + url: + type: string + nullable: true + description: | + 访问 URL,仅 ready 态非空——时效性预签名 GET(TTL 默认 1 小时,配置项), + 每次响应现签,客户端不得持久化、过期即重取;桶保持私有,无签名直访被拒 + 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 + description: 库内恒有唯一封面行(写侧保证:全 false 时服务端将 position 0 行置真) + url: + type: string + description: | + 图片访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取(运维前提:生产环境对象存储恒配置) + 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(不存在/非本人/已删 404/40405) + required: [assetId] + properties: + assetId: + type: string + format: uuid + description: 同帖 assetId 不得重复(400/40000) + position: + type: integer + minimum: 0 + maximum: 8 + description: | + **全给或全不给**:全给须恰为 0..n-1 连续不重复;全不给按数组序; + 混合 400/40000 + isCover: + type: boolean + default: false + description: 至多一个 true(uq_post_media_cover);全 false 时服务端将 position 0 行落库置为封面 + caption: + type: string + maxLength: 300 + description: trim 后 ≤300 + + CreatePostRequest: + type: object + required: [content] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + description: 可选标题(ck_posts_title;空白串 400/40000) + 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 不开放写入(提交 400/40000) + 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 + description: | + 部分更新:缺席字段不变;不支持清空回 null(M2 惯例)。media 若出现则 + **整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动),校验规则同创建。 + required: [version] + properties: + version: + type: integer + minimum: 0 + description: 乐观锁,必带(缺失 400/40000);过期 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(发布动作,服务端写 publishedAt); + 对已发布帖重复提交为幂等 no-op(200,version 照常 +1); + draft/hidden/archived 目标值 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/40403 + (M3 无端点能产生或解除运营态) + 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 + description: | + 行最后更新时刻——互动计数维护亦会推动该值;判断「内容是否编辑过」 + 以 version 为准,勿以 updatedAt 判断 + publishedAt: + type: string + format: date-time + nullable: true + description: 仅 published 非空(发布时恰写一次) + version: + type: integer + + FeedCard: + type: object + description: | + Feed / 收藏列表卡片形态(较 Post 裁剪,iteration-3 报告 16 定型:只带 + coverImage + mediaCount,不带整组图;content 全文、petId、visibility、 + version、media 整组、created/updated 时间戳对均不出现,全文走帖子详情)。 + 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 + description: 原样透传,无标题为 null + contentPreview: + type: string + description: | + 正文前 200 个 Unicode 码点,**码点边界截断**(emoji 等增补面字符绝不 + 劈开),不追加省略号;短于 200 码点原样透传。全文恒走帖子详情端点 + coverImage: + nullable: true + allOf: + - $ref: '#/components/schemas/PostMediaItem' + description: | + 封面图 = 库中唯一 is_cover 行(写侧保证有图必有唯一封面行,读侧零特判); + 纯文字帖为 null + mediaCount: + type: integer + minimum: 0 + maximum: 9 + 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 + description: 恒非空(Feed 与收藏列表谓词只放行 published) + + 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: trim 后 1~2000(ck_comments_content 同宽) + replyToUserId: + type: string + format: uuid + description: | + 可选 @ 回复目标(单层平铺,无 parentCommentId,ADR-018); + 目标须为存活用户,不存在/已注销 404/40406 + + Comment: + type: object + description: M3 无评论编辑,不带 updatedAt + 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: '@ 回复目标的公开摘要(含降级 id-only 形态);非回复为 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 为目标用户的粉丝数(实时 COUNT) + 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: 目标用户的粉丝数(实时 COUNT) + followingCount: + type: integer + format: int64 + description: 目标用户关注的人数(实时 COUNT) + 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' diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java new file mode 100644 index 0000000..565e1e5 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java @@ -0,0 +1,512 @@ +package com.patbond.patbond.community.contract; + +import com.jayway.jsonpath.JsonPath; +import com.patbond.patbond.community.post.PostApiTestBase; +import com.patbond.patbond.community.support.CommunityTestData; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; + +/** + * T3-20(M3 第二波收尾):community 域 17 个操作补进契约一致性保障,机制与 + * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照 + * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 + * {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用 + * {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名 + * 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 + * + *

覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()} + * 断言契约为这 17 个操作声明的每一个 (操作, 状态码) 单元格(共 64 格)都被 + * 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁 + * 冲突、422 均为业务规则拒绝,单线程即可确定性触发。 + * + *

media 域 2 个操作属 patbond-user 模块,由该模块的 + * MediaContractConformanceTest 覆盖(快照同一份)。 + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class CommunityContractConformanceTest extends PostApiTestBase { + + private static final OpenApiContract CONTRACT = OpenApiContract.load(); + private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT); + + /** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/feed 200"。 */ + private static final Set COVERED = ConcurrentHashMap.newKeySet(); + + /** community 域 17 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */ + private static final List COMMUNITY_OPERATIONS = List.of( + "POST /api/v1/posts", + "GET /api/v1/posts/{postId}", + "PATCH /api/v1/posts/{postId}", + "DELETE /api/v1/posts/{postId}", + "GET /api/v1/me/posts", + "GET /api/v1/feed", + "GET /api/v1/posts/{postId}/comments", + "POST /api/v1/posts/{postId}/comments", + "DELETE /api/v1/comments/{commentId}", + "PUT /api/v1/posts/{postId}/like", + "DELETE /api/v1/posts/{postId}/like", + "PUT /api/v1/posts/{postId}/bookmark", + "DELETE /api/v1/posts/{postId}/bookmark", + "GET /api/v1/me/bookmarks", + "PUT /api/v1/users/{userId}/follow", + "DELETE /api/v1/users/{userId}/follow", + "GET /api/v1/users/{userId}/follow-stats"); + + private static final String IDEMPOTENCY_KEY = "Idempotency-Key"; + + // ---- 校验骨架 ------------------------------------------------------ + + /** + * 执行请求,断言 HTTP 状态,并将响应体对照冻结契约严格校验;通过后把 + * (操作, 状态码) 记入覆盖表。返回响应体供取 id/cursor。 + */ + private String verified(MockHttpServletRequestBuilder rq, String method, + String pathTemplate, int expectedStatus) throws Exception { + MvcResult result = mockMvc.perform(rq).andReturn(); + int actual = result.getResponse().getStatus(); + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(actual) + .as("%s %s 的 HTTP 状态(响应体: %s)", method, pathTemplate, body) + .isEqualTo(expectedStatus); + List drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body); + assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty(); + COVERED.add(method + " " + pathTemplate + " " + actual); + return body; + } + + /** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */ + private String verifiedError(MockHttpServletRequestBuilder rq, String method, + String pathTemplate, int status, int bizCode) throws Exception { + String body = verified(rq, method, pathTemplate, status); + assertThat((Integer) JsonPath.read(body, "$.code")) + .as("%s %s %d 的业务错误码", method, pathTemplate, status) + .isEqualTo(bizCode); + return body; + } + + /** 经 verified 的创建(响应同样被契约校验),返回帖子 id。 */ + private String newPost(UUID author, String body) throws Exception { + String created = verified( + createPostRequest(author, UUID.randomUUID().toString(), body), + "POST", "/api/v1/posts", 201); + return JsonPath.read(created, "$.data.id"); + } + + private String newPublishedPost(UUID author, String content) throws Exception { + return newPost(author, """ + {"content":"%s","status":"published"} + """.formatted(content)); + } + + private String newComment(UUID author, String postId, String content) throws Exception { + String created = verified( + authed(post("/api/v1/posts/{postId}/comments", postId), author) + .header(IDEMPOTENCY_KEY, UUID.randomUUID().toString()) + .content("{\"content\":\"%s\"}".formatted(content)), + "POST", "/api/v1/posts/{postId}/comments", 201); + return JsonPath.read(created, "$.data.id"); + } + + // ---- 成功路径:17 操作全覆盖 --------------------------------------- + + @Test + @Order(1) + void postLifecycleSuccessShapes() throws Exception { + UUID author = newUser(); + UUID petId = CommunityTestData.insertPetOwnedBy(jdbcClient, author); + UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author); + + // 全字段草稿(petId + 单图封面 + caption) + String draftId = newPost(author, """ + {"title":"契约帖","content":"全字段草稿正文","category":"help", + "status":"draft","petId":"%s", + "media":[{"assetId":"%s","position":0,"isCover":true,"caption":"封面图"}]} + """.formatted(petId, asset)); + + // 可空字段全空的纯文字直接发布形态(nullable 声明的实证) + newPublishedPost(author, "契约纯文字发布帖"); + + verified(get("/api/v1/posts/{postId}", draftId) + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/posts/{postId}", 200); + + // 发布草稿(draft→published 唯一开放迁移) + String published = verified(authed(patch("/api/v1/posts/{postId}", draftId), author) + .content("{\"version\":0,\"status\":\"published\"}"), + "PATCH", "/api/v1/posts/{postId}", 200); + assertThat((String) JsonPath.read(published, "$.data.status")).isEqualTo("published"); + assertThat((Object) JsonPath.read(published, "$.data.publishedAt")).isNotNull(); + + // 我的帖子列表:keyset 翻页两态 + status 过滤 + String page1 = verified(get("/api/v1/me/posts").param("limit", "1") + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/me/posts", 200); + assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue(); + String cursor = JsonPath.read(page1, "$.data.nextCursor"); + assertThat(cursor).as("hasMore=true 时 nextCursor 非空").isNotNull(); + verified(get("/api/v1/me/posts").param("limit", "1").param("cursor", cursor) + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/me/posts", 200); + verified(get("/api/v1/me/posts").param("status", "draft") + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/me/posts", 200); + + // 软删(VoidEnvelope) + String victim = newPublishedPost(author, "契约待删帖"); + verified(authed(delete("/api/v1/posts/{postId}", victim), author), + "DELETE", "/api/v1/posts/{postId}", 200); + } + + @Test + @Order(2) + void feedSuccessShapes() throws Exception { + UUID author = newUser(); + UUID reader = newUser(); + UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author); + + // 有封面与纯文字两种卡片形态(coverImage 的 allOf 非空/null 两分支) + newPost(author, """ + {"title":"契约图帖","content":"Feed 封面卡片","status":"published", + "media":[{"assetId":"%s","isCover":true}]} + """.formatted(asset)); + newPublishedPost(author, "Feed 纯文字卡片"); + + String page1 = verified(get("/api/v1/feed").param("limit", "1") + .header("Authorization", "Bearer " + token(reader)), + "GET", "/api/v1/feed", 200); + assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue(); + String cursor = JsonPath.read(page1, "$.data.nextCursor"); + verified(get("/api/v1/feed").param("cursor", cursor) + .header("Authorization", "Bearer " + token(reader)), + "GET", "/api/v1/feed", 200); + } + + @Test + @Order(3) + void commentSuccessShapes() throws Exception { + UUID author = newUser(); + UUID commenter = newUser(); + String postId = newPublishedPost(author, "契约评论帖"); + + // 普通评论与 @ 回复(replyToUser 的 allOf null/非空两分支) + newComment(commenter, postId, "普通评论"); + verified(authed(post("/api/v1/posts/{postId}/comments", postId), author) + .header(IDEMPOTENCY_KEY, UUID.randomUUID().toString()) + .content(""" + {"content":"@ 回复","replyToUserId":"%s"} + """.formatted(commenter)), + "POST", "/api/v1/posts/{postId}/comments", 201); + + String page1 = verified(get("/api/v1/posts/{postId}/comments", postId) + .param("limit", "1") + .header("Authorization", "Bearer " + token(commenter)), + "GET", "/api/v1/posts/{postId}/comments", 200); + assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue(); + String cursor = JsonPath.read(page1, "$.data.nextCursor"); + verified(get("/api/v1/posts/{postId}/comments", postId) + .param("cursor", cursor) + .header("Authorization", "Bearer " + token(commenter)), + "GET", "/api/v1/posts/{postId}/comments", 200); + + // 作者软删自己的评论(VoidEnvelope) + String commentId = newComment(commenter, postId, "待删评论"); + verified(authed(delete("/api/v1/comments/{commentId}", commentId), commenter), + "DELETE", "/api/v1/comments/{commentId}", 200); + } + + @Test + @Order(4) + void interactionSuccessShapes() throws Exception { + UUID author = newUser(); + UUID actor = newUser(); + String postA = newPublishedPost(author, "契约互动帖 A"); + String postB = newPublishedPost(author, "契约互动帖 B"); + + // PUT/DELETE 权威终态(重复 PUT 同格,幂等语义顺带实证) + verified(authed(put("/api/v1/posts/{postId}/like", postA), actor), + "PUT", "/api/v1/posts/{postId}/like", 200); + String likedAgain = verified(authed(put("/api/v1/posts/{postId}/like", postA), actor), + "PUT", "/api/v1/posts/{postId}/like", 200); + assertThat((Boolean) JsonPath.read(likedAgain, "$.data.liked")).isTrue(); + assertThat((Integer) JsonPath.read(likedAgain, "$.data.likeCount")).isEqualTo(1); + verified(authed(delete("/api/v1/posts/{postId}/like", postA), actor), + "DELETE", "/api/v1/posts/{postId}/like", 200); + + verified(authed(put("/api/v1/posts/{postId}/bookmark", postA), actor), + "PUT", "/api/v1/posts/{postId}/bookmark", 200); + verified(authed(put("/api/v1/posts/{postId}/bookmark", postB), actor), + "PUT", "/api/v1/posts/{postId}/bookmark", 200); + + String page1 = verified(get("/api/v1/me/bookmarks").param("limit", "1") + .header("Authorization", "Bearer " + token(actor)), + "GET", "/api/v1/me/bookmarks", 200); + assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue(); + String cursor = JsonPath.read(page1, "$.data.nextCursor"); + verified(get("/api/v1/me/bookmarks").param("cursor", cursor) + .header("Authorization", "Bearer " + token(actor)), + "GET", "/api/v1/me/bookmarks", 200); + + verified(authed(delete("/api/v1/posts/{postId}/bookmark", postB), actor), + "DELETE", "/api/v1/posts/{postId}/bookmark", 200); + } + + @Test + @Order(5) + void followSuccessShapes() throws Exception { + UUID follower = newUser(); + UUID followee = newUser(); + + String followed = verified(authed(put("/api/v1/users/{userId}/follow", followee), follower), + "PUT", "/api/v1/users/{userId}/follow", 200); + assertThat((Boolean) JsonPath.read(followed, "$.data.following")).isTrue(); + + String stats = verified(get("/api/v1/users/{userId}/follow-stats", followee) + .header("Authorization", "Bearer " + token(follower)), + "GET", "/api/v1/users/{userId}/follow-stats", 200); + assertThat((Boolean) JsonPath.read(stats, "$.data.followedByMe")).isTrue(); + + // 查自己:followedByMe 恒 false 分支 + verified(get("/api/v1/users/{userId}/follow-stats", follower) + .header("Authorization", "Bearer " + token(follower)), + "GET", "/api/v1/users/{userId}/follow-stats", 200); + + verified(authed(delete("/api/v1/users/{userId}/follow", followee), follower), + "DELETE", "/api/v1/users/{userId}/follow", 200); + // 取消不存在的关注:幂等 no-op 仍 200 权威 false + String unfollowedAgain = verified( + authed(delete("/api/v1/users/{userId}/follow", followee), follower), + "DELETE", "/api/v1/users/{userId}/follow", 200); + assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse(); + } + + // ---- 错误信封 ------------------------------------------------------ + + @Test + @Order(6) + void unauthenticatedRequestsAnswer40101OnAllOperations() throws Exception { + for (String op : COMMUNITY_OPERATIONS) { + String[] parts = op.split(" ", 2); + String url = parts[1].replaceAll("\\{[^}]+}", UUID.randomUUID().toString()); + MockHttpServletRequestBuilder rq = request(HttpMethod.valueOf(parts[0]), url); + if (!"GET".equals(parts[0])) { + rq = rq.contentType(MediaType.APPLICATION_JSON).content("{}"); + } + verifiedError(rq, parts[0], parts[1], 401, 40101); + } + } + + @Test + @Order(7) + void validationErrorsAnswer40000() throws Exception { + UUID user = newUser(); + String postId = newPublishedPost(user, "契约校验帖"); + + // 创建:缺 Idempotency-Key 与空 body 两种 40000 + verifiedError(authed(post("/api/v1/posts"), user).content("{\"content\":\"无幂等键\"}"), + "POST", "/api/v1/posts", 400, 40000); + verifiedError(createPostRequest(user, UUID.randomUUID().toString(), "{}"), + "POST", "/api/v1/posts", 400, 40000); + // PATCH:缺 version + verifiedError(authed(patch("/api/v1/posts/{postId}", postId), user) + .content("{\"content\":\"缺版本\"}"), + "PATCH", "/api/v1/posts/{postId}", 400, 40000); + + verifiedError(get("/api/v1/me/posts").param("limit", "0") + .header("Authorization", "Bearer " + token(user)), + "GET", "/api/v1/me/posts", 400, 40000); + verifiedError(get("/api/v1/feed").param("cursor", "not-a-cursor") + .header("Authorization", "Bearer " + token(user)), + "GET", "/api/v1/feed", 400, 40000); + verifiedError(get("/api/v1/posts/{postId}/comments", postId).param("limit", "101") + .header("Authorization", "Bearer " + token(user)), + "GET", "/api/v1/posts/{postId}/comments", 400, 40000); + verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), user) + .header(IDEMPOTENCY_KEY, UUID.randomUUID().toString()) + .content("{}"), + "POST", "/api/v1/posts/{postId}/comments", 400, 40000); + verifiedError(get("/api/v1/me/bookmarks").param("cursor", "broken") + .header("Authorization", "Bearer " + token(user)), + "GET", "/api/v1/me/bookmarks", 400, 40000); + } + + @Test + @Order(8) + void antiEnumerationAndPermissionErrorsMatchContract() throws Exception { + UUID author = newUser(); + UUID other = newUser(); + String ghost = UUID.randomUUID().toString(); + + // -- 40403:帖子防枚举(不存在 / 他人 draft 同响应)-- + verifiedError(get("/api/v1/posts/{postId}", ghost) + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/posts/{postId}", 404, 40403); + String draftId = newPost(author, "{\"content\":\"他人不可见草稿\"}"); + verifiedError(authed(patch("/api/v1/posts/{postId}", draftId), other) + .content("{\"version\":0,\"content\":\"越权\"}"), + "PATCH", "/api/v1/posts/{postId}", 404, 40403); + verifiedError(authed(delete("/api/v1/posts/{postId}", ghost), author), + "DELETE", "/api/v1/posts/{postId}", 404, 40403); + verifiedError(get("/api/v1/posts/{postId}/comments", draftId) + .header("Authorization", "Bearer " + token(other)), + "GET", "/api/v1/posts/{postId}/comments", 404, 40403); + // 互动面 = 帖子公开面:作者本人草稿同样 40403 + verifiedError(authed(put("/api/v1/posts/{postId}/like", draftId), author), + "PUT", "/api/v1/posts/{postId}/like", 404, 40403); + verifiedError(authed(delete("/api/v1/posts/{postId}/like", draftId), author), + "DELETE", "/api/v1/posts/{postId}/like", 404, 40403); + verifiedError(authed(put("/api/v1/posts/{postId}/bookmark", ghost), author), + "PUT", "/api/v1/posts/{postId}/bookmark", 404, 40403); + verifiedError(authed(delete("/api/v1/posts/{postId}/bookmark", ghost), author), + "DELETE", "/api/v1/posts/{postId}/bookmark", 404, 40403); + + // -- 创建帖子的 404 双业务码:40401 幽灵宠物 / 40405 幽灵 asset -- + verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """ + {"content":"幽灵宠物","petId":"%s"} + """.formatted(ghost)), + "POST", "/api/v1/posts", 404, 40401); + verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """ + {"content":"幽灵媒体","media":[{"assetId":"%s"}]} + """.formatted(ghost)), + "POST", "/api/v1/posts", 404, 40405); + + // -- 评论的 404 双业务码:40403 帖子不可见 / 40406 幽灵 @ 目标 -- + String postId = newPublishedPost(author, "契约错误评论帖"); + verifiedError(authed(post("/api/v1/posts/{postId}/comments", draftId), other) + .header(IDEMPOTENCY_KEY, UUID.randomUUID().toString()) + .content("{\"content\":\"评论他人草稿\"}"), + "POST", "/api/v1/posts/{postId}/comments", 404, 40403); + verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), other) + .header(IDEMPOTENCY_KEY, UUID.randomUUID().toString()) + .content(""" + {"content":"@ 幽灵","replyToUserId":"%s"} + """.formatted(ghost)), + "POST", "/api/v1/posts/{postId}/comments", 404, 40406); + verifiedError(authed(delete("/api/v1/comments/{commentId}", ghost), author), + "DELETE", "/api/v1/comments/{commentId}", 404, 40404); + + // -- 40406:关注三端点的幽灵目标 -- + verifiedError(authed(put("/api/v1/users/{userId}/follow", ghost), author), + "PUT", "/api/v1/users/{userId}/follow", 404, 40406); + verifiedError(authed(delete("/api/v1/users/{userId}/follow", ghost), author), + "DELETE", "/api/v1/users/{userId}/follow", 404, 40406); + verifiedError(get("/api/v1/users/{userId}/follow-stats", ghost) + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/users/{userId}/follow-stats", 404, 40406); + + // -- 40301:可见但无权限(他人已发布帖改/删、他人可见评论删——含帖主)-- + verifiedError(authed(patch("/api/v1/posts/{postId}", postId), other) + .content("{\"version\":0,\"content\":\"越权改\"}"), + "PATCH", "/api/v1/posts/{postId}", 403, 40301); + verifiedError(authed(delete("/api/v1/posts/{postId}", postId), other), + "DELETE", "/api/v1/posts/{postId}", 403, 40301); + String commentId = newComment(other, postId, "帖主也删不得"); + verifiedError(authed(delete("/api/v1/comments/{commentId}", commentId), author), + "DELETE", "/api/v1/comments/{commentId}", 403, 40301); + } + + @Test + @Order(9) + void conflictAndRuleErrorsMatchContract() throws Exception { + UUID author = newUser(); + UUID self = author; + + // -- 409/40905:同幂等键不同 payload(帖子与评论)-- + String key = UUID.randomUUID().toString(); + verified(createPostRequest(author, key, "{\"content\":\"首次提交\"}"), + "POST", "/api/v1/posts", 201); + verifiedError(createPostRequest(author, key, "{\"content\":\"同键不同内容\"}"), + "POST", "/api/v1/posts", 409, 40905); + + String postId = newPublishedPost(author, "契约冲突帖"); + String commentKey = UUID.randomUUID().toString(); + verified(authed(post("/api/v1/posts/{postId}/comments", postId), author) + .header(IDEMPOTENCY_KEY, commentKey) + .content("{\"content\":\"首次评论\"}"), + "POST", "/api/v1/posts/{postId}/comments", 201); + verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), author) + .header(IDEMPOTENCY_KEY, commentKey) + .content("{\"content\":\"同键不同评论\"}"), + "POST", "/api/v1/posts/{postId}/comments", 409, 40905); + + // -- 409/40902:乐观锁过期(先成功一次把 version 顶到 1)-- + verified(authed(patch("/api/v1/posts/{postId}", postId), author) + .content("{\"version\":0,\"content\":\"第一次改\"}"), + "PATCH", "/api/v1/posts/{postId}", 200); + verifiedError(authed(patch("/api/v1/posts/{postId}", postId), author) + .content("{\"version\":0,\"content\":\"过期版本\"}"), + "PATCH", "/api/v1/posts/{postId}", 409, 40902); + + // -- 422/42203:引用本人 uploading asset(创建与编辑)-- + UUID uploading = CommunityTestData.insertAsset(jdbcClient, author, "uploading"); + verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """ + {"content":"未就绪媒体","media":[{"assetId":"%s"}]} + """.formatted(uploading)), + "POST", "/api/v1/posts", 422, 42203); + verifiedError(authed(patch("/api/v1/posts/{postId}", postId), author) + .content(""" + {"version":1,"media":[{"assetId":"%s"}]} + """.formatted(uploading)), + "PATCH", "/api/v1/posts/{postId}", 422, 42203); + + // -- 422/42204:自关注(仅 PUT;自取关 200 已在 Order(5) 语义内)-- + verifiedError(authed(put("/api/v1/users/{userId}/follow", self), self), + "PUT", "/api/v1/users/{userId}/follow", 422, 42204); + } + + // ---- 快照与覆盖门禁 ------------------------------------------------- + + /** + * 冻结快照守卫:与 pet/auth 侧同一纪律——正典契约升版时必须同步复制新快照 + * 并更新期望值,忘记同步在 CI 立即变红。 + */ + @Test + @Order(98) + void frozenSnapshotIsTheExpectedContractVersion() { + assertThat(CONTRACT.version()).isEqualTo("1.3.0"); + assertThat(CONTRACT.paths()).hasSize(31); + assertThat(CONTRACT.operations()).hasSize(43); + assertThat(CONTRACT.schemas()).hasSize(72); + assertThat(CONTRACT.operationsTagged( + Set.of("posts", "feed", "comments", "interactions", "follows"))) + .containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS); + } + + /** + * 全矩阵覆盖门禁:community 域 17 个操作声明的每个 (操作, 状态码) 都必须被 + * 前面的测试真实触发并通过契约校验(64 个单元格,无豁免)。 + */ + @Test + @Order(99) + void everyDeclaredResponseCellIsExercised() { + List missing = new ArrayList<>(); + for (String op : COMMUNITY_OPERATIONS) { + for (int status : CONTRACT.responseStatuses(op)) { + String cell = op + " " + status; + if (!COVERED.contains(cell)) { + missing.add(cell); + } + } + } + assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty(); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/contract/ContractValidator.java b/patbond-community/src/test/java/com/patbond/patbond/community/contract/ContractValidator.java new file mode 100644 index 0000000..dc18daa --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/contract/ContractValidator.java @@ -0,0 +1,256 @@ +package com.patbond.patbond.community.contract; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.patbond.patbond.community.contract.OpenApiContract.cast; +import static com.patbond.patbond.community.contract.OpenApiContract.list; +import static com.patbond.patbond.community.contract.OpenApiContract.map; + +/** + * Validates an actual HTTP response against the frozen contract, strictly: + * + *

    + *
  • the operation and the status must be declared;
  • + *
  • required fields must be present; a null value needs {@code nullable};
  • + *
  • fields the schema does not declare are rejected (this is what catches + * a renamed or newly leaked field — plain OpenAPI semantics would allow + * extra properties, but the frozen contract is "exactly these fields");
  • + *
  • types, enum membership, uuid / date-time / date formats and + * min/max(Length) bounds are checked.
  • + *
+ * + * Behavioural semantics (state machines, anti-enumeration, permission logic) + * stay with the existing integration tests — this class only pins structure. + */ +final class ContractValidator { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final OpenApiContract contract; + + ContractValidator(OpenApiContract contract) { + this.contract = contract; + } + + /** + * @return drift findings, empty when the response conforms; each entry is + * a human-readable "where: what" line + */ + List validateResponse(String method, String pathTemplate, int status, String body) { + List errors = new ArrayList<>(); + String opKey = method + " " + pathTemplate; + Map op = contract.operation(opKey); + if (op == null) { + errors.add("契约未声明该操作: " + opKey); + return errors; + } + Object respNode = map(op, "responses").get(String.valueOf(status)); + if (respNode == null) { + errors.add("契约未为 " + opKey + " 声明状态码 " + status); + return errors; + } + Map content = map(contract.resolve(cast(respNode)), "content"); + if (content == null) { + return errors; // response declared without a body + } + Map schema = map(map(content, "application/json"), "schema"); + if (schema == null) { + errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema"); + return errors; + } + JsonNode node; + try { + node = MAPPER.readTree(body); + } catch (JsonProcessingException e) { + errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage()); + return errors; + } + validate(schema, node, "$", errors); + return errors; + } + + private void validate(Map rawSchema, JsonNode node, String loc, List errors) { + Map schema = effectiveSchema(rawSchema); + if (node == null || node.isMissingNode()) { + errors.add(loc + ": 字段缺失"); + return; + } + if (node.isNull()) { + if (!Boolean.TRUE.equals(schema.get("nullable"))) { + errors.add(loc + ": 为 null,但契约未声明 nullable"); + } + return; + } + List allowed = list(schema, "enum"); + if (allowed != null && !enumMatches(allowed, node)) { + errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + " 内"); + } + String type = (String) schema.get("type"); + if (type == null) { + type = schema.containsKey("properties") ? "object" : null; + } + if (type == null) { + return; + } + switch (type) { + case "object" -> validateObject(schema, node, loc, errors); + case "array" -> validateArray(schema, node, loc, errors); + case "string" -> validateString(schema, node, loc, errors); + case "integer" -> { + if (!node.isIntegralNumber()) { + errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node); + } else { + checkRange(schema, node.decimalValue(), loc, errors); + } + } + case "number" -> { + if (!node.isNumber()) { + errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node); + } else { + checkRange(schema, node.decimalValue(), loc, errors); + } + } + case "boolean" -> { + if (!node.isBoolean()) { + errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node); + } + } + default -> errors.add(loc + ": 契约测试不支持的 type " + type); + } + } + + /** + * Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]} + * pattern into one plain schema (branch keys first, sibling keys — e.g. + * the outer {@code nullable} — win). The frozen contract only ever uses + * single-branch allOf, so a shallow merge is exact; overlapping + * {@code properties} across branches would need a deep merge and are not + * supported. + */ + private Map effectiveSchema(Map rawSchema) { + Map schema = contract.resolve(rawSchema); + List allOf = list(schema, "allOf"); + if (allOf == null) { + return schema; + } + Map merged = new LinkedHashMap<>(); + for (Object branch : allOf) { + merged.putAll(effectiveSchema(cast(branch))); + } + schema.forEach((key, value) -> { + if (!"allOf".equals(key)) { + merged.put(key, value); + } + }); + return merged; + } + + private void validateObject(Map schema, JsonNode node, String loc, List errors) { + if (!node.isObject()) { + errors.add(loc + ": 应为 object,实际 " + node.getNodeType()); + return; + } + Map props = map(schema, "properties"); + List required = list(schema, "required"); + if (required != null) { + for (Object r : required) { + if (!node.has((String) r)) { + errors.add(loc + "." + r + ": 契约必填字段缺失"); + } + } + } + Object additional = schema.get("additionalProperties"); + boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map; + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + Map propSchema = props == null ? null : cast(props.get(field.getKey())); + if (propSchema != null) { + validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors); + } else if (!open) { + errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)"); + } + } + } + + private void validateArray(Map schema, JsonNode node, String loc, List errors) { + if (!node.isArray()) { + errors.add(loc + ": 应为 array,实际 " + node.getNodeType()); + return; + } + Map items = map(schema, "items"); + if (items == null) { + return; + } + int i = 0; + for (JsonNode element : node) { + validate(items, element, loc + "[" + i++ + "]", errors); + } + } + + private void validateString(Map schema, JsonNode node, String loc, List errors) { + if (!node.isTextual()) { + errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node); + return; + } + String value = node.asText(); + String format = (String) schema.get("format"); + if (format != null) { + try { + switch (format) { + case "uuid" -> { + if (value.length() != 36) { + throw new IllegalArgumentException("非规范 UUID 长度"); + } + java.util.UUID.fromString(value); + } + case "date-time" -> OffsetDateTime.parse(value); + case "date" -> LocalDate.parse(value); + default -> { /* password 等纯标注格式不校验 */ } + } + } catch (IllegalArgumentException | DateTimeParseException e) { + errors.add(loc + ": \"" + value + "\" 不符合 format=" + format); + } + } + if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) { + errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min); + } + if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) { + errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max); + } + } + + private static void checkRange(Map schema, BigDecimal value, String loc, List errors) { + if (schema.get("minimum") instanceof Number min + && value.compareTo(new BigDecimal(min.toString())) < 0) { + errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min); + } + if (schema.get("maximum") instanceof Number max + && value.compareTo(new BigDecimal(max.toString())) > 0) { + errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max); + } + } + + private static boolean enumMatches(List allowed, JsonNode node) { + if (node.isTextual()) { + return allowed.contains(node.asText()); + } + if (node.isIntegralNumber()) { + long v = node.longValue(); + return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v); + } + return false; + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java b/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java new file mode 100644 index 0000000..7e6a966 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java @@ -0,0 +1,151 @@ +package com.patbond.patbond.community.contract; + +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.3.0.yaml}. + * + *

Sync discipline (T2-09, extended by T3-19): the canonical + * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this + * snapshot is a byte-identical copy taken at freeze time, and this class is + * the module-local copy of the pet module's contract framework (same + * per-module duplication discipline as BearerAuthFilter). Whenever the + * canonical contract changes, copy it into every framework-carrying module + * (patbond-pet / patbond-auth / patbond-community / patbond-user) under the + * new version's file name and update each conformance test (expected version + * + snapshot counts). The guard test on {@code info.version} makes a forgotten + * sync fail loudly in CI instead of silently testing against a stale + * contract. + * + *

Only the subset of OpenAPI 3.0 this contract actually uses is supported: + * local {@code #/} refs, plain types, {@code nullable}, {@code enum}, + * {@code required}, {@code properties}, {@code items}, and the v1.3.0 + * single-branch {@code nullable + allOf: [$ref]} pattern (merged in + * {@link ContractValidator}) — no oneOf/anyOf. + */ +final class OpenApiContract { + + static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; + + private static final Set HTTP_METHODS = + Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); + + private final Map root; + + private OpenApiContract(Map root) { + this.root = root; + } + + static OpenApiContract load() { + try (InputStream in = Objects.requireNonNull( + OpenApiContract.class.getResourceAsStream(RESOURCE), + "契约快照缺失: " + RESOURCE)) { + return new OpenApiContract(new Yaml().load(in)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + String version() { + return (String) map(root, "info").get("version"); + } + + Map paths() { + return map(root, "paths"); + } + + Map schemas() { + return map(map(root, "components"), "schemas"); + } + + /** All declared operations as "METHOD pathTemplate" (insertion order). */ + Set operations() { + Set ops = new LinkedHashSet<>(); + paths().forEach((path, item) -> cast(item).forEach((method, op) -> { + if (HTTP_METHODS.contains(method)) { + ops.add(method.toUpperCase(Locale.ROOT) + " " + path); + } + })); + return ops; + } + + /** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */ + Set operationsTagged(Set tags) { + Set ops = new LinkedHashSet<>(); + for (String key : operations()) { + List opTags = list(operation(key), "tags"); + if (opTags != null && opTags.stream().anyMatch(tags::contains)) { + ops.add(key); + } + } + return ops; + } + + /** Declared response statuses of an operation, as ints. */ + Set responseStatuses(String operationKey) { + Set statuses = new LinkedHashSet<>(); + map(operation(operationKey), "responses") + .keySet().forEach(s -> statuses.add(Integer.parseInt(s))); + return statuses; + } + + /** The single 2xx status the operation declares. */ + int successStatus(String operationKey) { + return responseStatuses(operationKey).stream() + .filter(s -> s >= 200 && s < 300) + .reduce((a, b) -> { + throw new IllegalStateException("多个 2xx 响应: " + operationKey); + }) + .orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey)); + } + + /** Operation object for "METHOD pathTemplate", or null when undeclared. */ + Map operation(String operationKey) { + String[] parts = operationKey.split(" ", 2); + Map pathItem = map(paths(), parts[1]); + return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT)); + } + + /** Follows local $ref chains; non-ref maps come back unchanged. */ + Map resolve(Map node) { + while (node != null && node.get("$ref") instanceof String ref) { + if (!ref.startsWith("#/")) { + throw new IllegalStateException("仅支持本地 $ref: " + ref); + } + Map cur = root; + for (String seg : ref.substring(2).split("/")) { + cur = map(cur, seg); + if (cur == null) { + throw new IllegalStateException("$ref 指向不存在的节点: " + ref); + } + } + node = cur; + } + return node; + } + + @SuppressWarnings("unchecked") + static Map cast(Object o) { + return (Map) o; + } + + static Map map(Map m, String key) { + return m == null ? null : cast(m.get(key)); + } + + @SuppressWarnings("unchecked") + static List list(Map m, String key) { + return m == null ? null : (List) m.get(key); + } +} diff --git a/patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml b/patbond-community/src/test/resources/contract/openapi-v1.3.0.yaml similarity index 60% rename from patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml rename to patbond-community/src/test/resources/contract/openapi-v1.3.0.yaml index 1ecb5a9..c9d3072 100644 --- a/patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml +++ b/patbond-community/src/test/resources/contract/openapi-v1.3.0.yaml @@ -1,13 +1,16 @@ openapi: 3.0.3 info: - title: Patbond API — Auth / Me / Events / Pets(公开契约) - version: 1.2.0 + title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) + version: 1.3.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 **1.2.0 M2 契约冻结:pets 域 12 路径**(宠物 CRUD、品种/疫苗目录、体重记录、疫苗记录、 健康事件、照护提醒、档案聚合摘要)按第二波已定型实现合入 (iteration-2 报告 13/16/17/18 定型表;冻结报告见 iteration-2/19)。 + **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 + 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 + (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -27,16 +30,25 @@ info: | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40400 | 404 | 资源不存在 | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | + | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | + | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | | 40902 | 409 | VERSION_CONFLICT:乐观锁版本冲突(PATCH 提交的 version 过期);照护提醒流转的状态守卫落空复用此码 | | 40903 | 409 | MICROCHIP_EXISTS:芯片号已被登记(uq_pets_microchip,跨用户唯一) | | 40904 | 409 | VACCINATION_DOSE_EXISTS:同宠物同疫苗同系列同剂次已有非 cancelled 记录(uq_pet_vaccination_dose) | + | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | + | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | | 50000 | 500 | 服务器内部错误 | | 50300 | 503 | 依赖服务暂不可用 | @@ -84,13 +96,45 @@ info: 「新增可选字段」纯增量补入。软删除端点不在 M2 契约(D2-7:首版仅归档 `status=archived`);`DELETE /api/v1/pets/{petId}` 未收录。 + ## Community / Media 域约定(M3 冻结,iteration-3 报告 13/15/16/17 定型) + - **鉴权**:全部端点强制 Bearer 鉴权,无匿名端点。帖子/Feed/评论/互动/关注在 + patbond-community(:8084),媒体上传两步流程在 patbond-user(:8082)。 + - **幂等按域(ADR-019,与 pets 域刻意不同,两域并存、pets 不回改)**:创建型写入 + (发帖/评论)`Idempotency-Key` **必带**(1~128 字符,trim 后计;缺失/空白/超长 + 400/40000),键按作者隔离,落表内幂等列并**比对规范化 request_hash**——hash 对象 + 是规范化后的创建命令(trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源; + 同键同 payload 返回首次创建的资源(同样 201);同键不同 payload 409/40905; + 同键重试撞已删除的首个资源 404(帖子 40403 / 评论 40404)。 + - **二元互动语义幂等**:点赞/收藏/关注用 PUT/DELETE,复合主键即幂等键(无键管理), + 重复调用返回 200 同一**权威终态**(`{liked, likeCount}` 族);客户端乐观更新以 + 响应对账回滚(回滚基准取响应值而非本地推算)。 + - **媒体两步上传(ADR-016)**:创建上传(登记 asset + 签发预签名 PUT 直传凭据, + TTL 10 分钟,配置项)→ 客户端直传(原样携带 requiredHeaders)→ complete 确认 + (服务端 HEAD 校验后 uploading→ready)。桶保持私有:**一切媒体读取 URL + (asset/帖图/头像)均为时效性预签名 GET URL**(TTL 默认 1 小时,配置项),由 + 服务端每次响应现签;客户端不得持久化 URL,过期即重取。 + - **防枚举 404**:一切不可见情形按资源合并给码(帖子 40403、评论 40404、asset + 40405、用户 40406),同码各情形响应完全一致;403/40301 只发给对资源「可见但 + 无权」的调用者,不泄露新信息。 + - **互动面 = 帖子公开面**:评论(读写删)与点赞/收藏只对 published 且未删的帖子 + 开放,**作者本人的草稿在互动路径同样 404/40403**——可见性回答「能不能看」, + 互动门禁回答「能不能社交」。 + - **列表分页**:全部列表复用 cursor 分页正典 `{items, nextCursor, hasMore}` + (`limit` 1~100 缺省 20),各列表排序键在端点描述中写死。 + - **ADR-018 裁剪**:话题全部端点、关注/粉丝**列表**(最小接口仅 follow/unfollow + + 计数)、作者主页帖子列表、`region`/`generationJob`/`visibility=followers|private` + 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 + (如作者公开资料批量接口)不属于本公开契约。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) - url: http://127.0.0.1:8082 - description: patbond-user(本地开发,/api/v1/me、/api/v1/events) + description: patbond-user(本地开发,/api/v1/me、/api/v1/events、/api/v1/media/**) - url: http://127.0.0.1:8083 description: patbond-pet(本地开发,pets 域全部端点) + - url: http://127.0.0.1:8084 + description: patbond-community(本地开发,帖子/Feed/评论/互动/关注全部端点) tags: - name: auth @@ -105,6 +149,18 @@ tags: description: 品种与疫苗目录(只读字典,patbond-pet) - name: health-records description: 体重、疫苗、健康事件、照护提醒、档案摘要(patbond-pet) + - name: media + description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) + - 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/auth/register: @@ -1016,6 +1072,617 @@ paths: '404': $ref: '#/components/responses/PetNotFound' + # ====================================================================== + # Community / Media 域(M3 冻结,13 路径;定型依据:iteration-3 报告 13/15/16/17) + # ====================================================================== + + /api/v1/media/uploads: + post: + tags: [media] + summary: 创建上传(登记 asset 并签发预签名直传凭据) + description: | + 两步上传第一步:校验白名单与上限(`purpose` 仅 post_image、`mimeType` 仅 + image/jpeg|png|webp、`byteSize` ≤ 10485760,均为服务端配置项,后续扩展为 + 向后兼容的枚举追加)→ 写 `media.assets` 行(status=uploading,bucket/objectKey + 服务端生成、不含任何用户输入)→ 返回预签名 PUT 直传凭据(TTL 10 分钟,配置项)。 + 客户端凭凭据直传对象存储,不经应用服务器;直传必须**原样携带 requiredHeaders** + (Content-Type 已签进签名,改动即被存储侧拒绝)。M3 仅 `kind=image` + (ADR-018 视频后置;video/document 为向后新增枚举预留)。 + operationId: createMediaUpload + security: + - bearerAuth: [] + 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/Content-Type → + uploading→ready、写 readyAt,返回可引用的 asset(含现签预签名 GET URL)。 + + - **幂等**:对已 ready 的 asset 重复 complete 返回 200 同一 asset(现签新 GET URL)。 + - 对象尚不存在(直传完成前确认)→ 422/42205,asset **保持 uploading 可重试** + (补传后再确认即恢复,凭据未过期时无须重新创建上传)。 + - 对象存在但大小/类型与登记不符 → 置 failed(终态),422/42205,须重新创建上传。 + - failed 态再确认 → 422/42205(终态);不存在/非本人/已删 → 404/40405(防枚举合并)。 + - `sha256` 照收照存,M3 不做内容核验(存储侧 HEAD 不返回内容散列;后续经 + 存储侧 checksum 特性补齐,不改契约形态)。 + operationId: completeMediaUpload + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/AssetIdParam' + responses: + '200': + description: 确认成功(或幂等重复确认),asset 为 ready + content: + application/json: + schema: + $ref: '#/components/schemas/MediaAssetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/MediaNotFound' + '422': + description: | + asset 非 uploading 态或对象校验未通过(code 42205):对象未上传保持可重试、 + 大小/类型不符置 failed 终态、failed 态再确认(已 ready 幂等 200 除外) + 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` **必带**(语义见该头参数描述与 info「Community / Media 域 + 约定」)。`status` 可 draft(缺省)或 published(直接发布,服务端写 + publishedAt)。纯文字帖合法(media 空数组或缺席,D3-4)。 + + media 挂接(每帖 ≤9 图):只接受本人所有且 ready 的 asset(uploading/failed + 422/42203;不存在/非本人/已删 404/40405);`position` **全给或全不给**——全给 + 须恰为 0..n-1 连续不重复,全不给按数组序,混合 400/40000;`isCover` 至多一个 + true,全 false 时服务端将 position 0 行落库置为封面(库内恒有唯一封面行); + 同帖 assetId 不重复;caption trim 后 ≤300。 + `petId` 须为调用者可见宠物,否则 404/40401(沿 pets 域防枚举语义)。 + operationId: createPost + security: + - bearerAuth: [] + 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' + '404': + description: petId 引用不可见宠物(code 40401)或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + '422': + $ref: '#/components/responses/MediaNotReady' + + /api/v1/posts/{postId}: + get: + tags: [posts] + summary: 帖子详情 + description: | + 权限矩阵(iteration-3 报告 15 定型):published 对全部登录用户开放;draft 仅 + 作者可见;hidden/archived(运营态)**对作者同样 404/40403**——M3 无端点能产生 + 或解除运营态,status 枚举保持两值。一切不可见情形响应完全一致(防枚举)。 + 响应含 likedByMe/bookmarkedByMe 与作者公开摘要(AuthorSummary)。 + operationId: getPost + security: + - bearerAuth: [] + 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;一切不可见情形——含他人 draft——404/40403)。 + PATCH 部分更新惯例:缺席字段不变,不支持清空回 null(M2 先例)。`version` + 必带(缺失 400/40000,过期 409/40902)。 + + - **发布** = `status: published` 的状态迁移(draft→published 是唯一开放迁移, + 服务端写 publishedAt,恰写一次);**对已发布帖重复提交 `status: published` + 为幂等 no-op(200,version 照常 +1)**——同态提交不是迁移,弱网重发不报错; + draft/hidden/archived 目标值由请求枚举拒为 400/40000(published→draft 不支持)。 + - media 出现即**整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动), + 校验规则同创建。 + operationId: updatePost + security: + - bearerAuth: [] + 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': + description: | + 帖子不可见(code 40403,防枚举合并);或 petId 引用不可见宠物(code 40401); + 或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/VersionConflict' + '422': + $ref: '#/components/responses/MediaNotReady' + delete: + tags: [posts] + summary: 删除帖子(软删,仅作者) + description: | + 软删(deleted_at 为全域唯一删除判定基准),删除后详情/Feed/列表/互动一切路径 + 404/40403。重复删除与删不存在的帖同响应 404/40403(防枚举合并)。 + 不提供恢复端点(M3 无回收站)。非作者对已发布帖 403/40301。 + operationId: deletePost + security: + - bearerAuth: [] + 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(软删不含,hidden/archived 不含)。排序 + `(created_at DESC, id DESC)` 走 `ix_posts_author_created`,keyset 游标。 + `status` 过滤可选(draft|published)。 + operationId: listMyPosts + security: + - bearerAuth: [] + 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 帖子下一次请求即不可见。 + 卡片形态见 FeedCard(iteration-3 报告 16 定型);likedByMe/bookmarkedByMe + 为当前用户视角。 + operationId: getFeed + security: + - bearerAuth: [] + 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`,keyset 游标; + 仅 visible 评论。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。作者与 @ 目标均为 AuthorSummary。 + operationId: listComments + security: + - bearerAuth: [] + 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`(键按作者隔离、天然全局跨帖)。`replyToUserId` 可选 @ 回复 + (单层平铺,无楼中楼,ADR-018);目标须为存活用户,不存在/已注销 404/40406 + (合并不泄露成因)。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。content trim 后 1~2000。comment_count 同事务 +1。 + operationId: createComment + security: + - bearerAuth: [] + 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': + description: 帖子不可见——含作者本人草稿(code 40403);或 replyToUserId 目标用户不存在/已注销(code 40406) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + userNotFound: + value: { code: 40406, message: 用户不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + + /api/v1/comments/{commentId}: + delete: + tags: [comments] + summary: 删除评论(仅评论作者;顶层短路径) + description: | + 顶层短路径先例(pets 域子资源同理):commentId 全局唯一。**仅评论作者可删—— + 帖主不可删除他人评论(D3-7 首版不做)**:对可见评论的非作者(含帖主) + 403/40301;不存在/已删/所属帖不可见合并 404/40404(防枚举)。 + 软删(status→deleted),comment_count 同事务 -1。 + operationId: deleteComment + security: + - bearerAuth: [] + 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) 即幂等键:重复 PUT 返回 200 同一权威终态(非 409), + 仅实际插入才 like_count 同事务 +1,并发 N 次计数恰为 1(M3 验收标准二)。 + 互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**)404/40403。 + operationId: likePost + security: + - bearerAuth: [] + 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)。 + 帖子不可见(含作者本人草稿)404/40403。 + operationId: unlikePost + security: + - bearerAuth: [] + 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 语义幂等,与点赞同构) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: bookmarkPost + security: + - bearerAuth: [] + 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 语义幂等) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: unbookmarkPost + security: + - bearerAuth: [] + 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`,游标键在收藏关系行上。项形态 = FeedCard, + 谓词与公共 Feed 恒等:被收藏帖软删/hidden/archived 后**静默剔除**(剔除在页 + 查询内完成,不破坏翻页不丢不重;publishedAt 恒非空不变式对本列表继续成立)。 + operationId: listMyBookmarks + security: + - bearerAuth: [] + 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 与关注/粉丝列表不在 M3(ADR-018 最小数据接口)。 + operationId: followUser + security: + - bearerAuth: [] + 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;仅 PUT——自取关走 DELETE 的 200 幂等 no-op) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + selfFollow: + value: { code: 42204, message: 不能关注自己, data: null } + delete: + tags: [follows] + summary: 取消关注(DELETE 语义幂等) + description: | + 取消不存在的关注不报错,返回 200 权威终态(following 恒 false)。 + **自取关同样 200 幂等 no-op**(关系行不可能存在,权威 false 即事实;42204 + 只在 PUT)。目标用户不存在/已注销 404/40406。 + operationId: unfollowUser + security: + - bearerAuth: [] + 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 为调用者视角, + 查自己时恒 false。目标用户不存在/已注销 404/40406。关注/粉丝**列表**端点 + 不在 M3(需时按纯增量补入)。 + operationId: getFollowStats + security: + - bearerAuth: [] + 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: @@ -1061,6 +1728,46 @@ components: 可选幂等键(≤255 字符,超长 400/40000)。键按「调用者 × 宠物 × 资源」隔离; 同键重试返回首次创建的记录(同样 201);不比对请求体(每次逻辑提交应换新键, 建议 UUID);键永久幂等(无 TTL)。不带键则无幂等语义。 + 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 + IdempotencyKeyRequiredHeader: + name: Idempotency-Key + in: header + required: true + schema: + type: string + maxLength: 128 + description: | + **必带**幂等键(1~128 字符,trim 后计;缺失/空白/超长 400/40000。与 pets 域 + 「可选、≤255、不比对请求体」刻意不同——community 域按 ADR-019 落表内幂等列, + 列宽 128)。键按作者隔离(跨用户同键互不干扰);同键重试返回首次创建的资源 + (同样 201);**比对规范化 request_hash**——hash 对象是规范化后的创建命令 + (trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源;同键不同 payload + 返回 409/40905;同键重试撞已删除的首个资源返回 404(帖子 40403 / 评论 40404, + 资源已消亡,不复活不另建)。客户端每次逻辑提交换新键(建议 UUID), + 重试间保持不变。 responses: ValidationError: @@ -1125,6 +1832,76 @@ components: examples: versionConflict: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + PostNotFound: + description: | + 帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(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(uploading/failed)状态的 asset(code 42203)。 + asset 不存在/非本人/已删则合并为 404/40405。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + notReady: + value: { code: 42203, message: 媒体尚未就绪, data: null } schemas: RegisterRequest: @@ -2383,3 +3160,700 @@ components: example: success data: $ref: '#/components/schemas/PetSummary' + + # ================================================================== + # Community / Media 域 schemas(M3 冻结;定型依据:iteration-3 报告 13/15/16/17) + # ================================================================== + + AuthorSummary: + type: object + description: | + 作者公开摘要(D3-9 方案 B,iteration-3 报告 16 定型;community 跨 schema + 只读 identity 取数,ADR-017)。正常路径 nickname 恒非空——空昵称由服务端 + 回退为 username(客户端不做回退拼装,回退后的展示名不标注来源); + nickname 与 avatarUrl 同为 null 即「降级/墓碑」形态(作者资料暂不可得, + 或用户已注销)——两种情形同一形态,客户端只需一种占位逻辑。 + 不露 bio、不露 username。 + required: [userId] + properties: + userId: + type: string + format: uuid + description: 恒非空,任何情形都在 + nickname: + type: string + nullable: true + maxLength: 32 + description: 昵称(空昵称已由服务端回退为 username);null 仅出现在降级/注销墓碑形态 + example: 毛毛的铲屎官 + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取;无头像 / 头像 asset 非 ready / 降级 → null + (客户端出占位) + + # ---------- 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: | + 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 + user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + mimeType: + type: string + enum: [image/jpeg, image/png, image/webp] + description: 白名单外 400/40000;不收 HEIC(客户端压缩管线统一转码 jpeg) + byteSize: + type: integer + format: int64 + minimum: 1 + maximum: 10485760 + description: 声明的文件字节数,complete 时与对象实测比对;上限 10485760(10 MiB,服务端配置项) + sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: 可选,64 位小写 hex;照收照存,M3 不做内容核验(后续经存储侧 checksum 特性补齐,不改契约形态) + + MediaUploadCredentials: + type: object + description: 预签名直传凭据(ADR-016,iteration-3 报告 13 定型) + required: [assetId, uploadUrl, method, requiredHeaders, expiresAt] + properties: + assetId: + type: string + format: uuid + description: 已登记的 asset ID(status=uploading) + uploadUrl: + type: string + description: | + 预签名 PUT 完整 URL——签名以 query 参数携带(X-Amz-Algorithm/-Credential/ + -Signature 族),指向客户端可达的对象存储端点;客户端直传,不经应用服务器 + method: + type: string + enum: [PUT] + requiredHeaders: + type: object + additionalProperties: + type: string + description: | + 直传请求必须**原样携带**的头。键集定型为恒且仅一键: + `{"Content-Type": <声明的 mimeType>}`——Content-Type 已签进签名, + 改动即被存储侧拒绝 + expiresAt: + type: string + format: date-time + description: | + 凭据过期时刻 = 签发时刻 + TTL(默认 10 分钟,配置项);过期后重新创建 + 上传(原 asset 在补传后仍可确认) + + 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 + description: complete 后回填,可空 + heightPx: + type: integer + nullable: true + status: + type: string + enum: [uploading, ready, failed] + description: deleted 态对外恒 404/40405,不出现在响应 + url: + type: string + nullable: true + description: | + 访问 URL,仅 ready 态非空——时效性预签名 GET(TTL 默认 1 小时,配置项), + 每次响应现签,客户端不得持久化、过期即重取;桶保持私有,无签名直访被拒 + 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 + description: 库内恒有唯一封面行(写侧保证:全 false 时服务端将 position 0 行置真) + url: + type: string + description: | + 图片访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取(运维前提:生产环境对象存储恒配置) + 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(不存在/非本人/已删 404/40405) + required: [assetId] + properties: + assetId: + type: string + format: uuid + description: 同帖 assetId 不得重复(400/40000) + position: + type: integer + minimum: 0 + maximum: 8 + description: | + **全给或全不给**:全给须恰为 0..n-1 连续不重复;全不给按数组序; + 混合 400/40000 + isCover: + type: boolean + default: false + description: 至多一个 true(uq_post_media_cover);全 false 时服务端将 position 0 行落库置为封面 + caption: + type: string + maxLength: 300 + description: trim 后 ≤300 + + CreatePostRequest: + type: object + required: [content] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + description: 可选标题(ck_posts_title;空白串 400/40000) + 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 不开放写入(提交 400/40000) + 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 + description: | + 部分更新:缺席字段不变;不支持清空回 null(M2 惯例)。media 若出现则 + **整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动),校验规则同创建。 + required: [version] + properties: + version: + type: integer + minimum: 0 + description: 乐观锁,必带(缺失 400/40000);过期 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(发布动作,服务端写 publishedAt); + 对已发布帖重复提交为幂等 no-op(200,version 照常 +1); + draft/hidden/archived 目标值 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/40403 + (M3 无端点能产生或解除运营态) + 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 + description: | + 行最后更新时刻——互动计数维护亦会推动该值;判断「内容是否编辑过」 + 以 version 为准,勿以 updatedAt 判断 + publishedAt: + type: string + format: date-time + nullable: true + description: 仅 published 非空(发布时恰写一次) + version: + type: integer + + FeedCard: + type: object + description: | + Feed / 收藏列表卡片形态(较 Post 裁剪,iteration-3 报告 16 定型:只带 + coverImage + mediaCount,不带整组图;content 全文、petId、visibility、 + version、media 整组、created/updated 时间戳对均不出现,全文走帖子详情)。 + 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 + description: 原样透传,无标题为 null + contentPreview: + type: string + description: | + 正文前 200 个 Unicode 码点,**码点边界截断**(emoji 等增补面字符绝不 + 劈开),不追加省略号;短于 200 码点原样透传。全文恒走帖子详情端点 + coverImage: + nullable: true + allOf: + - $ref: '#/components/schemas/PostMediaItem' + description: | + 封面图 = 库中唯一 is_cover 行(写侧保证有图必有唯一封面行,读侧零特判); + 纯文字帖为 null + mediaCount: + type: integer + minimum: 0 + maximum: 9 + 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 + description: 恒非空(Feed 与收藏列表谓词只放行 published) + + 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: trim 后 1~2000(ck_comments_content 同宽) + replyToUserId: + type: string + format: uuid + description: | + 可选 @ 回复目标(单层平铺,无 parentCommentId,ADR-018); + 目标须为存活用户,不存在/已注销 404/40406 + + Comment: + type: object + description: M3 无评论编辑,不带 updatedAt + 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: '@ 回复目标的公开摘要(含降级 id-only 形态);非回复为 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 为目标用户的粉丝数(实时 COUNT) + 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: 目标用户的粉丝数(实时 COUNT) + followingCount: + type: integer + format: int64 + description: 目标用户关注的人数(实时 COUNT) + 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' diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java index 7475198..45d5c41 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java @@ -27,8 +27,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; /** - * T2-09 契约一致性保障:对冻结契约 v1.2.0(快照 - * {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓 + * T2-09 契约一致性保障:对冻结契约 v1.3.0(快照 + * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 * {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求, * 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、 * 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 @@ -703,10 +703,10 @@ class ContractConformanceTest extends PetIntegrationTestSupport { @Test @Order(98) void frozenSnapshotIsTheExpectedContractVersion() { - assertThat(CONTRACT.version()).isEqualTo("1.2.0"); - assertThat(CONTRACT.paths()).hasSize(18); - assertThat(CONTRACT.operations()).hasSize(24); - assertThat(CONTRACT.schemas()).hasSize(45); + assertThat(CONTRACT.version()).isEqualTo("1.3.0"); + assertThat(CONTRACT.paths()).hasSize(31); + assertThat(CONTRACT.operations()).hasSize(43); + assertThat(CONTRACT.schemas()).hasSize(72); assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records"))) .containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS); } diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java index e46a18e..0cccf92 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java @@ -10,6 +10,7 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -81,7 +82,7 @@ final class ContractValidator { } private void validate(Map rawSchema, JsonNode node, String loc, List errors) { - Map schema = contract.resolve(rawSchema); + Map schema = effectiveSchema(rawSchema); if (node == null || node.isMissingNode()) { errors.add(loc + ": 字段缺失"); return; @@ -130,6 +131,32 @@ final class ContractValidator { } } + /** + * Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]} + * pattern into one plain schema (branch keys first, sibling keys — e.g. + * the outer {@code nullable} — win). The frozen contract only ever uses + * single-branch allOf, so a shallow merge is exact; overlapping + * {@code properties} across branches would need a deep merge and are not + * supported. + */ + private Map effectiveSchema(Map rawSchema) { + Map schema = contract.resolve(rawSchema); + List allOf = list(schema, "allOf"); + if (allOf == null) { + return schema; + } + Map merged = new LinkedHashMap<>(); + for (Object branch : allOf) { + merged.putAll(effectiveSchema(cast(branch))); + } + schema.forEach((key, value) -> { + if (!"allOf".equals(key)) { + merged.put(key, value); + } + }); + return merged; + } + private void validateObject(Map schema, JsonNode node, String loc, List errors) { if (!node.isObject()) { errors.add(loc + ": 应为 object,实际 " + node.getNodeType()); diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java index 93afb9c..ca302d4 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java @@ -13,8 +13,8 @@ import java.util.Objects; import java.util.Set; /** - * The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot - * {@code /contract/openapi-v1.2.0.yaml}. + * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.3.0.yaml}. * *

Sync discipline (T2-09): the canonical contract lives in the doc * repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical @@ -26,11 +26,13 @@ import java.util.Set; * *

Only the subset of OpenAPI 3.0 this contract actually uses is supported: * local {@code #/} refs, plain types, {@code nullable}, {@code enum}, - * {@code required}, {@code properties}, {@code items} — no allOf/oneOf. + * {@code required}, {@code properties}, {@code items}, and the v1.3.0 + * single-branch {@code nullable + allOf: [$ref]} pattern (merged in + * {@link ContractValidator}) — no oneOf/anyOf. */ final class OpenApiContract { - static final String RESOURCE = "/contract/openapi-v1.2.0.yaml"; + static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; private static final Set HTTP_METHODS = Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); diff --git a/patbond-pet/src/test/resources/contract/openapi-v1.3.0.yaml b/patbond-pet/src/test/resources/contract/openapi-v1.3.0.yaml new file mode 100644 index 0000000..c9d3072 --- /dev/null +++ b/patbond-pet/src/test/resources/contract/openapi-v1.3.0.yaml @@ -0,0 +1,3859 @@ +openapi: 3.0.3 +info: + title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) + version: 1.3.0 + description: | + Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), + 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 + **1.2.0 M2 契约冻结:pets 域 12 路径**(宠物 CRUD、品种/疫苗目录、体重记录、疫苗记录、 + 健康事件、照护提醒、档案聚合摘要)按第二波已定型实现合入 + (iteration-2 报告 13/16/17/18 定型表;冻结报告见 iteration-2/19)。 + **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 + 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 + (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + + ## 通用约定(development-plan 第 6 节) + - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 + - 所有时间字段为 ISO 8601 且带时区偏移(如 `2026-09-04T04:05:06.789Z`); + 纯日期字段(生日、接种日期等)为 `YYYY-MM-DD`。 + - 统一响应信封 `{"code": 0, "message": "success", "data": …}`;错误同时携带正确的 + HTTP 状态码与稳定业务码,业务码永不复用或改号。 + - `/internal/**` 为服务间接口,不属于本公开契约,需 `X-Internal-Token` 服务凭证, + 未携带或错误一律 401。 + + ## 错误码表 + | 业务码 | HTTP | 场景 | + | --- | --- | --- | + | 0 | 200 | 成功 | + | 40000 | 400 | 参数校验失败(含 JSON 不可解析;message 为首个字段错误) | + | 40100 | 401 | 用户名或密码错误 | + | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | + | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | + | 40400 | 404 | 资源不存在 | + | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | + | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | + | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | + | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | + | 40900 | 409 | 用户名已存在(大小写不敏感) | + | 40901 | 409 | 手机号已被使用 | + | 40902 | 409 | VERSION_CONFLICT:乐观锁版本冲突(PATCH 提交的 version 过期);照护提醒流转的状态守卫落空复用此码 | + | 40903 | 409 | MICROCHIP_EXISTS:芯片号已被登记(uq_pets_microchip,跨用户唯一) | + | 40904 | 409 | VACCINATION_DOSE_EXISTS:同宠物同疫苗同系列同剂次已有非 cancelled 记录(uq_pet_vaccination_dose) | + | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | + | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | + | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | + | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | + | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | + | 50000 | 500 | 服务器内部错误 | + | 50300 | 503 | 依赖服务暂不可用 | + + ## 会话模型(ADR-003,数值均为服务端配置项) + - access token:JWT(RS256),有效期 15 分钟;由资源服务用公钥本地验签。 + - refresh token:不透明随机串,有效期 30 天;**每次刷新即轮换**,旧值立即失效。 + - 已轮换/已失效的 refresh token 再次被使用时,判定为重用,**整个 token family + (该登录会话链)全部撤销**,持有者需重新登录。 + - 允许多设备并行会话;退出仅撤销当前会话(由所提交的 refreshToken 标识), + 其他设备不受影响。已签发的 access token 在剩余有效期内仍可用。 + - 登录失败限制:同一账号在 15 分钟窗口内密码错误累计 5 次(配置项),账号锁定 + 15 分钟;锁定期间即使密码正确也返回 423/42300;一次成功登录重置计数窗口。 + + ## Pets 域约定(M2 冻结,iteration-2 报告 13/16/17/18 定型) + - **鉴权**:pets 域全部端点强制 Bearer 鉴权,无匿名端点。 + - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: + - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; + - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; + - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + 权限每请求实时查库、无缓存:撤销照护关系立即生效。 + - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 + 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 + 「记录所属宠物对调用者不可见」响应完全一致(404/40402)。403/40300 只可能发给 + 「对宠物可见但角色不覆盖该操作」的调用者,不泄露新信息。 + - **PATCH 一律部分更新**:缺席字段不变;**M2 不支持将可选字段清空回 null** + (null-vs-absent 歧义挡在契约外)。pets / vaccinations / health-events 的 PATCH + 必须携带 `version` 乐观锁字段(缺失 400/40000,过期 409/40902,比对通过才写入并 +1)。 + - **子资源 PATCH 走顶层短路径**(`/api/v1/vaccinations/{id}` 等):记录 ID 全局唯一 + (UUID),短路径避免 path petId 与记录归属不一致的报错歧义。 + - **创建操作返回 201**(pets 域新约定;既有 auth 端点维持 200 不追改)。 + - **cursor 分页正典(全 API 唯一分页形态)**:响应 `data: {items, nextCursor, hasMore}`; + `limit` 1~100 缺省 20;`cursor` 传上一页返回的 `nextCursor`(不透明字符串,客户端不得 + 解析),首页不传;`hasMore=false` 时 `nextCursor` 恒为 null。体重与健康事件列表采用; + 疫苗列表(`series_key, dose_no, created_at, id` 排序)与提醒列表(`due_at ASC, id` + 排序 + `status` 过滤)量级小,不分页。 + - **幂等(可选 `Idempotency-Key` 头,≤255 字符)**:weights / vaccinations / + health-events / care-reminders 四个 POST 支持。键按「调用者 × 宠物 × 资源」隔离, + 两个用户的同名键不互斥;同键重试返回首次创建的记录(同样 201);**不比对请求体** + (客户端每次逻辑提交应换新键,建议 UUID);键永久幂等(无 TTL)。不带键则无幂等 + 语义,重复提交各自成行(疫苗由剂次唯一约束兜底 40904)。pets 的写接口不用幂等键, + 重试安全由乐观锁与唯一约束兜底。 + - **ADR-010 裁剪**:`avatarAssetId`、`certificateAssetId`、`providerId`、 + `providerNameSnapshot`、`bookingId` 等字段整体不出现(响应与请求皆无),M5+ 按 + 「新增可选字段」纯增量补入。软删除端点不在 M2 契约(D2-7:首版仅归档 + `status=archived`);`DELETE /api/v1/pets/{petId}` 未收录。 + + ## Community / Media 域约定(M3 冻结,iteration-3 报告 13/15/16/17 定型) + - **鉴权**:全部端点强制 Bearer 鉴权,无匿名端点。帖子/Feed/评论/互动/关注在 + patbond-community(:8084),媒体上传两步流程在 patbond-user(:8082)。 + - **幂等按域(ADR-019,与 pets 域刻意不同,两域并存、pets 不回改)**:创建型写入 + (发帖/评论)`Idempotency-Key` **必带**(1~128 字符,trim 后计;缺失/空白/超长 + 400/40000),键按作者隔离,落表内幂等列并**比对规范化 request_hash**——hash 对象 + 是规范化后的创建命令(trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源; + 同键同 payload 返回首次创建的资源(同样 201);同键不同 payload 409/40905; + 同键重试撞已删除的首个资源 404(帖子 40403 / 评论 40404)。 + - **二元互动语义幂等**:点赞/收藏/关注用 PUT/DELETE,复合主键即幂等键(无键管理), + 重复调用返回 200 同一**权威终态**(`{liked, likeCount}` 族);客户端乐观更新以 + 响应对账回滚(回滚基准取响应值而非本地推算)。 + - **媒体两步上传(ADR-016)**:创建上传(登记 asset + 签发预签名 PUT 直传凭据, + TTL 10 分钟,配置项)→ 客户端直传(原样携带 requiredHeaders)→ complete 确认 + (服务端 HEAD 校验后 uploading→ready)。桶保持私有:**一切媒体读取 URL + (asset/帖图/头像)均为时效性预签名 GET URL**(TTL 默认 1 小时,配置项),由 + 服务端每次响应现签;客户端不得持久化 URL,过期即重取。 + - **防枚举 404**:一切不可见情形按资源合并给码(帖子 40403、评论 40404、asset + 40405、用户 40406),同码各情形响应完全一致;403/40301 只发给对资源「可见但 + 无权」的调用者,不泄露新信息。 + - **互动面 = 帖子公开面**:评论(读写删)与点赞/收藏只对 published 且未删的帖子 + 开放,**作者本人的草稿在互动路径同样 404/40403**——可见性回答「能不能看」, + 互动门禁回答「能不能社交」。 + - **列表分页**:全部列表复用 cursor 分页正典 `{items, nextCursor, hasMore}` + (`limit` 1~100 缺省 20),各列表排序键在端点描述中写死。 + - **ADR-018 裁剪**:话题全部端点、关注/粉丝**列表**(最小接口仅 follow/unfollow + + 计数)、作者主页帖子列表、`region`/`generationJob`/`visibility=followers|private` + 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 + (如作者公开资料批量接口)不属于本公开契约。 + +servers: + - url: http://127.0.0.1:8081 + description: patbond-auth(本地开发,/api/v1/auth/**) + - url: http://127.0.0.1:8082 + description: patbond-user(本地开发,/api/v1/me、/api/v1/events、/api/v1/media/**) + - url: http://127.0.0.1:8083 + description: patbond-pet(本地开发,pets 域全部端点) + - url: http://127.0.0.1:8084 + description: patbond-community(本地开发,帖子/Feed/评论/互动/关注全部端点) + +tags: + - name: auth + description: 注册 / 登录 / 刷新 / 退出(patbond-auth) + - name: user + description: 当前用户(patbond-user) + - name: analytics + description: 产品事件批量上报(patbond-user) + - name: pets + description: 宠物档案 CRUD(patbond-pet) + - name: dictionaries + description: 品种与疫苗目录(只读字典,patbond-pet) + - name: health-records + description: 体重、疫苗、健康事件、照护提醒、档案摘要(patbond-pet) + - name: media + description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) + - 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/auth/register: + post: + tags: [auth] + summary: 注册并创建会话 + operationId: register + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '200': + description: 注册成功,返回令牌对 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '409': + description: 用户名或手机号已被占用(code 40900 / 40901) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + usernameTaken: + value: { code: 40900, message: 用户名已存在, data: null } + phoneTaken: + value: { code: 40901, message: 手机号已被使用, data: null } + + /api/v1/auth/login: + post: + tags: [auth] + summary: 登录并创建会话 + description: 多设备并行:每次登录开启独立会话(独立 token family),互不影响。 + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: 登录成功,返回令牌对 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + description: 用户名或密码错误(code 40100) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + invalidCredentials: + value: { code: 40100, message: 用户名或密码错误, data: null } + '423': + description: 登录失败次数过多,账号临时锁定(code 42300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + locked: + value: { code: 42300, message: 登录失败次数过多,账号已临时锁定, data: null } + + /api/v1/auth/refresh: + post: + tags: [auth] + summary: 轮换 refresh token + description: | + 成功时返回全新令牌对,旧 refreshToken 立即失效(轮换)。提交已轮换或已失效的 + refreshToken 返回 401/40102,且视为重用攻击:该 token family 的全部会话被撤销。 + operationId: refresh + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: 轮换成功,返回新的令牌对 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + description: refresh token 已失效或被重用(code 40102) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + invalidated: + value: { code: 40102, message: refresh token 已失效或被重用, data: null } + + /api/v1/auth/logout: + post: + tags: [auth] + summary: 退出(撤销当前会话) + description: | + 撤销 body 中 refreshToken 对应的会话;其他设备的会话不受影响(ADR-003)。 + 需携带有效的 access token(从中取用户身份,防止跨账号撤销)。幂等:对已 + 失效的 refreshToken 仍返回成功。 + operationId: logout + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LogoutRequest' + responses: + '200': + description: 已退出 + content: + application/json: + schema: + $ref: '#/components/schemas/VoidEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/me: + get: + tags: [user] + summary: 当前用户资料 + description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + operationId: me + security: + - bearerAuth: [] + responses: + '200': + description: 当前用户 + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 用户不存在(如已注销;code 40400) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/events: + post: + tags: [analytics] + summary: 批量上报产品事件 + description: | + 埋点批量上报(事件字典见迭代报告 13 与第二迭代 06 号报告)。 + `/api/v1` 下唯一允许匿名调用的写端点:`Authorization: Bearer` 可选—— + 缺失时按匿名处理放行;**一旦携带则完整校验**,无效 token 仍返回 401/40101。 + + - 单批 1–50 条;条数越界、字段校验失败或 JSON 不可解析时**整批** 400/40000。 + - 通过请求级校验的批次一律返回 **202**,`data.results` 与请求 `events` + 等长且按原顺序逐条给出结果(accepted / duplicate / rejected); + 客户端收到 202 即可删除本地队列中该批全部事件(rejected 条目不重试)。 + - 幂等以每条事件的 `eventId` 去重(落库 ON CONFLICT DO NOTHING),重复条目 + 返回 `duplicate`(视为成功);**不使用** `Idempotency-Key` 请求头。 + - 单条拒绝原因:事件名不在字典(`unknown_event_name`);已认证请求中事件 + `userId` 与 token subject 不一致(`identity_mismatch`);props 的键命中 + 隐私红线模式 password/token/secret/phone/mobile/email/credential/idfa/gaid + (`forbidden_field`);落库失败(`schema_invalid`)。 + - props 中字典白名单之外的键**剥离后入库**(事件保留,不拒绝)。 + - 匿名请求中事件携带的 `userId` 原样落库(分析归因数据,不参与权限判断)。 + operationId: trackEvents + security: + - {} # 匿名(注册/登录前) + - bearerAuth: [] # 登录后携带未过期 access token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrackEventsRequest' + responses: + '202': + description: 批次已受理,逐条结果见 data.results(与请求 events 等长、原顺序) + content: + application/json: + schema: + $ref: '#/components/schemas/TrackEventsEnvelope' + '400': + description: 整批拒绝——JSON 不可解析、events 为空或超过 50 条、单条事件字段校验失败(code 40000) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyBatch: + value: { code: 40000, message: events 长度必须在 1-50 之间, data: null } + '401': + description: 携带了 Authorization 头但 access token 无效或过期(code 40101);不携带该头则按匿名放行,不会返回 401 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + tokenInvalid: + value: { code: 40101, message: token 无效或过期, data: null } + + # ====================================================================== + # Pets 域(M2 冻结,12 路径;定型依据:iteration-2 报告 13/16/17/18) + # ====================================================================== + + /api/v1/pets: + get: + tags: [pets] + summary: 当前用户可见宠物列表 + description: | + 返回当前用户拥有任意角色(owner/caregiver/viewer)的宠物,按 `created_at DESC` + 排序,**不分页**(单人宠物量小)。每项含 `myRole`(调用者对该宠物的角色)。 + 列表按调用者的 pet_owners 关系行过滤,天然隔离他人宠物。 + operationId: listPets + security: + - bearerAuth: [] + responses: + '200': + description: 宠物列表(created_at DESC,不分页) + content: + application/json: + schema: + $ref: '#/components/schemas/PetListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + post: + tags: [pets] + summary: 创建宠物 + description: | + 创建宠物,返回 **201** 与完整 Pet。创建者自动成为 primary owner + (pet_owners 写入 role=owner、is_primary=true,与建宠同事务)。 + + - 品种:`breedId` 与 `customBreedName` 必须**二选一且互斥**(双填、双空、 + 品种与物种错配、品种不存在或已停用均为 400/40000,message 带具体原因)。 + - 芯片号跨用户唯一(uq_pets_microchip):已被登记返回 409/40903。 + - 不使用 `Idempotency-Key`:重试安全由唯一约束兜底(带芯片号重发得 40903)。 + operationId: createPet + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePetRequest' + responses: + '201': + description: 创建成功,返回完整 Pet(myRole 恒为 owner) + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '409': + description: 芯片号已被登记(code 40903) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + microchipExists: + value: { code: 40903, message: 芯片号已被登记, data: null } + + /api/v1/pets/{petId}: + get: + tags: [pets] + summary: 宠物详情 + description: | + 权限档:READ(三角色皆可)。返回宠物详情及 `myRole`(调用者对该宠物的角色, + 客户端据此显隐写入口)。 + operationId: getPet + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + responses: + '200': + description: 宠物详情(含 myRole) + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + patch: + tags: [pets] + summary: 更新宠物档案 + description: | + 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + + - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 + 整对,互斥校验同创建。 + - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 + - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH + 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 + - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - 芯片号改为已被登记的值:409/40903。 + operationId: updatePet + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePetRequest' + responses: + '200': + description: 更新成功,返回更新后完整 Pet + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + '409': + description: 版本冲突(code 40902)或芯片号已被登记(code 40903) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + versionConflict: + value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + microchipExists: + value: { code: 40903, message: 芯片号已被登记, data: null } + + /api/v1/breeds: + get: + tags: [dictionaries] + summary: 品种目录 + description: | + 品种目录(只读字典,非用户数据,仅需 Bearer 鉴权、无用户级权限)。 + 返回 enabled=true 的品种按 sort_order 排序,全量数组(种子约 30 行,不分页); + `?species=` 过滤,非法取值 400/40000。 + operationId: listBreeds + security: + - bearerAuth: [] + parameters: + - name: species + in: query + required: false + schema: + type: string + enum: [dog, cat, other] + description: 过滤物种;不传则返回全部 + responses: + '200': + description: 品种列表 + content: + application/json: + schema: + $ref: '#/components/schemas/BreedListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/pets/{petId}/weights: + get: + tags: [health-records] + summary: 体重记录列表 + description: | + 权限档:READ。cursor 分页(分页正典形态 `{items, nextCursor, hasMore}`), + 按 `measured_at DESC, id DESC` 排序(与索引 ix_pet_weight_pet_measured 逐列对齐, + 同刻多条时 id 大者在前)。`limit` 越界或 `cursor` 无效:400/40000。 + operationId: listWeights + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: 体重记录分页结果 + content: + application/json: + schema: + $ref: '#/components/schemas/WeightListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 添加体重记录 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 WeightRecord。 + 支持可选 `Idempotency-Key`(语义见 info 的「幂等」段)。体重记录 append-only、 + 无乐观锁;同一时刻允许多条。`weightKg` 范围 (0, 500]、最多两位小数 + (numeric(6,2)),违反 400/40000。 + operationId: createWeight + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWeightRequest' + responses: + '201': + description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/WeightEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + + /api/v1/vaccine-catalog: + get: + tags: [dictionaries] + summary: 疫苗目录 + description: | + 疫苗目录(只读字典,非用户数据,仅需 Bearer 鉴权、无用户级权限)。 + 返回 enabled=true 的疫苗(V4 种子 10 行),`ORDER BY species, name`,不分页; + `?species=` 过滤,非法取值 400/40000。 + operationId: listVaccineCatalog + security: + - bearerAuth: [] + parameters: + - name: species + in: query + required: false + schema: + type: string + enum: [dog, cat, other] + description: 过滤物种;不传则返回全部 + responses: + '200': + description: 疫苗目录列表 + content: + application/json: + schema: + $ref: '#/components/schemas/VaccineCatalogListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/pets/{petId}/vaccinations: + get: + tags: [health-records] + summary: 疫苗记录列表 + description: | + 权限档:READ。**不分页**(单宠疫苗量级为个位数~十位数),排序服务端定死: + `ORDER BY series_key, dose_no, created_at, id`,客户端按系列直接分组成卡。 + 列表不过滤 status(含 cancelled 行,客户端自行按需过滤)。 + operationId: listVaccinations + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + responses: + '200': + description: 疫苗记录列表(不分页,series_key/dose_no 排序) + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 创建疫苗记录 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 Vaccination。 + 支持可选 `Idempotency-Key`。 + + - 创建状态仅 `scheduled` / `completed`(创建即 cancelled 无业务意义,400/40000)。 + - 状态-日期规则(违反 422/42201):scheduled 必有 `plannedOn` 且不得带 + `administeredOn`;completed 必有 `administeredOn`;`nextDueOn` 与 + `administeredOn` 同时存在时须 `nextDueOn ≥ administeredOn`。 + - 疫苗必须存在、enabled 且 species 与宠物一致(400/40000)。 + - 同宠物同疫苗同系列同剂次的非 cancelled 记录唯一(uq_pet_vaccination_dose): + 重复 409/40904;cancel 后同剂次可重新登记。 + operationId: createVaccination + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateVaccinationRequest' + responses: + '201': + description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + '409': + description: 同系列同剂次非 cancelled 记录已存在(code 40904) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + doseExists: + value: { code: 40904, message: 同系列同剂次记录已存在, data: null } + '422': + description: 状态机或状态-日期规则违反(code 42201) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + ruleViolation: + value: { code: 42201, message: scheduled 状态必须提供 plannedOn, data: null } + + /api/v1/vaccinations/{vaccinationId}: + patch: + tags: [health-records] + summary: 更新疫苗记录 + description: | + 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。 + + - 部分更新:缺席字段不变;**不支持清空回 null**。 + - `vaccineId` / `seriesKey` / `doseNo` 不可改(不在请求体)——登记错剂次的 + 修正路径是 cancel 后重建。 + - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - 状态机:`scheduled → completed`(合并态必须有 administeredOn)、 + `scheduled → cancelled`(合并态 administeredOn 必须为空); + **completed 与 cancelled 均为终态**(completed→cancelled、cancelled→scheduled + 等一律 422/42201);同状态编辑(补批号/备注等)始终允许。 + - 校验时点:在「当前行 + 请求字段」的合并态上重跑与创建完全相同的状态-日期 + 规则,违反 422/42201。 + operationId: updateVaccination + security: + - bearerAuth: [] + parameters: + - name: vaccinationId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateVaccinationRequest' + responses: + '200': + description: 更新成功,返回更新后完整 Vaccination + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + '422': + description: 状态机非法迁移或状态-日期规则违反(code 42201) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + terminalState: + value: { code: 42201, message: completed 为终态,不可迁移至 cancelled, data: null } + + /api/v1/pets/{petId}/health-events: + get: + tags: [health-records] + summary: 健康事件时间线 + description: | + 权限档:READ。cursor 分页(分页正典形态),按 `occurred_at DESC, id DESC` 排序 + (与索引 ix_health_events_pet_time 逐列对齐)。`limit` 越界或 `cursor` 无效: + 400/40000。 + operationId: listHealthEvents + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: 健康事件分页结果 + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 添加健康事件 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 HealthEvent。 + 支持可选 `Idempotency-Key`。 + + - 六类事件类型:medical/feeding/deworming/grooming/measurement/note。 + - `createdByUserId` 取自验签 token,**不收请求体**、永不可改。 + - `title` 服务端 btrim,trim 后为空 400/40000。 + - 金额 `amountCents` 以整数分传输、非负、可缺席;**提交小数一律 400/40000** + (不做静默截断)。 + operationId: createHealthEvent + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateHealthEventRequest' + responses: + '201': + description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + + /api/v1/health-events/{eventId}: + patch: + tags: [health-records] + summary: 更新健康事件 + description: | + 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。 + + - **仅可编辑 `title` / `notes` / `amountCents`**;`eventType` / `occurredAt` + 为时间线条目的身份,不可改(不在请求体);`createdByUserId` 永不可改。 + - 部分更新:缺席字段不变;**不支持清空回 null**。 + - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `title` 提交空白串(trim 后为空)400/40000。 + operationId: updateHealthEvent + security: + - bearerAuth: [] + parameters: + - name: eventId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateHealthEventRequest' + responses: + '200': + description: 更新成功,返回更新后完整 HealthEvent + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + + /api/v1/pets/{petId}/care-reminders: + get: + tags: [health-records] + summary: 照护提醒列表 + description: | + 权限档:READ。**不分页**(单宠提醒量级小),`ORDER BY due_at ASC, id` + (待办最先到期在前)。`?status=` 白名单过滤(pending/completed/dismissed), + `?status=pending` 即「按 due_at 查询待办」视图;非法取值 400/40000。 + operationId: listCareReminders + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - name: status + in: query + required: false + schema: + type: string + enum: [pending, completed, dismissed] + description: 按状态过滤;不传则返回全部 + responses: + '200': + description: 提醒列表(不分页,due_at ASC 排序) + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 创建照护提醒 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 CareReminder。 + 支持可选 `Idempotency-Key`(提醒表无唯一约束兜底,重复提交只能靠键防)。 + 创建恒为 `pending`(请求体不收 status,多余字段被忽略,与全 API 一致)。 + M2 仅 app 内数据,不做推送(ADR-010)。提醒的 title/dueAt 后续编辑与删除端点 + 不在 M2 契约,改期路径为 dismiss 后重建。 + operationId: createCareReminder + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCareReminderRequest' + responses: + '201': + description: 创建成功,状态恒为 pending(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + + /api/v1/care-reminders/{reminderId}: + patch: + tags: [health-records] + summary: 更新提醒状态 + description: | + 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。 + **状态流转专用**:请求体仅 `status` + `completedAt`。 + + - 状态机:`pending → completed`(必带 completedAt)、`pending → dismissed` + (禁带 completedAt);completed / dismissed 为终态;**同状态重放始终允许** + (客户端重试「标记完成」幂等成功)。 + - completed-completedAt 一致性(违反 422/42202):`status=completed` 必带 + `completedAt`、其余状态禁带;终态互迁与回退 pending 均拒绝。 + - `completedAt` 由客户端提交(而非服务端 now()),允许补记实际完成时刻。 + - 提醒表无 version 列:并发流转采用当前状态条件更新守卫,读写窗口内被并发 + 流转抢先则 409/40902(「数据已被修改请刷新」,客户端处理方式与乐观锁一致)。 + operationId: updateCareReminder + security: + - bearerAuth: [] + parameters: + - name: reminderId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCareReminderRequest' + responses: + '200': + description: 更新成功,返回更新后完整 CareReminder + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + '422': + description: 状态机非法迁移或 completed-completedAt 一致性违反(code 42202) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + missingCompletedAt: + value: { code: 42202, message: 标记 completed 必须提供 completedAt, data: null } + + /api/v1/pets/{petId}/summary: + get: + tags: [health-records] + summary: 档案聚合摘要 + description: | + 权限档:READ(三角色皆可读)。实时聚合生成档案页摘要:最新体重、疫苗进度、 + 下次接种、当月花费——四项聚合全部从事实表实时计算,**无任何写路径** + (不持久化展示字符串)。各聚合口径逐字见 PetSummary schema 字段描述 + (iteration-2 报告 18 §3 定型)。 + + `tz`:可选,IANA 时区标识(如 `Asia/Shanghai`,也接受固定偏移如 `+08:00`), + 缺省 `UTC`,仅作用于当月花费的月度窗口;非法 tz 或超 64 字符 → 400/40000。 + 客户端应传自己的时区以获得符合直觉的月边界。 + operationId: getPetSummary + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - name: tz + in: query + required: false + schema: + type: string + maxLength: 64 + default: UTC + description: IANA 时区标识(如 Asia/Shanghai)或固定偏移(如 +08:00),仅作用于当月花费的月度窗口 + example: Asia/Shanghai + responses: + '200': + description: 聚合摘要 + content: + application/json: + schema: + $ref: '#/components/schemas/PetSummaryEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + + # ====================================================================== + # Community / Media 域(M3 冻结,13 路径;定型依据:iteration-3 报告 13/15/16/17) + # ====================================================================== + + /api/v1/media/uploads: + post: + tags: [media] + summary: 创建上传(登记 asset 并签发预签名直传凭据) + description: | + 两步上传第一步:校验白名单与上限(`purpose` 仅 post_image、`mimeType` 仅 + image/jpeg|png|webp、`byteSize` ≤ 10485760,均为服务端配置项,后续扩展为 + 向后兼容的枚举追加)→ 写 `media.assets` 行(status=uploading,bucket/objectKey + 服务端生成、不含任何用户输入)→ 返回预签名 PUT 直传凭据(TTL 10 分钟,配置项)。 + 客户端凭凭据直传对象存储,不经应用服务器;直传必须**原样携带 requiredHeaders** + (Content-Type 已签进签名,改动即被存储侧拒绝)。M3 仅 `kind=image` + (ADR-018 视频后置;video/document 为向后新增枚举预留)。 + operationId: createMediaUpload + security: + - bearerAuth: [] + 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/Content-Type → + uploading→ready、写 readyAt,返回可引用的 asset(含现签预签名 GET URL)。 + + - **幂等**:对已 ready 的 asset 重复 complete 返回 200 同一 asset(现签新 GET URL)。 + - 对象尚不存在(直传完成前确认)→ 422/42205,asset **保持 uploading 可重试** + (补传后再确认即恢复,凭据未过期时无须重新创建上传)。 + - 对象存在但大小/类型与登记不符 → 置 failed(终态),422/42205,须重新创建上传。 + - failed 态再确认 → 422/42205(终态);不存在/非本人/已删 → 404/40405(防枚举合并)。 + - `sha256` 照收照存,M3 不做内容核验(存储侧 HEAD 不返回内容散列;后续经 + 存储侧 checksum 特性补齐,不改契约形态)。 + operationId: completeMediaUpload + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/AssetIdParam' + responses: + '200': + description: 确认成功(或幂等重复确认),asset 为 ready + content: + application/json: + schema: + $ref: '#/components/schemas/MediaAssetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/MediaNotFound' + '422': + description: | + asset 非 uploading 态或对象校验未通过(code 42205):对象未上传保持可重试、 + 大小/类型不符置 failed 终态、failed 态再确认(已 ready 幂等 200 除外) + 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` **必带**(语义见该头参数描述与 info「Community / Media 域 + 约定」)。`status` 可 draft(缺省)或 published(直接发布,服务端写 + publishedAt)。纯文字帖合法(media 空数组或缺席,D3-4)。 + + media 挂接(每帖 ≤9 图):只接受本人所有且 ready 的 asset(uploading/failed + 422/42203;不存在/非本人/已删 404/40405);`position` **全给或全不给**——全给 + 须恰为 0..n-1 连续不重复,全不给按数组序,混合 400/40000;`isCover` 至多一个 + true,全 false 时服务端将 position 0 行落库置为封面(库内恒有唯一封面行); + 同帖 assetId 不重复;caption trim 后 ≤300。 + `petId` 须为调用者可见宠物,否则 404/40401(沿 pets 域防枚举语义)。 + operationId: createPost + security: + - bearerAuth: [] + 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' + '404': + description: petId 引用不可见宠物(code 40401)或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + '422': + $ref: '#/components/responses/MediaNotReady' + + /api/v1/posts/{postId}: + get: + tags: [posts] + summary: 帖子详情 + description: | + 权限矩阵(iteration-3 报告 15 定型):published 对全部登录用户开放;draft 仅 + 作者可见;hidden/archived(运营态)**对作者同样 404/40403**——M3 无端点能产生 + 或解除运营态,status 枚举保持两值。一切不可见情形响应完全一致(防枚举)。 + 响应含 likedByMe/bookmarkedByMe 与作者公开摘要(AuthorSummary)。 + operationId: getPost + security: + - bearerAuth: [] + 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;一切不可见情形——含他人 draft——404/40403)。 + PATCH 部分更新惯例:缺席字段不变,不支持清空回 null(M2 先例)。`version` + 必带(缺失 400/40000,过期 409/40902)。 + + - **发布** = `status: published` 的状态迁移(draft→published 是唯一开放迁移, + 服务端写 publishedAt,恰写一次);**对已发布帖重复提交 `status: published` + 为幂等 no-op(200,version 照常 +1)**——同态提交不是迁移,弱网重发不报错; + draft/hidden/archived 目标值由请求枚举拒为 400/40000(published→draft 不支持)。 + - media 出现即**整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动), + 校验规则同创建。 + operationId: updatePost + security: + - bearerAuth: [] + 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': + description: | + 帖子不可见(code 40403,防枚举合并);或 petId 引用不可见宠物(code 40401); + 或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/VersionConflict' + '422': + $ref: '#/components/responses/MediaNotReady' + delete: + tags: [posts] + summary: 删除帖子(软删,仅作者) + description: | + 软删(deleted_at 为全域唯一删除判定基准),删除后详情/Feed/列表/互动一切路径 + 404/40403。重复删除与删不存在的帖同响应 404/40403(防枚举合并)。 + 不提供恢复端点(M3 无回收站)。非作者对已发布帖 403/40301。 + operationId: deletePost + security: + - bearerAuth: [] + 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(软删不含,hidden/archived 不含)。排序 + `(created_at DESC, id DESC)` 走 `ix_posts_author_created`,keyset 游标。 + `status` 过滤可选(draft|published)。 + operationId: listMyPosts + security: + - bearerAuth: [] + 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 帖子下一次请求即不可见。 + 卡片形态见 FeedCard(iteration-3 报告 16 定型);likedByMe/bookmarkedByMe + 为当前用户视角。 + operationId: getFeed + security: + - bearerAuth: [] + 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`,keyset 游标; + 仅 visible 评论。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。作者与 @ 目标均为 AuthorSummary。 + operationId: listComments + security: + - bearerAuth: [] + 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`(键按作者隔离、天然全局跨帖)。`replyToUserId` 可选 @ 回复 + (单层平铺,无楼中楼,ADR-018);目标须为存活用户,不存在/已注销 404/40406 + (合并不泄露成因)。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。content trim 后 1~2000。comment_count 同事务 +1。 + operationId: createComment + security: + - bearerAuth: [] + 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': + description: 帖子不可见——含作者本人草稿(code 40403);或 replyToUserId 目标用户不存在/已注销(code 40406) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + userNotFound: + value: { code: 40406, message: 用户不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + + /api/v1/comments/{commentId}: + delete: + tags: [comments] + summary: 删除评论(仅评论作者;顶层短路径) + description: | + 顶层短路径先例(pets 域子资源同理):commentId 全局唯一。**仅评论作者可删—— + 帖主不可删除他人评论(D3-7 首版不做)**:对可见评论的非作者(含帖主) + 403/40301;不存在/已删/所属帖不可见合并 404/40404(防枚举)。 + 软删(status→deleted),comment_count 同事务 -1。 + operationId: deleteComment + security: + - bearerAuth: [] + 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) 即幂等键:重复 PUT 返回 200 同一权威终态(非 409), + 仅实际插入才 like_count 同事务 +1,并发 N 次计数恰为 1(M3 验收标准二)。 + 互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**)404/40403。 + operationId: likePost + security: + - bearerAuth: [] + 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)。 + 帖子不可见(含作者本人草稿)404/40403。 + operationId: unlikePost + security: + - bearerAuth: [] + 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 语义幂等,与点赞同构) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: bookmarkPost + security: + - bearerAuth: [] + 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 语义幂等) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: unbookmarkPost + security: + - bearerAuth: [] + 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`,游标键在收藏关系行上。项形态 = FeedCard, + 谓词与公共 Feed 恒等:被收藏帖软删/hidden/archived 后**静默剔除**(剔除在页 + 查询内完成,不破坏翻页不丢不重;publishedAt 恒非空不变式对本列表继续成立)。 + operationId: listMyBookmarks + security: + - bearerAuth: [] + 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 与关注/粉丝列表不在 M3(ADR-018 最小数据接口)。 + operationId: followUser + security: + - bearerAuth: [] + 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;仅 PUT——自取关走 DELETE 的 200 幂等 no-op) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + selfFollow: + value: { code: 42204, message: 不能关注自己, data: null } + delete: + tags: [follows] + summary: 取消关注(DELETE 语义幂等) + description: | + 取消不存在的关注不报错,返回 200 权威终态(following 恒 false)。 + **自取关同样 200 幂等 no-op**(关系行不可能存在,权威 false 即事实;42204 + 只在 PUT)。目标用户不存在/已注销 404/40406。 + operationId: unfollowUser + security: + - bearerAuth: [] + 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 为调用者视角, + 查自己时恒 false。目标用户不存在/已注销 404/40406。关注/粉丝**列表**端点 + 不在 M3(需时按纯增量补入)。 + operationId: getFollowStats + security: + - bearerAuth: [] + 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: + PetIdParam: + name: petId + 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 + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + schema: + type: string + maxLength: 255 + description: | + 可选幂等键(≤255 字符,超长 400/40000)。键按「调用者 × 宠物 × 资源」隔离; + 同键重试返回首次创建的记录(同样 201);不比对请求体(每次逻辑提交应换新键, + 建议 UUID);键永久幂等(无 TTL)。不带键则无幂等语义。 + 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 + IdempotencyKeyRequiredHeader: + name: Idempotency-Key + in: header + required: true + schema: + type: string + maxLength: 128 + description: | + **必带**幂等键(1~128 字符,trim 后计;缺失/空白/超长 400/40000。与 pets 域 + 「可选、≤255、不比对请求体」刻意不同——community 域按 ADR-019 落表内幂等列, + 列宽 128)。键按作者隔离(跨用户同键互不干扰);同键重试返回首次创建的资源 + (同样 201);**比对规范化 request_hash**——hash 对象是规范化后的创建命令 + (trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源;同键不同 payload + 返回 409/40905;同键重试撞已删除的首个资源返回 404(帖子 40403 / 评论 40404, + 资源已消亡,不复活不另建)。客户端每次逻辑提交换新键(建议 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 } + PetNotFound: + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401)。防枚举语义:三种情况 + 响应完全一致,随机探测 UUID 无法得知是否命中真实记录。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + RecordNotFound: + description: | + 记录不存在或记录所属宠物对调用者不可见(code 40402)。记录级防枚举语义: + 两种情况响应完全一致;只有对宠物可见的调用者才可能收到 403/40300。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + recordNotFound: + value: { code: 40402, message: 记录不存在, data: null } + PetWriteDenied: + description: | + 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 + 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + accessDenied: + value: { code: 40300, message: 无权限执行该操作, data: null } + VersionConflict: + description: | + 乐观锁版本冲突(code 40902):提交的 version 已过期(并发修改或重试)。 + 不静默覆盖,先写者数据保留;客户端刷新取新 version 后重提。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + versionConflict: + value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + PostNotFound: + description: | + 帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(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(uploading/failed)状态的 asset(code 42203)。 + asset 不存在/非本人/已删则合并为 404/40405。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + notReady: + value: { code: 42203, message: 媒体尚未就绪, data: null } + + schemas: + RegisterRequest: + type: object + required: [username, password] + properties: + username: + type: string + minLength: 3 + maxLength: 32 + description: 用户名,大小写不敏感唯一 + example: demo_user + phone: + type: string + pattern: '^\+[1-9][0-9]{7,14}$' + description: 手机号,E.164 格式;可选,唯一 + example: '+8613800138000' + password: + type: string + format: password + minLength: 6 + maxLength: 64 + example: secret123 + nickname: + type: string + minLength: 1 + maxLength: 32 + description: 昵称;可选(冻结稿之外的可选扩展字段,前端可忽略) + example: 小柴 + + LoginRequest: + type: object + required: [username, password] + properties: + username: + type: string + example: demo_user + password: + type: string + format: password + example: secret123 + + RefreshRequest: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + description: 当前持有的 refresh token(不透明随机串) + example: Zx3v…43位base64url…Qk + + LogoutRequest: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + description: 要撤销的当前会话的 refresh token + example: Zx3v…43位base64url…Qk + + AuthTokens: + type: object + description: 注册 / 登录 / 刷新共用的令牌对(冻结契约,恰好这 6 个字段) + required: + - userId + - tokenType + - accessToken + - accessTokenExpiresAt + - refreshToken + - refreshTokenExpiresAt + properties: + userId: + type: string + format: uuid + example: 019212aa-0000-7000-8000-000000000001 + tokenType: + type: string + enum: [Bearer] + example: Bearer + accessToken: + type: string + description: RS256 JWT,有效期 15 分钟(配置项) + example: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI… + accessTokenExpiresAt: + type: string + format: date-time + description: ISO 8601 带时区 + example: '2026-09-04T04:20:06.789Z' + refreshToken: + type: string + description: 不透明随机串,有效期 30 天(配置项),每次刷新轮换 + example: Zx3v…43位base64url…Qk + refreshTokenExpiresAt: + type: string + format: date-time + example: '2026-10-04T04:05:06.789Z' + + Me: + type: object + description: 当前用户资料(冻结契约,恰好这 4 个字段) + required: [userId, username, createdAt] + properties: + userId: + type: string + format: uuid + example: 019212aa-0000-7000-8000-000000000001 + username: + type: string + example: demo_user + phone: + type: string + nullable: true + description: E.164;未绑定时为 null + example: '+8613800138000' + createdAt: + type: string + format: date-time + example: '2026-09-04T04:05:06.789Z' + + AuthTokenEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/AuthTokens' + + MeEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Me' + + 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: 40101 + message: + type: string + example: token 无效或过期 + data: + nullable: true + example: null + + TrackEventsRequest: + type: object + required: [events] + properties: + events: + type: array + minItems: 1 + maxItems: 50 + description: 单批 1–50 条;越界整批 400/40000 + items: + $ref: '#/components/schemas/TrackedEvent' + + TrackedEvent: + type: object + required: + - eventId + - eventName + - eventVersion + - anonymousId + - sessionId + - clientTs + - appVersion + - platform + - osVersion + properties: + eventId: + type: string + format: uuid + description: 客户端生成的 UUID(规范要求 v7),服务端幂等去重键 + example: 019212aa-4444-7000-8000-000000000001 + eventName: + type: string + pattern: '^[a-z][a-z0-9_]{1,63}$' + description: 须在服务端事件字典内;不在字典中的事件名整条 rejected(unknown_event_name) + example: auth_login_succeeded + eventVersion: + type: integer + description: 事件 schema 版本(字典 v1 全部为 1) + example: 1 + anonymousId: + type: string + format: uuid + description: 设备级匿名标识,首次启动生成 + example: 019212aa-0000-7000-8000-000000000001 + userId: + type: string + format: uuid + nullable: true + description: | + 登录后填充,可选。已认证请求中若与 token subject 不一致,该条 + rejected(identity_mismatch);匿名请求中原样落库,不做校验。 + example: 019212aa-0000-7000-8000-000000000001 + sessionId: + type: string + format: uuid + description: 客户端会话标识 + example: 019212aa-1111-7000-8000-000000000001 + clientTs: + type: string + format: date-time + description: 客户端本地时间(ISO 8601 带时区);serverTs 由服务端补写,客户端不发 + example: '2026-09-07T04:05:06.789Z' + appVersion: + type: string + minLength: 1 + maxLength: 32 + example: 1.0.0+12 + platform: + type: string + enum: [android, ios] + example: android + osVersion: + type: string + minLength: 1 + maxLength: 32 + example: android-14 + props: + type: object + additionalProperties: true + description: | + 事件专有属性,可选。按事件字典白名单处理:白名单外的键剥离后入库 + (事件保留);键名命中隐私红线模式(password/token/secret/phone/ + mobile/email/credential/idfa/gaid,不区分大小写、子串匹配)则整条 + rejected(forbidden_field)。 + example: { identifierType: username, durationMs: 123 } + + TrackEventsResult: + type: object + description: 批次逐条结果(results 与请求 events 等长、按原顺序对应) + required: [accepted, duplicated, rejected, results] + properties: + accepted: + type: integer + description: 新落库条数 + example: 1 + duplicated: + type: integer + description: eventId 去重命中条数(视为成功,客户端不必重试) + example: 0 + rejected: + type: integer + description: 被拒条数(客户端不重试) + example: 0 + results: + type: array + items: + $ref: '#/components/schemas/EventResult' + + EventResult: + type: object + required: [eventId, status] + properties: + eventId: + type: string + format: uuid + example: 019212aa-4444-7000-8000-000000000001 + status: + type: string + enum: [accepted, duplicate, rejected] + example: accepted + reason: + type: string + enum: [unknown_event_name, identity_mismatch, forbidden_field, schema_invalid] + description: 仅 status=rejected 时出现(accepted/duplicate 不含该字段) + example: unknown_event_name + + TrackEventsEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/TrackEventsResult' + + # ================================================================== + # Pets 域 schemas(M2 冻结;响应主键统一裸 `id`,关联字段带类型名) + # ================================================================== + + Pet: + type: object + description: | + 宠物档案(列表 / 详情 / 创建 / 更新的统一响应形态,皆含 myRole)。 + `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); + `breedDisplayName` 由品种字典解出,随 breedId 存在。 + 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 + status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + required: + - id + - name + - species + - sex + - birthDateEstimated + - status + - myRole + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + name: + type: string + minLength: 1 + maxLength: 64 + species: + type: string + enum: [dog, cat, other] + description: 物种;创建即定,不可修改 + breedId: + type: string + format: uuid + nullable: true + description: 品种 ID(与 customBreedName 互斥,恰有其一非空) + breedDisplayName: + type: string + nullable: true + description: 品种展示名,由字典解出,随 breedId 存在 + customBreedName: + type: string + nullable: true + minLength: 1 + maxLength: 64 + description: 自定义品种名(与 breedId 互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + nullable: true + description: 生日(YYYY-MM-DD) + birthDateEstimated: + type: boolean + description: 生日是否为估计值 + personality: + type: string + nullable: true + maxLength: 64 + description: 性格标签 + microchipNo: + type: string + nullable: true + description: 芯片号(跨用户唯一) + sterilizedOn: + type: string + format: date + nullable: true + description: 绝育日期 + status: + type: string + enum: [active, lost, deceased, archived] + description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + myRole: + type: string + enum: [owner, caregiver, viewer] + description: 调用者对该宠物的权限角色(客户端据此显隐写入口) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + CreatePetRequest: + type: object + required: [name, species, sex] + properties: + name: + type: string + minLength: 1 + maxLength: 64 + species: + type: string + enum: [dog, cat, other] + description: 创建即定,之后不可修改 + breedId: + type: string + format: uuid + description: 品种 ID(与 customBreedName 二选一且互斥;双填/双空/物种错配/品种不存在或停用 → 400/40000) + customBreedName: + type: string + minLength: 1 + maxLength: 64 + description: 自定义品种名(与 breedId 二选一且互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + birthDateEstimated: + type: boolean + default: false + personality: + type: string + maxLength: 64 + microchipNo: + type: string + description: 芯片号;已被登记 → 409/40903 + sterilizedOn: + type: string + format: date + + UpdatePetRequest: + type: object + description: | + 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 + species 不可改(不在请求体)。 + required: [version] + properties: + version: + type: integer + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + name: + type: string + minLength: 1 + maxLength: 64 + breedId: + type: string + format: uuid + description: 品种对整体替换(与 customBreedName 互斥) + customBreedName: + type: string + minLength: 1 + maxLength: 64 + description: 品种对整体替换(与 breedId 互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + birthDateEstimated: + type: boolean + personality: + type: string + maxLength: 64 + microchipNo: + type: string + description: 已被登记 → 409/40903 + sterilizedOn: + type: string + format: date + status: + type: string + enum: [active, lost, deceased, archived] + description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + + PetEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Pet' + + PetListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + description: created_at DESC 排序,不分页 + items: + $ref: '#/components/schemas/Pet' + + Breed: + type: object + required: [id, species, code, displayName] + properties: + id: + type: string + format: uuid + species: + type: string + enum: [dog, cat, other] + code: + type: string + description: 品种代码(唯一标识) + displayName: + type: string + description: 展示名称 + + BreedListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/Breed' + + WeightRecord: + type: object + required: [id, petId, weightKg, measuredAt, source, createdAt] + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + weightKg: + type: number + format: double + minimum: 0.01 + maximum: 500 + description: 体重(公斤),最多两位小数(numeric(6,2)) + measuredAt: + type: string + format: date-time + description: 称重时间 + source: + type: string + enum: [manual, clinic, device] + description: 来源 + note: + type: string + nullable: true + maxLength: 500 + createdAt: + type: string + format: date-time + + CreateWeightRequest: + type: object + required: [weightKg, measuredAt] + properties: + weightKg: + type: number + format: double + minimum: 0.01 + maximum: 500 + description: 体重(公斤),(0, 500],最多两位小数;越界或三位小数 400/40000 + measuredAt: + type: string + format: date-time + source: + type: string + enum: [manual, clinic, device] + default: manual + note: + type: string + maxLength: 500 + + WeightEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/WeightRecord' + + WeightListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: object + description: cursor 分页正典信封;排序 measured_at DESC, id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/WeightRecord' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + VaccineCatalogItem: + type: object + required: [id, code, name, species] + properties: + id: + type: string + format: uuid + code: + type: string + description: 疫苗代码(唯一标识) + name: + type: string + description: 疫苗名称 + species: + type: string + enum: [dog, cat, other] + description: + type: string + nullable: true + maxLength: 500 + + VaccineCatalogListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/VaccineCatalogItem' + + Vaccination: + type: object + description: | + 疫苗记录。`vaccineName` 由疫苗目录解出(同 Pet.breedDisplayName 先例,列表页免 + 二次查字典)。`certificateAssetId / providerId / providerNameSnapshot / bookingId` + 整体不出现(ADR-010,M5 时纯增量补入)。 + required: + - id + - petId + - vaccineId + - vaccineName + - seriesKey + - doseNo + - status + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + vaccineId: + type: string + format: uuid + vaccineName: + type: string + description: 疫苗名称(出自疫苗目录) + seriesKey: + type: string + minLength: 1 + maxLength: 64 + description: 系列键(区分初次/加强等,与 doseNo 共同唯一);创建后不可改 + doseNo: + type: integer + minimum: 1 + maximum: 32767 + description: 剂次号(smallint);创建后不可改 + doseLabel: + type: string + nullable: true + maxLength: 64 + description: 剂次标签(如「第一针」) + status: + type: string + enum: [scheduled, completed, cancelled] + plannedOn: + type: string + format: date + nullable: true + description: 计划接种日期(scheduled 必有) + administeredOn: + type: string + format: date + nullable: true + description: 实际接种日期(completed 必有;scheduled/cancelled 必空) + nextDueOn: + type: string + format: date + nullable: true + description: 下次到期日期(与 administeredOn 同时存在时 ≥ administeredOn) + manufacturer: + type: string + nullable: true + maxLength: 128 + batchNo: + type: string + nullable: true + maxLength: 64 + notes: + type: string + nullable: true + maxLength: 1000 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + CreateVaccinationRequest: + type: object + description: | + 创建状态仅 scheduled / completed(创建即 cancelled 无业务意义,400/40000)。 + 疫苗必须存在、enabled 且 species 与宠物一致(400/40000)。 + 状态-日期规则违反 → 422/42201。 + required: [vaccineId, seriesKey, doseNo, status] + properties: + vaccineId: + type: string + format: uuid + seriesKey: + type: string + minLength: 1 + maxLength: 64 + doseNo: + type: integer + minimum: 1 + maximum: 32767 + doseLabel: + type: string + maxLength: 64 + status: + type: string + enum: [scheduled, completed] + plannedOn: + type: string + format: date + description: scheduled 状态必填 + administeredOn: + type: string + format: date + description: completed 状态必填;scheduled 不得携带 + nextDueOn: + type: string + format: date + description: 与 administeredOn 同时存在时须 ≥ administeredOn + manufacturer: + type: string + maxLength: 128 + batchNo: + type: string + maxLength: 64 + notes: + type: string + maxLength: 1000 + + UpdateVaccinationRequest: + type: object + description: | + 部分更新:缺席字段不变;不支持清空回 null。vaccineId / seriesKey / doseNo + 不可改(不在请求体)——登记错剂次的修正路径是 cancel 后重建。 + 合并态重跑与创建相同的状态-日期规则,违反 422/42201。 + required: [version] + properties: + version: + type: integer + description: 乐观锁(必填;缺失 400/40000,过期 409/40902) + status: + type: string + enum: [scheduled, completed, cancelled] + description: scheduled→completed / scheduled→cancelled;completed 与 cancelled 均为终态(非法迁移 422/42201);同状态编辑始终允许 + plannedOn: + type: string + format: date + administeredOn: + type: string + format: date + nextDueOn: + type: string + format: date + doseLabel: + type: string + maxLength: 64 + manufacturer: + type: string + maxLength: 128 + batchNo: + type: string + maxLength: 64 + notes: + type: string + maxLength: 1000 + + VaccinationEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Vaccination' + + VaccinationListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + description: 不分页;ORDER BY series_key, dose_no, created_at, id(含 cancelled 行) + items: + $ref: '#/components/schemas/Vaccination' + + HealthEvent: + type: object + description: | + 健康事件。`providerId / providerNameSnapshot / bookingId` 整体不出现 + (ADR-010,M5 时纯增量补入)。 + required: + - id + - petId + - eventType + - occurredAt + - title + - createdByUserId + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + eventType: + type: string + enum: [medical, feeding, deworming, grooming, measurement, note] + description: 事件类型;创建后不可改 + occurredAt: + type: string + format: date-time + description: 事件发生时间;创建后不可改 + title: + type: string + minLength: 1 + maxLength: 160 + description: 标题(服务端 btrim,trim 后为空 400/40000) + notes: + type: string + nullable: true + maxLength: 2000 + description: 备注(上限 2000 字符) + amountCents: + type: integer + format: int64 + nullable: true + minimum: 0 + description: 金额(整数分,非负);提交小数 400/40000(不做静默截断) + createdByUserId: + type: string + format: uuid + description: 创建者用户 ID(取自验签 token,永不可改) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + CreateHealthEventRequest: + type: object + required: [eventType, occurredAt, title] + properties: + eventType: + type: string + enum: [medical, feeding, deworming, grooming, measurement, note] + occurredAt: + type: string + format: date-time + title: + type: string + minLength: 1 + maxLength: 160 + notes: + type: string + maxLength: 2000 + amountCents: + type: integer + format: int64 + minimum: 0 + description: 金额(整数分,非负);提交小数 400/40000 + + UpdateHealthEventRequest: + type: object + description: | + 部分更新:缺席字段不变;不支持清空回 null。仅可编辑 title / notes / amountCents; + eventType / occurredAt / createdByUserId 不可改(不在请求体)。 + required: [version] + properties: + version: + type: integer + description: 乐观锁(必填;缺失 400/40000,过期 409/40902) + title: + type: string + minLength: 1 + maxLength: 160 + description: 提交空白串(trim 后为空)400/40000 + notes: + type: string + maxLength: 2000 + amountCents: + type: integer + format: int64 + minimum: 0 + description: 提交小数 400/40000 + + HealthEventEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/HealthEvent' + + HealthEventListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: object + description: cursor 分页正典信封;排序 occurred_at DESC, id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/HealthEvent' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + CareReminder: + type: object + description: | + 照护提醒。**无 version 字段**(care_reminders 表无该列,状态流转用当前状态 + 条件更新守卫,守卫落空 409/40902)。completedAt 非空当且仅当 status=completed。 + required: + - id + - petId + - reminderType + - title + - dueAt + - status + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + reminderType: + type: string + enum: [deworming, checkup, medication, other] + title: + type: string + minLength: 1 + maxLength: 160 + dueAt: + type: string + format: date-time + description: 到期时间 + status: + type: string + enum: [pending, completed, dismissed] + completedAt: + type: string + format: date-time + nullable: true + description: 完成时间;非空当且仅当 status=completed + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + CreateCareReminderRequest: + type: object + description: 创建恒为 pending(不收 status 字段,多余字段被忽略) + required: [reminderType, title, dueAt] + properties: + reminderType: + type: string + enum: [deworming, checkup, medication, other] + title: + type: string + minLength: 1 + maxLength: 160 + dueAt: + type: string + format: date-time + + UpdateCareReminderRequest: + type: object + description: | + 状态流转专用(仅 status + completedAt)。pending→completed 必带 completedAt、 + pending→dismissed 禁带;终态互迁与回退 pending 拒绝(422/42202); + 同状态重放始终允许(幂等成功)。completedAt 由客户端提交,允许补记实际完成时刻。 + required: [status] + properties: + status: + type: string + enum: [pending, completed, dismissed] + completedAt: + type: string + format: date-time + description: status=completed 时必填;其余状态禁带(违反 422/42202) + + CareReminderEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CareReminder' + + CareReminderListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + description: 不分页;ORDER BY due_at ASC, id;支持 ?status= 白名单过滤 + items: + $ref: '#/components/schemas/CareReminder' + + PetSummary: + type: object + description: | + 档案聚合摘要(四项聚合全部从事实表实时计算,无持久化;口径为 iteration-2 + 报告 18 §3 定型表逐字收录)。latestWeight / vaccinationProgress / + nextVaccination 三项可为 null(无对应记录);monthlyExpense 恒非 null。 + required: [petId, monthlyExpense] + properties: + petId: + type: string + format: uuid + description: 恒非 null,回显路径参数 + latestWeight: + type: object + nullable: true + description: | + 最新体重。口径:pet_weight_records 按 (measured_at DESC, id DESC) 取首行—— + 与体重列表接口首行完全一致(同一索引 ix_pet_weight_pet_measured、同一 + tie-break),同刻多条时后写入者(id 更大)胜出。无记录 → null。 + required: [weightKg, measuredAt] + properties: + weightKg: + type: number + format: double + description: 两位小数(numeric(6,2)),对象存在时非 null + measuredAt: + type: string + format: date-time + description: 对象存在时非 null + vaccinationProgress: + type: object + nullable: true + description: | + 疫苗进度。口径:范围 = 该宠物非 cancelled 的 pet_vaccinations 行。 + completedDoses = 其中 status=completed 的行数;totalDoses = 全部非 cancelled + 行数(= scheduled + completed,即「已登记剂次」——数据模型没有权威的 + 「系列应打总针数」,分母取用户已登记数)。cancelled 分子分母皆不计入。 + totalDoses=0 → 整体 null(**不是 0/0**)。 + required: [completedDoses, totalDoses] + properties: + completedDoses: + type: integer + minimum: 0 + description: 已完成剂次,对象存在时非 null + totalDoses: + type: integer + minimum: 1 + description: 已登记剂次(scheduled + completed),对象存在时非 null(=0 即整体 null) + nextVaccination: + type: object + nullable: true + description: | + 下次接种。口径:候选集两类并集:① 全部 scheduled 行的 planned_on(约束保证 + 非空;含过期——逾期计划在完成/取消前仍是下一针),source=planned; + ② completed 行的非空 next_due_on,仅当同 (pet, vaccine, series_key) 不存在 + 更高 dose_no 的非 cancelled 记录(后续针一经登记,其自身即代表下一针, + 前一针的到期日失效),source=nextDue。cancelled 行不产生任何候选。 + 取 dueOn 最小者;同日 planned 优先于 nextDue,再按 id 升序保证确定性。 + 候选集空 → null。 + required: [vaccinationId, vaccineId, vaccineName, doseNo, dueOn, source] + properties: + vaccinationId: + type: string + format: uuid + description: 命中的疫苗记录 id(客户端可跳详情),非 null + vaccineId: + type: string + format: uuid + description: 非 null + vaccineName: + type: string + description: 非 null,出自 vaccine_catalog(同 breedDisplayName 先例) + doseNo: + type: integer + description: 非 null + doseLabel: + type: string + nullable: true + description: 记录本身可无标签 + dueOn: + type: string + format: date + description: 非 null;**可为过去日期**(逾期针仍是下一针) + source: + type: string + enum: [planned, nextDue] + description: 非 null,标注取值来源(scheduled 的 plannedOn 或 completed 的 nextDueOn) + monthlyExpense: + type: object + description: | + 当月花费,**恒非 null**(月份/时区总可确定)。口径:health_events.amount_cents + 求和,窗口为请求时刻在 tz 时区的自然月半开区间 [当月1日00:00, 次月1日00:00), + 对 occurred_at(timestamptz)比较;月初第一刻含、次月第一刻不含。 + amount_cents 为 NULL 的事件不计入;不按 event_type 过滤(任何事件类型的 + 金额都算支出)。tz 缺省 UTC,客户端应传自己的时区获得符合直觉的月边界—— + 月边界随 tz 移动。恒返回对象:month 为窗口所属 ISO 年月、timezone 回显、 + 无支出 amountCents=0。 + required: [month, timezone, amountCents] + properties: + month: + type: string + description: ISO year-month(如 2026-09),非 null + example: '2026-09' + timezone: + type: string + description: 回显窗口所用时区(缺省 UTC),非 null + example: UTC + amountCents: + type: integer + format: int64 + minimum: 0 + description: 非 null,无支出为 0 + + PetSummaryEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/PetSummary' + + # ================================================================== + # Community / Media 域 schemas(M3 冻结;定型依据:iteration-3 报告 13/15/16/17) + # ================================================================== + + AuthorSummary: + type: object + description: | + 作者公开摘要(D3-9 方案 B,iteration-3 报告 16 定型;community 跨 schema + 只读 identity 取数,ADR-017)。正常路径 nickname 恒非空——空昵称由服务端 + 回退为 username(客户端不做回退拼装,回退后的展示名不标注来源); + nickname 与 avatarUrl 同为 null 即「降级/墓碑」形态(作者资料暂不可得, + 或用户已注销)——两种情形同一形态,客户端只需一种占位逻辑。 + 不露 bio、不露 username。 + required: [userId] + properties: + userId: + type: string + format: uuid + description: 恒非空,任何情形都在 + nickname: + type: string + nullable: true + maxLength: 32 + description: 昵称(空昵称已由服务端回退为 username);null 仅出现在降级/注销墓碑形态 + example: 毛毛的铲屎官 + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取;无头像 / 头像 asset 非 ready / 降级 → null + (客户端出占位) + + # ---------- 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: | + 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 + user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + mimeType: + type: string + enum: [image/jpeg, image/png, image/webp] + description: 白名单外 400/40000;不收 HEIC(客户端压缩管线统一转码 jpeg) + byteSize: + type: integer + format: int64 + minimum: 1 + maximum: 10485760 + description: 声明的文件字节数,complete 时与对象实测比对;上限 10485760(10 MiB,服务端配置项) + sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: 可选,64 位小写 hex;照收照存,M3 不做内容核验(后续经存储侧 checksum 特性补齐,不改契约形态) + + MediaUploadCredentials: + type: object + description: 预签名直传凭据(ADR-016,iteration-3 报告 13 定型) + required: [assetId, uploadUrl, method, requiredHeaders, expiresAt] + properties: + assetId: + type: string + format: uuid + description: 已登记的 asset ID(status=uploading) + uploadUrl: + type: string + description: | + 预签名 PUT 完整 URL——签名以 query 参数携带(X-Amz-Algorithm/-Credential/ + -Signature 族),指向客户端可达的对象存储端点;客户端直传,不经应用服务器 + method: + type: string + enum: [PUT] + requiredHeaders: + type: object + additionalProperties: + type: string + description: | + 直传请求必须**原样携带**的头。键集定型为恒且仅一键: + `{"Content-Type": <声明的 mimeType>}`——Content-Type 已签进签名, + 改动即被存储侧拒绝 + expiresAt: + type: string + format: date-time + description: | + 凭据过期时刻 = 签发时刻 + TTL(默认 10 分钟,配置项);过期后重新创建 + 上传(原 asset 在补传后仍可确认) + + 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 + description: complete 后回填,可空 + heightPx: + type: integer + nullable: true + status: + type: string + enum: [uploading, ready, failed] + description: deleted 态对外恒 404/40405,不出现在响应 + url: + type: string + nullable: true + description: | + 访问 URL,仅 ready 态非空——时效性预签名 GET(TTL 默认 1 小时,配置项), + 每次响应现签,客户端不得持久化、过期即重取;桶保持私有,无签名直访被拒 + 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 + description: 库内恒有唯一封面行(写侧保证:全 false 时服务端将 position 0 行置真) + url: + type: string + description: | + 图片访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取(运维前提:生产环境对象存储恒配置) + 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(不存在/非本人/已删 404/40405) + required: [assetId] + properties: + assetId: + type: string + format: uuid + description: 同帖 assetId 不得重复(400/40000) + position: + type: integer + minimum: 0 + maximum: 8 + description: | + **全给或全不给**:全给须恰为 0..n-1 连续不重复;全不给按数组序; + 混合 400/40000 + isCover: + type: boolean + default: false + description: 至多一个 true(uq_post_media_cover);全 false 时服务端将 position 0 行落库置为封面 + caption: + type: string + maxLength: 300 + description: trim 后 ≤300 + + CreatePostRequest: + type: object + required: [content] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + description: 可选标题(ck_posts_title;空白串 400/40000) + 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 不开放写入(提交 400/40000) + 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 + description: | + 部分更新:缺席字段不变;不支持清空回 null(M2 惯例)。media 若出现则 + **整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动),校验规则同创建。 + required: [version] + properties: + version: + type: integer + minimum: 0 + description: 乐观锁,必带(缺失 400/40000);过期 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(发布动作,服务端写 publishedAt); + 对已发布帖重复提交为幂等 no-op(200,version 照常 +1); + draft/hidden/archived 目标值 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/40403 + (M3 无端点能产生或解除运营态) + 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 + description: | + 行最后更新时刻——互动计数维护亦会推动该值;判断「内容是否编辑过」 + 以 version 为准,勿以 updatedAt 判断 + publishedAt: + type: string + format: date-time + nullable: true + description: 仅 published 非空(发布时恰写一次) + version: + type: integer + + FeedCard: + type: object + description: | + Feed / 收藏列表卡片形态(较 Post 裁剪,iteration-3 报告 16 定型:只带 + coverImage + mediaCount,不带整组图;content 全文、petId、visibility、 + version、media 整组、created/updated 时间戳对均不出现,全文走帖子详情)。 + 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 + description: 原样透传,无标题为 null + contentPreview: + type: string + description: | + 正文前 200 个 Unicode 码点,**码点边界截断**(emoji 等增补面字符绝不 + 劈开),不追加省略号;短于 200 码点原样透传。全文恒走帖子详情端点 + coverImage: + nullable: true + allOf: + - $ref: '#/components/schemas/PostMediaItem' + description: | + 封面图 = 库中唯一 is_cover 行(写侧保证有图必有唯一封面行,读侧零特判); + 纯文字帖为 null + mediaCount: + type: integer + minimum: 0 + maximum: 9 + 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 + description: 恒非空(Feed 与收藏列表谓词只放行 published) + + 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: trim 后 1~2000(ck_comments_content 同宽) + replyToUserId: + type: string + format: uuid + description: | + 可选 @ 回复目标(单层平铺,无 parentCommentId,ADR-018); + 目标须为存活用户,不存在/已注销 404/40406 + + Comment: + type: object + description: M3 无评论编辑,不带 updatedAt + 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: '@ 回复目标的公开摘要(含降级 id-only 形态);非回复为 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 为目标用户的粉丝数(实时 COUNT) + 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: 目标用户的粉丝数(实时 COUNT) + followingCount: + type: integer + format: int64 + description: 目标用户关注的人数(实时 COUNT) + 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' diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/contract/ContractValidator.java b/patbond-user/src/test/java/com/patbond/patbond/user/contract/ContractValidator.java new file mode 100644 index 0000000..4d64f0b --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/contract/ContractValidator.java @@ -0,0 +1,256 @@ +package com.patbond.patbond.user.contract; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.patbond.patbond.user.contract.OpenApiContract.cast; +import static com.patbond.patbond.user.contract.OpenApiContract.list; +import static com.patbond.patbond.user.contract.OpenApiContract.map; + +/** + * Validates an actual HTTP response against the frozen contract, strictly: + * + *

    + *
  • the operation and the status must be declared;
  • + *
  • required fields must be present; a null value needs {@code nullable};
  • + *
  • fields the schema does not declare are rejected (this is what catches + * a renamed or newly leaked field — plain OpenAPI semantics would allow + * extra properties, but the frozen contract is "exactly these fields");
  • + *
  • types, enum membership, uuid / date-time / date formats and + * min/max(Length) bounds are checked.
  • + *
+ * + * Behavioural semantics (state machines, anti-enumeration, permission logic) + * stay with the existing integration tests — this class only pins structure. + */ +final class ContractValidator { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final OpenApiContract contract; + + ContractValidator(OpenApiContract contract) { + this.contract = contract; + } + + /** + * @return drift findings, empty when the response conforms; each entry is + * a human-readable "where: what" line + */ + List validateResponse(String method, String pathTemplate, int status, String body) { + List errors = new ArrayList<>(); + String opKey = method + " " + pathTemplate; + Map op = contract.operation(opKey); + if (op == null) { + errors.add("契约未声明该操作: " + opKey); + return errors; + } + Object respNode = map(op, "responses").get(String.valueOf(status)); + if (respNode == null) { + errors.add("契约未为 " + opKey + " 声明状态码 " + status); + return errors; + } + Map content = map(contract.resolve(cast(respNode)), "content"); + if (content == null) { + return errors; // response declared without a body + } + Map schema = map(map(content, "application/json"), "schema"); + if (schema == null) { + errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema"); + return errors; + } + JsonNode node; + try { + node = MAPPER.readTree(body); + } catch (JsonProcessingException e) { + errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage()); + return errors; + } + validate(schema, node, "$", errors); + return errors; + } + + private void validate(Map rawSchema, JsonNode node, String loc, List errors) { + Map schema = effectiveSchema(rawSchema); + if (node == null || node.isMissingNode()) { + errors.add(loc + ": 字段缺失"); + return; + } + if (node.isNull()) { + if (!Boolean.TRUE.equals(schema.get("nullable"))) { + errors.add(loc + ": 为 null,但契约未声明 nullable"); + } + return; + } + List allowed = list(schema, "enum"); + if (allowed != null && !enumMatches(allowed, node)) { + errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + " 内"); + } + String type = (String) schema.get("type"); + if (type == null) { + type = schema.containsKey("properties") ? "object" : null; + } + if (type == null) { + return; + } + switch (type) { + case "object" -> validateObject(schema, node, loc, errors); + case "array" -> validateArray(schema, node, loc, errors); + case "string" -> validateString(schema, node, loc, errors); + case "integer" -> { + if (!node.isIntegralNumber()) { + errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node); + } else { + checkRange(schema, node.decimalValue(), loc, errors); + } + } + case "number" -> { + if (!node.isNumber()) { + errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node); + } else { + checkRange(schema, node.decimalValue(), loc, errors); + } + } + case "boolean" -> { + if (!node.isBoolean()) { + errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node); + } + } + default -> errors.add(loc + ": 契约测试不支持的 type " + type); + } + } + + /** + * Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]} + * pattern into one plain schema (branch keys first, sibling keys — e.g. + * the outer {@code nullable} — win). The frozen contract only ever uses + * single-branch allOf, so a shallow merge is exact; overlapping + * {@code properties} across branches would need a deep merge and are not + * supported. + */ + private Map effectiveSchema(Map rawSchema) { + Map schema = contract.resolve(rawSchema); + List allOf = list(schema, "allOf"); + if (allOf == null) { + return schema; + } + Map merged = new LinkedHashMap<>(); + for (Object branch : allOf) { + merged.putAll(effectiveSchema(cast(branch))); + } + schema.forEach((key, value) -> { + if (!"allOf".equals(key)) { + merged.put(key, value); + } + }); + return merged; + } + + private void validateObject(Map schema, JsonNode node, String loc, List errors) { + if (!node.isObject()) { + errors.add(loc + ": 应为 object,实际 " + node.getNodeType()); + return; + } + Map props = map(schema, "properties"); + List required = list(schema, "required"); + if (required != null) { + for (Object r : required) { + if (!node.has((String) r)) { + errors.add(loc + "." + r + ": 契约必填字段缺失"); + } + } + } + Object additional = schema.get("additionalProperties"); + boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map; + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + Map propSchema = props == null ? null : cast(props.get(field.getKey())); + if (propSchema != null) { + validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors); + } else if (!open) { + errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)"); + } + } + } + + private void validateArray(Map schema, JsonNode node, String loc, List errors) { + if (!node.isArray()) { + errors.add(loc + ": 应为 array,实际 " + node.getNodeType()); + return; + } + Map items = map(schema, "items"); + if (items == null) { + return; + } + int i = 0; + for (JsonNode element : node) { + validate(items, element, loc + "[" + i++ + "]", errors); + } + } + + private void validateString(Map schema, JsonNode node, String loc, List errors) { + if (!node.isTextual()) { + errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node); + return; + } + String value = node.asText(); + String format = (String) schema.get("format"); + if (format != null) { + try { + switch (format) { + case "uuid" -> { + if (value.length() != 36) { + throw new IllegalArgumentException("非规范 UUID 长度"); + } + java.util.UUID.fromString(value); + } + case "date-time" -> OffsetDateTime.parse(value); + case "date" -> LocalDate.parse(value); + default -> { /* password 等纯标注格式不校验 */ } + } + } catch (IllegalArgumentException | DateTimeParseException e) { + errors.add(loc + ": \"" + value + "\" 不符合 format=" + format); + } + } + if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) { + errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min); + } + if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) { + errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max); + } + } + + private static void checkRange(Map schema, BigDecimal value, String loc, List errors) { + if (schema.get("minimum") instanceof Number min + && value.compareTo(new BigDecimal(min.toString())) < 0) { + errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min); + } + if (schema.get("maximum") instanceof Number max + && value.compareTo(new BigDecimal(max.toString())) > 0) { + errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max); + } + } + + private static boolean enumMatches(List allowed, JsonNode node) { + if (node.isTextual()) { + return allowed.contains(node.asText()); + } + if (node.isIntegralNumber()) { + long v = node.longValue(); + return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v); + } + return false; + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java new file mode 100644 index 0000000..04f88d2 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java @@ -0,0 +1,274 @@ +package com.patbond.patbond.user.contract; + +import com.jayway.jsonpath.JsonPath; +import com.patbond.patbond.user.TestcontainersConfiguration; +import com.patbond.patbond.user.support.TestJwtKeys; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.utility.DockerImageName; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * T3-20(M3 第二波收尾):media 域 2 个操作(两步上传,属 user 模块)补进契约 + * 一致性保障,机制与 patbond-pet 的 ContractConformanceTest 同构——对冻结契约 + * v1.3.0(快照 {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 + * doc 仓 {@code docs/api/openapi.yaml})逐操作真实起服务发请求(真实 MinIO + * Testcontainer,直传走真实 HTTP PUT),用 {@link ContractValidator} 严格校验 + * 响应结构,最后以全响应矩阵门禁兜底(8 个单元格,无豁免)。 + * + *

auth 域 6 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域 + * 17 操作在 patbond-community 的同构测试内(快照同一份)。 + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class MediaContractConformanceTest { + + private static final OpenApiContract CONTRACT = OpenApiContract.load(); + private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT); + + /** 已被真实响应校验过的 (操作, 状态码) 单元格。 */ + private static final Set COVERED = ConcurrentHashMap.newKeySet(); + + /** media 域 2 个操作(= 契约中 tags ∈ {media})。 */ + private static final List MEDIA_OPERATIONS = List.of( + "POST /api/v1/media/uploads", + "POST /api/v1/media/uploads/{assetId}/complete"); + + /** 与 docker-compose.yml 的 minio 服务钉同一 tag(ADR-016 三环境零分叉)。 */ + private static final MinIOContainer MINIO = new MinIOContainer( + DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z")) + // 值仅为测试占位(dummy),非真实凭证 + .withUserName("minio-dummy-access") + .withPassword("minio-dummy-secret"); + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private static final byte[] FAKE_JPEG = fakeJpeg(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcClient jdbcClient; + + @DynamicPropertySource + static void wireMedia(DynamicPropertyRegistry registry) { + MINIO.start(); + registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem); + registry.add("patbond.media.endpoint", MINIO::getS3URL); + registry.add("patbond.media.access-key", MINIO::getUserName); + registry.add("patbond.media.secret-key", MINIO::getPassword); + } + + // ---- 校验骨架 ------------------------------------------------------ + + private String verified(MockHttpServletRequestBuilder rq, String method, + String pathTemplate, int expectedStatus) throws Exception { + MvcResult result = mockMvc.perform(rq).andReturn(); + int actual = result.getResponse().getStatus(); + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(actual) + .as("%s %s 的 HTTP 状态(响应体: %s)", method, pathTemplate, body) + .isEqualTo(expectedStatus); + List drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body); + assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty(); + COVERED.add(method + " " + pathTemplate + " " + actual); + return body; + } + + private String verifiedError(MockHttpServletRequestBuilder rq, String method, + String pathTemplate, int status, int bizCode) throws Exception { + String body = verified(rq, method, pathTemplate, status); + assertThat((Integer) JsonPath.read(body, "$.code")) + .as("%s %s %d 的业务错误码", method, pathTemplate, status) + .isEqualTo(bizCode); + return body; + } + + private static byte[] fakeJpeg() { + byte[] bytes = new byte[2048]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (byte) (i * 31); + } + bytes[0] = (byte) 0xFF; + bytes[1] = (byte) 0xD8; // JPEG SOI,凑个像样的文件头 + return bytes; + } + + private UUID newUser(String username) { + UUID id = UUID.randomUUID(); + jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)") + .param("id", id) + .param("username", username) + .update(); + return id; + } + + private static String bearer(UUID userId) { + return "Bearer " + TestJwtKeys.accessToken( + TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15)); + } + + /** 创建上传(经 verified,201 凭据形态即被契约校验),返回响应体。 */ + private String createUpload(UUID user, long byteSize) throws Exception { + return verified(post("/api/v1/media/uploads") + .header("Authorization", bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"kind":"image","purpose":"post_image", + "mimeType":"image/jpeg","byteSize":%d} + """.formatted(byteSize)), + "POST", "/api/v1/media/uploads", 201); + } + + /** 按凭据把字节真实 PUT 到 MinIO。 */ + private void directPut(String createdBody, byte[] bytes) throws Exception { + String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl"); + Map headers = JsonPath.read(createdBody, "$.data.requiredHeaders"); + HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(bytes)); + headers.forEach(put::header); + int status = HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode(); + assertThat(status).as("预签名直传应被 MinIO 接受").isEqualTo(200); + } + + private MockHttpServletRequestBuilder completeRequest(UUID user, String assetId) { + return post("/api/v1/media/uploads/{assetId}/complete", assetId) + .header("Authorization", bearer(user)); + } + + // ---- 成功路径 ------------------------------------------------------ + + @Test + @Order(1) + void twoStepUploadSuccessShapes() throws Exception { + UUID user = newUser("contract_media_owner"); + String created = createUpload(user, FAKE_JPEG.length); + String assetId = JsonPath.read(created, "$.data.assetId"); + directPut(created, FAKE_JPEG); + + String completed = verified(completeRequest(user, assetId), + "POST", "/api/v1/media/uploads/{assetId}/complete", 200); + assertThat((String) JsonPath.read(completed, "$.data.status")).isEqualTo("ready"); + assertThat((String) JsonPath.read(completed, "$.data.url")) + .as("ready 资产必须带预签名 GET URL").isNotNull(); + + // 幂等重复确认:同格 200,同一 asset + String again = verified(completeRequest(user, assetId), + "POST", "/api/v1/media/uploads/{assetId}/complete", 200); + assertThat((String) JsonPath.read(again, "$.data.id")).isEqualTo(assetId); + } + + // ---- 错误信封 ------------------------------------------------------ + + @Test + @Order(2) + void errorEnvelopesMatchContract() throws Exception { + UUID user = newUser("contract_media_err"); + + // 400/40000:mime 白名单外;complete 的畸形 assetId + verifiedError(post("/api/v1/media/uploads") + .header("Authorization", bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"kind":"image","purpose":"post_image", + "mimeType":"image/gif","byteSize":1024} + """), + "POST", "/api/v1/media/uploads", 400, 40000); + verifiedError(completeRequest(user, "not-a-uuid"), + "POST", "/api/v1/media/uploads/{assetId}/complete", 400, 40000); + + // 401/40101:两操作均缺 token + verifiedError(post("/api/v1/media/uploads") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"kind":"image","purpose":"post_image", + "mimeType":"image/jpeg","byteSize":1024} + """), + "POST", "/api/v1/media/uploads", 401, 40101); + verifiedError(post("/api/v1/media/uploads/{assetId}/complete", UUID.randomUUID()), + "POST", "/api/v1/media/uploads/{assetId}/complete", 401, 40101); + + // 404/40405:他人 asset 与不存在 asset 防枚举合并 + UUID intruder = newUser("contract_media_intruder"); + String created = createUpload(user, FAKE_JPEG.length); + String assetId = JsonPath.read(created, "$.data.assetId"); + verifiedError(completeRequest(intruder, assetId), + "POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405); + verifiedError(completeRequest(user, UUID.randomUUID().toString()), + "POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405); + + // 422/42205:直传完成前确认(asset 保持 uploading 可重试) + verifiedError(completeRequest(user, assetId), + "POST", "/api/v1/media/uploads/{assetId}/complete", 422, 42205); + } + + // ---- 快照与覆盖门禁 ------------------------------------------------- + + /** + * 冻结快照守卫:与 pet/auth/community 侧同一纪律——正典契约升版时必须同步 + * 复制新快照并更新期望值,忘记同步在 CI 立即变红。 + */ + @Test + @Order(98) + void frozenSnapshotIsTheExpectedContractVersion() { + assertThat(CONTRACT.version()).isEqualTo("1.3.0"); + assertThat(CONTRACT.paths()).hasSize(31); + assertThat(CONTRACT.operations()).hasSize(43); + assertThat(CONTRACT.schemas()).hasSize(72); + assertThat(CONTRACT.operationsTagged(Set.of("media"))) + .containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS); + } + + /** + * 全矩阵覆盖门禁:media 域 2 个操作声明的每个 (操作, 状态码) 都必须被 + * 前面的测试真实触发并通过契约校验(8 个单元格,无豁免)。 + */ + @Test + @Order(99) + void everyDeclaredResponseCellIsExercised() { + List missing = new ArrayList<>(); + for (String op : MEDIA_OPERATIONS) { + for (int status : CONTRACT.responseStatuses(op)) { + String cell = op + " " + status; + if (!COVERED.contains(cell)) { + missing.add(cell); + } + } + } + assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty(); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java b/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java new file mode 100644 index 0000000..0ea219a --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java @@ -0,0 +1,151 @@ +package com.patbond.patbond.user.contract; + +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.3.0.yaml}. + * + *

Sync discipline (T2-09, extended by T3-19): the canonical + * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this + * snapshot is a byte-identical copy taken at freeze time, and this class is + * the module-local copy of the pet module's contract framework (same + * per-module duplication discipline as BearerAuthFilter). Whenever the + * canonical contract changes, copy it into every framework-carrying module + * (patbond-pet / patbond-auth / patbond-community / patbond-user) under the + * new version's file name and update each conformance test (expected version + * + snapshot counts). The guard test on {@code info.version} makes a forgotten + * sync fail loudly in CI instead of silently testing against a stale + * contract. + * + *

Only the subset of OpenAPI 3.0 this contract actually uses is supported: + * local {@code #/} refs, plain types, {@code nullable}, {@code enum}, + * {@code required}, {@code properties}, {@code items}, and the v1.3.0 + * single-branch {@code nullable + allOf: [$ref]} pattern (merged in + * {@link ContractValidator}) — no oneOf/anyOf. + */ +final class OpenApiContract { + + static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; + + private static final Set HTTP_METHODS = + Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); + + private final Map root; + + private OpenApiContract(Map root) { + this.root = root; + } + + static OpenApiContract load() { + try (InputStream in = Objects.requireNonNull( + OpenApiContract.class.getResourceAsStream(RESOURCE), + "契约快照缺失: " + RESOURCE)) { + return new OpenApiContract(new Yaml().load(in)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + String version() { + return (String) map(root, "info").get("version"); + } + + Map paths() { + return map(root, "paths"); + } + + Map schemas() { + return map(map(root, "components"), "schemas"); + } + + /** All declared operations as "METHOD pathTemplate" (insertion order). */ + Set operations() { + Set ops = new LinkedHashSet<>(); + paths().forEach((path, item) -> cast(item).forEach((method, op) -> { + if (HTTP_METHODS.contains(method)) { + ops.add(method.toUpperCase(Locale.ROOT) + " " + path); + } + })); + return ops; + } + + /** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */ + Set operationsTagged(Set tags) { + Set ops = new LinkedHashSet<>(); + for (String key : operations()) { + List opTags = list(operation(key), "tags"); + if (opTags != null && opTags.stream().anyMatch(tags::contains)) { + ops.add(key); + } + } + return ops; + } + + /** Declared response statuses of an operation, as ints. */ + Set responseStatuses(String operationKey) { + Set statuses = new LinkedHashSet<>(); + map(operation(operationKey), "responses") + .keySet().forEach(s -> statuses.add(Integer.parseInt(s))); + return statuses; + } + + /** The single 2xx status the operation declares. */ + int successStatus(String operationKey) { + return responseStatuses(operationKey).stream() + .filter(s -> s >= 200 && s < 300) + .reduce((a, b) -> { + throw new IllegalStateException("多个 2xx 响应: " + operationKey); + }) + .orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey)); + } + + /** Operation object for "METHOD pathTemplate", or null when undeclared. */ + Map operation(String operationKey) { + String[] parts = operationKey.split(" ", 2); + Map pathItem = map(paths(), parts[1]); + return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT)); + } + + /** Follows local $ref chains; non-ref maps come back unchanged. */ + Map resolve(Map node) { + while (node != null && node.get("$ref") instanceof String ref) { + if (!ref.startsWith("#/")) { + throw new IllegalStateException("仅支持本地 $ref: " + ref); + } + Map cur = root; + for (String seg : ref.substring(2).split("/")) { + cur = map(cur, seg); + if (cur == null) { + throw new IllegalStateException("$ref 指向不存在的节点: " + ref); + } + } + node = cur; + } + return node; + } + + @SuppressWarnings("unchecked") + static Map cast(Object o) { + return (Map) o; + } + + static Map map(Map m, String key) { + return m == null ? null : cast(m.get(key)); + } + + @SuppressWarnings("unchecked") + static List list(Map m, String key) { + return m == null ? null : (List) m.get(key); + } +} diff --git a/patbond-user/src/test/resources/contract/openapi-v1.3.0.yaml b/patbond-user/src/test/resources/contract/openapi-v1.3.0.yaml new file mode 100644 index 0000000..c9d3072 --- /dev/null +++ b/patbond-user/src/test/resources/contract/openapi-v1.3.0.yaml @@ -0,0 +1,3859 @@ +openapi: 3.0.3 +info: + title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) + version: 1.3.0 + description: | + Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), + 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 + **1.2.0 M2 契约冻结:pets 域 12 路径**(宠物 CRUD、品种/疫苗目录、体重记录、疫苗记录、 + 健康事件、照护提醒、档案聚合摘要)按第二波已定型实现合入 + (iteration-2 报告 13/16/17/18 定型表;冻结报告见 iteration-2/19)。 + **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 + 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 + (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + + ## 通用约定(development-plan 第 6 节) + - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 + - 所有时间字段为 ISO 8601 且带时区偏移(如 `2026-09-04T04:05:06.789Z`); + 纯日期字段(生日、接种日期等)为 `YYYY-MM-DD`。 + - 统一响应信封 `{"code": 0, "message": "success", "data": …}`;错误同时携带正确的 + HTTP 状态码与稳定业务码,业务码永不复用或改号。 + - `/internal/**` 为服务间接口,不属于本公开契约,需 `X-Internal-Token` 服务凭证, + 未携带或错误一律 401。 + + ## 错误码表 + | 业务码 | HTTP | 场景 | + | --- | --- | --- | + | 0 | 200 | 成功 | + | 40000 | 400 | 参数校验失败(含 JSON 不可解析;message 为首个字段错误) | + | 40100 | 401 | 用户名或密码错误 | + | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | + | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | + | 40400 | 404 | 资源不存在 | + | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | + | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | + | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | + | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | + | 40900 | 409 | 用户名已存在(大小写不敏感) | + | 40901 | 409 | 手机号已被使用 | + | 40902 | 409 | VERSION_CONFLICT:乐观锁版本冲突(PATCH 提交的 version 过期);照护提醒流转的状态守卫落空复用此码 | + | 40903 | 409 | MICROCHIP_EXISTS:芯片号已被登记(uq_pets_microchip,跨用户唯一) | + | 40904 | 409 | VACCINATION_DOSE_EXISTS:同宠物同疫苗同系列同剂次已有非 cancelled 记录(uq_pet_vaccination_dose) | + | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | + | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | + | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | + | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | + | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | + | 50000 | 500 | 服务器内部错误 | + | 50300 | 503 | 依赖服务暂不可用 | + + ## 会话模型(ADR-003,数值均为服务端配置项) + - access token:JWT(RS256),有效期 15 分钟;由资源服务用公钥本地验签。 + - refresh token:不透明随机串,有效期 30 天;**每次刷新即轮换**,旧值立即失效。 + - 已轮换/已失效的 refresh token 再次被使用时,判定为重用,**整个 token family + (该登录会话链)全部撤销**,持有者需重新登录。 + - 允许多设备并行会话;退出仅撤销当前会话(由所提交的 refreshToken 标识), + 其他设备不受影响。已签发的 access token 在剩余有效期内仍可用。 + - 登录失败限制:同一账号在 15 分钟窗口内密码错误累计 5 次(配置项),账号锁定 + 15 分钟;锁定期间即使密码正确也返回 423/42300;一次成功登录重置计数窗口。 + + ## Pets 域约定(M2 冻结,iteration-2 报告 13/16/17/18 定型) + - **鉴权**:pets 域全部端点强制 Bearer 鉴权,无匿名端点。 + - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: + - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; + - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; + - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + 权限每请求实时查库、无缓存:撤销照护关系立即生效。 + - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 + 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 + 「记录所属宠物对调用者不可见」响应完全一致(404/40402)。403/40300 只可能发给 + 「对宠物可见但角色不覆盖该操作」的调用者,不泄露新信息。 + - **PATCH 一律部分更新**:缺席字段不变;**M2 不支持将可选字段清空回 null** + (null-vs-absent 歧义挡在契约外)。pets / vaccinations / health-events 的 PATCH + 必须携带 `version` 乐观锁字段(缺失 400/40000,过期 409/40902,比对通过才写入并 +1)。 + - **子资源 PATCH 走顶层短路径**(`/api/v1/vaccinations/{id}` 等):记录 ID 全局唯一 + (UUID),短路径避免 path petId 与记录归属不一致的报错歧义。 + - **创建操作返回 201**(pets 域新约定;既有 auth 端点维持 200 不追改)。 + - **cursor 分页正典(全 API 唯一分页形态)**:响应 `data: {items, nextCursor, hasMore}`; + `limit` 1~100 缺省 20;`cursor` 传上一页返回的 `nextCursor`(不透明字符串,客户端不得 + 解析),首页不传;`hasMore=false` 时 `nextCursor` 恒为 null。体重与健康事件列表采用; + 疫苗列表(`series_key, dose_no, created_at, id` 排序)与提醒列表(`due_at ASC, id` + 排序 + `status` 过滤)量级小,不分页。 + - **幂等(可选 `Idempotency-Key` 头,≤255 字符)**:weights / vaccinations / + health-events / care-reminders 四个 POST 支持。键按「调用者 × 宠物 × 资源」隔离, + 两个用户的同名键不互斥;同键重试返回首次创建的记录(同样 201);**不比对请求体** + (客户端每次逻辑提交应换新键,建议 UUID);键永久幂等(无 TTL)。不带键则无幂等 + 语义,重复提交各自成行(疫苗由剂次唯一约束兜底 40904)。pets 的写接口不用幂等键, + 重试安全由乐观锁与唯一约束兜底。 + - **ADR-010 裁剪**:`avatarAssetId`、`certificateAssetId`、`providerId`、 + `providerNameSnapshot`、`bookingId` 等字段整体不出现(响应与请求皆无),M5+ 按 + 「新增可选字段」纯增量补入。软删除端点不在 M2 契约(D2-7:首版仅归档 + `status=archived`);`DELETE /api/v1/pets/{petId}` 未收录。 + + ## Community / Media 域约定(M3 冻结,iteration-3 报告 13/15/16/17 定型) + - **鉴权**:全部端点强制 Bearer 鉴权,无匿名端点。帖子/Feed/评论/互动/关注在 + patbond-community(:8084),媒体上传两步流程在 patbond-user(:8082)。 + - **幂等按域(ADR-019,与 pets 域刻意不同,两域并存、pets 不回改)**:创建型写入 + (发帖/评论)`Idempotency-Key` **必带**(1~128 字符,trim 后计;缺失/空白/超长 + 400/40000),键按作者隔离,落表内幂等列并**比对规范化 request_hash**——hash 对象 + 是规范化后的创建命令(trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源; + 同键同 payload 返回首次创建的资源(同样 201);同键不同 payload 409/40905; + 同键重试撞已删除的首个资源 404(帖子 40403 / 评论 40404)。 + - **二元互动语义幂等**:点赞/收藏/关注用 PUT/DELETE,复合主键即幂等键(无键管理), + 重复调用返回 200 同一**权威终态**(`{liked, likeCount}` 族);客户端乐观更新以 + 响应对账回滚(回滚基准取响应值而非本地推算)。 + - **媒体两步上传(ADR-016)**:创建上传(登记 asset + 签发预签名 PUT 直传凭据, + TTL 10 分钟,配置项)→ 客户端直传(原样携带 requiredHeaders)→ complete 确认 + (服务端 HEAD 校验后 uploading→ready)。桶保持私有:**一切媒体读取 URL + (asset/帖图/头像)均为时效性预签名 GET URL**(TTL 默认 1 小时,配置项),由 + 服务端每次响应现签;客户端不得持久化 URL,过期即重取。 + - **防枚举 404**:一切不可见情形按资源合并给码(帖子 40403、评论 40404、asset + 40405、用户 40406),同码各情形响应完全一致;403/40301 只发给对资源「可见但 + 无权」的调用者,不泄露新信息。 + - **互动面 = 帖子公开面**:评论(读写删)与点赞/收藏只对 published 且未删的帖子 + 开放,**作者本人的草稿在互动路径同样 404/40403**——可见性回答「能不能看」, + 互动门禁回答「能不能社交」。 + - **列表分页**:全部列表复用 cursor 分页正典 `{items, nextCursor, hasMore}` + (`limit` 1~100 缺省 20),各列表排序键在端点描述中写死。 + - **ADR-018 裁剪**:话题全部端点、关注/粉丝**列表**(最小接口仅 follow/unfollow + + 计数)、作者主页帖子列表、`region`/`generationJob`/`visibility=followers|private` + 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 + (如作者公开资料批量接口)不属于本公开契约。 + +servers: + - url: http://127.0.0.1:8081 + description: patbond-auth(本地开发,/api/v1/auth/**) + - url: http://127.0.0.1:8082 + description: patbond-user(本地开发,/api/v1/me、/api/v1/events、/api/v1/media/**) + - url: http://127.0.0.1:8083 + description: patbond-pet(本地开发,pets 域全部端点) + - url: http://127.0.0.1:8084 + description: patbond-community(本地开发,帖子/Feed/评论/互动/关注全部端点) + +tags: + - name: auth + description: 注册 / 登录 / 刷新 / 退出(patbond-auth) + - name: user + description: 当前用户(patbond-user) + - name: analytics + description: 产品事件批量上报(patbond-user) + - name: pets + description: 宠物档案 CRUD(patbond-pet) + - name: dictionaries + description: 品种与疫苗目录(只读字典,patbond-pet) + - name: health-records + description: 体重、疫苗、健康事件、照护提醒、档案摘要(patbond-pet) + - name: media + description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) + - 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/auth/register: + post: + tags: [auth] + summary: 注册并创建会话 + operationId: register + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '200': + description: 注册成功,返回令牌对 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '409': + description: 用户名或手机号已被占用(code 40900 / 40901) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + usernameTaken: + value: { code: 40900, message: 用户名已存在, data: null } + phoneTaken: + value: { code: 40901, message: 手机号已被使用, data: null } + + /api/v1/auth/login: + post: + tags: [auth] + summary: 登录并创建会话 + description: 多设备并行:每次登录开启独立会话(独立 token family),互不影响。 + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: 登录成功,返回令牌对 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + description: 用户名或密码错误(code 40100) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + invalidCredentials: + value: { code: 40100, message: 用户名或密码错误, data: null } + '423': + description: 登录失败次数过多,账号临时锁定(code 42300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + locked: + value: { code: 42300, message: 登录失败次数过多,账号已临时锁定, data: null } + + /api/v1/auth/refresh: + post: + tags: [auth] + summary: 轮换 refresh token + description: | + 成功时返回全新令牌对,旧 refreshToken 立即失效(轮换)。提交已轮换或已失效的 + refreshToken 返回 401/40102,且视为重用攻击:该 token family 的全部会话被撤销。 + operationId: refresh + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: 轮换成功,返回新的令牌对 + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + description: refresh token 已失效或被重用(code 40102) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + invalidated: + value: { code: 40102, message: refresh token 已失效或被重用, data: null } + + /api/v1/auth/logout: + post: + tags: [auth] + summary: 退出(撤销当前会话) + description: | + 撤销 body 中 refreshToken 对应的会话;其他设备的会话不受影响(ADR-003)。 + 需携带有效的 access token(从中取用户身份,防止跨账号撤销)。幂等:对已 + 失效的 refreshToken 仍返回成功。 + operationId: logout + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LogoutRequest' + responses: + '200': + description: 已退出 + content: + application/json: + schema: + $ref: '#/components/schemas/VoidEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/me: + get: + tags: [user] + summary: 当前用户资料 + description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + operationId: me + security: + - bearerAuth: [] + responses: + '200': + description: 当前用户 + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 用户不存在(如已注销;code 40400) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/events: + post: + tags: [analytics] + summary: 批量上报产品事件 + description: | + 埋点批量上报(事件字典见迭代报告 13 与第二迭代 06 号报告)。 + `/api/v1` 下唯一允许匿名调用的写端点:`Authorization: Bearer` 可选—— + 缺失时按匿名处理放行;**一旦携带则完整校验**,无效 token 仍返回 401/40101。 + + - 单批 1–50 条;条数越界、字段校验失败或 JSON 不可解析时**整批** 400/40000。 + - 通过请求级校验的批次一律返回 **202**,`data.results` 与请求 `events` + 等长且按原顺序逐条给出结果(accepted / duplicate / rejected); + 客户端收到 202 即可删除本地队列中该批全部事件(rejected 条目不重试)。 + - 幂等以每条事件的 `eventId` 去重(落库 ON CONFLICT DO NOTHING),重复条目 + 返回 `duplicate`(视为成功);**不使用** `Idempotency-Key` 请求头。 + - 单条拒绝原因:事件名不在字典(`unknown_event_name`);已认证请求中事件 + `userId` 与 token subject 不一致(`identity_mismatch`);props 的键命中 + 隐私红线模式 password/token/secret/phone/mobile/email/credential/idfa/gaid + (`forbidden_field`);落库失败(`schema_invalid`)。 + - props 中字典白名单之外的键**剥离后入库**(事件保留,不拒绝)。 + - 匿名请求中事件携带的 `userId` 原样落库(分析归因数据,不参与权限判断)。 + operationId: trackEvents + security: + - {} # 匿名(注册/登录前) + - bearerAuth: [] # 登录后携带未过期 access token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrackEventsRequest' + responses: + '202': + description: 批次已受理,逐条结果见 data.results(与请求 events 等长、原顺序) + content: + application/json: + schema: + $ref: '#/components/schemas/TrackEventsEnvelope' + '400': + description: 整批拒绝——JSON 不可解析、events 为空或超过 50 条、单条事件字段校验失败(code 40000) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyBatch: + value: { code: 40000, message: events 长度必须在 1-50 之间, data: null } + '401': + description: 携带了 Authorization 头但 access token 无效或过期(code 40101);不携带该头则按匿名放行,不会返回 401 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + tokenInvalid: + value: { code: 40101, message: token 无效或过期, data: null } + + # ====================================================================== + # Pets 域(M2 冻结,12 路径;定型依据:iteration-2 报告 13/16/17/18) + # ====================================================================== + + /api/v1/pets: + get: + tags: [pets] + summary: 当前用户可见宠物列表 + description: | + 返回当前用户拥有任意角色(owner/caregiver/viewer)的宠物,按 `created_at DESC` + 排序,**不分页**(单人宠物量小)。每项含 `myRole`(调用者对该宠物的角色)。 + 列表按调用者的 pet_owners 关系行过滤,天然隔离他人宠物。 + operationId: listPets + security: + - bearerAuth: [] + responses: + '200': + description: 宠物列表(created_at DESC,不分页) + content: + application/json: + schema: + $ref: '#/components/schemas/PetListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + post: + tags: [pets] + summary: 创建宠物 + description: | + 创建宠物,返回 **201** 与完整 Pet。创建者自动成为 primary owner + (pet_owners 写入 role=owner、is_primary=true,与建宠同事务)。 + + - 品种:`breedId` 与 `customBreedName` 必须**二选一且互斥**(双填、双空、 + 品种与物种错配、品种不存在或已停用均为 400/40000,message 带具体原因)。 + - 芯片号跨用户唯一(uq_pets_microchip):已被登记返回 409/40903。 + - 不使用 `Idempotency-Key`:重试安全由唯一约束兜底(带芯片号重发得 40903)。 + operationId: createPet + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePetRequest' + responses: + '201': + description: 创建成功,返回完整 Pet(myRole 恒为 owner) + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '409': + description: 芯片号已被登记(code 40903) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + microchipExists: + value: { code: 40903, message: 芯片号已被登记, data: null } + + /api/v1/pets/{petId}: + get: + tags: [pets] + summary: 宠物详情 + description: | + 权限档:READ(三角色皆可)。返回宠物详情及 `myRole`(调用者对该宠物的角色, + 客户端据此显隐写入口)。 + operationId: getPet + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + responses: + '200': + description: 宠物详情(含 myRole) + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + patch: + tags: [pets] + summary: 更新宠物档案 + description: | + 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + + - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 + 整对,互斥校验同创建。 + - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 + - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH + 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 + - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - 芯片号改为已被登记的值:409/40903。 + operationId: updatePet + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePetRequest' + responses: + '200': + description: 更新成功,返回更新后完整 Pet + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + '409': + description: 版本冲突(code 40902)或芯片号已被登记(code 40903) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + versionConflict: + value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + microchipExists: + value: { code: 40903, message: 芯片号已被登记, data: null } + + /api/v1/breeds: + get: + tags: [dictionaries] + summary: 品种目录 + description: | + 品种目录(只读字典,非用户数据,仅需 Bearer 鉴权、无用户级权限)。 + 返回 enabled=true 的品种按 sort_order 排序,全量数组(种子约 30 行,不分页); + `?species=` 过滤,非法取值 400/40000。 + operationId: listBreeds + security: + - bearerAuth: [] + parameters: + - name: species + in: query + required: false + schema: + type: string + enum: [dog, cat, other] + description: 过滤物种;不传则返回全部 + responses: + '200': + description: 品种列表 + content: + application/json: + schema: + $ref: '#/components/schemas/BreedListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/pets/{petId}/weights: + get: + tags: [health-records] + summary: 体重记录列表 + description: | + 权限档:READ。cursor 分页(分页正典形态 `{items, nextCursor, hasMore}`), + 按 `measured_at DESC, id DESC` 排序(与索引 ix_pet_weight_pet_measured 逐列对齐, + 同刻多条时 id 大者在前)。`limit` 越界或 `cursor` 无效:400/40000。 + operationId: listWeights + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: 体重记录分页结果 + content: + application/json: + schema: + $ref: '#/components/schemas/WeightListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 添加体重记录 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 WeightRecord。 + 支持可选 `Idempotency-Key`(语义见 info 的「幂等」段)。体重记录 append-only、 + 无乐观锁;同一时刻允许多条。`weightKg` 范围 (0, 500]、最多两位小数 + (numeric(6,2)),违反 400/40000。 + operationId: createWeight + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWeightRequest' + responses: + '201': + description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/WeightEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + + /api/v1/vaccine-catalog: + get: + tags: [dictionaries] + summary: 疫苗目录 + description: | + 疫苗目录(只读字典,非用户数据,仅需 Bearer 鉴权、无用户级权限)。 + 返回 enabled=true 的疫苗(V4 种子 10 行),`ORDER BY species, name`,不分页; + `?species=` 过滤,非法取值 400/40000。 + operationId: listVaccineCatalog + security: + - bearerAuth: [] + parameters: + - name: species + in: query + required: false + schema: + type: string + enum: [dog, cat, other] + description: 过滤物种;不传则返回全部 + responses: + '200': + description: 疫苗目录列表 + content: + application/json: + schema: + $ref: '#/components/schemas/VaccineCatalogListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/pets/{petId}/vaccinations: + get: + tags: [health-records] + summary: 疫苗记录列表 + description: | + 权限档:READ。**不分页**(单宠疫苗量级为个位数~十位数),排序服务端定死: + `ORDER BY series_key, dose_no, created_at, id`,客户端按系列直接分组成卡。 + 列表不过滤 status(含 cancelled 行,客户端自行按需过滤)。 + operationId: listVaccinations + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + responses: + '200': + description: 疫苗记录列表(不分页,series_key/dose_no 排序) + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 创建疫苗记录 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 Vaccination。 + 支持可选 `Idempotency-Key`。 + + - 创建状态仅 `scheduled` / `completed`(创建即 cancelled 无业务意义,400/40000)。 + - 状态-日期规则(违反 422/42201):scheduled 必有 `plannedOn` 且不得带 + `administeredOn`;completed 必有 `administeredOn`;`nextDueOn` 与 + `administeredOn` 同时存在时须 `nextDueOn ≥ administeredOn`。 + - 疫苗必须存在、enabled 且 species 与宠物一致(400/40000)。 + - 同宠物同疫苗同系列同剂次的非 cancelled 记录唯一(uq_pet_vaccination_dose): + 重复 409/40904;cancel 后同剂次可重新登记。 + operationId: createVaccination + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateVaccinationRequest' + responses: + '201': + description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + '409': + description: 同系列同剂次非 cancelled 记录已存在(code 40904) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + doseExists: + value: { code: 40904, message: 同系列同剂次记录已存在, data: null } + '422': + description: 状态机或状态-日期规则违反(code 42201) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + ruleViolation: + value: { code: 42201, message: scheduled 状态必须提供 plannedOn, data: null } + + /api/v1/vaccinations/{vaccinationId}: + patch: + tags: [health-records] + summary: 更新疫苗记录 + description: | + 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。 + + - 部分更新:缺席字段不变;**不支持清空回 null**。 + - `vaccineId` / `seriesKey` / `doseNo` 不可改(不在请求体)——登记错剂次的 + 修正路径是 cancel 后重建。 + - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - 状态机:`scheduled → completed`(合并态必须有 administeredOn)、 + `scheduled → cancelled`(合并态 administeredOn 必须为空); + **completed 与 cancelled 均为终态**(completed→cancelled、cancelled→scheduled + 等一律 422/42201);同状态编辑(补批号/备注等)始终允许。 + - 校验时点:在「当前行 + 请求字段」的合并态上重跑与创建完全相同的状态-日期 + 规则,违反 422/42201。 + operationId: updateVaccination + security: + - bearerAuth: [] + parameters: + - name: vaccinationId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateVaccinationRequest' + responses: + '200': + description: 更新成功,返回更新后完整 Vaccination + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + '422': + description: 状态机非法迁移或状态-日期规则违反(code 42201) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + terminalState: + value: { code: 42201, message: completed 为终态,不可迁移至 cancelled, data: null } + + /api/v1/pets/{petId}/health-events: + get: + tags: [health-records] + summary: 健康事件时间线 + description: | + 权限档:READ。cursor 分页(分页正典形态),按 `occurred_at DESC, id DESC` 排序 + (与索引 ix_health_events_pet_time 逐列对齐)。`limit` 越界或 `cursor` 无效: + 400/40000。 + operationId: listHealthEvents + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: 健康事件分页结果 + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 添加健康事件 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 HealthEvent。 + 支持可选 `Idempotency-Key`。 + + - 六类事件类型:medical/feeding/deworming/grooming/measurement/note。 + - `createdByUserId` 取自验签 token,**不收请求体**、永不可改。 + - `title` 服务端 btrim,trim 后为空 400/40000。 + - 金额 `amountCents` 以整数分传输、非负、可缺席;**提交小数一律 400/40000** + (不做静默截断)。 + operationId: createHealthEvent + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateHealthEventRequest' + responses: + '201': + description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + + /api/v1/health-events/{eventId}: + patch: + tags: [health-records] + summary: 更新健康事件 + description: | + 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。 + + - **仅可编辑 `title` / `notes` / `amountCents`**;`eventType` / `occurredAt` + 为时间线条目的身份,不可改(不在请求体);`createdByUserId` 永不可改。 + - 部分更新:缺席字段不变;**不支持清空回 null**。 + - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `title` 提交空白串(trim 后为空)400/40000。 + operationId: updateHealthEvent + security: + - bearerAuth: [] + parameters: + - name: eventId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateHealthEventRequest' + responses: + '200': + description: 更新成功,返回更新后完整 HealthEvent + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + + /api/v1/pets/{petId}/care-reminders: + get: + tags: [health-records] + summary: 照护提醒列表 + description: | + 权限档:READ。**不分页**(单宠提醒量级小),`ORDER BY due_at ASC, id` + (待办最先到期在前)。`?status=` 白名单过滤(pending/completed/dismissed), + `?status=pending` 即「按 due_at 查询待办」视图;非法取值 400/40000。 + operationId: listCareReminders + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - name: status + in: query + required: false + schema: + type: string + enum: [pending, completed, dismissed] + description: 按状态过滤;不传则返回全部 + responses: + '200': + description: 提醒列表(不分页,due_at ASC 排序) + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + post: + tags: [health-records] + summary: 创建照护提醒 + description: | + 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 CareReminder。 + 支持可选 `Idempotency-Key`(提醒表无唯一约束兜底,重复提交只能靠键防)。 + 创建恒为 `pending`(请求体不收 status,多余字段被忽略,与全 API 一致)。 + M2 仅 app 内数据,不做推送(ADR-010)。提醒的 title/dueAt 后续编辑与删除端点 + 不在 M2 契约,改期路径为 dismiss 后重建。 + operationId: createCareReminder + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCareReminderRequest' + responses: + '201': + description: 创建成功,状态恒为 pending(同 Idempotency-Key 重试返回首次创建的记录,同样 201) + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/PetNotFound' + + /api/v1/care-reminders/{reminderId}: + patch: + tags: [health-records] + summary: 更新提醒状态 + description: | + 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。 + **状态流转专用**:请求体仅 `status` + `completedAt`。 + + - 状态机:`pending → completed`(必带 completedAt)、`pending → dismissed` + (禁带 completedAt);completed / dismissed 为终态;**同状态重放始终允许** + (客户端重试「标记完成」幂等成功)。 + - completed-completedAt 一致性(违反 422/42202):`status=completed` 必带 + `completedAt`、其余状态禁带;终态互迁与回退 pending 均拒绝。 + - `completedAt` 由客户端提交(而非服务端 now()),允许补记实际完成时刻。 + - 提醒表无 version 列:并发流转采用当前状态条件更新守卫,读写窗口内被并发 + 流转抢先则 409/40902(「数据已被修改请刷新」,客户端处理方式与乐观锁一致)。 + operationId: updateCareReminder + security: + - bearerAuth: [] + parameters: + - name: reminderId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCareReminderRequest' + responses: + '200': + description: 更新成功,返回更新后完整 CareReminder + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PetWriteDenied' + '404': + $ref: '#/components/responses/RecordNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + '422': + description: 状态机非法迁移或 completed-completedAt 一致性违反(code 42202) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + missingCompletedAt: + value: { code: 42202, message: 标记 completed 必须提供 completedAt, data: null } + + /api/v1/pets/{petId}/summary: + get: + tags: [health-records] + summary: 档案聚合摘要 + description: | + 权限档:READ(三角色皆可读)。实时聚合生成档案页摘要:最新体重、疫苗进度、 + 下次接种、当月花费——四项聚合全部从事实表实时计算,**无任何写路径** + (不持久化展示字符串)。各聚合口径逐字见 PetSummary schema 字段描述 + (iteration-2 报告 18 §3 定型)。 + + `tz`:可选,IANA 时区标识(如 `Asia/Shanghai`,也接受固定偏移如 `+08:00`), + 缺省 `UTC`,仅作用于当月花费的月度窗口;非法 tz 或超 64 字符 → 400/40000。 + 客户端应传自己的时区以获得符合直觉的月边界。 + operationId: getPetSummary + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/PetIdParam' + - name: tz + in: query + required: false + schema: + type: string + maxLength: 64 + default: UTC + description: IANA 时区标识(如 Asia/Shanghai)或固定偏移(如 +08:00),仅作用于当月花费的月度窗口 + example: Asia/Shanghai + responses: + '200': + description: 聚合摘要 + content: + application/json: + schema: + $ref: '#/components/schemas/PetSummaryEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PetNotFound' + + # ====================================================================== + # Community / Media 域(M3 冻结,13 路径;定型依据:iteration-3 报告 13/15/16/17) + # ====================================================================== + + /api/v1/media/uploads: + post: + tags: [media] + summary: 创建上传(登记 asset 并签发预签名直传凭据) + description: | + 两步上传第一步:校验白名单与上限(`purpose` 仅 post_image、`mimeType` 仅 + image/jpeg|png|webp、`byteSize` ≤ 10485760,均为服务端配置项,后续扩展为 + 向后兼容的枚举追加)→ 写 `media.assets` 行(status=uploading,bucket/objectKey + 服务端生成、不含任何用户输入)→ 返回预签名 PUT 直传凭据(TTL 10 分钟,配置项)。 + 客户端凭凭据直传对象存储,不经应用服务器;直传必须**原样携带 requiredHeaders** + (Content-Type 已签进签名,改动即被存储侧拒绝)。M3 仅 `kind=image` + (ADR-018 视频后置;video/document 为向后新增枚举预留)。 + operationId: createMediaUpload + security: + - bearerAuth: [] + 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/Content-Type → + uploading→ready、写 readyAt,返回可引用的 asset(含现签预签名 GET URL)。 + + - **幂等**:对已 ready 的 asset 重复 complete 返回 200 同一 asset(现签新 GET URL)。 + - 对象尚不存在(直传完成前确认)→ 422/42205,asset **保持 uploading 可重试** + (补传后再确认即恢复,凭据未过期时无须重新创建上传)。 + - 对象存在但大小/类型与登记不符 → 置 failed(终态),422/42205,须重新创建上传。 + - failed 态再确认 → 422/42205(终态);不存在/非本人/已删 → 404/40405(防枚举合并)。 + - `sha256` 照收照存,M3 不做内容核验(存储侧 HEAD 不返回内容散列;后续经 + 存储侧 checksum 特性补齐,不改契约形态)。 + operationId: completeMediaUpload + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/AssetIdParam' + responses: + '200': + description: 确认成功(或幂等重复确认),asset 为 ready + content: + application/json: + schema: + $ref: '#/components/schemas/MediaAssetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/MediaNotFound' + '422': + description: | + asset 非 uploading 态或对象校验未通过(code 42205):对象未上传保持可重试、 + 大小/类型不符置 failed 终态、failed 态再确认(已 ready 幂等 200 除外) + 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` **必带**(语义见该头参数描述与 info「Community / Media 域 + 约定」)。`status` 可 draft(缺省)或 published(直接发布,服务端写 + publishedAt)。纯文字帖合法(media 空数组或缺席,D3-4)。 + + media 挂接(每帖 ≤9 图):只接受本人所有且 ready 的 asset(uploading/failed + 422/42203;不存在/非本人/已删 404/40405);`position` **全给或全不给**——全给 + 须恰为 0..n-1 连续不重复,全不给按数组序,混合 400/40000;`isCover` 至多一个 + true,全 false 时服务端将 position 0 行落库置为封面(库内恒有唯一封面行); + 同帖 assetId 不重复;caption trim 后 ≤300。 + `petId` 须为调用者可见宠物,否则 404/40401(沿 pets 域防枚举语义)。 + operationId: createPost + security: + - bearerAuth: [] + 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' + '404': + description: petId 引用不可见宠物(code 40401)或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + '422': + $ref: '#/components/responses/MediaNotReady' + + /api/v1/posts/{postId}: + get: + tags: [posts] + summary: 帖子详情 + description: | + 权限矩阵(iteration-3 报告 15 定型):published 对全部登录用户开放;draft 仅 + 作者可见;hidden/archived(运营态)**对作者同样 404/40403**——M3 无端点能产生 + 或解除运营态,status 枚举保持两值。一切不可见情形响应完全一致(防枚举)。 + 响应含 likedByMe/bookmarkedByMe 与作者公开摘要(AuthorSummary)。 + operationId: getPost + security: + - bearerAuth: [] + 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;一切不可见情形——含他人 draft——404/40403)。 + PATCH 部分更新惯例:缺席字段不变,不支持清空回 null(M2 先例)。`version` + 必带(缺失 400/40000,过期 409/40902)。 + + - **发布** = `status: published` 的状态迁移(draft→published 是唯一开放迁移, + 服务端写 publishedAt,恰写一次);**对已发布帖重复提交 `status: published` + 为幂等 no-op(200,version 照常 +1)**——同态提交不是迁移,弱网重发不报错; + draft/hidden/archived 目标值由请求枚举拒为 400/40000(published→draft 不支持)。 + - media 出现即**整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动), + 校验规则同创建。 + operationId: updatePost + security: + - bearerAuth: [] + 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': + description: | + 帖子不可见(code 40403,防枚举合并);或 petId 引用不可见宠物(code 40401); + 或 media 引用的 asset 不存在/非本人/已删(code 40405) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + '409': + $ref: '#/components/responses/VersionConflict' + '422': + $ref: '#/components/responses/MediaNotReady' + delete: + tags: [posts] + summary: 删除帖子(软删,仅作者) + description: | + 软删(deleted_at 为全域唯一删除判定基准),删除后详情/Feed/列表/互动一切路径 + 404/40403。重复删除与删不存在的帖同响应 404/40403(防枚举合并)。 + 不提供恢复端点(M3 无回收站)。非作者对已发布帖 403/40301。 + operationId: deletePost + security: + - bearerAuth: [] + 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(软删不含,hidden/archived 不含)。排序 + `(created_at DESC, id DESC)` 走 `ix_posts_author_created`,keyset 游标。 + `status` 过滤可选(draft|published)。 + operationId: listMyPosts + security: + - bearerAuth: [] + 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 帖子下一次请求即不可见。 + 卡片形态见 FeedCard(iteration-3 报告 16 定型);likedByMe/bookmarkedByMe + 为当前用户视角。 + operationId: getFeed + security: + - bearerAuth: [] + 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`,keyset 游标; + 仅 visible 评论。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。作者与 @ 目标均为 AuthorSummary。 + operationId: listComments + security: + - bearerAuth: [] + 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`(键按作者隔离、天然全局跨帖)。`replyToUserId` 可选 @ 回复 + (单层平铺,无楼中楼,ADR-018);目标须为存活用户,不存在/已注销 404/40406 + (合并不泄露成因)。互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**) + 404/40403。content trim 后 1~2000。comment_count 同事务 +1。 + operationId: createComment + security: + - bearerAuth: [] + 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': + description: 帖子不可见——含作者本人草稿(code 40403);或 replyToUserId 目标用户不存在/已注销(code 40406) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + userNotFound: + value: { code: 40406, message: 用户不存在, data: null } + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + + /api/v1/comments/{commentId}: + delete: + tags: [comments] + summary: 删除评论(仅评论作者;顶层短路径) + description: | + 顶层短路径先例(pets 域子资源同理):commentId 全局唯一。**仅评论作者可删—— + 帖主不可删除他人评论(D3-7 首版不做)**:对可见评论的非作者(含帖主) + 403/40301;不存在/已删/所属帖不可见合并 404/40404(防枚举)。 + 软删(status→deleted),comment_count 同事务 -1。 + operationId: deleteComment + security: + - bearerAuth: [] + 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) 即幂等键:重复 PUT 返回 200 同一权威终态(非 409), + 仅实际插入才 like_count 同事务 +1,并发 N 次计数恰为 1(M3 验收标准二)。 + 互动面 = 帖子公开面:帖子不可见(**含作者本人草稿**)404/40403。 + operationId: likePost + security: + - bearerAuth: [] + 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)。 + 帖子不可见(含作者本人草稿)404/40403。 + operationId: unlikePost + security: + - bearerAuth: [] + 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 语义幂等,与点赞同构) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: bookmarkPost + security: + - bearerAuth: [] + 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 语义幂等) + description: 帖子不可见(含作者本人草稿)404/40403。 + operationId: unbookmarkPost + security: + - bearerAuth: [] + 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`,游标键在收藏关系行上。项形态 = FeedCard, + 谓词与公共 Feed 恒等:被收藏帖软删/hidden/archived 后**静默剔除**(剔除在页 + 查询内完成,不破坏翻页不丢不重;publishedAt 恒非空不变式对本列表继续成立)。 + operationId: listMyBookmarks + security: + - bearerAuth: [] + 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 与关注/粉丝列表不在 M3(ADR-018 最小数据接口)。 + operationId: followUser + security: + - bearerAuth: [] + 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;仅 PUT——自取关走 DELETE 的 200 幂等 no-op) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + selfFollow: + value: { code: 42204, message: 不能关注自己, data: null } + delete: + tags: [follows] + summary: 取消关注(DELETE 语义幂等) + description: | + 取消不存在的关注不报错,返回 200 权威终态(following 恒 false)。 + **自取关同样 200 幂等 no-op**(关系行不可能存在,权威 false 即事实;42204 + 只在 PUT)。目标用户不存在/已注销 404/40406。 + operationId: unfollowUser + security: + - bearerAuth: [] + 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 为调用者视角, + 查自己时恒 false。目标用户不存在/已注销 404/40406。关注/粉丝**列表**端点 + 不在 M3(需时按纯增量补入)。 + operationId: getFollowStats + security: + - bearerAuth: [] + 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: + PetIdParam: + name: petId + 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 + IdempotencyKeyHeader: + name: Idempotency-Key + in: header + required: false + schema: + type: string + maxLength: 255 + description: | + 可选幂等键(≤255 字符,超长 400/40000)。键按「调用者 × 宠物 × 资源」隔离; + 同键重试返回首次创建的记录(同样 201);不比对请求体(每次逻辑提交应换新键, + 建议 UUID);键永久幂等(无 TTL)。不带键则无幂等语义。 + 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 + IdempotencyKeyRequiredHeader: + name: Idempotency-Key + in: header + required: true + schema: + type: string + maxLength: 128 + description: | + **必带**幂等键(1~128 字符,trim 后计;缺失/空白/超长 400/40000。与 pets 域 + 「可选、≤255、不比对请求体」刻意不同——community 域按 ADR-019 落表内幂等列, + 列宽 128)。键按作者隔离(跨用户同键互不干扰);同键重试返回首次创建的资源 + (同样 201);**比对规范化 request_hash**——hash 对象是规范化后的创建命令 + (trim、缺省展开),语义相同仅格式不同的重试仍命中首个资源;同键不同 payload + 返回 409/40905;同键重试撞已删除的首个资源返回 404(帖子 40403 / 评论 40404, + 资源已消亡,不复活不另建)。客户端每次逻辑提交换新键(建议 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 } + PetNotFound: + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401)。防枚举语义:三种情况 + 响应完全一致,随机探测 UUID 无法得知是否命中真实记录。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + RecordNotFound: + description: | + 记录不存在或记录所属宠物对调用者不可见(code 40402)。记录级防枚举语义: + 两种情况响应完全一致;只有对宠物可见的调用者才可能收到 403/40300。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + recordNotFound: + value: { code: 40402, message: 记录不存在, data: null } + PetWriteDenied: + description: | + 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 + 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + accessDenied: + value: { code: 40300, message: 无权限执行该操作, data: null } + VersionConflict: + description: | + 乐观锁版本冲突(code 40902):提交的 version 已过期(并发修改或重试)。 + 不静默覆盖,先写者数据保留;客户端刷新取新 version 后重提。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + versionConflict: + value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + PostNotFound: + description: | + 帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(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(uploading/failed)状态的 asset(code 42203)。 + asset 不存在/非本人/已删则合并为 404/40405。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + notReady: + value: { code: 42203, message: 媒体尚未就绪, data: null } + + schemas: + RegisterRequest: + type: object + required: [username, password] + properties: + username: + type: string + minLength: 3 + maxLength: 32 + description: 用户名,大小写不敏感唯一 + example: demo_user + phone: + type: string + pattern: '^\+[1-9][0-9]{7,14}$' + description: 手机号,E.164 格式;可选,唯一 + example: '+8613800138000' + password: + type: string + format: password + minLength: 6 + maxLength: 64 + example: secret123 + nickname: + type: string + minLength: 1 + maxLength: 32 + description: 昵称;可选(冻结稿之外的可选扩展字段,前端可忽略) + example: 小柴 + + LoginRequest: + type: object + required: [username, password] + properties: + username: + type: string + example: demo_user + password: + type: string + format: password + example: secret123 + + RefreshRequest: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + description: 当前持有的 refresh token(不透明随机串) + example: Zx3v…43位base64url…Qk + + LogoutRequest: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + description: 要撤销的当前会话的 refresh token + example: Zx3v…43位base64url…Qk + + AuthTokens: + type: object + description: 注册 / 登录 / 刷新共用的令牌对(冻结契约,恰好这 6 个字段) + required: + - userId + - tokenType + - accessToken + - accessTokenExpiresAt + - refreshToken + - refreshTokenExpiresAt + properties: + userId: + type: string + format: uuid + example: 019212aa-0000-7000-8000-000000000001 + tokenType: + type: string + enum: [Bearer] + example: Bearer + accessToken: + type: string + description: RS256 JWT,有效期 15 分钟(配置项) + example: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI… + accessTokenExpiresAt: + type: string + format: date-time + description: ISO 8601 带时区 + example: '2026-09-04T04:20:06.789Z' + refreshToken: + type: string + description: 不透明随机串,有效期 30 天(配置项),每次刷新轮换 + example: Zx3v…43位base64url…Qk + refreshTokenExpiresAt: + type: string + format: date-time + example: '2026-10-04T04:05:06.789Z' + + Me: + type: object + description: 当前用户资料(冻结契约,恰好这 4 个字段) + required: [userId, username, createdAt] + properties: + userId: + type: string + format: uuid + example: 019212aa-0000-7000-8000-000000000001 + username: + type: string + example: demo_user + phone: + type: string + nullable: true + description: E.164;未绑定时为 null + example: '+8613800138000' + createdAt: + type: string + format: date-time + example: '2026-09-04T04:05:06.789Z' + + AuthTokenEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/AuthTokens' + + MeEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Me' + + 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: 40101 + message: + type: string + example: token 无效或过期 + data: + nullable: true + example: null + + TrackEventsRequest: + type: object + required: [events] + properties: + events: + type: array + minItems: 1 + maxItems: 50 + description: 单批 1–50 条;越界整批 400/40000 + items: + $ref: '#/components/schemas/TrackedEvent' + + TrackedEvent: + type: object + required: + - eventId + - eventName + - eventVersion + - anonymousId + - sessionId + - clientTs + - appVersion + - platform + - osVersion + properties: + eventId: + type: string + format: uuid + description: 客户端生成的 UUID(规范要求 v7),服务端幂等去重键 + example: 019212aa-4444-7000-8000-000000000001 + eventName: + type: string + pattern: '^[a-z][a-z0-9_]{1,63}$' + description: 须在服务端事件字典内;不在字典中的事件名整条 rejected(unknown_event_name) + example: auth_login_succeeded + eventVersion: + type: integer + description: 事件 schema 版本(字典 v1 全部为 1) + example: 1 + anonymousId: + type: string + format: uuid + description: 设备级匿名标识,首次启动生成 + example: 019212aa-0000-7000-8000-000000000001 + userId: + type: string + format: uuid + nullable: true + description: | + 登录后填充,可选。已认证请求中若与 token subject 不一致,该条 + rejected(identity_mismatch);匿名请求中原样落库,不做校验。 + example: 019212aa-0000-7000-8000-000000000001 + sessionId: + type: string + format: uuid + description: 客户端会话标识 + example: 019212aa-1111-7000-8000-000000000001 + clientTs: + type: string + format: date-time + description: 客户端本地时间(ISO 8601 带时区);serverTs 由服务端补写,客户端不发 + example: '2026-09-07T04:05:06.789Z' + appVersion: + type: string + minLength: 1 + maxLength: 32 + example: 1.0.0+12 + platform: + type: string + enum: [android, ios] + example: android + osVersion: + type: string + minLength: 1 + maxLength: 32 + example: android-14 + props: + type: object + additionalProperties: true + description: | + 事件专有属性,可选。按事件字典白名单处理:白名单外的键剥离后入库 + (事件保留);键名命中隐私红线模式(password/token/secret/phone/ + mobile/email/credential/idfa/gaid,不区分大小写、子串匹配)则整条 + rejected(forbidden_field)。 + example: { identifierType: username, durationMs: 123 } + + TrackEventsResult: + type: object + description: 批次逐条结果(results 与请求 events 等长、按原顺序对应) + required: [accepted, duplicated, rejected, results] + properties: + accepted: + type: integer + description: 新落库条数 + example: 1 + duplicated: + type: integer + description: eventId 去重命中条数(视为成功,客户端不必重试) + example: 0 + rejected: + type: integer + description: 被拒条数(客户端不重试) + example: 0 + results: + type: array + items: + $ref: '#/components/schemas/EventResult' + + EventResult: + type: object + required: [eventId, status] + properties: + eventId: + type: string + format: uuid + example: 019212aa-4444-7000-8000-000000000001 + status: + type: string + enum: [accepted, duplicate, rejected] + example: accepted + reason: + type: string + enum: [unknown_event_name, identity_mismatch, forbidden_field, schema_invalid] + description: 仅 status=rejected 时出现(accepted/duplicate 不含该字段) + example: unknown_event_name + + TrackEventsEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/TrackEventsResult' + + # ================================================================== + # Pets 域 schemas(M2 冻结;响应主键统一裸 `id`,关联字段带类型名) + # ================================================================== + + Pet: + type: object + description: | + 宠物档案(列表 / 详情 / 创建 / 更新的统一响应形态,皆含 myRole)。 + `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); + `breedDisplayName` 由品种字典解出,随 breedId 存在。 + 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 + status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + required: + - id + - name + - species + - sex + - birthDateEstimated + - status + - myRole + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + name: + type: string + minLength: 1 + maxLength: 64 + species: + type: string + enum: [dog, cat, other] + description: 物种;创建即定,不可修改 + breedId: + type: string + format: uuid + nullable: true + description: 品种 ID(与 customBreedName 互斥,恰有其一非空) + breedDisplayName: + type: string + nullable: true + description: 品种展示名,由字典解出,随 breedId 存在 + customBreedName: + type: string + nullable: true + minLength: 1 + maxLength: 64 + description: 自定义品种名(与 breedId 互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + nullable: true + description: 生日(YYYY-MM-DD) + birthDateEstimated: + type: boolean + description: 生日是否为估计值 + personality: + type: string + nullable: true + maxLength: 64 + description: 性格标签 + microchipNo: + type: string + nullable: true + description: 芯片号(跨用户唯一) + sterilizedOn: + type: string + format: date + nullable: true + description: 绝育日期 + status: + type: string + enum: [active, lost, deceased, archived] + description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + myRole: + type: string + enum: [owner, caregiver, viewer] + description: 调用者对该宠物的权限角色(客户端据此显隐写入口) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + CreatePetRequest: + type: object + required: [name, species, sex] + properties: + name: + type: string + minLength: 1 + maxLength: 64 + species: + type: string + enum: [dog, cat, other] + description: 创建即定,之后不可修改 + breedId: + type: string + format: uuid + description: 品种 ID(与 customBreedName 二选一且互斥;双填/双空/物种错配/品种不存在或停用 → 400/40000) + customBreedName: + type: string + minLength: 1 + maxLength: 64 + description: 自定义品种名(与 breedId 二选一且互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + birthDateEstimated: + type: boolean + default: false + personality: + type: string + maxLength: 64 + microchipNo: + type: string + description: 芯片号;已被登记 → 409/40903 + sterilizedOn: + type: string + format: date + + UpdatePetRequest: + type: object + description: | + 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 + species 不可改(不在请求体)。 + required: [version] + properties: + version: + type: integer + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + name: + type: string + minLength: 1 + maxLength: 64 + breedId: + type: string + format: uuid + description: 品种对整体替换(与 customBreedName 互斥) + customBreedName: + type: string + minLength: 1 + maxLength: 64 + description: 品种对整体替换(与 breedId 互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + birthDateEstimated: + type: boolean + personality: + type: string + maxLength: 64 + microchipNo: + type: string + description: 已被登记 → 409/40903 + sterilizedOn: + type: string + format: date + status: + type: string + enum: [active, lost, deceased, archived] + description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + + PetEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Pet' + + PetListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + description: created_at DESC 排序,不分页 + items: + $ref: '#/components/schemas/Pet' + + Breed: + type: object + required: [id, species, code, displayName] + properties: + id: + type: string + format: uuid + species: + type: string + enum: [dog, cat, other] + code: + type: string + description: 品种代码(唯一标识) + displayName: + type: string + description: 展示名称 + + BreedListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/Breed' + + WeightRecord: + type: object + required: [id, petId, weightKg, measuredAt, source, createdAt] + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + weightKg: + type: number + format: double + minimum: 0.01 + maximum: 500 + description: 体重(公斤),最多两位小数(numeric(6,2)) + measuredAt: + type: string + format: date-time + description: 称重时间 + source: + type: string + enum: [manual, clinic, device] + description: 来源 + note: + type: string + nullable: true + maxLength: 500 + createdAt: + type: string + format: date-time + + CreateWeightRequest: + type: object + required: [weightKg, measuredAt] + properties: + weightKg: + type: number + format: double + minimum: 0.01 + maximum: 500 + description: 体重(公斤),(0, 500],最多两位小数;越界或三位小数 400/40000 + measuredAt: + type: string + format: date-time + source: + type: string + enum: [manual, clinic, device] + default: manual + note: + type: string + maxLength: 500 + + WeightEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/WeightRecord' + + WeightListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: object + description: cursor 分页正典信封;排序 measured_at DESC, id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/WeightRecord' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + VaccineCatalogItem: + type: object + required: [id, code, name, species] + properties: + id: + type: string + format: uuid + code: + type: string + description: 疫苗代码(唯一标识) + name: + type: string + description: 疫苗名称 + species: + type: string + enum: [dog, cat, other] + description: + type: string + nullable: true + maxLength: 500 + + VaccineCatalogListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/VaccineCatalogItem' + + Vaccination: + type: object + description: | + 疫苗记录。`vaccineName` 由疫苗目录解出(同 Pet.breedDisplayName 先例,列表页免 + 二次查字典)。`certificateAssetId / providerId / providerNameSnapshot / bookingId` + 整体不出现(ADR-010,M5 时纯增量补入)。 + required: + - id + - petId + - vaccineId + - vaccineName + - seriesKey + - doseNo + - status + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + vaccineId: + type: string + format: uuid + vaccineName: + type: string + description: 疫苗名称(出自疫苗目录) + seriesKey: + type: string + minLength: 1 + maxLength: 64 + description: 系列键(区分初次/加强等,与 doseNo 共同唯一);创建后不可改 + doseNo: + type: integer + minimum: 1 + maximum: 32767 + description: 剂次号(smallint);创建后不可改 + doseLabel: + type: string + nullable: true + maxLength: 64 + description: 剂次标签(如「第一针」) + status: + type: string + enum: [scheduled, completed, cancelled] + plannedOn: + type: string + format: date + nullable: true + description: 计划接种日期(scheduled 必有) + administeredOn: + type: string + format: date + nullable: true + description: 实际接种日期(completed 必有;scheduled/cancelled 必空) + nextDueOn: + type: string + format: date + nullable: true + description: 下次到期日期(与 administeredOn 同时存在时 ≥ administeredOn) + manufacturer: + type: string + nullable: true + maxLength: 128 + batchNo: + type: string + nullable: true + maxLength: 64 + notes: + type: string + nullable: true + maxLength: 1000 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + CreateVaccinationRequest: + type: object + description: | + 创建状态仅 scheduled / completed(创建即 cancelled 无业务意义,400/40000)。 + 疫苗必须存在、enabled 且 species 与宠物一致(400/40000)。 + 状态-日期规则违反 → 422/42201。 + required: [vaccineId, seriesKey, doseNo, status] + properties: + vaccineId: + type: string + format: uuid + seriesKey: + type: string + minLength: 1 + maxLength: 64 + doseNo: + type: integer + minimum: 1 + maximum: 32767 + doseLabel: + type: string + maxLength: 64 + status: + type: string + enum: [scheduled, completed] + plannedOn: + type: string + format: date + description: scheduled 状态必填 + administeredOn: + type: string + format: date + description: completed 状态必填;scheduled 不得携带 + nextDueOn: + type: string + format: date + description: 与 administeredOn 同时存在时须 ≥ administeredOn + manufacturer: + type: string + maxLength: 128 + batchNo: + type: string + maxLength: 64 + notes: + type: string + maxLength: 1000 + + UpdateVaccinationRequest: + type: object + description: | + 部分更新:缺席字段不变;不支持清空回 null。vaccineId / seriesKey / doseNo + 不可改(不在请求体)——登记错剂次的修正路径是 cancel 后重建。 + 合并态重跑与创建相同的状态-日期规则,违反 422/42201。 + required: [version] + properties: + version: + type: integer + description: 乐观锁(必填;缺失 400/40000,过期 409/40902) + status: + type: string + enum: [scheduled, completed, cancelled] + description: scheduled→completed / scheduled→cancelled;completed 与 cancelled 均为终态(非法迁移 422/42201);同状态编辑始终允许 + plannedOn: + type: string + format: date + administeredOn: + type: string + format: date + nextDueOn: + type: string + format: date + doseLabel: + type: string + maxLength: 64 + manufacturer: + type: string + maxLength: 128 + batchNo: + type: string + maxLength: 64 + notes: + type: string + maxLength: 1000 + + VaccinationEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Vaccination' + + VaccinationListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + description: 不分页;ORDER BY series_key, dose_no, created_at, id(含 cancelled 行) + items: + $ref: '#/components/schemas/Vaccination' + + HealthEvent: + type: object + description: | + 健康事件。`providerId / providerNameSnapshot / bookingId` 整体不出现 + (ADR-010,M5 时纯增量补入)。 + required: + - id + - petId + - eventType + - occurredAt + - title + - createdByUserId + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + eventType: + type: string + enum: [medical, feeding, deworming, grooming, measurement, note] + description: 事件类型;创建后不可改 + occurredAt: + type: string + format: date-time + description: 事件发生时间;创建后不可改 + title: + type: string + minLength: 1 + maxLength: 160 + description: 标题(服务端 btrim,trim 后为空 400/40000) + notes: + type: string + nullable: true + maxLength: 2000 + description: 备注(上限 2000 字符) + amountCents: + type: integer + format: int64 + nullable: true + minimum: 0 + description: 金额(整数分,非负);提交小数 400/40000(不做静默截断) + createdByUserId: + type: string + format: uuid + description: 创建者用户 ID(取自验签 token,永不可改) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + CreateHealthEventRequest: + type: object + required: [eventType, occurredAt, title] + properties: + eventType: + type: string + enum: [medical, feeding, deworming, grooming, measurement, note] + occurredAt: + type: string + format: date-time + title: + type: string + minLength: 1 + maxLength: 160 + notes: + type: string + maxLength: 2000 + amountCents: + type: integer + format: int64 + minimum: 0 + description: 金额(整数分,非负);提交小数 400/40000 + + UpdateHealthEventRequest: + type: object + description: | + 部分更新:缺席字段不变;不支持清空回 null。仅可编辑 title / notes / amountCents; + eventType / occurredAt / createdByUserId 不可改(不在请求体)。 + required: [version] + properties: + version: + type: integer + description: 乐观锁(必填;缺失 400/40000,过期 409/40902) + title: + type: string + minLength: 1 + maxLength: 160 + description: 提交空白串(trim 后为空)400/40000 + notes: + type: string + maxLength: 2000 + amountCents: + type: integer + format: int64 + minimum: 0 + description: 提交小数 400/40000 + + HealthEventEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/HealthEvent' + + HealthEventListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: object + description: cursor 分页正典信封;排序 occurred_at DESC, id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/HealthEvent' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + CareReminder: + type: object + description: | + 照护提醒。**无 version 字段**(care_reminders 表无该列,状态流转用当前状态 + 条件更新守卫,守卫落空 409/40902)。completedAt 非空当且仅当 status=completed。 + required: + - id + - petId + - reminderType + - title + - dueAt + - status + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + petId: + type: string + format: uuid + reminderType: + type: string + enum: [deworming, checkup, medication, other] + title: + type: string + minLength: 1 + maxLength: 160 + dueAt: + type: string + format: date-time + description: 到期时间 + status: + type: string + enum: [pending, completed, dismissed] + completedAt: + type: string + format: date-time + nullable: true + description: 完成时间;非空当且仅当 status=completed + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + CreateCareReminderRequest: + type: object + description: 创建恒为 pending(不收 status 字段,多余字段被忽略) + required: [reminderType, title, dueAt] + properties: + reminderType: + type: string + enum: [deworming, checkup, medication, other] + title: + type: string + minLength: 1 + maxLength: 160 + dueAt: + type: string + format: date-time + + UpdateCareReminderRequest: + type: object + description: | + 状态流转专用(仅 status + completedAt)。pending→completed 必带 completedAt、 + pending→dismissed 禁带;终态互迁与回退 pending 拒绝(422/42202); + 同状态重放始终允许(幂等成功)。completedAt 由客户端提交,允许补记实际完成时刻。 + required: [status] + properties: + status: + type: string + enum: [pending, completed, dismissed] + completedAt: + type: string + format: date-time + description: status=completed 时必填;其余状态禁带(违反 422/42202) + + CareReminderEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CareReminder' + + CareReminderListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + description: 不分页;ORDER BY due_at ASC, id;支持 ?status= 白名单过滤 + items: + $ref: '#/components/schemas/CareReminder' + + PetSummary: + type: object + description: | + 档案聚合摘要(四项聚合全部从事实表实时计算,无持久化;口径为 iteration-2 + 报告 18 §3 定型表逐字收录)。latestWeight / vaccinationProgress / + nextVaccination 三项可为 null(无对应记录);monthlyExpense 恒非 null。 + required: [petId, monthlyExpense] + properties: + petId: + type: string + format: uuid + description: 恒非 null,回显路径参数 + latestWeight: + type: object + nullable: true + description: | + 最新体重。口径:pet_weight_records 按 (measured_at DESC, id DESC) 取首行—— + 与体重列表接口首行完全一致(同一索引 ix_pet_weight_pet_measured、同一 + tie-break),同刻多条时后写入者(id 更大)胜出。无记录 → null。 + required: [weightKg, measuredAt] + properties: + weightKg: + type: number + format: double + description: 两位小数(numeric(6,2)),对象存在时非 null + measuredAt: + type: string + format: date-time + description: 对象存在时非 null + vaccinationProgress: + type: object + nullable: true + description: | + 疫苗进度。口径:范围 = 该宠物非 cancelled 的 pet_vaccinations 行。 + completedDoses = 其中 status=completed 的行数;totalDoses = 全部非 cancelled + 行数(= scheduled + completed,即「已登记剂次」——数据模型没有权威的 + 「系列应打总针数」,分母取用户已登记数)。cancelled 分子分母皆不计入。 + totalDoses=0 → 整体 null(**不是 0/0**)。 + required: [completedDoses, totalDoses] + properties: + completedDoses: + type: integer + minimum: 0 + description: 已完成剂次,对象存在时非 null + totalDoses: + type: integer + minimum: 1 + description: 已登记剂次(scheduled + completed),对象存在时非 null(=0 即整体 null) + nextVaccination: + type: object + nullable: true + description: | + 下次接种。口径:候选集两类并集:① 全部 scheduled 行的 planned_on(约束保证 + 非空;含过期——逾期计划在完成/取消前仍是下一针),source=planned; + ② completed 行的非空 next_due_on,仅当同 (pet, vaccine, series_key) 不存在 + 更高 dose_no 的非 cancelled 记录(后续针一经登记,其自身即代表下一针, + 前一针的到期日失效),source=nextDue。cancelled 行不产生任何候选。 + 取 dueOn 最小者;同日 planned 优先于 nextDue,再按 id 升序保证确定性。 + 候选集空 → null。 + required: [vaccinationId, vaccineId, vaccineName, doseNo, dueOn, source] + properties: + vaccinationId: + type: string + format: uuid + description: 命中的疫苗记录 id(客户端可跳详情),非 null + vaccineId: + type: string + format: uuid + description: 非 null + vaccineName: + type: string + description: 非 null,出自 vaccine_catalog(同 breedDisplayName 先例) + doseNo: + type: integer + description: 非 null + doseLabel: + type: string + nullable: true + description: 记录本身可无标签 + dueOn: + type: string + format: date + description: 非 null;**可为过去日期**(逾期针仍是下一针) + source: + type: string + enum: [planned, nextDue] + description: 非 null,标注取值来源(scheduled 的 plannedOn 或 completed 的 nextDueOn) + monthlyExpense: + type: object + description: | + 当月花费,**恒非 null**(月份/时区总可确定)。口径:health_events.amount_cents + 求和,窗口为请求时刻在 tz 时区的自然月半开区间 [当月1日00:00, 次月1日00:00), + 对 occurred_at(timestamptz)比较;月初第一刻含、次月第一刻不含。 + amount_cents 为 NULL 的事件不计入;不按 event_type 过滤(任何事件类型的 + 金额都算支出)。tz 缺省 UTC,客户端应传自己的时区获得符合直觉的月边界—— + 月边界随 tz 移动。恒返回对象:month 为窗口所属 ISO 年月、timezone 回显、 + 无支出 amountCents=0。 + required: [month, timezone, amountCents] + properties: + month: + type: string + description: ISO year-month(如 2026-09),非 null + example: '2026-09' + timezone: + type: string + description: 回显窗口所用时区(缺省 UTC),非 null + example: UTC + amountCents: + type: integer + format: int64 + minimum: 0 + description: 非 null,无支出为 0 + + PetSummaryEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/PetSummary' + + # ================================================================== + # Community / Media 域 schemas(M3 冻结;定型依据:iteration-3 报告 13/15/16/17) + # ================================================================== + + AuthorSummary: + type: object + description: | + 作者公开摘要(D3-9 方案 B,iteration-3 报告 16 定型;community 跨 schema + 只读 identity 取数,ADR-017)。正常路径 nickname 恒非空——空昵称由服务端 + 回退为 username(客户端不做回退拼装,回退后的展示名不标注来源); + nickname 与 avatarUrl 同为 null 即「降级/墓碑」形态(作者资料暂不可得, + 或用户已注销)——两种情形同一形态,客户端只需一种占位逻辑。 + 不露 bio、不露 username。 + required: [userId] + properties: + userId: + type: string + format: uuid + description: 恒非空,任何情形都在 + nickname: + type: string + nullable: true + maxLength: 32 + description: 昵称(空昵称已由服务端回退为 username);null 仅出现在降级/注销墓碑形态 + example: 毛毛的铲屎官 + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取;无头像 / 头像 asset 非 ready / 降级 → null + (客户端出占位) + + # ---------- 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: | + 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 + user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + mimeType: + type: string + enum: [image/jpeg, image/png, image/webp] + description: 白名单外 400/40000;不收 HEIC(客户端压缩管线统一转码 jpeg) + byteSize: + type: integer + format: int64 + minimum: 1 + maximum: 10485760 + description: 声明的文件字节数,complete 时与对象实测比对;上限 10485760(10 MiB,服务端配置项) + sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: 可选,64 位小写 hex;照收照存,M3 不做内容核验(后续经存储侧 checksum 特性补齐,不改契约形态) + + MediaUploadCredentials: + type: object + description: 预签名直传凭据(ADR-016,iteration-3 报告 13 定型) + required: [assetId, uploadUrl, method, requiredHeaders, expiresAt] + properties: + assetId: + type: string + format: uuid + description: 已登记的 asset ID(status=uploading) + uploadUrl: + type: string + description: | + 预签名 PUT 完整 URL——签名以 query 参数携带(X-Amz-Algorithm/-Credential/ + -Signature 族),指向客户端可达的对象存储端点;客户端直传,不经应用服务器 + method: + type: string + enum: [PUT] + requiredHeaders: + type: object + additionalProperties: + type: string + description: | + 直传请求必须**原样携带**的头。键集定型为恒且仅一键: + `{"Content-Type": <声明的 mimeType>}`——Content-Type 已签进签名, + 改动即被存储侧拒绝 + expiresAt: + type: string + format: date-time + description: | + 凭据过期时刻 = 签发时刻 + TTL(默认 10 分钟,配置项);过期后重新创建 + 上传(原 asset 在补传后仍可确认) + + 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 + description: complete 后回填,可空 + heightPx: + type: integer + nullable: true + status: + type: string + enum: [uploading, ready, failed] + description: deleted 态对外恒 404/40405,不出现在响应 + url: + type: string + nullable: true + description: | + 访问 URL,仅 ready 态非空——时效性预签名 GET(TTL 默认 1 小时,配置项), + 每次响应现签,客户端不得持久化、过期即重取;桶保持私有,无签名直访被拒 + 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 + description: 库内恒有唯一封面行(写侧保证:全 false 时服务端将 position 0 行置真) + url: + type: string + description: | + 图片访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + 客户端不得持久化、过期即重取(运维前提:生产环境对象存储恒配置) + 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(不存在/非本人/已删 404/40405) + required: [assetId] + properties: + assetId: + type: string + format: uuid + description: 同帖 assetId 不得重复(400/40000) + position: + type: integer + minimum: 0 + maximum: 8 + description: | + **全给或全不给**:全给须恰为 0..n-1 连续不重复;全不给按数组序; + 混合 400/40000 + isCover: + type: boolean + default: false + description: 至多一个 true(uq_post_media_cover);全 false 时服务端将 position 0 行落库置为封面 + caption: + type: string + maxLength: 300 + description: trim 后 ≤300 + + CreatePostRequest: + type: object + required: [content] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + description: 可选标题(ck_posts_title;空白串 400/40000) + 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 不开放写入(提交 400/40000) + 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 + description: | + 部分更新:缺席字段不变;不支持清空回 null(M2 惯例)。media 若出现则 + **整组替换**(删旧插新;`[]` 清空为纯文字帖;缺席不动),校验规则同创建。 + required: [version] + properties: + version: + type: integer + minimum: 0 + description: 乐观锁,必带(缺失 400/40000);过期 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(发布动作,服务端写 publishedAt); + 对已发布帖重复提交为幂等 no-op(200,version 照常 +1); + draft/hidden/archived 目标值 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/40403 + (M3 无端点能产生或解除运营态) + 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 + description: | + 行最后更新时刻——互动计数维护亦会推动该值;判断「内容是否编辑过」 + 以 version 为准,勿以 updatedAt 判断 + publishedAt: + type: string + format: date-time + nullable: true + description: 仅 published 非空(发布时恰写一次) + version: + type: integer + + FeedCard: + type: object + description: | + Feed / 收藏列表卡片形态(较 Post 裁剪,iteration-3 报告 16 定型:只带 + coverImage + mediaCount,不带整组图;content 全文、petId、visibility、 + version、media 整组、created/updated 时间戳对均不出现,全文走帖子详情)。 + 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 + description: 原样透传,无标题为 null + contentPreview: + type: string + description: | + 正文前 200 个 Unicode 码点,**码点边界截断**(emoji 等增补面字符绝不 + 劈开),不追加省略号;短于 200 码点原样透传。全文恒走帖子详情端点 + coverImage: + nullable: true + allOf: + - $ref: '#/components/schemas/PostMediaItem' + description: | + 封面图 = 库中唯一 is_cover 行(写侧保证有图必有唯一封面行,读侧零特判); + 纯文字帖为 null + mediaCount: + type: integer + minimum: 0 + maximum: 9 + 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 + description: 恒非空(Feed 与收藏列表谓词只放行 published) + + 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: trim 后 1~2000(ck_comments_content 同宽) + replyToUserId: + type: string + format: uuid + description: | + 可选 @ 回复目标(单层平铺,无 parentCommentId,ADR-018); + 目标须为存活用户,不存在/已注销 404/40406 + + Comment: + type: object + description: M3 无评论编辑,不带 updatedAt + 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: '@ 回复目标的公开摘要(含降级 id-only 形态);非回复为 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 为目标用户的粉丝数(实时 COUNT) + 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: 目标用户的粉丝数(实时 COUNT) + followingCount: + type: integer + format: int64 + description: 目标用户关注的人数(实时 COUNT) + 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'