From 5f02909af6977eff410a30d28b75c3313f3689fb Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:19:19 +0800 Subject: [PATCH] =?UTF-8?q?docs(api):=20M3.5=20=E5=A5=91=E7=BA=A6=E5=86=BB?= =?UTF-8?q?=E7=BB=93=20v1.4.0=E2=80=94=E2=80=94=E7=94=A8=E6=88=B7=E8=B5=84?= =?UTF-8?q?=E6=96=99=E4=B8=8E=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 iteration-3.5/03 号报告定型表冻结第一波后端交付,相对 v1.3.0 纯增量 (无字段删改、无类型变更、无必填收紧),v1.3.0 客户端无需改动: - GET /api/v1/me 响应补 nickname(DB 原值、不做 username 回退)与 avatarUrl(时效性预签名 GET,会过期、客户端不得持久化),两者键恒在值可空 - 新增 PATCH /api/v1/me:三态部分更新(键缺省=不改 / 显式 null=清空 / 给值=设置),空 patch 与纯空白昵称 400/40000,无乐观锁无幂等键 - Pet 补 avatarUrl(列表/详情/创建/更新四处统一);PATCH /api/v1/pets/{petId} 收三态 avatarAssetId,补 404/40405 与 422/42203 两格,权限按本次触及字段 定档(仅头像 WRITE、触及资料 MANAGE、混合取更严) - 新增 GET /api/v1/me/community-stats:receivedLikeCount/publishedPostCount (int64,空数据 0,永不 404),聚合口径逐条进描述 - 两处均不外露 avatarAssetId(只写不读,"有头像"等价 avatarUrl != null) - 媒体 purpose 白名单枚举追加 user_avatar/pet_avatar - 零新增错误码:复用 40000/40101/40300/40400/40401/40405/40902/42203, 错误码表只补语义(40300/40405/42203 三行) 规模 31→32 路径 / 43→45 操作 / 72→75 schemas(UpdateMeRequest、 CommunityStats、CommunityStatsEnvelope——后者为与全 API「每个 200 响应引一个 XxxEnvelope」的既有形态保持一致,故比 03 号报告预估多一个)。 校验:yaml 解析通过、96 处 $ref 全解析、45 个 operationId 无重复、 零未引用 schema、mkdocs build --strict 通过。 Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/index.md | 12 +- docs/api/openapi.yaml | 342 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 325 insertions(+), 29 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index 65f6d9a..10a48c0 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,6 +1,6 @@ # API 契约 -正式契约见 [openapi.yaml](openapi.yaml)(OpenAPI 3,v1.3.0),当前 31 路径 / 43 操作: +正式契约见 [openapi.yaml](openapi.yaml)(OpenAPI 3,v1.4.0),当前 32 路径 / 45 操作: - 认证域(第一迭代冻结):注册、登录、刷新、退出、当前用户 5 个端点,统一错误信封 `{code, message, data}` 与错误码表,以及会话轮换与登录锁定策略说明。 - 埋点域(M2 第一波补录):`POST /api/v1/events` 批量上报产品事件——单批 1–50 条、202 逐条结果(accepted/duplicate/rejected)、`eventId` 幂等去重、唯一允许匿名的写端点(携带 Bearer 则完整校验)。 @@ -25,4 +25,12 @@ 创建型写入(发帖/评论)`Idempotency-Key` **必带**(1~128,比对规范化 request_hash,与 pets 域可选键刻意不同);互动面 = 帖子公开面(作者本人草稿在互动路径同样 404);错误码新增 40301/40403/40404/40405/40406/40905/42203/42204/42205。 -约定:契约变更须先改本文件目录下的 OpenAPI,再改实现(契约先行);错误码只增不改义;**pets 域已冻结(1.2.0)、community/media 域已冻结(1.3.0)——冻结后任何字段变更须显著上报、两端同步**。 +- 用户资料与头像(M3.5 第一波冻结,1 新路径 / 2 新操作;冻结报告为 iteration-3.5 的 04 号报告,波末入档): + - 本人资料读写:`GET/PATCH /api/v1/me`(`nickname` + `avatarUrl` 读,昵称与头像写;**三态部分更新**:键缺省 = 不改 / 显式 `null` = 清空 / 给值 = 设置;空 patch 400/40000;无乐观锁、无幂等键) + - 宠物头像:`Pet.avatarUrl`(列表/详情/创建/更新四处统一)+ `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId`(三态;权限**按本次触及字段定档**——仅头像 WRITE、触及资料字段 MANAGE、混合取更严) + - 我的社区数字:`GET /api/v1/me/community-stats`(`receivedLikeCount`/`publishedPostCount`,读侧实时聚合,空数据 0,**永不 404**) + - 媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar`(枚举纯追加) + + 头像读取一律为**时效性预签名 GET**(会过期、客户端不得持久化);`avatarAssetId` **只写不读**,「有头像」等价 `avatarUrl != null`;三种用途互不通用(不符 404/40405,未就绪 422/42203)。**零新增错误码**(复用 40000/40101/40300/40400/40401/40405/40902/42203),故本次错误码表只补语义不加号。 + +约定:契约变更须先改本文件目录下的 OpenAPI,再改实现(契约先行);错误码只增不改义;**pets 域已冻结(1.2.0)、community/media 域已冻结(1.3.0)、用户资料与头像已冻结(1.4.0)——冻结后任何字段变更须显著上报、两端同步**。v1.4.0 相对 v1.3.0 **纯增量**(新增操作/响应字段/可选请求字段/响应格/枚举追加),v1.3.0 客户端无需改动。 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c9d3072..d669f33 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) - version: 1.3.0 + version: 1.4.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 @@ -11,6 +11,14 @@ info: **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + **1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`, + 新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且 + `PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats` + (获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar` + (iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。 + **1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、 + 新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加, + v1.3.0 客户端无需改动即可继续工作。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -29,14 +37,14 @@ info: | 40100 | 401 | 用户名或密码错误 | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | - | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档) | | 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 不存在、非本人所有或已删(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | @@ -46,7 +54,7 @@ info: | 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 | + | 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 | 登录失败次数过多,账号已临时锁定(见下) | @@ -68,7 +76,10 @@ info: - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; - - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + **M3.5 起还含宠物头像字段 `avatarAssetId`**(ADR-022:头像属日常照护信息)。 + - `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。 + **同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE, + 触及任一资料字段走 MANAGE,混合请求取更严的一半。 权限每请求实时查库、无缓存:撤销照护关系立即生效。 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 @@ -126,6 +137,41 @@ info: 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 (如作者公开资料批量接口)不属于本公开契约。 + ## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022) + - **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**:`PATCH /api/v1/me` 的 + `nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId` + 按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。 + 同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。 + - **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留 + 显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。 + - **昵称**:btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**(ADR-022, + 允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**, + 未设置即 `null`,**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定 + 固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`(SQL COALESCE), + 本人视角的展示回退由客户端做 `nickname ?? username`。 + - **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签, + **会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置 + 三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。 + 指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。 + - **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段); + 「是否有头像」等价于 `avatarUrl != null`。 + - **两种头像用途互不通用**:`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、 + `post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405 + (四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但 + uploading/failed → 422/42203)。 + - **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档): + 仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料 + 字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像 + 请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。 + - **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version` + 只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求 + 真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。 + - **`GET /api/v1/me/community-stats` 为独立端点**(ADR-022 决策 A,不并入 + `/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」 + 会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限 + 判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) @@ -140,7 +186,7 @@ tags: - name: auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth) - name: user - description: 当前用户(patbond-user) + description: 当前用户资料:读取与昵称/头像更新(patbond-user) - name: analytics description: 产品事件批量上报(patbond-user) - name: pets @@ -152,7 +198,9 @@ tags: - name: media description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) - name: posts - description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) + description: | + 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」 + 聚合(patbond-community) - name: feed description: 公共 Feed 游标分页(patbond-community) - name: comments @@ -301,7 +349,15 @@ paths: get: tags: [user] summary: 当前用户资料 - description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + description: | + 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + + - `nickname` 为 **DB 原值**,未设置即 `null`(**不做 username 回退**,见 info + 「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。 + - `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**, + 过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。 + - 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 + `avatarUrl != null`。 operationId: me security: - bearerAuth: [] @@ -320,6 +376,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + patch: + tags: [user] + summary: 更新当前用户资料(昵称 / 头像,三态部分更新) + description: | + 本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。 + + - **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。 + - **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200, + 空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。 + - `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed + 422/42203。 + - **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由 + 列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。 + - 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。 + operationId: updateMe + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeRequest' + responses: + '200': + description: 更新成功,返回更新后的完整 Me + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '400': + description: | + 参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、 + 昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyPatch: + value: { code: 40000, message: 请求未包含任何可更新字段, data: null } + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: | + 用户不存在或已注销(code 40400,token 仍有效但账号已注销);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + userNotFound: + value: { code: 40400, message: 用户不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/events: post: @@ -468,18 +584,32 @@ paths: $ref: '#/components/responses/PetNotFound' patch: tags: [pets] - summary: 更新宠物档案 + summary: 更新宠物档案(含头像) description: | - 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022): - - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + | 请求体触及 | 所需档位 | caregiver | viewer | + | --- | --- | --- | --- | + | 仅 `avatarAssetId`(+ `version`) | WRITE | ✅ | ✗ 403/40300 | + | 任一资料字段(name/sex/status/…) | MANAGE(仅 owner) | ✗ 403/40300 | ✗ 403/40300 | + | 资料字段 + `avatarAssetId` 混合 | MANAGE(**取更严的一半**) | ✗ 403/40300 | ✗ 403/40300 | + + 混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。 + + - 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。 + - **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改; + 键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空 + 需求的字段上。 - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 整对,互斥校验同创建。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 - - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1; + 过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。 - 芯片号改为已被登记的值:409/40903。 + - `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。 operationId: updatePet security: - bearerAuth: [] @@ -505,7 +635,19 @@ paths: '403': $ref: '#/components/responses/PetWriteDenied' '404': - $ref: '#/components/responses/PetNotFound' + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar` + (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': description: 版本冲突(code 40902)或芯片号已被登记(code 40903) content: @@ -517,6 +659,8 @@ paths: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } microchipExists: value: { code: 40903, message: 芯片号已被登记, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/breeds: get: @@ -1201,7 +1345,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' '422': @@ -1284,7 +1428,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/VersionConflict' '422': @@ -1348,6 +1492,35 @@ paths: '401': $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/me/community-stats: + get: + tags: [posts] + summary: 我的社区数字(获赞总数 / 作品数) + description: | + 主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**—— + 「查不到别人的获赞」不靠权限判断,而是入口本身不存在。 + + - **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。 + 草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与 + `/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见, + 含作者本人);他人帖自然不计。 + - **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。 + - `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务 + 维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。 + - **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。 + operationId: getMyCommunityStats + security: + - bearerAuth: [] + responses: + '200': + description: 我的获赞总数与作品数 + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityStatsEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/feed: get: tags: [feed] @@ -1812,8 +1985,9 @@ components: value: { code: 40402, message: 记录不存在, data: null } PetWriteDenied: description: | - 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 - 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer + 改宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)。 + 仅发给对宠物「可见」的调用者,不泄露新信息。 content: application/json: schema: @@ -1854,14 +2028,16 @@ components: commentNotFound: value: { code: 40404, message: 评论不存在, data: null } MediaNotFound: - description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) + description: | + asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。 + 用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } UserNotFound: description: 目标用户不存在或已注销(code 40406,合并不泄露成因) content: @@ -2001,8 +2177,10 @@ components: Me: type: object - description: 当前用户资料(冻结契约,恰好这 4 个字段) - required: [userId, username, createdAt] + description: | + 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。 + **不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。 + required: [userId, username, nickname, avatarUrl, createdAt] properties: userId: type: string @@ -2011,16 +2189,65 @@ components: username: type: string example: demo_user + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。 + **本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成 + 真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的 + 回退在 `/internal/users/profiles`(SQL COALESCE,不属本公开契约)。 + 不设唯一约束(ADR-022,允许重名)。 + example: 小柴 phone: type: string nullable: true description: E.164;未绑定时为 null example: '+8613800138000' + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=… createdAt: type: string format: date-time example: '2026-09-04T04:05:06.789Z' + UpdateMeRequest: + type: object + description: | + 本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同): + **键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。 + 无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。 + properties: + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。 + 显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空** + (清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。 + example: 小柴 + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。 + 显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405; + 本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。 + **响应不回显该字段**(只写不读)。 + example: 019212bb-0000-7000-8000-000000000009 + AuthTokenEnvelope: type: object required: [code, message] @@ -2226,7 +2453,8 @@ components: `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedDisplayName` 由品种字典解出,随 breedId 存在。 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 - status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回, + **`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。 required: - id - name @@ -2234,6 +2462,7 @@ components: - sex - birthDateEstimated - status + - avatarUrl - myRole - createdAt - updatedAt @@ -2294,6 +2523,15 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + avatarUrl: + type: string + nullable: true + description: | + 宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。 + example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=… myRole: type: string enum: [owner, caregiver, viewer] @@ -2351,14 +2589,18 @@ components: UpdatePetRequest: type: object description: | - 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对 (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 species 不可改(不在请求体)。 + **`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改; + 键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。 + 权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严), + 见端点描述。 required: [version] properties: version: type: integer - description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902);即便只改头像也必带 name: type: string minLength: 1 @@ -2393,6 +2635,16 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。 + **三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/ + 非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203; + 非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。 + example: 019212cc-0000-7000-8000-00000000000a PetEnvelope: type: object @@ -3205,10 +3457,13 @@ components: description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) purpose: type: string - enum: [post_image] + enum: [post_image, user_avatar, pet_avatar] description: | - 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 - user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀 + `/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、 + `user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。 + **用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、 + 两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。 mimeType: type: string enum: [image/jpeg, image/png, image/webp] @@ -3857,3 +4112,36 @@ components: example: success data: $ref: '#/components/schemas/FollowStats' + + CommunityStats: + type: object + description: | + 调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、 + 未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。 + required: [receivedLikeCount, publishedPostCount] + properties: + receivedLikeCount: + type: integer + format: int64 + description: | + 获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。 + **自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。 + example: 128 + publishedPostCount: + type: integer + format: int64 + description: 作品数 = 该集合的帖子数。空数据为 0,非 null。 + example: 12 + + CommunityStatsEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CommunityStats'