Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38d9e97174 | |||
| f9b1358b37 | |||
| 5f02909af6 | |||
| 6e1ab8ebf1 | |||
| 95976ae2c3 | |||
| 1bcfe444c6 |
+10
-2
@@ -1,6 +1,6 @@
|
|||||||
# API 契约
|
# 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}` 与错误码表,以及会话轮换与登录锁定策略说明。
|
- 认证域(第一迭代冻结):注册、登录、刷新、退出、当前用户 5 个端点,统一错误信封 `{code, message, data}` 与错误码表,以及会话轮换与登录锁定策略说明。
|
||||||
- 埋点域(M2 第一波补录):`POST /api/v1/events` 批量上报产品事件——单批 1–50 条、202 逐条结果(accepted/duplicate/rejected)、`eventId` 幂等去重、唯一允许匿名的写端点(携带 Bearer 则完整校验)。
|
- 埋点域(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。
|
创建型写入(发帖/评论)`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 客户端无需改动。
|
||||||
|
|||||||
+315
-27
@@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约)
|
title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约)
|
||||||
version: 1.3.0
|
version: 1.4.0
|
||||||
description: |
|
description: |
|
||||||
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
|
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
|
||||||
1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。
|
1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。
|
||||||
@@ -11,6 +11,14 @@ info:
|
|||||||
**1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、
|
**1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、
|
||||||
公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入
|
公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入
|
||||||
(iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。
|
(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 节)
|
## 通用约定(development-plan 第 6 节)
|
||||||
- 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
|
- 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
|
||||||
@@ -29,14 +37,14 @@ info:
|
|||||||
| 40100 | 401 | 用户名或密码错误 |
|
| 40100 | 401 | 用户名或密码错误 |
|
||||||
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
|
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
|
||||||
| 40102 | 401 | refresh 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:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 |
|
| 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 |
|
||||||
| 40400 | 404 | 资源不存在 |
|
| 40400 | 404 | 资源不存在 |
|
||||||
| 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
|
| 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
|
||||||
| 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
|
| 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
|
||||||
| 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 |
|
| 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 |
|
||||||
| 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) |
|
| 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——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) |
|
| 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) |
|
||||||
| 40900 | 409 | 用户名已存在(大小写不敏感) |
|
| 40900 | 409 | 用户名已存在(大小写不敏感) |
|
||||||
| 40901 | 409 | 手机号已被使用 |
|
| 40901 | 409 | 手机号已被使用 |
|
||||||
@@ -46,7 +54,7 @@ info:
|
|||||||
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) |
|
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) |
|
||||||
| 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
|
| 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
|
||||||
| 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 |
|
| 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) |
|
| 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) |
|
||||||
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
||||||
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
||||||
@@ -68,7 +76,10 @@ info:
|
|||||||
- **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
|
- **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
|
||||||
- `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
|
- `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
|
||||||
- `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH;
|
- `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH;
|
||||||
- `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。
|
**M3.5 起还含宠物头像字段 `avatarAssetId`**(ADR-022:头像属日常照护信息)。
|
||||||
|
- `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。
|
||||||
|
**同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE,
|
||||||
|
触及任一资料字段走 MANAGE,混合请求取更严的一半。
|
||||||
权限每请求实时查库、无缓存:撤销照护关系立即生效。
|
权限每请求实时查库、无缓存:撤销照护关系立即生效。
|
||||||
- **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
|
- **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
|
||||||
响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
|
响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
|
||||||
@@ -126,6 +137,41 @@ info:
|
|||||||
整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口
|
整体不出现,后续按新增可选字段/端点纯增量补入。`/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:
|
servers:
|
||||||
- url: http://127.0.0.1:8081
|
- url: http://127.0.0.1:8081
|
||||||
description: patbond-auth(本地开发,/api/v1/auth/**)
|
description: patbond-auth(本地开发,/api/v1/auth/**)
|
||||||
@@ -140,7 +186,7 @@ tags:
|
|||||||
- name: auth
|
- name: auth
|
||||||
description: 注册 / 登录 / 刷新 / 退出(patbond-auth)
|
description: 注册 / 登录 / 刷新 / 退出(patbond-auth)
|
||||||
- name: user
|
- name: user
|
||||||
description: 当前用户(patbond-user)
|
description: 当前用户资料:读取与昵称/头像更新(patbond-user)
|
||||||
- name: analytics
|
- name: analytics
|
||||||
description: 产品事件批量上报(patbond-user)
|
description: 产品事件批量上报(patbond-user)
|
||||||
- name: pets
|
- name: pets
|
||||||
@@ -152,7 +198,9 @@ tags:
|
|||||||
- name: media
|
- name: media
|
||||||
description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传)
|
description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传)
|
||||||
- name: posts
|
- name: posts
|
||||||
description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community)
|
description: |
|
||||||
|
帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」
|
||||||
|
聚合(patbond-community)
|
||||||
- name: feed
|
- name: feed
|
||||||
description: 公共 Feed 游标分页(patbond-community)
|
description: 公共 Feed 游标分页(patbond-community)
|
||||||
- name: comments
|
- name: comments
|
||||||
@@ -301,7 +349,15 @@ paths:
|
|||||||
get:
|
get:
|
||||||
tags: [user]
|
tags: [user]
|
||||||
summary: 当前用户资料
|
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
|
operationId: me
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
@@ -320,6 +376,66 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorEnvelope'
|
$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:
|
/api/v1/events:
|
||||||
post:
|
post:
|
||||||
@@ -468,18 +584,32 @@ paths:
|
|||||||
$ref: '#/components/responses/PetNotFound'
|
$ref: '#/components/responses/PetNotFound'
|
||||||
patch:
|
patch:
|
||||||
tags: [pets]
|
tags: [pets]
|
||||||
summary: 更新宠物档案
|
summary: 更新宠物档案(含头像)
|
||||||
description: |
|
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`)**整体替换**——提交任一侧即替换
|
- 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换
|
||||||
整对,互斥校验同创建。
|
整对,互斥校验同创建。
|
||||||
- `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
|
- `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
|
||||||
- `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH
|
- `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH
|
||||||
设置**(400/40000,软删除留待专用端点,M2 契约不含)。
|
设置**(400/40000,软删除留待专用端点,M2 契约不含)。
|
||||||
- `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。
|
- `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1;
|
||||||
|
过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。
|
||||||
- 芯片号改为已被登记的值:409/40903。
|
- 芯片号改为已被登记的值:409/40903。
|
||||||
|
- `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset:
|
||||||
|
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。
|
||||||
operationId: updatePet
|
operationId: updatePet
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
@@ -505,7 +635,19 @@ paths:
|
|||||||
'403':
|
'403':
|
||||||
$ref: '#/components/responses/PetWriteDenied'
|
$ref: '#/components/responses/PetWriteDenied'
|
||||||
'404':
|
'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':
|
'409':
|
||||||
description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
|
description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
|
||||||
content:
|
content:
|
||||||
@@ -517,6 +659,8 @@ paths:
|
|||||||
value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
|
value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
|
||||||
microchipExists:
|
microchipExists:
|
||||||
value: { code: 40903, message: 芯片号已被登记, data: null }
|
value: { code: 40903, message: 芯片号已被登记, data: null }
|
||||||
|
'422':
|
||||||
|
$ref: '#/components/responses/MediaNotReady'
|
||||||
|
|
||||||
/api/v1/breeds:
|
/api/v1/breeds:
|
||||||
get:
|
get:
|
||||||
@@ -1201,7 +1345,7 @@ paths:
|
|||||||
petNotFound:
|
petNotFound:
|
||||||
value: { code: 40401, message: 宠物不存在, data: null }
|
value: { code: 40401, message: 宠物不存在, data: null }
|
||||||
mediaNotFound:
|
mediaNotFound:
|
||||||
value: { code: 40405, message: 媒体不存在, data: null }
|
value: { code: 40405, message: 媒体资源不存在, data: null }
|
||||||
'409':
|
'409':
|
||||||
$ref: '#/components/responses/IdempotencyPayloadMismatch'
|
$ref: '#/components/responses/IdempotencyPayloadMismatch'
|
||||||
'422':
|
'422':
|
||||||
@@ -1284,7 +1428,7 @@ paths:
|
|||||||
petNotFound:
|
petNotFound:
|
||||||
value: { code: 40401, message: 宠物不存在, data: null }
|
value: { code: 40401, message: 宠物不存在, data: null }
|
||||||
mediaNotFound:
|
mediaNotFound:
|
||||||
value: { code: 40405, message: 媒体不存在, data: null }
|
value: { code: 40405, message: 媒体资源不存在, data: null }
|
||||||
'409':
|
'409':
|
||||||
$ref: '#/components/responses/VersionConflict'
|
$ref: '#/components/responses/VersionConflict'
|
||||||
'422':
|
'422':
|
||||||
@@ -1348,6 +1492,35 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/AccessTokenInvalid'
|
$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:
|
/api/v1/feed:
|
||||||
get:
|
get:
|
||||||
tags: [feed]
|
tags: [feed]
|
||||||
@@ -1812,8 +1985,9 @@ components:
|
|||||||
value: { code: 40402, message: 记录不存在, data: null }
|
value: { code: 40402, message: 记录不存在, data: null }
|
||||||
PetWriteDenied:
|
PetWriteDenied:
|
||||||
description: |
|
description: |
|
||||||
对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改
|
对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer
|
||||||
宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。
|
改宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)。
|
||||||
|
仅发给对宠物「可见」的调用者,不泄露新信息。
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -1854,14 +2028,16 @@ components:
|
|||||||
commentNotFound:
|
commentNotFound:
|
||||||
value: { code: 40404, message: 评论不存在, data: null }
|
value: { code: 40404, message: 评论不存在, data: null }
|
||||||
MediaNotFound:
|
MediaNotFound:
|
||||||
description: asset 不存在、非本人所有或已删(code 40405,防枚举合并)
|
description: |
|
||||||
|
asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。
|
||||||
|
用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorEnvelope'
|
$ref: '#/components/schemas/ErrorEnvelope'
|
||||||
examples:
|
examples:
|
||||||
mediaNotFound:
|
mediaNotFound:
|
||||||
value: { code: 40405, message: 媒体不存在, data: null }
|
value: { code: 40405, message: 媒体资源不存在, data: null }
|
||||||
UserNotFound:
|
UserNotFound:
|
||||||
description: 目标用户不存在或已注销(code 40406,合并不泄露成因)
|
description: 目标用户不存在或已注销(code 40406,合并不泄露成因)
|
||||||
content:
|
content:
|
||||||
@@ -2001,8 +2177,10 @@ components:
|
|||||||
|
|
||||||
Me:
|
Me:
|
||||||
type: object
|
type: object
|
||||||
description: 当前用户资料(冻结契约,恰好这 4 个字段)
|
description: |
|
||||||
required: [userId, username, createdAt]
|
本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。
|
||||||
|
**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。
|
||||||
|
required: [userId, username, nickname, avatarUrl, createdAt]
|
||||||
properties:
|
properties:
|
||||||
userId:
|
userId:
|
||||||
type: string
|
type: string
|
||||||
@@ -2011,16 +2189,65 @@ components:
|
|||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
example: demo_user
|
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:
|
phone:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
description: E.164;未绑定时为 null
|
description: E.164;未绑定时为 null
|
||||||
example: '+8613800138000'
|
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:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
example: '2026-09-04T04:05:06.789Z'
|
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:
|
AuthTokenEnvelope:
|
||||||
type: object
|
type: object
|
||||||
required: [code, message]
|
required: [code, message]
|
||||||
@@ -2226,7 +2453,8 @@ components:
|
|||||||
`breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
|
`breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
|
||||||
`breedDisplayName` 由品种字典解出,随 breedId 存在。
|
`breedDisplayName` 由品种字典解出,随 breedId 存在。
|
||||||
软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
|
软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
|
||||||
status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。
|
status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回,
|
||||||
|
**`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。
|
||||||
required:
|
required:
|
||||||
- id
|
- id
|
||||||
- name
|
- name
|
||||||
@@ -2234,6 +2462,7 @@ components:
|
|||||||
- sex
|
- sex
|
||||||
- birthDateEstimated
|
- birthDateEstimated
|
||||||
- status
|
- status
|
||||||
|
- avatarUrl
|
||||||
- myRole
|
- myRole
|
||||||
- createdAt
|
- createdAt
|
||||||
- updatedAt
|
- updatedAt
|
||||||
@@ -2294,6 +2523,15 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [active, lost, deceased, archived]
|
enum: [active, lost, deceased, archived]
|
||||||
description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401)
|
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:
|
myRole:
|
||||||
type: string
|
type: string
|
||||||
enum: [owner, caregiver, viewer]
|
enum: [owner, caregiver, viewer]
|
||||||
@@ -2351,14 +2589,18 @@ components:
|
|||||||
UpdatePetRequest:
|
UpdatePetRequest:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
部分更新:缺席字段不变;不支持清空回 null。例外:品种对
|
部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对
|
||||||
(breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
|
(breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
|
||||||
species 不可改(不在请求体)。
|
species 不可改(不在请求体)。
|
||||||
|
**`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改;
|
||||||
|
键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。
|
||||||
|
权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严),
|
||||||
|
见端点描述。
|
||||||
required: [version]
|
required: [version]
|
||||||
properties:
|
properties:
|
||||||
version:
|
version:
|
||||||
type: integer
|
type: integer
|
||||||
description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902)
|
description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902);即便只改头像也必带
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
minLength: 1
|
minLength: 1
|
||||||
@@ -2393,6 +2635,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [active, lost, deceased, archived]
|
enum: [active, lost, deceased, archived]
|
||||||
description: 状态流转;deleted 不可经 PATCH 设置(400/40000)
|
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:
|
PetEnvelope:
|
||||||
type: object
|
type: object
|
||||||
@@ -3205,10 +3457,13 @@ components:
|
|||||||
description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
||||||
purpose:
|
purpose:
|
||||||
type: string
|
type: string
|
||||||
enum: [post_image]
|
enum: [post_image, user_avatar, pet_avatar]
|
||||||
description: |
|
description: |
|
||||||
用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩
|
用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀
|
||||||
user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展)
|
`<purpose>/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、
|
||||||
|
`user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。
|
||||||
|
**用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、
|
||||||
|
两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。
|
||||||
mimeType:
|
mimeType:
|
||||||
type: string
|
type: string
|
||||||
enum: [image/jpeg, image/png, image/webp]
|
enum: [image/jpeg, image/png, image/webp]
|
||||||
@@ -3857,3 +4112,36 @@ components:
|
|||||||
example: success
|
example: success
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/FollowStats'
|
$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'
|
||||||
|
|||||||
@@ -180,3 +180,16 @@
|
|||||||
- M3 末执行首次 dev→main 发布(8 步 checklist 见 iteration-3/08);patbond-api 远端 main 与 dev 历史不相干,届时经 Gitea 平台删除重建 main,禁止 force push 缝合。
|
- M3 末执行首次 dev→main 发布(8 步 checklist 见 iteration-3/08);patbond-api 远端 main 与 dev 历史不相干,届时经 Gitea 平台删除重建 main,禁止 force push 缝合。
|
||||||
- 对象存储凭证(MinIO AK/SK)防泄漏:CI 兜底 grep 在第一波、**先于凭证进开发机**落地。
|
- 对象存储凭证(MinIO AK/SK)防泄漏:CI 兜底 grep 在第一波、**先于凭证进开发机**落地。
|
||||||
- E2E 烟囱不进 push 门禁,保持波次手动 + 可选 workflow_dispatch。
|
- E2E 烟囱不进 push 门禁,保持波次手动 + 可选 workflow_dispatch。
|
||||||
|
|
||||||
|
## ADR-022 M3.5 体验补齐的范围与关键决策
|
||||||
|
|
||||||
|
**决策**(2026-09-10,用户实测反馈后拍板):
|
||||||
|
|
||||||
|
- **首页 demo 裁剪范围**:本迭代**仅做问候语真实化**(改用真实昵称)。天气与位置(需接外部服务,含 key 与配额管理)、圈子入口(实为话题,ADR-018 已剪出)、促销卡(属 M5 服务域)三项**留待对应里程碑**;保留期间须在代码注释与迭代报告显式标注为「刻意保留的 demo 占位」,避免后续实测重复反馈。
|
||||||
|
- **昵称不设唯一约束**:`identity.users.nickname` 维持现状(仅 `ck_users_nickname` 的 btrim + 1~32 长度校验),**允许重名**,靠 userId 区分(社区产品常规做法)。加唯一约束需迁移,且会破坏本迭代零迁移前提。
|
||||||
|
- **注册流程不加昵称输入**:沿 ADR-004 的最小注册面,注册仍只收用户名/手机号/密码;昵称在资料页设置,未设置时展示层回退 username(回退逻辑已在 `/internal/users/profiles` 的 SQL 层实现,M3 T3-05 交付)。
|
||||||
|
- **宠物头像的写权限为 WRITE 档**(owner + caregiver 均可改,ADR-015 三档权限模型下):头像属日常照护信息,与体重/疫苗记录同档;viewer 只读。
|
||||||
|
- **本迭代零 Flyway 迁移**:开工审计确认所需列均已存在——`identity.users.nickname` 与 `avatar_asset_id`(V1)、`pet_health.pets.avatar_asset_id`(V3)、`community.posts.like_count` 等冗余列(V5);`media.assets.purpose` 无 CHECK 约束、白名单为配置项 `MediaProperties.allowedPurposes`,新增 `user_avatar`/`pet_avatar` 只改配置与契约枚举。下一个 Flyway 版本号 V6 留给后续真正需要建表的迭代。
|
||||||
|
- **获赞总数走读侧实时聚合**(`SUM(like_count)` over 本人未删帖),**不引入新冗余列**:写侧维护成本高于读侧聚合收益,且数据量级远未到瓶颈。端点为新增的 `GET /api/v1/me/community-stats`,不并入既有 `follow-stats`(后者主体是「某用户的关注数」,混入「我的获赞」会造成主体歧义)。
|
||||||
|
|
||||||
|
**版本号**:本迭代交付按 **v0.4.0** 发布(新增端点与字段属功能增量,非纯补丁)。
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ docker restart act_runner
|
|||||||
| 拉镜像 `dial tcp ...443: i/o timeout` | 服务器直连 Docker Hub 不通。配镜像加速后 `sudo systemctl restart docker`:腾讯云机器优先内网源 `https://mirror.ccs.tencentyun.com`,公共源如 `https://docker.1ms.run`(可用性随时间变化,失效就换)。写入 `/etc/docker/daemon.json` 的 `registry-mirrors` 数组 |
|
| 拉镜像 `dial tcp ...443: i/o timeout` | 服务器直连 Docker Hub 不通。配镜像加速后 `sudo systemctl restart docker`:腾讯云机器优先内网源 `https://mirror.ccs.tencentyun.com`,公共源如 `https://docker.1ms.run`(可用性随时间变化,失效就换)。写入 `/etc/docker/daemon.json` 的 `registry-mirrors` 数组 |
|
||||||
| job 卡在 `actions/checkout` 或 `setup-java` 拉不下来 | runner 访问不了 github.com(与上一条通常同时出现)。两种解法:a) `app.ini` 的 `[actions]` 加 `DEFAULT_ACTIONS_URL = https://gitea.com`(用 gitea.com 上的 Action 镜像仓)后重启 Gitea;b) 把工作流的 setup-java 步骤删掉,改用自带 JDK17 的 job 镜像(ci.yml 头部注释已写明) |
|
| job 卡在 `actions/checkout` 或 `setup-java` 拉不下来 | runner 访问不了 github.com(与上一条通常同时出现)。两种解法:a) `app.ini` 的 `[actions]` 加 `DEFAULT_ACTIONS_URL = https://gitea.com`(用 gitea.com 上的 Action 镜像仓)后重启 Gitea;b) 把工作流的 setup-java 步骤删掉,改用自带 JDK17 的 job 镜像(ci.yml 头部注释已写明) |
|
||||||
| Testcontainers 报 `Could not find a valid Docker environment` | 第 3 步的 container.options 没生效,job 容器内没有 docker.sock |
|
| Testcontainers 报 `Could not find a valid Docker environment` | 第 3 步的 container.options 没生效,job 容器内没有 docker.sock |
|
||||||
|
| **CI 秒失败、`steps` 为空** | **第一动作:在本机复现 CI 的第一个 step**(通常是 `git clone --depth 1 https://git.patbond.cn/<owner>/<repo>.git`)。本机同样失败 ⇒ 问题在 Gitea/网络侧,与 runner 无关;本机成功 ⇒ 再查 runner。2026-09-11 的事件中,先查 runner 走了两次弯路,本机复现一步到位(见[事件复盘](iterations/iteration-3.5/07-security-incident-20260911.md)) |
|
||||||
|
| 跨仓比较 CI 状态得出「runner 还活着」 | **必须核对状态的时间戳**:某仓「最新提交 success」可能是前一天的旧记录。用 `curl .../commits/<sha>/status` 看 `created_at` |
|
||||||
|
| `git clone` 报 `bad pack header` / `early EOF` 而 push 正常 | 二者走不同方向:push 是 `receive-pack`,clone 是 `upload-pack`。检查 Gitea 的 gitconfig 是否被注入 `uploadpack.packObjectsHook`(`sudo grep -rn packObjectsHook /var/lib/gitea/*/. gitconfig`),并核对[服务器暴露面清单](server-exposure.md) |
|
||||||
| runner 显示 offline | `docker logs act_runner` 看注册错误;令牌只能用一次,重新注册需删 `/data/.runner`;重试 `docker run` 前先 `docker rm -f act_runner` 清残留容器 |
|
| runner 显示 offline | `docker logs act_runner` 看注册错误;令牌只能用一次,重新注册需删 `/data/.runner`;重试 `docker run` 前先 `docker rm -f act_runner` 清残留容器 |
|
||||||
| Maven 每次全量下载依赖很慢 | 在 config.yaml 的 container.options 追加 `-v act_m2:/root/.m2` 做持久缓存 |
|
| Maven 每次全量下载依赖很慢 | 在 config.yaml 的 container.options 追加 `-v act_m2:/root/.m2` 做持久缓存 |
|
||||||
|
|
||||||
|
|||||||
@@ -166,3 +166,57 @@ _(待真机到位后填写:日期、设备型号/Android 版本、两项结
|
|||||||
## 执行记录(M3)
|
## 执行记录(M3)
|
||||||
|
|
||||||
_(待补)_
|
_(待补)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# M3.5 预登记(用户资料与头像,2026-09-11 登记)
|
||||||
|
|
||||||
|
M3.5 第二波(T3.5-08/09/10)交付后新增的真机专属项。**桌面已覆盖的不重复登记**:注册/登录 → 资料页真实化 → 设昵称 → 传用户头像 → Feed 作者名同步 → 宠物头像上传的整条链路已在 Linux 桌面对 compose 真后端跑通并逐步截图(`integration_test/profile_avatar_live_test.dart`,见 iteration-3.5/05 号报告 §4);下列四项是**桌面替代不了**的部分。
|
||||||
|
|
||||||
|
1. **头像上传弱网表现**(T3.5-08/09 收口登记):真机蜂窝/弱 Wi-Fi 下「选图 → 压缩 → 预签名直传 → confirm → PATCH 挂载」全链路。
|
||||||
|
|
||||||
|
**前置**:通用前置准备的后端六容器在位;`PATBOND_MINIO_PUBLIC_ENDPOINT` 必须配置为手机可达地址(工作机局域网 IP:9000,.env 覆盖后重启 compose)——头像直传与预签名读都直指 MinIO,漏配则手机端必失败;`flutter run` 四个 base URL 全传。入口:我的资料 → 编辑资料 → 更换头像;档案 → 宠物详情 → 点头像。
|
||||||
|
|
||||||
|
**与第 1 项(媒体上传弱网)的差异**:头像走的是**单图、单并发**的 `MediaUploader`(`maxImages: 1`、`maxConcurrentUploads: 1`),且交付口是「预览后点『使用这张』才落 assetId」,不是九宫格的批量 gating。故槽位调度与孤儿防护的表现需单独看。
|
||||||
|
|
||||||
|
**步骤与通过标准**:
|
||||||
|
- [ ] (a)**正常网真机相册**:从相册选一张 12MP 竖拍照 → sheet 内出线性进度条与递增百分比(非一跳 100%)→ 出圆形预览 → 点「使用这张」→ 回编辑页显示「已选择新头像」→ 保存后资料页头像**真的画出图**(不是爪印/人形占位)。此项同时验证原生压缩层(桌面实测用的是透传替身,真机才走 flutter_image_compress)。
|
||||||
|
- [ ] (b)**弱网中断重试**:上传中开飞行模式掐断 → sheet 转失败态并给「重试 + 重新选择」两个按钮 → 恢复网络点「重试」→ 转就绪可确认。中断产生的旧 asset 保持 `uploading`(属服务端超时清理范围,不算失败);核对 `identity.users.avatar_asset_id` / `pet_health.pets.avatar_asset_id` **未**指向中断的那个 assetId。
|
||||||
|
- [ ] (c)**压缩后仍超限**:选一张超大原图(若压缩后仍 >10 MiB)→ 失败态**只给「重新选择」、不给「重试」**(重试同一张必然再失败)。
|
||||||
|
- [ ] (d)**凭据过期**:选图后把 App 挂起 >10 分钟再回前台触发重试 → 自动换新凭据完成上传,用户无感知,不弹「签名过期」类错误。
|
||||||
|
- [ ] (e)**中途退出不留引用**:上传中直接关掉 sheet → 无 SnackBar 报错、资料/宠物头像不变;`post_media_upload_failed(failureReason=cancelled)` 一条(头像沿用同一套媒体埋点)。
|
||||||
|
- [ ] (f)**HEIC 与方向**:iPhone 传输的 HEIC 与横拍各一张 → 压缩层统一出 jpeg 且方向正确(服务端 mime 白名单不收 HEIC,只能真机验原生编解码)。
|
||||||
|
|
||||||
|
2. **头像缓存表现**(T3.5-08/09 收口登记):预签名 URL 每次响应现签,缓存 key 已剥 `X-Amz-*`;真机需确认同一张头像不会反复下载、过期后能重取。
|
||||||
|
|
||||||
|
**前置**:同第 1 项。数据:本人已设头像、至少一只宠物已设头像、Feed 内有本人发布的帖(作者头像与资料页头像同一对象)。
|
||||||
|
|
||||||
|
**步骤与通过标准**:
|
||||||
|
- [ ] (a)**跨页命中**:资料页 → 首页 Feed(作者头像)→ 档案列表 → 宠物详情,来回切三轮 → 已展示过的头像**不再转圈**;抓包或 MinIO 访问日志核对同一 object key 未重复 GET(缓存 key 剥签名参数生效;若「每次进页面同图重下」即为 `presignedImageCacheKey` 回归,判失败)。
|
||||||
|
- [ ] (b)**下拉刷新后仍命中**:Feed 下拉刷新(服务端重新现签、URL 必变)→ 作者头像即时出图不转圈。
|
||||||
|
- [ ] (c)**过期后重取**:停留 >1 小时(预签名 TTL 默认 1h)后进未加载过的页 → 旧 URL 过期走占位属预期;下拉刷新/重进页面取新签 URL 后恢复出图,无崩溃、不缓存坏图。
|
||||||
|
- [ ] (d)**清除头像后不留残影**:编辑页「清除头像」保存 → 资料页立刻回人形占位,Feed 作者头像在服务端作者缓存过期后(≤60s,见下方备注)也回占位;重启 App 后仍是占位(URL 不得被持久化,纪律 R2)。
|
||||||
|
|
||||||
|
3. **caregiver 账号改宠物头像**(T3.5-09 收口登记;ADR-022 D3.5-3 的 WRITE 档实证):桌面实测只跑了 owner 路径,caregiver 需第二个账号 + 一条协作关系。
|
||||||
|
|
||||||
|
**前置**:两个账号 A(owner)/ B(caregiver),B 对 A 的宠物有 caregiver 角色(关系授予入口尚未开放,按 `pet_health` 的协作表直接造数据,收口时补 SQL)。
|
||||||
|
|
||||||
|
**步骤与通过标准**:
|
||||||
|
- [ ] (a)B 打开该宠物详情 → **头像铅笔角标在**(WRITE 档),但「编辑资料」入口**不在**(MANAGE 档,仅 owner)。
|
||||||
|
- [ ] (b)B 上传头像 → 成功;A 侧刷新详情看到同一张。
|
||||||
|
- [ ] (c)viewer 角色的第三个账号 C → 头像不可点、无角标。
|
||||||
|
|
||||||
|
4. **获赞数与帖子点赞数对账**(T3.5-08 收口登记):资料页「获赞」= 本人已发布未删帖的 `like_count` 之和(**含自赞**,与帖子详情同口径)。
|
||||||
|
|
||||||
|
**步骤与通过标准**:
|
||||||
|
- [ ] (a)自己给自己的帖点赞 → 帖子详情 likeCount +1,资料页「获赞」也 +1(两处数字必须能对上;对不上说明口径分叉)。
|
||||||
|
- [ ] (b)他人点赞 2 次不同帖 → 资料页「获赞」为各帖 likeCount 之和。
|
||||||
|
- [ ] (c)软删一篇被赞的帖 → 「获赞」与「我的作品」同时回落(删帖即撤回其数字)。
|
||||||
|
- [ ] (d)存一篇草稿 → 「我的作品」**不**变(草稿尚非作品)。
|
||||||
|
|
||||||
|
> **备注(服务端刻意的滞后,不是缺陷)**:改昵称/换头像后,**Feed 与帖子详情里的作者名与作者头像**最多滞后 60 秒才更新——community 侧 `AuthorProfileGateway` 把 `/internal/users/profiles` 的结果放在 60s TTL 的进程内缓存里(`patbond.author-profile.cache-ttl`,M3 T3-05)。资料页与首页问候语读的是 `/me`,**没有这层缓存,立即生效**。真机执行第 2、4 项时若看到「资料页已变、Feed 还是旧名」,先等过 60 秒再判定。
|
||||||
|
|
||||||
|
## 执行记录(M3.5)
|
||||||
|
|
||||||
|
_(待补)_
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# 01 M3.5 任务拆解:体验补齐
|
||||||
|
|
||||||
|
**迭代定位**:M3 收官(v0.3.0 发布)后,用户桌面实测反馈 6 项问题的补齐迭代。规模远小于 M1~M3,不做 8 角色开工分析。
|
||||||
|
**执行人**:主会话(PM agent 两次卡死未落盘,实情由主会话独立审计取得)
|
||||||
|
**日期**:2026-09-10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 缘起与范围
|
||||||
|
|
||||||
|
用户 2026-09-10 在 Linux 桌面实测 M3 成果,反馈 6 项问题。分类后:
|
||||||
|
|
||||||
|
| 用户反馈 | 定性 | 归属 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ① 日历英文 | 缺陷(从未配本地化) | **第一批已修**(M3.5-01) |
|
||||||
|
| ② 月份只能 `< >` 切 | 可用性缺陷,已实际致误录 | **第一批已修**(M3.5-02) |
|
||||||
|
| ⑤ 本月花费 ¥0 | **不是 bug**:记录在 2026-04-09、当天 2026-09-10,9 月确实为 0;根因是 ② | **第一批已修** UI 可自查性(M3.5-03) |
|
||||||
|
| ③ 宠物无头像上传 | ADR-010 剪出项,M3 媒体链路已就位 | 本批 |
|
||||||
|
| ④ 资料页无法改昵称/头像、显示 demo | 代码层缺口 | 本批 |
|
||||||
|
| ⑥ 资料页关注/获赞/作品是假数据 | 同 ④(同一 demo 页) | 本批 |
|
||||||
|
| (未提)首页顶部 demo | 混合性质,须裁剪 | 本批部分 |
|
||||||
|
|
||||||
|
**本批范围一句话**:把「用户资料(昵称+头像)与宠物头像」从 demo 打通为真实读写,资料页数据真实化,并裁剪首页 demo 中属本迭代的部分。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 数据模型实情审计(**推翻了原先的迁移预估**)
|
||||||
|
|
||||||
|
开工前一度以为需要 Flyway V6 加列。**逐一核实原始 SQL 与代码后确认:所需列全部早已存在,本批零迁移。**
|
||||||
|
|
||||||
|
| 需求 | 原预估 | **实情(已核实)** |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 用户昵称 | `identity.users` 无 nickname 列,需加 | **V1 第 63 行就有** `nickname varchar(32)`,含 `ck_users_nickname`(btrim + 1~32 长度);`UserRepository.insertUser(id, username, nickname, phone)` 注册时已在写 |
|
||||||
|
| 用户头像 | 需加列 | **V1 第 67 行就有** `avatar_asset_id uuid`,含 `fk_users_avatar_asset`(→`media.assets` ON DELETE SET NULL)与索引 `ix_users_avatar_asset` |
|
||||||
|
| 宠物头像 | 需加列 | **V3 第 64 行就有** `avatar_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL`,含索引 `ix_pets_avatar`。但 **pet 模块代码零处读写它**(grep 无命中) |
|
||||||
|
| 获赞总数 | 需加冗余列 | `community.posts` 已有 `like_count/comment_count/bookmark_count` 冗余列(M3 写侧同事务维护),`SUM(like_count)` 即可 |
|
||||||
|
| 头像 media 用途 | 需加枚举/约束迁移 | `media.assets.purpose` 为 `varchar(32) NOT NULL` **无 CHECK 约束**;白名单在 **配置项** `MediaProperties.allowedPurposes = List.of("post_image")` —— 加新用途只改配置 + 契约枚举 |
|
||||||
|
|
||||||
|
**已就位可直接复用的能力**:
|
||||||
|
- `/internal/users/profiles` 已返回 `PublicProfileResponse(userId, nickname, avatarAssetId)`,**nickname→username 回退在 SQL 层完成**(M3 T3-05 交付)。即 Feed 作者名一旦用户设了昵称即自动生效,无需改社区侧。
|
||||||
|
- media 两步上传(user :8082)+ 客户端 `MediaUploader` 六态编排(M3 T3-03/T3-13)。
|
||||||
|
- `MediaAssetGateway` 的 asset 校验先例(ready + 属本人,M3 T3-04)。
|
||||||
|
- `GET /api/v1/me/posts`(含草稿)、`GET /api/v1/me/bookmarks`、`GET /api/v1/users/{id}/follow-stats` 均已实现。
|
||||||
|
|
||||||
|
**真实缺口只在代码层**:`MeResponse` 只有 `(userId, username, phone, createdAt)` 缺昵称/头像;**无任何写接口**(无 `PATCH /me`);pets 不读写头像列;获赞总数无端点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 工单拆解
|
||||||
|
|
||||||
|
编号自 **T3.5-04** 起(01~03 已由第一批客户端修复占用)。
|
||||||
|
|
||||||
|
### 第一波:后端与契约
|
||||||
|
|
||||||
|
#### T3.5-04 用户资料读写(user 模块)
|
||||||
|
- **仓库**:patbond-api(patbond-user)
|
||||||
|
- **描述**:`GET /api/v1/me` 响应补 `nickname`、`avatarUrl`(预签名 GET,沿用 M3 私有桶签名读口径,URL 会过期不得持久化);新增 `PATCH /api/v1/me` 支持改 `nickname` 与 `avatarAssetId`(置 null 即清除头像)。校验:nickname 与 `ck_users_nickname` 对齐(btrim、1~32);avatarAssetId 必须 `status='ready'`、属当前用户、`purpose='user_avatar'`(复用 MediaAssetGateway 同类校验语义,错误码沿用 40405/42203)。`MediaProperties.allowedPurposes` 增 `user_avatar`。
|
||||||
|
- **验收**:设昵称后 `/me` 与 `/internal/users/profiles` 双端一致;清空昵称回退 username(回退逻辑已在 SQL 层,勿重复实现);非 ready/非本人/错 purpose 的 asset 被拒;六类路径覆盖。
|
||||||
|
- **依赖**:无。**规模**:M
|
||||||
|
|
||||||
|
#### T3.5-05 宠物头像读写(pet 模块)
|
||||||
|
- **仓库**:patbond-api(patbond-pet)
|
||||||
|
- **描述**:`PATCH /api/v1/pets/{petId}` 支持 `avatarAssetId`(含置 null);宠物详情/列表/FeedCard 无关响应补 `avatarUrl`(预签名 GET,community 侧本地现签先例见 M3 T3-04)。asset 校验复用 `MediaAssetGateway`(ready + 属本人)+ `purpose='pet_avatar'`。权限沿用 `PetAccessService`:改头像属 **WRITE 档**(owner+caregiver)还是 **MANAGE 档**(仅 owner)——见待拍板 D3.5-3。`allowedPurposes` 增 `pet_avatar`。
|
||||||
|
- **验收**:owner 设头像后详情返回可访问 URL;viewer 改被拒;version 乐观锁沿用 40902;非法 asset 被拒。
|
||||||
|
- **依赖**:无(与 T3.5-04 可并行,但同仓需串行提交)。**规模**:S~M
|
||||||
|
|
||||||
|
#### T3.5-06 获赞总数聚合(community 模块)
|
||||||
|
- **仓库**:patbond-api(patbond-community)
|
||||||
|
- **描述**:提供当前用户的社区统计:获赞总数(`SUM(like_count)` over 本人未删帖)、作品数(已发布帖数)。端点形态见待拍板 D3.5-2(新增 `GET /api/v1/me/community-stats` vs 扩展既有 follow-stats)。**不引入新冗余列**——写侧维护成本高于读侧聚合收益,且量级远未到瓶颈。
|
||||||
|
- **验收**:草稿/软删帖不计入;空数据返回 0 而非 null;口径写入契约描述。
|
||||||
|
- **依赖**:无。**规模**:S
|
||||||
|
|
||||||
|
#### T3.5-07 契约冻结 v1.4.0
|
||||||
|
- **仓库**:patbond-doc(openapi.yaml)+ patbond-api(快照同步)
|
||||||
|
- **描述**:沿用 M2/M3 迭代式冻结:草案(TODO-FREEZE 标注待定型点)→ 随 04/05/06 实现定型回填 → 拍板 → 合入 **v1.4.0** → **四模块字节级快照同步**(pet/auth/community/user 的 `src/test/resources/contract/`)→ 契约矩阵扩展新操作全响应格。
|
||||||
|
- **验收**:`mkdocs build --strict` 通过;契约矩阵零漂移;升版后 CI 绿(**漏快照同步必红**,见 iteration-3/19)。
|
||||||
|
- **依赖**:T3.5-04/05/06 定型。**本波闸门,不冻结不放行第二波前端。规模**:M
|
||||||
|
|
||||||
|
### 第二波:客户端
|
||||||
|
|
||||||
|
#### T3.5-08 资料页真实化 + 编辑页
|
||||||
|
- **仓库**:patbond-flutter
|
||||||
|
- **描述**:`lib/features/profile/profile_page.dart`(现 176 行全硬编码 demo:「萌宠新手(豆豆家长)」/24/1.8k/2)替换为真实数据——昵称(空则 username)、头像、获赞、作品数、关注数;新增编辑页(昵称输入 + 头像上传复用 `MediaUploader`,复用 M3 的 gating/失败语义);四态齐备。
|
||||||
|
- **验收**:登录 `llx` 显示 `llx` 而非 demo 文案;改昵称后 Feed 作者名同步(同一后端回退链);头像上传全链路;四态有 widget 测试。
|
||||||
|
- **依赖**:T3.5-07 冻结。**规模**:L
|
||||||
|
|
||||||
|
#### T3.5-09 宠物头像上传接线
|
||||||
|
- **仓库**:patbond-flutter
|
||||||
|
- **描述**:宠物详情页头像的铅笔角标(**当前已渲染但无功能**)接 `MediaUploader`;列表/详情展示真实头像(`SignedNetworkImage` 复用,缓存 key 剥签名参数已就位);无头像回退现有爪印占位。
|
||||||
|
- **验收**:上传后详情与列表同步显示;viewer 不显示编辑入口;失败态可重试。
|
||||||
|
- **依赖**:T3.5-07 冻结。**规模**:M
|
||||||
|
|
||||||
|
#### T3.5-10 首页 demo 裁剪
|
||||||
|
- **仓库**:patbond-flutter
|
||||||
|
- **描述**:首页顶部 demo 分项处置——问候语「下午好,豆豆」改真实昵称(**本批做**);天气/位置(接外部服务,**建议出本批**);圈子「柴犬圈/猫咪圈/救助站」(实为话题,ADR-018 已剪出,**建议出本批**);促销卡「新用户首单立减 ¥20」(属 M5 服务域,**建议出本批**)。见待拍板 D3.5-1。
|
||||||
|
- **验收**:按拍板结果,保留项标注为「刻意保留的占位」并在报告登记,避免下次实测重复反馈。
|
||||||
|
- **依赖**:T3.5-04 定型(昵称字段)。**规模**:S~M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 波次与关键路径
|
||||||
|
|
||||||
|
```text
|
||||||
|
第一波: T3.5-04 / T3.5-05 / T3.5-06(同仓串行提交)→ [T3.5-07 契约冻结 v1.4.0 闸门]
|
||||||
|
第二波: T3.5-08(L) / T3.5-09 / T3.5-10
|
||||||
|
```
|
||||||
|
|
||||||
|
预计一到两波收,无 L 工单堆叠在关键路径(仅 T3.5-08 一个 L)。发布按 releases.md 的**新流程走 PR**(main 已受保护,首发的直推写法已作废)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 待拍板清单
|
||||||
|
|
||||||
|
| # | 决策 | 选项与影响 | 建议 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| D3.5-1 | **首页 demo 裁剪范围** | 天气/位置需接外部服务(和风天气类,含 key 管理与配额);圈子=话题(ADR-018 剪出);促销卡属 M5 服务域 | **仅做问候语真实化**,其余三项留待对应里程碑,并在代码注释与报告显式标注「刻意保留的 demo 占位」 |
|
||||||
|
| D3.5-2 | **获赞总数端点形态** | A. 新增 `GET /api/v1/me/community-stats`(语义清晰、可扩展);B. 扩展既有 `follow-stats`(少一个端点,但语义混杂——它现在是「某用户的关注数」,加"我的获赞"会变成两种主体) | **A** |
|
||||||
|
| D3.5-3 | **宠物头像的写权限档** | WRITE(owner+caregiver 都能改)vs MANAGE(仅 owner) | **WRITE**——头像属日常照护信息,与体重/疫苗同档;照护人本就能改这些 |
|
||||||
|
| D3.5-4 | **nickname 唯一性** | 现 DB 无唯一约束(仅长度/btrim CHECK)。允许重名(社区常见,靠 userId 区分)vs 加唯一约束(需迁移,破本批零迁移前提) | **允许重名**,不加约束 |
|
||||||
|
| D3.5-5 | **注册时是否让用户填昵称** | 现注册只收 username/phone/password(nickname 走 `insertUser` 但值来源需确认)。加一个可选昵称输入 vs 保持不填、注册后到资料页设 | **保持不填**(少一步注册摩擦),资料页可设 |
|
||||||
|
| D3.5-6 | **版本号** | v0.3.1(补丁语义)vs v0.4.0(含新端点,属功能增量) | **v0.4.0**——加了端点与字段,不是纯修补 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 风险清单
|
||||||
|
|
||||||
|
| # | 风险 | 缓解 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | **契约升版漏同步快照** → 四模块守卫测试全红 | 冻结与快照同步放**同一工单**(T3.5-07)连贯执行,iteration-3/19 已有可照抄的操作序列 |
|
||||||
|
| R2 | 头像 URL 是**会过期的预签名 GET**,客户端若持久化会出现"图突然裂" | 沿用 M3 纪律:URL 不入本地存储;`SignedNetworkImage` 缓存 key 已剥签名参数 |
|
||||||
|
| R3 | 同仓(patbond-api)三个后端工单并行会抢工作树 | 同仓串行派工(M2/M3 已验证的模式) |
|
||||||
|
| R4 | 首页 demo 裁剪范围失控,滑向"顺手把首页重做一遍" | D3.5-1 钉死范围;保留项显式登记,避免反复 |
|
||||||
|
| R5 | 头像功能上线后,真机验证清单需新增项(弱网上传头像、头像缓存) | 收口时按维护约定登记进 `development/device-verification.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与既有纪律的衔接
|
||||||
|
|
||||||
|
- **零迁移**:本批不新增 Flyway 版本(下一个版本号仍为 V6,留给后续真正需要建表的迭代)
|
||||||
|
- **契约先行**:T3.5-07 冻结前,前端不得依赖未冻结字段
|
||||||
|
- **分支保护**:日常推 dev;发布走 PR(releases.md「发布后生效的纪律」)
|
||||||
|
- **路径参数化**:文档内命令一律 `cd <你的工作区>/<仓名>`
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# M3.5 第一批 · 客户端体验修复(Flutter)
|
||||||
|
|
||||||
|
- **单号**:M3.5-01 中文本地化 / M3.5-02 日期选择器可用性 / M3.5-03 「本月花费」卡可自查
|
||||||
|
- **仓库**:`patbond-flutter`,分支 `dev`(`main` 已受保护,本单只推 dev)
|
||||||
|
- **基线**:`dev` HEAD `0e87413`,502 测试全绿
|
||||||
|
- **范围红线**:纯客户端。不动 `patbond-api`、不动 `openapi.yaml`、不碰契约。
|
||||||
|
用户反馈的另外 3 项(宠物头像、用户资料编辑、资料页真实数据)需契约变更,
|
||||||
|
本单不涉及,留给第二批走正式迭代流程。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 三项修复:根因与修法
|
||||||
|
|
||||||
|
### 1.1 M3.5-01 中文本地化(根因:完全没配)
|
||||||
|
|
||||||
|
**根因**:`lib/app/app.dart` 的 `MaterialApp` 从一迭代建起就没有
|
||||||
|
`localizationsDelegates` / `supportedLocales` / `locale`,`pubspec.yaml` 也没有
|
||||||
|
`flutter_localizations`。Flutter 在缺 delegate 时**静默**回退内置的
|
||||||
|
`DefaultMaterialLocalizations`(只有英文),于是业务自绘文案全中文、Material
|
||||||
|
内置组件全英文,同一个弹窗里中英混排。实测确认的英文兜底文案:
|
||||||
|
|
||||||
|
| 位置 | 英文兜底 | 挂上 zh-CN 后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 日期选择器标题 | `Select date` | 选择日期 |
|
||||||
|
| 确认 / 取消 | `OK` / `Cancel` | 确定 / 取消 |
|
||||||
|
| 手输模式标签 | `Enter Date` | 输入日期 |
|
||||||
|
| 模式切换按钮 tooltip | `Switch to input` / `Switch to calendar` | 切换到输入模式 / 切换到日历模式 |
|
||||||
|
| 手输格式报错 | `Invalid format.` | 格式无效。 |
|
||||||
|
| 越界报错 | `Out of range.` | 超出范围。 |
|
||||||
|
| 手输提示格式 | `mm/dd/yyyy` | `yyyy/mm/dd`(zh 顺序年在前) |
|
||||||
|
| 月份年份表头 | `September 2026` | 2026年9月 |
|
||||||
|
|
||||||
|
**修法**:
|
||||||
|
|
||||||
|
1. `pubspec.yaml` 加 `flutter_localizations`(sdk 依赖)与 `intl: ^0.20.2`
|
||||||
|
(`flutter_localizations` 的日期符号/数字格式底座,显式直接依赖以锁版本)。
|
||||||
|
2. 新增 `lib/app/app_localization.dart`:把 `Global{Material,Cupertino,Widgets}Localizations.delegate`
|
||||||
|
三件套、`appSupportedLocales`、`appLocale = Locale('zh','CN')` 收在一处常量。
|
||||||
|
**收在一处的理由**:widget 测试若只 `pumpWidget(MaterialApp(home: ...))` 而
|
||||||
|
不挂 delegate,测到的「中文」是假的(仍是英文兜底);测试直接引用同一份常量,
|
||||||
|
生产与测试不会各配一套而漂移。
|
||||||
|
3. `lib/app/app.dart` 的 `MaterialApp` 挂上三者。
|
||||||
|
4. **单语言 zh-CN**(不列 `Locale('en')`):避免设备语言为英文时回退英文,
|
||||||
|
再次造出「业务中文 + 组件英文」的混排。
|
||||||
|
|
||||||
|
**顺带修的配色**(用户截图里日期选择器是暗红棕,脱离品牌色):
|
||||||
|
|
||||||
|
根因是 `buildAppTheme()` 里 `ColorScheme.fromSeed(seedColor: #FF6F4C)` 派生出的
|
||||||
|
M3 调和色被日期选择器直接吃掉,而项目从未定制 `datePickerTheme`。新增的
|
||||||
|
`_datePickerTheme` **只复用 05 号规范(iteration-2/05、iteration-3/05)已审计过
|
||||||
|
的色对,不新造任何色值**:
|
||||||
|
|
||||||
|
| 位置 | 色对 | 对比度 | 来源 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 头部(帮助文字 + 标题 + 模式切换图标) | `surfaceTint #FFE8D6` 底 + `primaryDark #7A2E12` | 7.98:1 | 选中 chip 同款(iteration-2/05 §3 D7) |
|
||||||
|
| 年份下拉 / 上下月箭头 | 白底 + `primaryDark` | 8.74:1 | 同上族 |
|
||||||
|
| 选中日 / 选中年 | `primaryStrong #D6431A` 实底 + 白字 | 4.49:1 | FAB / 头像徽标同款(iteration-2/05 §2) |
|
||||||
|
| 今日(未选中)文字与 1.5px 描边 | 白底 + `primaryStrong` | 4.49:1(非文字门槛 3:1) | 同上 |
|
||||||
|
| 未选中日 / 年 | 白底 + `ink #3E2A1F` | ≥12:1 | 正文主色 |
|
||||||
|
| 星期表头 | 白底 + `inkSoft #6B5A4A` | 6.59:1 | 承载信息的次级文字(DEBT-2) |
|
||||||
|
| 越界不可选日 | 白底 + `muted #9C8977` | 3.36:1 | 禁用态,DEBT-2 允许的 `muted` 用途 |
|
||||||
|
| 确定 | 白底 + `primaryStrong` 文字 | 4.49:1 | 可点击文字链接 |
|
||||||
|
| 取消 | 白底 + `inkSoft` 文字 | 6.59:1 | 次级动作 |
|
||||||
|
|
||||||
|
另外 `headerHeadlineStyle` 取 22px(默认 32):中文 `formatMediumDate` 是
|
||||||
|
「9月10日周四」5~6 字,横屏侧栏头部宽度下 26px 起就折行,实测截图确认 22 一行放得下。
|
||||||
|
弹窗形状对齐 `cardTheme`(radius `xl` 24 + `border` 描边),`elevation: 0` +
|
||||||
|
`surfaceTintColor: transparent` 去掉 M3 的紫调 tint 叠色。
|
||||||
|
|
||||||
|
### 1.2 M3.5-02 日期选择器可用性(根因:月份只能逐月切)
|
||||||
|
|
||||||
|
**根因**:Flutter 原生 `showDatePicker` 的日历模式只给了**年份网格**,月份必须靠
|
||||||
|
`<` `>` 逐月点。用户从 9 月要回到 4 月得点 5 次,**已实际造成误录**——他把当月
|
||||||
|
(2026-09)的就医记录记成了 2026-04-09,进而误判「本月花费 ¥0」是聚合坏了。
|
||||||
|
次要根因是手输快路虽然原生就有(头部铅笔按钮),但在英文兜底下提示是
|
||||||
|
`mm/dd/yyyy`、报错是 `Invalid format.`,中文用户看不懂也不敢用。
|
||||||
|
|
||||||
|
**修法**:新增 `lib/core/widgets/app_date_picker.dart` 共享层,7 处调用点全部收口。
|
||||||
|
|
||||||
|
- `pickAppDate({context, initialDate, firstDate, lastDate})`:
|
||||||
|
- `initialEntryMode: DatePickerEntryMode.calendar`(日历首屏,**保留**头部铅笔
|
||||||
|
切手输)。
|
||||||
|
- `initialDate` 自动夹进 `[firstDate, lastDate]`,防原生越界断言(调用方常传
|
||||||
|
「当前值 ?? 今天」,而「到期日期」的 `firstDate` 就是今天,历史值可能已越界)。
|
||||||
|
- 返回值统一 `dateOnly()` 抹掉时分秒。
|
||||||
|
- **中文文案一律交给本地化,不在此硬编码**(不传 `helpText`/`confirmText`/
|
||||||
|
`fieldHintText` 等),避免两处文案漂移。
|
||||||
|
- `AppDateFieldTrailing({firstDate, lastDate, onToday, enabled})`:日期行尾部统一
|
||||||
|
形态 = 「今天」按钮 + 日历图标。今天越界时按钮自动隐藏,只留图标;
|
||||||
|
`enabled: false`(提交中)时按钮禁用;触控目标 44×44(项目最小口径),
|
||||||
|
文字 `primaryStrong` 白底 4.49:1,带 `设为今天` tooltip。
|
||||||
|
- 各调用点原有的 `firstDate` / `lastDate` 业务约束**原样传入,一字未改**
|
||||||
|
(健康事件 `lastDate: now` 不许未来、到期日 `firstDate: now` 不许补记过去、
|
||||||
|
疫苗 `allowFuture` 双态、生日 `lastDate: now`)。已由 widget 测试直接断言
|
||||||
|
`DatePickerDialog.firstDate/lastDate`,防后续改动悄悄放宽。
|
||||||
|
|
||||||
|
### 1.3 M3.5-03 「本月花费」卡可自查(根因:无法自证记到哪个月)
|
||||||
|
|
||||||
|
**根因**:卡片标签硬编码「本月花费」,而服务端 `summary.monthlyExpense` 本就返回
|
||||||
|
`month`(ISO year-month,如 `2026-09`,按 `tz` 归月)。用户看不到实际月份,
|
||||||
|
所以无法自证「我这条记录到底落在哪个月」,把正确的 ¥0 当成统计故障。
|
||||||
|
|
||||||
|
**核实结论:不改后端聚合逻辑**。用户记录落在 2026-04-09、当天是 2026-09-10,
|
||||||
|
「本月花费 ¥0」是**正确行为**。本单只让客户端把口径亮出来。
|
||||||
|
|
||||||
|
**修法**:
|
||||||
|
|
||||||
|
- `lib/features/pets/health_record_display.dart` 新增纯函数
|
||||||
|
`monthlyExpenseCardLabel(String month, {DateTime? now})`:
|
||||||
|
- 同年 → 「9 月花费」。
|
||||||
|
- 跨年(服务端归月年份 ≠ 设备当前年份,如设备已跨到 1 月而窗口仍是去年 12 月)
|
||||||
|
→ 「2026/12 花费」补年份消歧。
|
||||||
|
- 串非法 → 退回「本月花费」(不崩、不显示脏值)。
|
||||||
|
- `pet_detail_page.dart` 花费卡标签改用该函数。
|
||||||
|
- 可点提示:`_SummaryCard` 在 `onTap != null` 时右上角补 `chevron_right`
|
||||||
|
(16px,`muted`)。**沿用项目既有可点行/卡的表达方式**(宠物列表卡
|
||||||
|
`pets_page.dart:221`、健康提醒卡 `pet_detail_page.dart:692`、资料页设置行
|
||||||
|
`profile_page.dart:125` 全是 `chevron_right`),不自创。
|
||||||
|
- 同时把整卡包一层 `MergeSemantics`:读屏一次读全「¥128.50,9 月花费,按钮」,
|
||||||
|
而不是两段孤立文字。**没有用 `excludeSemantics`**——那会连带丢掉 InkWell 的
|
||||||
|
可激活性,读屏用户就点不动了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 日期选择器方案的取舍理由
|
||||||
|
|
||||||
|
### 2.1 入口模式:为什么是 `calendar` 而不是 `calendarOnly` / `input`
|
||||||
|
|
||||||
|
| 候选 | 结论 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `DatePickerEntryMode.calendar`(选用) | ✅ | 日历首屏对「今天/最近几天」(健康记录的绝对多数)一眼可点;头部铅笔按钮保留,日期已知时一行敲完。两条路都在,代价是多一次点击。 |
|
||||||
|
| `calendarOnly` | ❌ | 恰好**砍掉**手输按钮。任务里提到「评估是否该放开」——评估结论是相反方向:它会把「快速录入一个已知日期」这条唯一的快路堵死。 |
|
||||||
|
| `input`(手输首屏) | ❌ | 「记今天」这类高频场景反而更慢(要敲 8 个数字 + 认格式),且首屏不给日历会让不确定日期的用户懵。 |
|
||||||
|
| `inputOnly` | ❌ | 无日历可翻,比现状更糟。 |
|
||||||
|
|
||||||
|
补充:手输这条路**只有在 M3.5-01 之后才真正可用**(此前提示 `mm/dd/yyyy`、
|
||||||
|
报错 `Invalid format.`),所以「本地化」与「日期可用性」实际是同一个修复的两半。
|
||||||
|
|
||||||
|
### 2.2 「今天」快捷键:为什么放在表单行而不是弹窗内
|
||||||
|
|
||||||
|
先说被否掉的方案:**原生 `showDatePicker` 无法注入自定义动作**。
|
||||||
|
`builder` 参数只能包裹整个 `Dialog`,拿不到它的内部选中态,也就没法「把日历跳到
|
||||||
|
今天并选中」;把按钮塞进 `Column` 里还会因为 `Dialog` 在无界高度下贪心布局而
|
||||||
|
溢出,并且按钮会浮在遮罩上与弹窗视觉脱节。自绘一个带月份网格的选择器成本远超
|
||||||
|
本单范围。
|
||||||
|
|
||||||
|
选定方案:**「今天」放在调用方的日期行尾部**(`AppDateFieldTrailing`),
|
||||||
|
一次实现、7 处统一:
|
||||||
|
|
||||||
|
- **更快**:一键落值,连弹窗都不用开(原方案是「开弹窗 → 找今天 → 确定」3 步)。
|
||||||
|
- **绕开根因**:「记今天的事」是健康记录的主场景,这条路整段避开了容易走错的
|
||||||
|
月份导航。
|
||||||
|
- **零风险**:不与 Flutter 弹窗内部结构较劲,不影响 a11y 与布局。
|
||||||
|
- **一致**:一个共享 widget,7 处形态完全相同(5 处原来是裸的日历图标,
|
||||||
|
2 处对话框里原来什么都没有,现在统一成「今天 + 日历图标」)。
|
||||||
|
|
||||||
|
一处判断说明:`pet_form_page` 的「生日(可选)」也挂了「今天」。语义上是
|
||||||
|
「今天出生的新生宠物」,合法但少见;为了 7 处形态一致仍然保留,代价可忽略。
|
||||||
|
|
||||||
|
### 2.3 什么**没有**做
|
||||||
|
|
||||||
|
- 没有实现月份网格选择器(需自绘或引三方包,超出本单「纯客户端小修」范围)。
|
||||||
|
年份网格 + 手输 + 「今天」三条路已经覆盖了实测暴露的全部痛点。
|
||||||
|
- 没有改任何 `firstDate` / `lastDate` 业务约束。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 7 处调用点收敛情况
|
||||||
|
|
||||||
|
改造前 `grep -rn showDatePicker lib/` 命中 7 处裸调用;改造后 `lib/` 下
|
||||||
|
`showDatePicker` **只出现在 `app_date_picker.dart` 内部一次**。
|
||||||
|
|
||||||
|
| # | 调用点 | 字段 | 业务约束(未改) | `pickAppDate` | 「今天」 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | `pets/pet_form_page.dart` | 生日(可选) | `[1990, 今天]` | ✅ | ✅ |
|
||||||
|
| 2 | `pets/weight_form_page.dart` | 称重日期 | `[1990, 今天]` | ✅ | ✅ |
|
||||||
|
| 3 | `pets/health_event_form_page.dart` | 发生日期 | `[1990, 今天]`(不许未来) | ✅ | ✅ |
|
||||||
|
| 4 | `pets/care_reminder_form_page.dart` | 到期日期 | `[今天, 今年+5]`(不许补记过去) | ✅ | ✅ |
|
||||||
|
| 5 | `pets/vaccination_form_page.dart` | 接种/下次日期 | `[1990, allowFuture ? 今年+5 : 今天]` | ✅ | ✅ |
|
||||||
|
| 6 | `pets/vaccination_records_page.dart` | 标记完成对话框 · 接种/下次 | 同上 | ✅ | ✅(原无 trailing) |
|
||||||
|
| 7 | `pets/care_reminders_page.dart` | 标记完成对话框 · 完成日期 | `[1990, 今天]` | ✅ | ✅(原无 trailing) |
|
||||||
|
|
||||||
|
删掉的重复代码:7 份手写的 `showDatePicker(...)` 参数块 + 5 份手写的
|
||||||
|
`trailing: const Icon(Icons.calendar_month_outlined, color: AppColors.muted)`。
|
||||||
|
|
||||||
|
测试侧同步:`vaccination_records_page_test` / `care_reminders_page_test` /
|
||||||
|
`vaccination_form_page_test` / `care_reminder_form_page_test` /
|
||||||
|
`health_event_form_page_test` 的 `MaterialApp` 都挂上了 zh-CN delegate,
|
||||||
|
`tester.tap(find.text('OK'))` 相应改为 `'确定'`——让 pets 域的 widget 测试与
|
||||||
|
生产环境一致,而不是在英文兜底下测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. compose 桌面实测记录
|
||||||
|
|
||||||
|
### 4.1 环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <你的工作区>/patbond-api
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps # 六容器:postgres / minio / auth / user / pet / community 全 running
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <你的工作区>/patbond-flutter
|
||||||
|
PATBOND_UX_LIVE=1 flutter test integration_test/client_ux_live_test.dart -d linux
|
||||||
|
```
|
||||||
|
|
||||||
|
新增的 `integration_test/client_ux_live_test.dart` 沿用 M3 既有 live 测试的形态
|
||||||
|
(环境变量门控、默认 skip、不计入常规测试套件),驱动**真实 App**(Linux 桌面
|
||||||
|
GTK 渲染管线 + 真实 HTTP,仅注入内存 token 存储因桌面无 keyring)。
|
||||||
|
|
||||||
|
截图方案说明:本机是 Wayland 会话,X11 的 `import -window root` 取不到根窗口
|
||||||
|
(实测 `exit=1`),改为把整棵 `App` 包一层 `RepaintBoundary` 后 `toImage()`
|
||||||
|
直出真实渲染像素(含 Overlay 里的弹窗),落到 `build/ux-live/*.png`。
|
||||||
|
|
||||||
|
### 4.2 逐步所见
|
||||||
|
|
||||||
|
| # | 截图 | 所见 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 01 | `01-pet-form.png` | 建档表单;「生日(可选)」行右侧是新的「今天 + 日历图标」 |
|
||||||
|
| 02 | `02-date-picker-zh.png` | 日期选择器:**标题「选择日期」**、头部 `2026年9月`(原 `September 2026`)、星期表头「一二三四五六日」、底部**「取消」/「确定」**;配色为 peach 头部 + 深棕字 + **珊瑚红实底选中日**(不再是暗红棕);左下角铅笔按钮在位 |
|
||||||
|
| 03 | `03-date-input-zh.png` | 点铅笔切手输:标签**「输入日期」**、输入框预填 `2025/9/1`(zh 年在前)、焦点边框珊瑚色、「取消」/「确定」中文 |
|
||||||
|
| 04 | `04-date-typed.png` | 敲入 `2024/03/15` → 确定 → 表单行显示 `2024-03-15`(**未点任何月份箭头**) |
|
||||||
|
| 05 | `05-today-shortcut.png` | 点「今天」→ 表单行直接变 `2026-09-10`,**弹窗未打开**(`DatePickerDialog` findsNothing 断言通过) |
|
||||||
|
| 06 | `06-pets-list.png` | 档案列表含种子宠物「实测豆豆」 |
|
||||||
|
| 07 | `07-pet-detail-expense-card.png` | 详情页四张数据卡**每张右上角都有 `>` 可点提示**;花费卡显示 **`¥128.50` / 「9 月花费」**,「本月花费」已不存在 |
|
||||||
|
|
||||||
|
### 4.3 花费卡口径的真链路验证
|
||||||
|
|
||||||
|
种子数据经真实 HTTP 下到 pet 服务(`POST /api/v1/pets` +
|
||||||
|
`POST /api/v1/pets/{id}/health-events`,`occurredAt = now`、`amountCents = 12850`),
|
||||||
|
详情页 `GET /pets/{id}/summary?tz=+08:00` 返回 `month: 2026-09` →
|
||||||
|
卡片渲染「9 月花费 ¥128.50」。这正是用户当初无法自证的那一格:记录落在当月才计入,
|
||||||
|
标签现在直接把「当月是几月」写在卡上。
|
||||||
|
|
||||||
|
实测断言全部通过(`00:08 +1: All tests passed!`),收尾 `docker compose down` 已执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试数变化
|
||||||
|
|
||||||
|
| 项 | 改造前 | 改造后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `flutter test` | **502** passed / 2 skipped | **526** passed / 2 skipped(+24) |
|
||||||
|
| `flutter analyze` | No issues | **No issues** |
|
||||||
|
| `dart format` | 无 diff | **无 diff**(151 文件 0 changed) |
|
||||||
|
| live 实测 | — | `client_ux_live_test.dart` 1 passed(门控,不计入 526) |
|
||||||
|
|
||||||
|
新增 24 个测试的分布:
|
||||||
|
|
||||||
|
- `test/core/widgets/app_date_picker_test.dart`(14):`dateOnly`/`today`/
|
||||||
|
`isDateSelectable` 纯函数;日历模式中文文案**实际渲染**(并反向断言
|
||||||
|
`Select date`/`OK`/`Cancel` findsNothing);手输切换按钮在位 + 切换后
|
||||||
|
「输入日期」;手输敲入日期即返回;取消返 null / 确认抹时分秒;
|
||||||
|
`initialDate` 越界夹取;「今天」回调今天且不开弹窗 / 越界隐藏 / 禁用态 /
|
||||||
|
44×44 触控;`datePickerTheme` 色对(含 disabled → `muted`)与**渲染层**
|
||||||
|
选中日 `Ink` 圆底取 `primaryStrong`。
|
||||||
|
- `test/app/app_localization_test.dart`(1):根 `MaterialApp` 实际挂上三件套
|
||||||
|
delegate + `locale zh-CN`,并从运行期 `MaterialLocalizations` 取回中文文案
|
||||||
|
(守住「delegate 一行」不被回删)。
|
||||||
|
- `test/features/pets/health_record_display_test.dart`(3):
|
||||||
|
`monthlyExpenseCardLabel` 同年 / 跨年 / 非法串三组。
|
||||||
|
- `test/features/pets/health_event_form_page_test.dart`(3):先手输改到
|
||||||
|
2026-04-09(复现用户那格)再点「今天」一键回今天且触发 started 埋点;
|
||||||
|
选择器中文 + `firstDate/lastDate` 业务约束不变;提交中禁用「今天」。
|
||||||
|
- `test/features/pets/care_reminder_form_page_test.dart`(2):`firstDate` 就是
|
||||||
|
今天的那一格——未选日期时「今天」也在位且一键清掉必填校验错;
|
||||||
|
约束仍是 `[今天, 今年+5]`。
|
||||||
|
- `test/features/pets/pet_detail_page_test.dart`(1):四张数据卡都有
|
||||||
|
`chevron_right` 可点提示。
|
||||||
|
|
||||||
|
既有测试的口径调整(非新增):`pet_detail_page_test` 两处「本月花费」断言改为
|
||||||
|
实际月份,且样本 `monthlyExpense.month` 改用**当月**串,使断言不随年份漂移
|
||||||
|
(跨年格式由纯函数单测覆盖)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 遗留与移交
|
||||||
|
|
||||||
|
- **未做**:月份网格选择器(需自绘/引包)。若第二批有余量可评估,但当前
|
||||||
|
年份网格 + 手输 + 「今天」已覆盖实测暴露的全部痛点。
|
||||||
|
- **未做**:`SegmentedButton` 选中态仍是 `fromSeed` 派生的粉底(实测截图 06 可见),
|
||||||
|
与品牌 `surfaceTint + primaryDark` 的既有语言不一致。这是 M2 遗留的既有债,
|
||||||
|
不在本单范围,建议并入后续主题收敛单。
|
||||||
|
- **不在本单**:宠物头像、用户资料编辑、资料页真实数据——需契约变更,
|
||||||
|
走第二批正式迭代流程。
|
||||||
|
- **契约**:零改动。`openapi.yaml` 未触碰,`patbond-api` 未触碰。
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
# 03 M3.5 后端第一波:用户资料读写 + 宠物头像 + 获赞聚合
|
||||||
|
|
||||||
|
> 作者:Senior Developer(后端)
|
||||||
|
> 日期:2026-09-11
|
||||||
|
> 工单:T3.5-04(用户资料读写)+ T3.5-05(宠物头像)+ T3.5-06(获赞聚合)三单合并
|
||||||
|
> 输入:patbond-api dev@8089c06(334 测试基线);ADR-022 已拍板;01 号任务拆解 §1 数据模型审计实情
|
||||||
|
> 提交:`a5634c5`(T3.5-04)→ `15c2e66`(T3.5-05)→ `d98a400`(T3.5-06),均已推 `origin/dev`
|
||||||
|
> 结论先行:**三单全部落地,零 Flyway 迁移(ADR-022 前提成立,所需列 V1/V3/V5 全已存在);新增 1 个端点(`PATCH /api/v1/me`)、1 个新资源(`GET /api/v1/me/community-stats`)、3 处响应字段扩充;测试 334 → 379(+45),业务测试全绿;`check-secrets.sh --all` exit 0。⚠️ 唯一红:v1.3.0 冻结契约守卫 11 格漂移(auth 2 + pet 9),根因为本单新增字段尚未冻结——按工单要求未自行修改快照或守卫,处置归 T3.5-07,详见 §6。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 端点与字段定型表(契约冻结 v1.4.0 的直接输入)
|
||||||
|
|
||||||
|
以下为**实现的权威形态**,T3.5-07 冻结时照此回填即可。命名一律 camelCase,信封仍是 `{code, message, data}`。
|
||||||
|
|
||||||
|
### 1.1 `GET /api/v1/me`(既有端点,扩字段)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | nullable | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `userId` | string(uuid) | ✅ | ✗ | 不变 |
|
||||||
|
| `username` | string | ✅ | ✗ | 不变 |
|
||||||
|
| `nickname` | string | ✅(键恒在) | ✅ | **新增**。DB 原值,**不做 username 回退**;未设置为 null。长度 1~32 码点 |
|
||||||
|
| `phone` | string | ✗ | ✅ | 不变(E.164) |
|
||||||
|
| `avatarUrl` | string | ✅(键恒在) | ✅ | **新增**。预签名 GET(私有桶),**会过期、不得持久化**;无头像或 asset 非 ready 或存储未配置均为 null |
|
||||||
|
| `createdAt` | string(date-time) | ✅ | ✗ | 不变 |
|
||||||
|
|
||||||
|
- 响应状态:`200`。`401/40101`(无/坏 token)、`404/40400`(用户已注销)。
|
||||||
|
- **不含** `avatarAssetId`:客户端只写不读它,"是否有头像" 等价于 `avatarUrl != null`。
|
||||||
|
|
||||||
|
### 1.2 `PATCH /api/v1/me`(**新增操作**)
|
||||||
|
|
||||||
|
请求体(无必填字段,但至少要有一个):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 缺省语义 | 显式 null 语义 | 给值语义 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `nickname` | string \| null | 不改 | **清空为 null** | 设置(btrim 后 1~32 码点) |
|
||||||
|
| `avatarAssetId` | string(uuid) \| null | 不改 | **清除头像** | 设置(校验见下) |
|
||||||
|
|
||||||
|
- 成功:`200`,`data` 为与 1.1 完全相同的 `Me` 形态(回显更新后全量资料)。
|
||||||
|
- 错误谱:
|
||||||
|
|
||||||
|
| 状态 | 业务码 | 触发条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 400 | 40000 | 空 patch(两字段都未出现,含只带未声明字段);`nickname` btrim 后长度不在 1~32 码点;`nickname` 纯空白或空串;`avatarAssetId` 非法 UUID;body 非法 JSON |
|
||||||
|
| 401 | 40101 | 无 token / token 无效或过期 |
|
||||||
|
| 404 | 40400 | 用户不存在或已软删(token 仍有效但账号已注销) |
|
||||||
|
| 404 | 40405 | `avatarAssetId` 不存在 / 非本人 / 已删 / **用途不是 `user_avatar`** |
|
||||||
|
| 422 | 42203 | 本人的 `user_avatar` asset 仍在 `uploading` 或 `failed` |
|
||||||
|
|
||||||
|
- **无 `version` 乐观锁**、**无 `Idempotency-Key`**:见 §2.3。
|
||||||
|
|
||||||
|
### 1.3 `PATCH /api/v1/pets/{petId}`(既有端点,扩字段)
|
||||||
|
|
||||||
|
请求体新增:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 缺省语义 | 显式 null 语义 | 给值语义 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `avatarAssetId` | string(uuid) \| null | 不改 | **清除头像** | 设置(校验见下) |
|
||||||
|
|
||||||
|
- `version` 仍必填(即便只改头像)。
|
||||||
|
- 新增错误格:`404/40405`(asset 不存在/非本人/已删/用途不是 `pet_avatar`)、`422/42203`(本人 `pet_avatar` asset 未就绪)。既有 `400/40000`、`403/40300`、`404/40401`、`409/40902`、`409/40903` 不变。
|
||||||
|
|
||||||
|
### 1.4 `Pet` 响应形态(列表 / 详情 / 创建 / 更新四处统一,扩字段)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | nullable | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `avatarUrl` | string | ✅(键恒在) | ✅ | **新增**。预签名 GET,会过期、不得持久化;无头像或 asset 非 ready 或存储未配置为 null |
|
||||||
|
|
||||||
|
- 字段位置在 `status` 之后、`myRole` 之前(JSON 键顺序不构成契约,仅备注实现顺序)。
|
||||||
|
- 其余 17 字段与 v1.3.0 逐字不变。**不含** `avatarAssetId`(同 1.1 的理由)。
|
||||||
|
- v1.3.0 的 `Pet` schema description 里那句「`avatarAssetId` 不出现在 M2 契约(ADR-010)」冻结时需改写为「头像以 `avatarUrl` 现签形式返回;asset id 不外露」。
|
||||||
|
|
||||||
|
### 1.5 `GET /api/v1/me/community-stats`(**新增资源**)
|
||||||
|
|
||||||
|
- 无查询参数、无路径参数:主体恒为 token 里的调用者。
|
||||||
|
- 成功 `200`,`data`:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | nullable | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `receivedLikeCount` | integer(int64) | ✅ | ✗ | 获赞总数;本人「已发布且未软删」帖的 `like_count` 之和;空数据为 `0` |
|
||||||
|
| `publishedPostCount` | integer(int64) | ✅ | ✗ | 作品数;同一集合的帖子数;空数据为 `0` |
|
||||||
|
|
||||||
|
- 错误谱:仅 `401/40101`。**永不 404**——任何已认证用户都有 stats。
|
||||||
|
|
||||||
|
### 1.6 media `purpose` 枚举(契约里 `CreateMediaUploadRequest.purpose`)
|
||||||
|
|
||||||
|
`post_image` → **`post_image` | `user_avatar` | `pet_avatar`**(见 §4)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 语义取舍(本单定型,逐条附理由)
|
||||||
|
|
||||||
|
### 2.1 `/me` 的 nickname **不做** username 回退
|
||||||
|
|
||||||
|
**定型:返回 DB 原值,未设置即 null。**
|
||||||
|
|
||||||
|
- `/internal/users/profiles` 回退是对的:它产出的是**别人看到的展示名**,消费方(Feed 作者名)需要一个永不为空的字符串,回退在 SQL 层(`COALESCE(nickname, username)`)已由 M3 T3-05 交付,本单**未在应用层重复实现**。
|
||||||
|
- `/me` 是**本人的编辑态**。若这里也回退,资料编辑页会把 `llx` 预填进昵称输入框,用户会误以为自己设过昵称;更糟的是下一次保存会把这个回退值**固化成真实昵称**,从此 `/internal` 的回退链再也不会触发——一个纯展示约定被写进了数据。
|
||||||
|
- 因此展示回退的责任在**展示侧**:他人视角由 `/internal` 承担,本人视角(首页问候语 T3.5-10、资料页标题 T3.5-08)由客户端做 `nickname ?? username`。
|
||||||
|
- 实证:`MeProfileIntegrationTest.clearingNicknameIsNullOnMeButFallsBackForOtherPeople` —— 同一用户,`/me` 为 null 而 `/internal` 为 `me_nick_clear`。
|
||||||
|
|
||||||
|
### 2.2 PATCH 的"不改 vs 清空"用三态表达(键缺省 / 显式 null / 给值)
|
||||||
|
|
||||||
|
**定型:键不出现 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。**
|
||||||
|
|
||||||
|
- pets 域 M2 的既有惯例是「缺省或 null 皆为不改」(`UpdatePetRequest` 类注释、iteration-3/15 的「PATCH 不支持清空回 null」)。那个取舍在当时是对的:`name`/`species`/`sex` 的 CHECK 约束本就不允许空值,"清空" 无意义,于是把 null-vs-absent 这个麻烦从契约里挪走是净收益。
|
||||||
|
- 但**昵称与头像天生可选,且"删掉我设的那个"是一等公民操作**。只有两态就根本无法表达它——除非引入 `clearNickname: true` 之类的伴生布尔(更丑,且两个字段就要两个布尔),或用空串当哨兵(与"参数错"撞车)。
|
||||||
|
- 实现手段不需要额外依赖:Jackson **只在 JSON 出现该键时才调 setter**(包含值为 null 的情况),故在 setter 里置 `xxxPresent = true` 即可精确区分。`UpdateMeRequest` 两个字段都这样;`UpdatePetRequest` **只有 `avatarAssetId`** 这样,其余字段保持 M2 语义不动——差异刻意限定在有清空需求的字段上,并写进了类注释。
|
||||||
|
- 配套定型:**纯空白 nickname 是 400/40000,不是隐式清空**。否则"用户误提交了空格"与"用户想删昵称"无法区分;清空只留显式 null 一条路。
|
||||||
|
- 配套定型:**空 patch(什么都没碰)答 400/40000**,不静默 200。空 PATCH 几乎总是客户端 bug(比如表单没收集到变更),静默成功会把它藏起来。
|
||||||
|
|
||||||
|
### 2.3 `/me` 不引入版本号乐观锁,但也不允许丢失更新
|
||||||
|
|
||||||
|
- `/me` 只有一个合法写者(账号本人),不存在 pets 域那种多角色协同改同一行的场景,因此暴露 `version` 只是给客户端加负担(先 GET 拿版本再 PATCH)。
|
||||||
|
- 代价本来是**丢失更新**:若走"读当前行 → 内存合并 → 整行写回",并发的"改昵称"与"改头像"里后到的那个会把对方刚写的字段悄悄还原。
|
||||||
|
- 所以 `UserRepository.updateOwnProfile` 是**列级选择性 UPDATE**:SET 列表里只出现本次请求真正携带的列(`UPDATE identity.users SET nickname = :nickname WHERE ...`)。两个并发 PATCH 改不同列时都留下,Postgres 的行锁把它们排成序即可。
|
||||||
|
- 实证:`MeProfileIntegrationTest.concurrentDisjointPatchesBothSurvive`(两线程 `CyclicBarrier` 同时发车,最终昵称与头像同时生效)。
|
||||||
|
- 重放语义随之是天然幂等:同一 body 连发两次,第二次仍 200 且状态与首次一致(`repeatingTheSamePatchIsStable`)。
|
||||||
|
|
||||||
|
### 2.4 宠物头像的权限档:**按"本次请求碰了哪些字段"定档**,而非整个端点降档
|
||||||
|
|
||||||
|
ADR-022 定的是「宠物头像的写权限为 WRITE 档」,而 `PATCH /api/v1/pets/{petId}` 整体自 M2 起是 **MANAGE**(仅 owner)。两者不冲突,但需要一条实现规则:
|
||||||
|
|
||||||
|
| 请求体触及 | 所需档位 | caregiver | viewer |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 仅 `avatarAssetId`(+`version`) | **WRITE** | ✅ 可改 | ✗ 403/40300 |
|
||||||
|
| 任一资料字段(name/sex/status/…) | **MANAGE** | ✗ 403/40300 | ✗ 403/40300 |
|
||||||
|
| 资料字段 + `avatarAssetId` 混合 | **MANAGE**(取更严的一半) | ✗ 403/40300 | ✗ 403/40300 |
|
||||||
|
| 只带 `version`(资料形态的空操作) | **MANAGE**(沿用历史行为,未改) | ✗ 403/40300 | ✗ 403/40300 |
|
||||||
|
|
||||||
|
- 混合请求按更严判,是为了堵住"夹带":否则 caregiver 可以把改名塞进一个头像请求里绕过 MANAGE。实证 `caregiverMayChangeTheAvatarButNotTheProfile` 三段断言。
|
||||||
|
- **另一条可选路线是新开 `PATCH /pets/{petId}/avatar` 子资源**(端点级单一档位,实现最直白)。未采用:工单明确要求走既有 PATCH;且头像与资料共用同一把乐观锁(`version`)更自然——头像变更也应让并发编辑者感知到行已变。
|
||||||
|
|
||||||
|
### 2.5 获赞与作品数的口径
|
||||||
|
|
||||||
|
**统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖。**
|
||||||
|
|
||||||
|
| 判定 | 计入? | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 草稿(draft) | ✗ | 尚非"作品";其获赞也不可能存在(未发布不可被赞) |
|
||||||
|
| 软删(deleted_at 非空) | ✗ | 删帖即撤回其数字,与 `/me/posts`、Feed 的可见性一致 |
|
||||||
|
| 运营态 hidden / archived | ✗ | M3 契约里对所有人不可见(含作者本人,见 iteration-3/15 可见性矩阵);"看不到的帖"不该出现在作品计数里。注意软删已发布帖会被置为 `archived`,故这条与上一条在实现上是同一个过滤 |
|
||||||
|
| 他人帖 | ✗ | 主体是 token 里的自己 |
|
||||||
|
| **自己赞自己** | ✅ 计入 | 与帖子详情页显示的 `likeCount` 保持同一口径——两处数字必须能对上,否则用户会认为其中一个是错的 |
|
||||||
|
| 空数据 | 返回 `0` | `COALESCE(SUM(like_count), 0)`;契约上两字段 required 且非 nullable |
|
||||||
|
|
||||||
|
- **读侧实时聚合,不引冗余列**(ADR-022):写侧无"按人累计"计数器,也就没有可漂移的副本;`SUM(like_count)` 读的是 M3 写侧同事务维护的帖级冗余列,所以是精确值而非估算。查询压在 `ix_posts_author_created` 的前导列 `author_user_id` 上。
|
||||||
|
- **独立端点而非扩 `follow-stats`**(ADR-022 决策 A):后者主体是"某用户的关注数",混入"我的获赞"会让一个载荷有两个主体。附带收益:本端点路径上**没有 userId**,"查不到别人的获赞"不靠权限判断,而是入口本身不存在。
|
||||||
|
- 实证:`MeCommunityStatsIntegrationTest` 12 例,含 `draftsAreExcludedFromBothNumbers`、`softDeletedPostsDropOutOfBothNumbers`、`operationalStatesAreExcluded`、`otherPeoplesPostsNeverLeakIntoMyStats`、`countsSelfLikesExactlyAsThePerPostNumberDoes`、`freshUserGetsZerosNotNullsAndNever404`。
|
||||||
|
|
||||||
|
### 2.6 头像 asset 的四态校验与错误码归属
|
||||||
|
|
||||||
|
沿用 T3-03 引用侧协议(iteration-3/15 先例),两个域同构:
|
||||||
|
|
||||||
|
| asset 状况 | 答复 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 不存在(幽灵 id) | 404/40405 | 防枚举合并 |
|
||||||
|
| 存在但非本人 | 404/40405 | 同上——不能据响应差异探出"这个 id 是真的" |
|
||||||
|
| 本人、已 `deleted` | 404/40405 | 已删资源对引用方即不存在 |
|
||||||
|
| 本人、`ready`、**用途不符** | 404/40405(message 具体) | 从头像域看,一张帖子配图"不是头像"。**未新增错误码**(工单要求沿用 40405/42203)。message 可以具体是因为这条分支只对**调用者自己的** asset 可达,无枚举风险 |
|
||||||
|
| 本人、用途对、`uploading`/`failed` | 422/42203 | 状态机拒绝,可重试 |
|
||||||
|
|
||||||
|
- 两种头像用途互不通用:`user_avatar` 资源不能当宠物头像,反之亦然(`rejectsUnknownForeignOrWrongPurposeAsset` 明确覆盖)。
|
||||||
|
|
||||||
|
### 2.7 avatarUrl 的降级而非报错
|
||||||
|
|
||||||
|
- **签名只在 asset 为 `ready` 时进行**:读 SQL 的 `LEFT JOIN media.assets ... AND status='ready'` 把非 ready 直接收敛为 null。理由——签一个下载必 404 的 URL 比返回 null 更糟:客户端会显示破图而不是占位图。
|
||||||
|
- **指针不隐式清理**:asset 事后退出 ready(如后台清理置 failed)时,`avatar_asset_id` 保留、`avatarUrl` 为 null。读请求不做写副作用。实证 `PetAvatarIntegrationTest.avatarUrlDegradesToNullWhenTheAssetLeavesReady`。
|
||||||
|
- **对象存储未配置时整体降级**:user 侧判 `patbond.media.endpoint` 是否为空(与 `MediaStorageConfig` 同一条件),pet 侧判 `public-endpoint`(`MediaUrlSigner` 返回 null)。资料读取不会因为存储没配就 500——与"JWT 公钥缺失"的既有降级先例一致。
|
||||||
|
- **每次响应现签**,从不缓存 URL:`MeAvatarSigningIntegrationTest` 里 PATCH 与随后的 GET 各拿到一个可真实下载的签名 URL。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 实现要点与分层
|
||||||
|
|
||||||
|
| 位置 | 变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| `patbond-user/.../dto/MeResponse.java` | 6 字段(+nickname +avatarUrl) |
|
||||||
|
| `patbond-user/.../dto/UpdateMeRequest.java` | **新增**,两字段 + presence 标志 |
|
||||||
|
| `patbond-user/.../service/MeProfileService.java` | **新增**,GET/PATCH 全部语义与校验 |
|
||||||
|
| `patbond-user/.../controller/MeController.java` | 加 `PATCH`;改依赖 `MeProfileService`(`UserService` 仍服务 `/internal` 注册与验密) |
|
||||||
|
| `patbond-user/.../repository/UserRepository.java` | 加 `MeRow` 投影 + `findMeById`(LEFT JOIN ready asset 取 object_key)+ `updateOwnProfile`(列级选择性 UPDATE) |
|
||||||
|
| `patbond-user/.../media/MediaProperties.java` | `allowedPurposes` 三值 |
|
||||||
|
| `patbond-pet/.../media/`(新包) | `PetMediaProperties` / `MediaUrlSigner` / `MediaAssetGateway` / `MediaAssetRef`,均为 community 读侧的同构副本 |
|
||||||
|
| `patbond-pet/.../config/MediaConfig.java` | **新增**,签名器 bean(destroy 关闭 presigner) |
|
||||||
|
| `patbond-pet/.../repository/PetRepository.java` | 读改返 `PetRow`(含 `avatarAssetId` 原值 + ready asset 的 bucket/object_key);`updateWithVersion` 加 `avatar_asset_id` |
|
||||||
|
| `patbond-pet/.../service/PetService.java` | 装配 `PetResponse` 并现签 URL;`requiredLevel(request)` 决定 WRITE/MANAGE;asset 校验 |
|
||||||
|
| `patbond-pet/pom.xml` | 加 `software.amazon.awssdk:s3`(仅本地 SigV4,不直连存储;版本由根 pom BOM 管) |
|
||||||
|
| `patbond-community/.../dto/CommunityStatsResponse.java` | **新增** |
|
||||||
|
| `patbond-community/.../repository/PostRepository.java` | 加 `aggregateByAuthor` |
|
||||||
|
| `patbond-community/.../service/PostService.java` / `controller/PostController.java` | 加 `communityStats` 与路由(放在 `/me/posts` 旁边,帖子是两个数字的唯一来源) |
|
||||||
|
| `docker-compose.yml` | pet 服务补 `PATBOND_MINIO_PUBLIC_ENDPOINT/ACCESS_KEY/SECRET_KEY`(与 user/community 同一组旋钮,无需 `depends_on: minio`——纯本地签名) |
|
||||||
|
| `patbond-{user,pet}/src/main/resources/application.yml.sample` | user 更新 `allowed-purposes`;pet 新增整段 `patbond.media` 读侧配置 |
|
||||||
|
|
||||||
|
**分层纪律**:预签名 URL 一律在 Service 层生成,仓储层只出存储坐标(bucket + object_key)。pet 侧因此把 `PetRepository` 的返回类型从 DTO 改成了 `PetRow`——与 community 的 `PostRow → PostResponse` 装配同构。理由:会过期的 URL 绝不能沉到可能被缓存的仓储返回值里。
|
||||||
|
|
||||||
|
**零迁移复核**:ADR-022 的前提逐条成立——`identity.users.nickname`/`avatar_asset_id`(V1 第 63/67 行)、`pet_health.pets.avatar_asset_id`(V3 第 64 行)、`community.posts.like_count`(V5)、`media.assets.purpose` 无 CHECK。**未新增任何 Flyway 版本,V6 仍留给后续真正建表的迭代。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. purpose 白名单变更
|
||||||
|
|
||||||
|
| 项 | 变更前 | 变更后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MediaProperties.allowedPurposes` 默认值 | `["post_image"]` | `["post_image", "user_avatar", "pet_avatar"]` |
|
||||||
|
| `patbond-user/.../application.yml.sample` 的 `allowed-purposes` | `post_image` | `post_image,user_avatar,pet_avatar` |
|
||||||
|
| objectKey 前缀 | `post_image/yyyy/MM/{assetId}` | 同规则,前缀随 purpose:`user_avatar/…`、`pet_avatar/…` |
|
||||||
|
| DB 约束 | 无(`varchar(32)` 无 CHECK) | **不变**(这正是本变更为零迁移的原因) |
|
||||||
|
|
||||||
|
- **配置项也是引用侧的类型检查**:引用时校验 `purpose` 相符,所以帖子配图永远不会被当成头像挂上去,两种头像也各归各。
|
||||||
|
- 既有测试 `MediaUploadIntegrationTest.rejectsKindAndPurposeOutsideWhitelist` 原以 `pet_avatar` 作反例——已改用仍未开放的 `id_card`,保住"白名单外必拒"的语义(本单唯一一处必须改的既有断言)。
|
||||||
|
- 部署侧注意:`patbond-user` 的实际 `application.yml`(git-ignored)若显式写了 `allowed-purposes: post_image`,**必须同步改**,否则头像上传在该环境会答 400/40000。sample 已更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试数变化
|
||||||
|
|
||||||
|
| 模块 | 基线 | 现在 | 增量 | 说明 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| patbond-common | 3 | 3 | — | |
|
||||||
|
| patbond-user | 100 | **131** | +31 | 新增 `MeProfileIntegrationTest`(19) + `MeAvatarSigningIntegrationTest`(3);`MeEndpointTest`/`MediaUploadIntegrationTest` 断言随形态更新(数量不变) |
|
||||||
|
| patbond-auth | 39 | 39 | — | 无代码改动(但契约守卫因 `/me` 扩字段变红,见 §6) |
|
||||||
|
| patbond-pet | 89 | **100** | +11 | 新增 `PetAvatarIntegrationTest`(11) |
|
||||||
|
| patbond-community | 94 | **106** | +12 | 新增 `MeCommunityStatsIntegrationTest`(12) |
|
||||||
|
| **合计** | **334** | **379** | **+45** | |
|
||||||
|
|
||||||
|
### 六类路径覆盖矩阵
|
||||||
|
|
||||||
|
| 路径 | T3.5-04 | T3.5-05 | T3.5-06 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 成功 | 设昵称/清昵称/设头像/清头像/一次改两样/双端一致 | owner 设头像、详情+列表双处 URL、清空、缺省保留 | 空数据零值、多帖求和、自赞计入、取消赞回落 |
|
||||||
|
| 参数错 | 33 码点(CJK 与 emoji 各一)、纯空白、空串、空 patch、只带未声明字段、非法 UUID、坏 JSON | 非法 UUID、缺 `version` | (无入参可错——端点无参数,形态断言代之) |
|
||||||
|
| 不存在 | 用户软删 → 40400(GET 与 PATCH 双动词);asset 幽灵/他人 → 40405 | 陌生人与幽灵 petId 同答 40401;asset 五态 | 永不 404(`freshUserGetsZerosNotNullsAndNever404`) |
|
||||||
|
| 无权限 | 无 token / 坏 token → 40101 | viewer 改头像 403、caregiver 改资料 403、caregiver 夹带混合 403 | 无 token / 坏 token → 40101;无他人入口 |
|
||||||
|
| 并发冲突 | 两线程改不同字段皆存活(列级 UPDATE 自证) | 旧 version 抢改 → 40902 | 并发重复读答案一致且无副作用 |
|
||||||
|
| 幂等/重放 | 同 body 重放两次结果一致 | 同 body 同旧 version 重放 → 40902;新 version 重放同头像幂等 | 读侧天然幂等,连续两读相同 |
|
||||||
|
|
||||||
|
### 三项专项
|
||||||
|
|
||||||
|
- **昵称边界值与清空**:1 码点 / 32 CJK 码点 / **32 emoji 码点**(UTF-16 长度 64)全部接受,33 一律拒——这一格专门证明长度按**码点**计。若按 `String.length()` 校验,数据库能存的 32 emoji 昵称会被应用层误拒(PostgreSQL `char_length` 数的是码点)。`btrim` 对齐用 `" 豆豆 "` → `"豆豆"` 实证。
|
||||||
|
- **头像 asset 非法态**:幽灵 / 他人 / 已删 / 错用途(post_image、以及跨域的 user_avatar↔pet_avatar)/ uploading / failed 逐一覆盖,两个域各一套。user 侧另有**真实**未就绪路径(真发了上传凭据但没直传,非 SQL 造数据)。
|
||||||
|
- **viewer 拒写 + caregiver 分档**:见上表"无权限"行三段。
|
||||||
|
- **聚合空数据与草稿排除**:见 §2.5 实证列表。
|
||||||
|
|
||||||
|
### 真实依赖的实证
|
||||||
|
|
||||||
|
- `MeAvatarSigningIntegrationTest` 用**真实 MinIO Testcontainer**(镜像 tag 与 compose 一致)跑完整链路:`purpose=user_avatar` 创建上传 → 真实 HTTP PUT 直传 → complete 置 ready → PATCH /me 挂头像 → **拿签名 URL 真实 GET 下载并逐字节比对**。这条同时证明了新加入白名单的用途端到端可用、私有桶靠签名而非公开读。
|
||||||
|
- pet 侧的签名是纯本地 SigV4 计算,故用占位端点 + 占位凭证断言 URL 形态即可(与 community 的 `PostApiTestBase` 同先例),不额外起容器。
|
||||||
|
|
||||||
|
### 门禁
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <你的工作区>/patbond-api
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
|
||||||
|
bash scripts/check-secrets.sh --all # exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- `check-secrets.sh --all`:**exit 0**。(新增的 `PetMediaProperties` setter 沿用 `value` 形参名规避 KEY-ASSIGN 误报,与 user/community 同构,ADR-021。)
|
||||||
|
- `./mvnw clean test`:**业务测试 368 格全绿,契约守卫 11 格红**,见下节。
|
||||||
|
|
||||||
|
> 排障备注:若只跑单模块(`./mvnw -o -pl patbond-community test`),本地仓库里的旧 `patbond-common` 会导致 `NoSuchFieldError: FOLLOW_RULE_VIOLATION` 一类假红。验证一律走根反应堆 `./mvnw clean test`(或加 `-am`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ⚠️ 未闭环项:v1.3.0 冻结契约守卫 11 格漂移(按工单要求停下并上报)
|
||||||
|
|
||||||
|
本单新增字段使**既有契约守卫**报出结构漂移。按工单要求,**未修改快照、未修改守卫、未触碰 doc 仓 `openapi.yaml`**;这正是冻结纪律的预期行为,处置归 **T3.5-07**。
|
||||||
|
|
||||||
|
| 模块 | 测试类 | 红格数 | 漂移内容 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| patbond-auth | `AuthContractConformanceTest` | 2 | `GET /api/v1/me 200`:`$.data.nickname` 与 `$.data.avatarUrl` 契约未声明;连带 `everyDeclaredResponseCellIsExercised` 少一格 |
|
||||||
|
| patbond-pet | `ContractConformanceTest` | 9 | `POST /api/v1/pets 201`(以及依赖它建宠物的 6 个用例):`$.data.avatarUrl` 契约未声明;连带 `everyDeclaredResponseCellIsExercised` |
|
||||||
|
|
||||||
|
- **根因单一**:`ContractValidator` 的设计就是"未声明字段即漂移"(v1.3.0 冻结面 = 恰好这些字段),而 v1.4.0 尚未冻结。9 个 pet 红格实际是**一个**字段导致的连锁(`newCat()` 建宠物是多数用例的前置步骤),非 9 个独立问题。
|
||||||
|
- **`GET /api/v1/me` 的守卫在 auth 模块**(`patbond-auth` 的契约测试连带起 user 应用)——这点容易漏,T3.5-07 需同时更新 auth 与 pet 两处守卫期望,而不只是 pet。
|
||||||
|
- **community 侧零红**:`GET /api/v1/me/community-stats` 是全新操作,不在 v1.3.0 矩阵里,守卫不校验未声明的操作。冻结后需入矩阵。
|
||||||
|
|
||||||
|
### T3.5-07 的机械化清单(照此执行即恢复全绿)
|
||||||
|
|
||||||
|
1. doc 仓 `docs/api/openapi.yaml` 升 `info.version: 1.4.0`,并按 §1 定型表:
|
||||||
|
- `components.schemas.Me`:加 `nickname`(string, nullable) 与 `avatarUrl`(string, nullable);两者进 `required`(键恒在,值可 null);改掉 description 里"恰好这 4 个字段"的措辞。
|
||||||
|
- **新增** `paths./api/v1/me.patch`(请求体 schema `UpdateMeRequest`,响应 200 复用 `Me`,错误 400/401/404/422)。
|
||||||
|
- `components.schemas.Pet`:加 `avatarUrl`(string, nullable) 进 properties 与 required;改写 description 里「`avatarAssetId` 不出现在 M2 契约」那句。
|
||||||
|
- `paths./api/v1/pets/{petId}.patch` 的请求体加 `avatarAssetId`(uuid, nullable);错误响应补 404/40405 与 422/42203 两格。
|
||||||
|
- **新增** `paths./api/v1/me/community-stats.get` + schema `CommunityStats`(两个 int64,均 required 非 nullable)。
|
||||||
|
- `CreateMediaUploadRequest.purpose` 枚举补 `user_avatar`/`pet_avatar`。
|
||||||
|
2. 四模块 `src/test/resources/contract/openapi-v1.3.0.yaml` → `openapi-v1.4.0.yaml`(**字节级复制正典 + md5 逐一比对**,删旧文件),并更新四份 `OpenApiContract.RESOURCE` 与守卫的 `info.version` / 路径数 / 操作数 / schemas 数期望:**路径 31 → 32**(仅 `/api/v1/me/community-stats` 是新路径;`PATCH /api/v1/me` 挂在既有路径上)、**操作 43 → 45**、**schemas 72 → 74**(`UpdateMeRequest` + `CommunityStats`),最终以正典实际计数为准。
|
||||||
|
3. 各域 `operationsTagged` 断言随之更新;新操作入契约矩阵(`PATCH /me`、`GET /me/community-stats` 全响应格)。
|
||||||
|
4. 操作序列可照抄 iteration-3/19 §1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 其余遗留与交接
|
||||||
|
|
||||||
|
- **契约矩阵本单不加**(工单要求,契约未冻结):`PATCH /api/v1/me` 与 `GET /api/v1/me/community-stats` 的契约一致性测试待 T3.5-07 统一入场。
|
||||||
|
- **第二波(客户端)可依赖的定型**:§1 全表即为 T3.5-08/09/10 的接口面。特别提醒:`avatarUrl` **会过期,禁止入本地存储**(沿用 M3 纪律 R2,`SignedNetworkImage` 缓存 key 已剥签名参数);"是否有头像"判 `avatarUrl != null`;本人昵称展示需客户端做 `nickname ?? username`(见 §2.1)。
|
||||||
|
- **真机验证清单待补项**(M3.5 收口时登记进 `development/device-verification.md`):弱网上传头像、头像预签名 URL 过期后的重取、caregiver 账号改宠物头像、资料页获赞数与帖子详情点赞数对账。
|
||||||
|
- **未做(不在本单范围)**:`PetSummaryResponse` 未补 avatarUrl;`bio` 字段(V1 已有列)未开放读写;昵称唯一性不加约束(ADR-022 决策 D3.5-4,允许重名);注册不收昵称(决策 D3.5-5)。
|
||||||
|
- 本报告只写不提交;`mkdocs.yml` 本次不动,随波末统一挂导航入档。
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# 04 M3.5 契约冻结 v1.3.0 → v1.4.0(doc 正典 + api 四模块快照与矩阵,一单连贯)
|
||||||
|
|
||||||
|
> 作者:API Platform Engineer(契约)
|
||||||
|
> 日期:2026-09-11
|
||||||
|
> 工单:T3.5-07 契约冻结 + api 侧快照/矩阵同步(M3 分两单,本次规模小故连贯执行,避免 api 侧 CI 长时间红)
|
||||||
|
> 输入:iteration-3.5/03 号报告 §1 定型表与 §6 机械化清单(**实现定型表 > 推断**);ADR-022;正典 v1.3.0(doc main@6e1ab8e);patbond-api dev@d98a400(379 测试,其中契约守卫 11 格红)
|
||||||
|
> 提交:doc `5f02909`(origin/main)→ api `3cd8005`(origin/dev)
|
||||||
|
> 结论先行:**v1.4.0 已冻结并两侧同步。规模 31→32 路径 / 43→45 操作 / 72→**75** schemas(比 03 号预估的 74 多一个,理由见 §1.4)。四模块快照字节级一致(md5 `a7081f…5801` 五处相同)。矩阵 173→181 格(+8),豁免仍 1 格。T3.5-04/05/06 遗留的 11 格守卫红**全部转绿**,本地根反应堆 `clean test` 全绿(379→381),mutation 三处定向注毒均红、还原即绿,`check-secrets.sh --all` exit 0。**与定型表零矛盾**(一处可选口径与两处措辞修正见 §6,均已在此列明)。⚠️ Gitea CI 仍红——但是**先于本单存在的 runner 级故障**(job 无任何 step、1~2 秒即失败),最后一次 CI 绿是 M3 末的 `8089c06`,处置见 §7。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 冻结总表(逐项:新增 / 变更)
|
||||||
|
|
||||||
|
信封、命名(camelCase)、时间格式、分页正典、错误信封均沿 v1.3.0 不变。**v1.4.0 相对 v1.3.0 纯增量**——无字段删改、无类型变更、无必填收紧,v1.3.0 客户端无需改动即可继续工作(这句已写进 `info.description`,作为对既有集成方的显式承诺)。
|
||||||
|
|
||||||
|
### 1.1 路径与操作
|
||||||
|
|
||||||
|
| 变更类 | 操作 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **新增操作** | `PATCH /api/v1/me` | 挂在既有路径上(故路径只 +1 不 +2);tag `user` → 守卫归 **patbond-auth** |
|
||||||
|
| **新增路径 + 操作** | `GET /api/v1/me/community-stats` | tag `posts` → 守卫归 patbond-community(tag 选择理由见 §6.2) |
|
||||||
|
| 变更(扩响应字段) | `GET /api/v1/me` 200 | 补 `nickname`、`avatarUrl` |
|
||||||
|
| 变更(扩响应字段) | `GET /api/v1/pets`、`GET/POST/PATCH` 的 Pet 四处 | `Pet` schema 补 `avatarUrl`,一处改动波及四个操作的响应 |
|
||||||
|
| 变更(扩请求字段 + 新响应格) | `PATCH /api/v1/pets/{petId}` | 请求体补三态 `avatarAssetId`;**新增 422/42203 单元格**;404 单元格补第二种业务码 40405 |
|
||||||
|
| 变更(枚举追加) | `POST /api/v1/media/uploads` 请求 | `purpose` 枚举 `[post_image]` → `[post_image, user_avatar, pet_avatar]` |
|
||||||
|
|
||||||
|
规模:**路径 31 → 32;操作 43 → 45**(与 03 号 §6 预期一致)。
|
||||||
|
|
||||||
|
### 1.2 Schema 变更
|
||||||
|
|
||||||
|
| Schema | 动作 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Me` | 变更 | 补 `nickname`(string, nullable, 1~32 码点) 与 `avatarUrl`(string, nullable),两者**进 required**(键恒在、值可空);description 从「恰好这 4 个字段」改为 6 个字段,并写明**不含 `avatarAssetId`** |
|
||||||
|
| `UpdateMeRequest` | **新增** | 两字段皆 nullable、皆非必填;三态语义(键缺省/显式 null/给值)逐条进 description |
|
||||||
|
| `Pet` | 变更 | 补 `avatarUrl`(string, nullable) 进 properties 与 required(位置在 `status` 后、`myRole` 前,与实现的键序一致);description 里「`avatarAssetId` 不出现在 M2 契约(ADR-010)」**改写**为「头像以 `avatarUrl` 现签形式返回,`avatarAssetId` 不外露(只写不读)」 |
|
||||||
|
| `UpdatePetRequest` | 变更 | 补 `avatarAssetId`(uuid, nullable);description 声明它是**本 schema 唯一的三态字段**,其余字段保持 M2 两态语义 |
|
||||||
|
| `CommunityStats` | **新增** | `receivedLikeCount` / `publishedPostCount`(int64,required,非 nullable),聚合口径逐条进 description |
|
||||||
|
| `CommunityStatsEnvelope` | **新增** | 见 §1.4 |
|
||||||
|
| `CreateMediaUploadRequest` | 变更 | `purpose` 枚举三值 + 「用途即引用侧类型检查」的说明 |
|
||||||
|
|
||||||
|
规模:**schemas 72 → 75**。
|
||||||
|
|
||||||
|
### 1.3 描述与错误码表(零新增错误码)
|
||||||
|
|
||||||
|
本单**未新增任何业务码**——复用 40000/40101/40300/40400/40401/40405/40902/42203。错误码表因此只补语义、不加号:
|
||||||
|
|
||||||
|
| 行 | 修改 |
|
||||||
|
| --- | --- |
|
||||||
|
| 40300 | 补「viewer 写记录**或改头像**、caregiver 改宠物档案——**头像除外**,见 M3.5 分档」 |
|
||||||
|
| 40405 | 补「或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等)」,并注明用途不符分支只对调用者自己的 asset 可达,故 message 可以具体 |
|
||||||
|
| 42203 | 补「用途相符但非 ready」与「帖图与用户/宠物头像同构」 |
|
||||||
|
|
||||||
|
新增 `info.description` 的**「用户资料与头像域约定(M3.5 冻结)」**整段(与既有 Pets 域、Community/Media 域约定同格式),把 10 条跨端点约定写在一处:三态语义的适用范围、空 patch/纯空白的 400、昵称按码点计与不做 username 回退、avatarUrl 的过期纪律与三重降级、`avatarAssetId` 只写不读、三种用途互不通用与四态校验、宠物头像的按字段分档与混合取更严、`/me` 无乐观锁但靠列级 UPDATE 防丢失更新、community-stats 的独立主体与「永不 404」。
|
||||||
|
|
||||||
|
同步修正的既有条目(不属新增,但不改会自相矛盾):Pets 域约定的三档权限表补「M3.5 起 WRITE 档还含 `avatarAssetId`」与「按本次请求触及字段定档」;`responses.PetWriteDenied` 与 `responses.MediaNotFound` 的 description 随之更新。
|
||||||
|
|
||||||
|
### 1.4 与 03 号 §6 预估的唯一偏差:schemas 74 → 75
|
||||||
|
|
||||||
|
03 号预估 `72 + UpdateMeRequest + CommunityStats = 74`,并注明「最终以正典实际计数为准」。实际为 **75**,多出的一个是 **`CommunityStatsEnvelope`**。
|
||||||
|
|
||||||
|
理由是一致性而非必要性:全 API 的每一个 200 响应都 `$ref` 一个 `XxxEnvelope` schema(`MeEnvelope`/`PetEnvelope`/`FollowStatsEnvelope`…),没有任何一处内联信封。为 community-stats 内联一个信封会成为全契约唯一的例外——对生成 SDK 的工具与阅读契约的人都是一处无理由的不规则。**多一个 schema 比多一处例外便宜。**(守卫期望按实际计数写 75,四处一致。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 四模块快照同步(字节级)
|
||||||
|
|
||||||
|
| 位置 | 旧 | 新 | 处置 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `patbond-auth/src/test/resources/contract/` | openapi-v1.3.0.yaml | openapi-v1.4.0.yaml | 替换(**删旧**) |
|
||||||
|
| `patbond-user/src/test/resources/contract/` | openapi-v1.3.0.yaml | openapi-v1.4.0.yaml | 替换(删旧) |
|
||||||
|
| `patbond-pet/src/test/resources/contract/` | openapi-v1.3.0.yaml | openapi-v1.4.0.yaml | 替换(删旧) |
|
||||||
|
| `patbond-community/src/test/resources/contract/` | openapi-v1.3.0.yaml | openapi-v1.4.0.yaml | 替换(删旧) |
|
||||||
|
|
||||||
|
一致性校验(正典 = doc 仓 `main@5f02909` 的 `docs/api/openapi.yaml`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
md5 a7081fb84f1207eef579ab94025f5801 ← 正典与四份快照,五处完全相同
|
||||||
|
sha256 0ba7bd53f4937d33dfbbf0c6d70aff79000fabeb5178eaea700e845332fcab5b ← 正典
|
||||||
|
```
|
||||||
|
|
||||||
|
- **旧 v1.3.0 快照删除而非保留**:沿 T3-19 先例——每个模块的 `OpenApiContract.RESOURCE` 只认一份快照,守卫锁 `info.version`,保留旧文件只是死重;历史版本由 git 历史与 doc 仓承载。
|
||||||
|
- 四份守卫期望同步升版:`1.3.0 / 31 路径 / 43 操作 / 72 schemas` → **`1.4.0 / 32 / 45 / 75`**。
|
||||||
|
- 各域 `operationsTagged` 断言随操作面更新:auth 域 6→**7**(+`PATCH /api/v1/me`)、community 域 17→**18**(+`GET /api/v1/me/community-stats`)、pets 域 18 与 media 域 2 不变(= v1.2.0/v1.3.0 的冻结面未被 v1.4.0 触碰的实证)。
|
||||||
|
- `ContractValidator` 的 `allOf` 展平注释里那句「v1.3.0 引入的 `nullable + allOf: [$ref]` 模式」**刻意保留 v1.3.0 字样**:那是历史事实(该模式的引入版本),不是当前快照版本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 矩阵扩展(173 → 181 格,+8;豁免仍 1 格)
|
||||||
|
|
||||||
|
| 域 | 模块 | 测试类 | 操作 | 单元格 | 增量 | 豁免 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| auth/user/analytics | patbond-auth | `AuthContractConformanceTest` | 6 → **7** | 19 → **24** | **+5** | 0 |
|
||||||
|
| pets/dictionaries/health-records | patbond-pet | `ContractConformanceTest` | 18 | 82 → **83** | **+1** | 1(沿用) |
|
||||||
|
| community | patbond-community | `CommunityContractConformanceTest` | 17 → **18** | 64 → **66** | **+2** | 0 |
|
||||||
|
| media | patbond-user | `MediaContractConformanceTest` | 2 | 8 | — | 0 |
|
||||||
|
| **合计(v1.4.0 全部 45 操作)** | 4 模块 | 4 类 | **45** | **181** | **+8** | **1** |
|
||||||
|
|
||||||
|
### 3.1 `PATCH /api/v1/me` 全响应矩阵(auth 模块,5 格)
|
||||||
|
|
||||||
|
**易漏点复核**:`/api/v1/me` 的守卫与矩阵都在 **patbond-auth**(实现在 patbond-user,但契约测试在 auth 模块内启同 JVM 的真实 user 服务、跨服务发真实 HTTP)。03 号 §6 特别提示过这点,本单在类 javadoc 里把它写成了常设备注,避免下一次扩契约再踩。
|
||||||
|
|
||||||
|
| 单元格 | 触发方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 200 | 三态「给值」设昵称(并断言 `avatarUrl` 为 null——存储未配置时的降级实证);三态「显式 null」清空昵称(断言回 null) |
|
||||||
|
| 400/40000 | 空 patch `{}`(证明不静默 200) |
|
||||||
|
| 401/40101 | 无 token |
|
||||||
|
| 404 | **同格两码**:合法签名但用户不存在 → 40400;幽灵 `avatarAssetId` → 40405 |
|
||||||
|
| 422/42203 | 本人的 `user_avatar` asset 仍在 `uploading` |
|
||||||
|
|
||||||
|
附带把 `GET /api/v1/me` 200 在 **nickname 非空分支**上再走了一遍严格校验(v1.3.0 时代该字段不存在,此前只覆盖过全空形态)。
|
||||||
|
|
||||||
|
- 422 需要一枚可控状态的 asset。本上下文未配置对象存储(走 `POST /media/uploads` 会 500),故按 `MeProfileIntegrationTest` 的先例直接写 `media.assets` 行——**测试数据造法,不触碰实现**。
|
||||||
|
|
||||||
|
### 3.2 `GET /api/v1/me/community-stats`(community 模块,2 格)
|
||||||
|
|
||||||
|
| 单元格 | 触发方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 200 | ① 全新用户:断言两数为 **0 而非 null 且不是 404**;② 两篇已发布帖 + 他人赞一次:断言 `1 赞 / 2 作品` |
|
||||||
|
| 401/40101 | 由既有「全操作 401 循环」自动覆盖(新操作入 `COMMUNITY_OPERATIONS` 即被纳入) |
|
||||||
|
|
||||||
|
该操作**只有两格**——契约上没有 404,正是「永不 404」这条定型的机器可读表达:矩阵门禁不会去找一个不存在的 404 格。
|
||||||
|
|
||||||
|
### 3.3 `PATCH /api/v1/pets/{petId}` 的新格(pet 模块,1 格 + 1 码)
|
||||||
|
|
||||||
|
| 单元格 | 触发方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| **422/42203(新增状态码)** | 本人的 `pet_avatar` asset 仍在 `uploading` |
|
||||||
|
| 404 的第二种业务码 40405 | 幽灵 `avatarAssetId`;以及**用途不符**(本人的 `post_image` asset)——两者合并同答,防枚举语义实证 |
|
||||||
|
| 200(补一格路径) | 挂上 ready 的 `pet_avatar` |
|
||||||
|
|
||||||
|
- pet 模块的测试数不变(100):新单元格加在既有测试方法 `validationConflictAndRuleErrorsMatchContract` 内,不新建方法。
|
||||||
|
- `avatarUrl` 的**非 null 分支不在契约矩阵内**:pet/auth 两个契约上下文都未配置对象存储,`avatarUrl` 恒 null(nullable 声明因此得到实证)。真实签名 URL 的全链路由 `MeAvatarSigningIntegrationTest`(真实 MinIO、真实下载比对)与 `PetAvatarIntegrationTest` 覆盖——它们是业务测试而非契约测试,分工不变。
|
||||||
|
|
||||||
|
### 3.4 豁免格清单
|
||||||
|
|
||||||
|
**本单新增矩阵零豁免。** 全仓唯一豁免格仍是 pet 侧沿用的 `PATCH /api/v1/care-reminders/{reminderId} 409`(并发条件更新守卫落空,单线程无法确定性构造,行为语义由并发一致性设计文档背书)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 11 格红转绿对照
|
||||||
|
|
||||||
|
| 模块 | 测试类 | 原红格 | 根因 | 本单处置 | 现状 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| patbond-auth | `AuthContractConformanceTest` | 2(`GET /api/v1/me 200` 报 `$.data.nickname`、`$.data.avatarUrl` 契约未声明;连带 `everyDeclaredResponseCellIsExercised`) | v1.3.0 的 `Me` 冻结面不含两字段 | `Me` 补两字段进 properties + required,快照升版 | ✅ 绿 |
|
||||||
|
| patbond-pet | `ContractConformanceTest` | 9(`POST /api/v1/pets 201` 报 `$.data.avatarUrl` 契约未声明,经 `newCat()` 连锁到 6 个用例;连带矩阵门禁) | v1.3.0 的 `Pet` 冻结面不含 `avatarUrl` | `Pet` 补 `avatarUrl` 进 properties + required,快照升版 | ✅ 绿 |
|
||||||
|
| **合计** | | **11** | 单一根因:「未声明字段即漂移」× 尚未冻结 | | **11/11 转绿** |
|
||||||
|
|
||||||
|
9 个 pet 红格确如 03 号判断,是**一个字段**经建宠前置步骤放大的连锁,而非 9 个独立问题——一处 schema 改动即全部消解。
|
||||||
|
|
||||||
|
测试数:**379 → 381(+2)**
|
||||||
|
|
||||||
|
| 模块 | 基线 | 现在 | 增量 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| patbond-common | 3 | 3 | — |
|
||||||
|
| patbond-user | 131 | 131 | —(守卫升版,数量不变) |
|
||||||
|
| patbond-auth | 39 | **40** | +1(`meProfileWriteShapes`) |
|
||||||
|
| patbond-pet | 100 | 100 | —(新格并入既有方法) |
|
||||||
|
| patbond-community | 106 | **107** | +1(`myCommunityStatsSuccessShapes`) |
|
||||||
|
| **合计** | **379** | **381** | **+2** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. mutation 自证(注毒应红、还原应绿)
|
||||||
|
|
||||||
|
三处注毒**定向打本单新增的冻结面**,而不是随便找一处已有字段——目的是证明新增的声明真的在校验路径上,不是写在契约里没人读的死字。
|
||||||
|
|
||||||
|
| 轮次 | 注毒点(快照) | 预期 | 实测 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | auth:`Me.required` 追加 `fakeMeField` | 红 | 3 失败:`GET /api/v1/me 200` 与 **`PATCH /api/v1/me 200`** 均报 `$.data.fakeMeField: 契约必填字段缺失`,矩阵门禁连带红 |
|
||||||
|
| 2 | pet:`Pet.required` 追加 `fakePetAvatarField`(紧邻新加的 `avatarUrl`) | 红 | 9 失败:`POST /api/v1/pets 201` 等报 `$.data.fakePetAvatarField: 契约必填字段缺失`(与 §4 的 9 格连锁同形,反向印证根因判断) |
|
||||||
|
| 3 | community:`CommunityStats.required` 追加 `fakeStatsField` | 红 | 2 失败:**`GET /api/v1/me/community-stats 200`** 报 `$.data.fakeStatsField: 契约必填字段缺失`,矩阵门禁连带红 |
|
||||||
|
| 还原 | 四快照 `cp` 回正典 + md5 复核 | 绿 | 五处 md5 一致;根反应堆 `clean test` **BUILD SUCCESS**,381 测试全绿 |
|
||||||
|
|
||||||
|
第 1 与第 3 轮的失败点分别落在 `PATCH /api/v1/me 200` 与 `GET /api/v1/me/community-stats 200` 上,正是本单**新入矩阵**的两个操作——这两条即「新格真的在跑」的证据。
|
||||||
|
|
||||||
|
> 排障备注(沿 03 号 §5):本轮第一次用 `./mvnw test -pl patbond-auth,patbond-pet,patbond-community`(未加 `-am`)跑 mutation,得到 9 个 **假红**——`NoClassDefFoundError: JdbcClient`,根因是 patbond-user 从本地仓库的旧 jar 解析而非反应堆。**mutation 与门禁验证一律走根反应堆**(`./mvnw clean test` 或加 `-am`),本报告的红/绿结论均来自根反应堆运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 冻结中的判断记录(含三处偏离与修正,逐条列明不自行仲裁的部分)
|
||||||
|
|
||||||
|
### 6.1 与 03 号定型表的一致性:零矛盾
|
||||||
|
|
||||||
|
逐项对照实现代码复核(`MeResponse` 6 字段、`UpdateMeRequest` 两个 presence 标志与 `isEmptyPatch`、`PetResponse` 的 `avatarUrl` 在 `status` 之后、`CommunityStatsResponse` 两个 `long`、`MeProfileService` 的错误码序列、`PetService.requiredLevel` 的按字段分档、`ErrorCode.USER_NOT_FOUND=40400`、`MediaProperties.allowedPurposes` 三值)——**定型表与实现一致,与 ADR-022 一致,内部无矛盾**。冻结按定型表照单全收,未作任何自行裁量的语义改动。
|
||||||
|
|
||||||
|
### 6.2 需要拍板者知晓的三处判断(均不改变定型语义)
|
||||||
|
|
||||||
|
1. **`CommunityStatsEnvelope`(schemas 75 而非 74)**——见 §1.4。定型表授权「以正典实际计数为准」,此处按一致性优先。
|
||||||
|
2. **`GET /api/v1/me/community-stats` 的 tag 选 `posts`,而非新开一个 `stats` tag**。约束前提:四个守卫用 **tag 集合划分模块归属**(auth 模块认 `{auth,user,analytics}`、community 模块认 `{posts,feed,comments,interactions,follows}`),所以该操作**必须**带一个 community 侧的 tag,不能用 `user`。在 `posts` 与新 tag 之间选了 `posts`:两个数字都由帖子派生(`SUM(posts.like_count)` 与帖数),端点也住在 `PostController` 里 `/me/posts` 旁边;而新开一个 `stats` tag 会让门户上出现两个 stats 分区(`follow-stats` 按主体归在 `follows`),对翻文档的人是无来由的意外。`posts` 的 tag description 已相应补一句「含由帖子派生的『我的社区数字』聚合」。若拍板者更倾向独立 tag,改动面是 1 行 tag + community 守卫的 tag 集合 + 本行说明。
|
||||||
|
3. **40405 的 example message 从「媒体不存在」改为「媒体资源不存在」(3 处)**——服务端 `ErrorCode.MEDIA_NOT_FOUND` 的实际 message 是「媒体资源不存在」,v1.3.0 的三处 example 与之不符。example 不在守卫校验范围内,但同一业务码在契约里出现两种 message 正是集成方会踩的那类小意外,故一并对齐到实现。**未改任何业务码与 HTTP 语义。**
|
||||||
|
|
||||||
|
### 6.3 刻意不动的一处既有不规则(留档,不在本单裁量)
|
||||||
|
|
||||||
|
`Me.phone` 的键与 `nickname`/`avatarUrl` 一样恒存在(record 序列化),但 v1.3.0 只把它放在 properties、未进 `required`。本单把新增的两字段**放进了 required**(03 号定型表明确「键恒在」),于是同一 schema 内出现「同样恒在的三个可空字段,两个 required 一个不 required」的不规则。**未顺手把 `phone` 补进 required**——那会改动一个既有字段的声明(虽然对消费方是安全的强化),超出本单「冻结第一波新增面」的范围。建议留作一次独立的、显式的契约整理,不夹带在冻结里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 提交、门禁与 CI 状态
|
||||||
|
|
||||||
|
### 7.1 提交
|
||||||
|
|
||||||
|
| 仓 | 分支 | hash | 内容 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| patbond-doc | main(未受保护,直推) | **`5f02909`** | `docs/api/openapi.yaml` 升 v1.4.0 + `docs/api/index.md` 同步(32 路径/45 操作、M3.5 段落、冻结纪律行) |
|
||||||
|
| patbond-api | **dev**(main 受保护,禁直推) | **`3cd8005`** | 四模块快照替换 + 四份守卫升版 + 矩阵扩展;12 文件,**零 `src/main` 改动** |
|
||||||
|
|
||||||
|
### 7.2 本地门禁(两侧全通)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# doc 侧
|
||||||
|
cd <你的工作区>/patbond-doc
|
||||||
|
python3 -c "import yaml;yaml.safe_load(open('docs/api/openapi.yaml'))" # 解析通过
|
||||||
|
# 另校验:96 处 $ref 全解析、45 个 operationId 无重复无缺失、零未引用 schema、
|
||||||
|
# tags 声明与使用双向闭合
|
||||||
|
mkdocs build --strict # 通过
|
||||||
|
bash scripts/check-secrets.sh --all # exit 0
|
||||||
|
|
||||||
|
# api 侧
|
||||||
|
cd <你的工作区>/patbond-api
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test # BUILD SUCCESS,381 测试全绿
|
||||||
|
bash scripts/check-secrets.sh --all # exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 ⚠️ Gitea CI:红,但先于本单存在(runner 级故障,非测试失败)
|
||||||
|
|
||||||
|
`3cd8005` 的 commit status:`failure`,`CI / backend-test (push)`,**"Failing after 2s"**。
|
||||||
|
|
||||||
|
判定为**基础设施故障而非本单引入**,三条证据:
|
||||||
|
|
||||||
|
1. **前一个提交同症**:`d98a400`(T3.5-06)同样 `failure`,"Failing after **1s**"。最后一次 CI 绿是 **M3 末的 `8089c06`**("Successful in 5m26s")——即 M3.5 第一波开始后 CI 就没再绿过。
|
||||||
|
2. **job 无任何 step 执行**:Gitea 的 run 详情返回 `currentJob.steps: null`、`logs.stepsLog: null`,`duration: 2s`。工作流第一步是手动 checkout,连它都没跑起来,说明失败发生在 job 装配阶段(`runs-on: ubuntu-latest` 无匹配 runner,或 runner 拉不起容器镜像)。真实的 `./mvnw -B clean test` 需要数分钟,1~2 秒不可能是测试红。
|
||||||
|
3. **同一条命令本地绿**:CI 跑的是 `./mvnw -B clean test` 与 `sh scripts/check-secrets.sh --all`,两者本地在 `3cd8005` 的树上均通过(§7.2)。
|
||||||
|
|
||||||
|
处置:**不在本单范围内自行修 runner**(需服务器侧凭证与 Actions 配置,属 Git/CI 工程角色)。请波末收口时一并处理,并按 iteration-3/08 的 CI 规划复核 runner 在线状态与镜像可用性。在 CI 恢复前,api 侧的放行证据以**根反应堆本地门禁 + 本报告的 mutation 自证**为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 冻结后的纪律与交接
|
||||||
|
|
||||||
|
- **契约同步纪律自此仍是「五处」**:doc 正典升版 → 四模块字节级复制新快照 + 四份守卫期望(`info.version` / 路径 / 操作 / schemas / `operationsTagged`)更新。任一处忘记同步,CI(与本地门禁)立即红。
|
||||||
|
- **`/api/v1/me` 的守卫在 patbond-auth** ——已写进该类 javadoc 的常设备注。下次扩 `/me` 面时先看 auth 模块。
|
||||||
|
- **第二波(客户端 T3.5-08/09/10)可依赖的接口面即 v1.4.0 正典**,无需再读 03 号定型表推断。三条客户端纪律再强调:`avatarUrl` **会过期、禁止入本地存储**(沿 M3 纪律 R2,`SignedNetworkImage` 的缓存 key 已剥签名参数);「是否有头像」判 `avatarUrl != null`(响应里没有 `avatarAssetId`,别去找);本人昵称展示做 `nickname ?? username`(`/me` 不回退是定型,不是遗漏)。
|
||||||
|
- **v1.4.0 之后的新增仍走纯增量**:新增可选字段/新增端点/枚举追加不需要新主版本;任何字段删改、类型变更、必填收紧都需要 v2 + 迁移指南 + 日落期,不得在 v1 内静默进行(`info.description` 的纯增量承诺已把这条写给集成方看)。
|
||||||
|
- 本报告只写不提交(随波末统一入档);`mkdocs.yml` 本次未动;03 号报告仍未 commit(波末一并入档)。
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# 05 M3.5 第二波:资料页真实化 + 编辑页 + 宠物头像 + 首页问候语
|
||||||
|
|
||||||
|
> 作者:Frontend Developer(Flutter)
|
||||||
|
> 日期:2026-09-11
|
||||||
|
> 工单:T3.5-08(资料页真实化 + 编辑页)+ T3.5-09(宠物头像接线)+ T3.5-10(首页问候语)三单合并(同仓串行)
|
||||||
|
> 输入:patbond-flutter dev@7d5c84d(526 测试基线);冻结契约 **openapi v1.4.0**(doc main@5f02909);ADR-022;03 号定型表;04 号冻结报告
|
||||||
|
> 提交:`a4a97c0`(T3.5-08)→ `eff3526`(T3.5-09)→ `6945436`(T3.5-10),均已推 `origin/dev`
|
||||||
|
> 结论先行:**三单全部落地。测试 526 → 597(+71)全绿,`flutter analyze` 0 问题,`dart format` 无 diff,`check-secrets.sh --all` exit 0。compose 六容器桌面实测**整条链路走通并逐步截图**(注册 → 登录 → 资料真实化 → 设昵称 → 传用户头像 → Feed 作者名同步 → 宠物头像),像素级确认头像真的画出来。⚠️ 过程中修掉一个**先于本单存在的错线**:`/api/v1/me` 挂在 auth(:8081) 而该端点由 user(:8082) 提供,此前无人消费 `me()` 故一直没暴露(§5.1)。另发现一处**服务端刻意的 60s 滞后**(Feed 作者名,非缺陷)已写进真机清单备注(§4.4)。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 三单交付内容
|
||||||
|
|
||||||
|
### 1.1 T3.5-08 资料页真实化 + 编辑页
|
||||||
|
|
||||||
|
| 位置 | 变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| `lib/core/models/patch_field.dart` | **新增** `PatchField<T>`:PATCH 三态的类型载体(absent / clear / value) |
|
||||||
|
| `lib/features/auth/auth_models.dart` | `UserProfile` 6 字段(+nickname +avatarUrl)+ `displayName` / `hasAvatar`;**新增** `UpdateMeRequest`(两个三态字段 + `isEmpty`) |
|
||||||
|
| `lib/features/auth/auth_repository.dart` | 加 `updateMe`;**加 `userApi` 线路**(`/me` 归 user 服务,见 §5.1) |
|
||||||
|
| `lib/features/community/community_models.dart` | `MediaPurpose` 三值(+user_avatar +pet_avatar);**新增** `CommunityStats` |
|
||||||
|
| `lib/features/community/community_repository.dart` | 加 `getMyCommunityStats()` |
|
||||||
|
| `lib/features/community/media_uploader.dart` | `purpose` 参数化(缺省 postImage,发布页行为不变) |
|
||||||
|
| `lib/core/widgets/avatar_upload_sheet.dart` | **新增**:复用 MediaUploader 六态编排的单图头像上传 sheet + 两个构造口 typedef |
|
||||||
|
| `lib/features/profile/profile_controller.dart` | **新增**:`/me` 主链路四态 + 统计块独立三态 + `save` + `reset` |
|
||||||
|
| `lib/features/profile/profile_display.dart` | **新增**:错误文案分层 + `validateNickname`(按码点) |
|
||||||
|
| `lib/features/profile/profile_edit_page.dart` | **新增**:昵称输入 + 头像上传 + 昵称/头像各自的清除入口 |
|
||||||
|
| `lib/features/profile/profile_page.dart` | 176 行全 demo → 真实数据四态 |
|
||||||
|
| `lib/app/app.dart` / `main_shell_page.dart` | `ProfileController` 单例装配 + 头像上传构造口注入 + 登出 reset |
|
||||||
|
|
||||||
|
**头部三项自此全部来自服务端**:展示名(`/me`)、头像(`/me` 的现签 `avatarUrl`)、四个数字(`/me/community-stats` 的获赞与作品 + `follow-stats` 的粉丝与关注)。demo 的「萌宠新手(豆豆家长)」/「Patbond 社区创作达人」/「24 / 1.8k / 2」全部退役,widget 测试反向钉住这几串文案不再出现。
|
||||||
|
|
||||||
|
**「关注数」补齐为两个数字**(关注我 / 我关注)而非只取一个:`follow-stats` 一次调用同时给出 `followerCount` 与 `followingCount`,只显示一半反而要用户猜是哪一半。
|
||||||
|
|
||||||
|
**数字不做 `1.8k` 式压缩**:获赞总数要能与帖子详情的 `likeCount` 逐一对上(同一口径、含自赞,是 03 号 §2.5 的定型),压缩会让「对不上」变成常态——这条也进了真机清单第 4 项。
|
||||||
|
|
||||||
|
### 1.2 T3.5-09 宠物头像接线
|
||||||
|
|
||||||
|
| 位置 | 变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| `lib/features/pets/pet_models.dart` | `Pet.avatarUrl` 入模型;`UpdatePetRequest.avatarAssetId` 三态(该 schema 唯一) |
|
||||||
|
| `lib/features/pets/pet_display.dart` | 加 `petAvatarSaveErrorMessage`(40405 / 42203 / 40300 / 40902 分层) |
|
||||||
|
| `lib/features/pets/pet_detail_page.dart` | 头像展示真实 URL;铅笔角标接上传;「更换 / 移除」二选一;权限 WRITE 档 |
|
||||||
|
| `lib/features/pets/pets_page.dart` | 列表卡展示真实头像 + 透传上传构造口 |
|
||||||
|
| `lib/core/widgets/pet_avatar.dart` | 文档更新(M2 的「不做上传」注释改为 T3.5-09 的实情) |
|
||||||
|
|
||||||
|
- **铅笔角标自此有功能**。此前它渲染着但点下去是打开资料表单(表单里没有头像字段),从用户视角等于「渲染了但没用」——工单的描述与实情一致。
|
||||||
|
- **权限按 WRITE 档呈现**(owner + caregiver 有入口,viewer 没有),与资料编辑的 MANAGE 档刻意不同档。
|
||||||
|
- **纯头像 PATCH 只发 `version` + `avatarAssetId`**,一个资料字段都不带。这不是节省字节:服务端按「本次请求触及了哪些字段」定档,夹带任一资料字段就会把档位抬到 MANAGE,caregiver 立刻 403(03 号 §2.4 的「防夹带」在客户端这一侧的对应义务)。widget 测试对此有 `payload.keys.length == 2` 的直接断言。
|
||||||
|
- **40902 冲突不静默重放**:提示「档案已被更新,请重新操作」+ 重取档案拿新 version,让用户决定是否重来。头像是用户可见的覆盖操作,自动生效两次比失败更糟。
|
||||||
|
|
||||||
|
### 1.3 T3.5-10 首页问候语
|
||||||
|
|
||||||
|
- 「下午好,豆豆」→ `下午好,<nickname ?? username> 👋`。数据源是**与资料页同一个 `ProfileController`**:一次 `/me` 供两个消费点,改昵称后两处一起变,无需手动刷新(widget 测试直接验这条)。
|
||||||
|
- 资料未到手时退化为不称名的「下午好 👋」,**不编造假名**(不回落 demo 宠物名、不显示占位串)。
|
||||||
|
- **其余首页 demo 一律未动**(ADR-022 决策 D3.5-1),并在代码里逐项标注为「刻意保留的 demo 占位」+ 去向:
|
||||||
|
|
||||||
|
| 保留项 | 标注位置 | 去向 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 天气条 / 地区选择 | `HomePage` 类文档 + `_WeatherStatusBar` | 需接外部天气服务(含 key 与配额管理) |
|
||||||
|
| 圈子「柴犬圈 / 猫咪圈 / 救助站」 | `_StoryRow` | 实为**话题**,ADR-018 已剪出 |
|
||||||
|
| 促销卡「新用户首单立减 ¥20」 | `_PromoCard` | M5 服务域(优惠/订单能力不存在) |
|
||||||
|
| 搜索框与「本地服务」段 | `HomePage` 类文档 | 契约无检索端点;服务商为 demo 常量,同属 M5 |
|
||||||
|
| 问候卡右侧大图 | `_PetGreetingCard.petAvatar` | 需要「当前宠物」概念,尚不存在,不在 ADR-022 范围内 |
|
||||||
|
|
||||||
|
`home_greeting_test.dart` 里有一个用例**反向钉住「保留项仍在」**——避免后续有人以「顺手清理 demo」为名越出拍板范围(真要动得先改 ADR)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 展示名回退与 PATCH 三态:客户端实现要点
|
||||||
|
|
||||||
|
### 2.1 展示名回退做在展示层,而且只做一层
|
||||||
|
|
||||||
|
**规则**:`displayName => nickname ?? username`,实现在 `UserProfile` 的 getter 上,两个消费点(资料页头部、首页问候语)共用。
|
||||||
|
|
||||||
|
**为什么不让服务端回退**(03 号 §2.1 的定型,客户端这一侧的对应义务):`/me` 是本人的**编辑态**。若服务端回退,编辑页会把 `llx` 预填进昵称输入框,用户会以为自己设过昵称;下一次保存就把这个纯展示约定**固化成真实数据**,`/internal/users/profiles` 的 SQL 回退链从此再也不触发。所以:
|
||||||
|
|
||||||
|
| 视角 | 回退在哪 | 客户端做什么 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本人(资料页 / 问候语) | **客户端展示层** | `nickname ?? username` |
|
||||||
|
| 他人(Feed 作者名 / 评论) | 服务端 SQL(`COALESCE`,M3 T3-05) | **什么都不做**——`AuthorSummary.nickname` 直接上屏,不拼装 |
|
||||||
|
|
||||||
|
**编辑页预填只用 DB 原值**(`_initial.nickname ?? ''`,空则空串),并把「现在别人看到的是用户名」写在 helperText 里(`未设置,当前展示为用户名「llx」`)而不是写进输入框。这是三态之外第二条容易写错的地方,widget 测试与桌面实测各钉一次。
|
||||||
|
|
||||||
|
### 2.2 PATCH 三态:类型承载,不靠约定
|
||||||
|
|
||||||
|
Dart 的 `String?` 只有两态,无法区分「不改」与「清空」。若把「不改」也编码成 `null`,**用户只改昵称就会连头像一起被清掉**(服务端把显式 null 当清空指令执行)。故三态由类型承载:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
PatchField<String>.absent() // 键不出现 → 不改
|
||||||
|
PatchField<String>.clear() // 键出现为 null → 清空
|
||||||
|
PatchField<String>.value('小柴') // 键出现有值 → 设置
|
||||||
|
```
|
||||||
|
|
||||||
|
序列化只有一条路径 `PatchField.writeTo(json, key)`——「absent 不落键」这条纪律只实现一次,各请求 DTO 不自己拼 map,避免某处漏写 `isPresent` 判断。
|
||||||
|
|
||||||
|
**编辑页维护「三态意图」而不是「当前值」**。它不做「读当前表单值 → 整体提交」,而是与进页时的服务端快照比对后产出三态:
|
||||||
|
|
||||||
|
| 用户动作 | `nickname` | `avatarAssetId` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 什么都没碰 | absent | absent(**空 patch → 直接短路不发请求**) |
|
||||||
|
| 只改昵称 | value | **absent(键不出现)** |
|
||||||
|
| 点「清除昵称」 | clear | absent |
|
||||||
|
| 只传新头像 | absent | value |
|
||||||
|
| 点「清除头像」 | absent | clear |
|
||||||
|
| 改昵称 + 传头像 | value | value(一次 PATCH 改两样) |
|
||||||
|
| 输入与原昵称相同 | absent(视作未改,保存钮禁用) | absent |
|
||||||
|
| 昵称输入框留空/纯空白 | **absent**(意为「不改」,不是清空) | absent |
|
||||||
|
|
||||||
|
最后一行是刻意的:**清空只走「清除昵称」这一个显式入口**。服务端对纯空白答 400/40000 而非隐式清空(03 号 §2.2),客户端与之对齐——空输入框判为「不改」,于是「用户误删了输入框内容」不会变成「删掉我的昵称」。
|
||||||
|
|
||||||
|
三处配套:
|
||||||
|
|
||||||
|
- **空 patch 前置短路**:`ProfileController.save` 与编辑页各判一次 `request.isEmpty`,不去撞服务端刻意留的 400。
|
||||||
|
- **昵称校验按码点**:`trimmed.runes.length > 32` 而非 `String.length`。32 个 emoji 的合法昵称 UTF-16 长度是 64,按 `String.length` 校验会**误拒数据库存得下的昵称**(PostgreSQL `char_length` 数码点)。测试里 `'🐕' * 32` 这一格专门证明这点。
|
||||||
|
- **btrim 先行**:先 `trim()` 再判长度,与 `ck_users_nickname` 同序。
|
||||||
|
|
||||||
|
`UpdatePetRequest` 同理,但**只有 `avatarAssetId` 是三态**,其余字段保持 M2 两态语义(它们的 CHECK 约束本就不允许空值,「清空」无意义)——差异刻意限定在有清空需求的字段上,并写进了类文档。
|
||||||
|
|
||||||
|
### 2.3 头像:只写不读 assetId
|
||||||
|
|
||||||
|
- 「有头像」一律判 `avatarUrl != null`。响应里没有 `avatarAssetId`,代码里也没有任何地方去找它。
|
||||||
|
- `avatarUrl` **不入任何本地存储**(纪律 R2)。编辑页上传成功到保存之间没有可用 URL(不缓存 complete 响应里的那个),改为以「已选择新头像」占位说明,保存后由服务端回显现签 URL。
|
||||||
|
- 展示统一走 `RemoteImage` → `SignedNetworkImage`(缓存 key 剥 `X-Amz-*`),同对象的不同签名命中同一内存缓存。
|
||||||
|
- 无图一律本地占位(资料页人形、宠物爪印),不显示破图——与服务端「非 ready 的 asset 直接给 null 而不是签一个必 404 的 URL」(03 号 §2.7)配对。
|
||||||
|
|
||||||
|
### 2.4 上传编排:复用而非重写
|
||||||
|
|
||||||
|
`AvatarUploadSheet` 只做呈现,状态机、凭据过期换新、失败可重试、孤儿防护全部沿用 `MediaUploader`(新增 `purpose` 参数,`maxImages: 1`、`maxConcurrentUploads: 1`)。六态映射:
|
||||||
|
|
||||||
|
| 阶段 | 呈现 |
|
||||||
|
| --- | --- |
|
||||||
|
| picking | 「正在打开相册…」+ 转圈 |
|
||||||
|
| queued / compressing | 「正在处理图片…」 |
|
||||||
|
| uploading | 线性进度条 + 「上传中 N%」 |
|
||||||
|
| confirming | 进度定格 100% + 「正在确认…」 |
|
||||||
|
| ready | 圆形预览 + **「使用这张」** + 「重新选择」 |
|
||||||
|
| failed | 原因文案 + 「重试」(仅可重试时)+ 「重新选择」 |
|
||||||
|
|
||||||
|
两处刻意的取舍:**ready 后仍要用户点「使用这张」**(上传成功 ≠ 用户满意这张图;头像是长期可见的身份标识,不该剥夺预览确认);**不可重试的失败只给「重新选择」**(压缩后仍超 10 MB,重试同一张必然再失败,给重试钮是误导)。
|
||||||
|
|
||||||
|
`purpose` 由调用页给定并透传到 `createUpload`,测试直接断言 `user_avatar` / `pet_avatar` ——用途即服务端引用侧的类型检查,给错会被答 404/40405。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 权限与四态
|
||||||
|
|
||||||
|
### 3.1 宠物头像的按字段分档(客户端呈现侧)
|
||||||
|
|
||||||
|
| 角色 | 头像入口(WRITE) | 资料编辑入口(MANAGE) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| owner | ✅ 角标 + 可点 | ✅ |
|
||||||
|
| caregiver | ✅ 角标 + 可点 | ✗ |
|
||||||
|
| viewer | ✗ | ✗ |
|
||||||
|
|
||||||
|
widget 测试三格全覆盖。第四格:**未装配上传能力(构造口为 null)时 owner 也不渲染入口**——意为「本次构建没有上传能力」,而不是「有入口但点了没反应」;生产装配(`app.dart`)恒注入,既有不关心头像的 widget 测试因此无需改动。
|
||||||
|
|
||||||
|
### 3.2 四态口径
|
||||||
|
|
||||||
|
| 页面 | loading | ready | error | 空态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 资料页主链路 | 居中转圈 | 头部 + 菜单 | 横幅 + 重试 | **无独立空态**,见下 |
|
||||||
|
| 资料页统计块 | 转圈占位 | 四个数字 | 「统计加载失败 + 重试」 | 一排 `0` |
|
||||||
|
| 宠物详情头像 | 沿用详情页四态 | 真实头像 | — | 爪印占位 |
|
||||||
|
|
||||||
|
**资料页没有独立空态是定型而非遗漏**:任何已认证用户都有资料,`/me/community-stats` 契约上「永不 404、空数据返回 0」。所以「新用户什么都没有」的形态就是 ready 态里的一排 `0`,不是另一个页面态。widget 测试 `expect(find.text('0'), findsNWidgets(4))` 钉住这一格。
|
||||||
|
|
||||||
|
**统计块的三态是独立的**:`/me` 成功而统计失败时只降级这一块,不把整页打成 error——昵称和头像已经拿到了,为两个数字丢掉整页是过度反应。两块统计同失败共用一个「重试」(两个数字并列在同一张卡上,只有一半是数字、另一半是「—」比整块失败更费解)。
|
||||||
|
|
||||||
|
**已有资料副本时刷新失败保留副本**(停在 ready),沿宠物详情页「有副本即不打断阅读」的既有取舍。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. compose 桌面实测(逐步记录)
|
||||||
|
|
||||||
|
### 4.1 环境与命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端六容器
|
||||||
|
cd <你的工作区>/patbond-api
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package # BUILD SUCCESS
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps # postgres/minio healthy + auth/user/pet/community up(六容器)
|
||||||
|
|
||||||
|
# 桌面真链路(默认跳过,不进常规测试套件)
|
||||||
|
cd <你的工作区>/patbond-flutter
|
||||||
|
PATBOND_PROFILE_LIVE=1 flutter test integration_test/profile_avatar_live_test.dart -d linux
|
||||||
|
# 逐步截图落到 build/profile-live/
|
||||||
|
|
||||||
|
cd <你的工作区>/patbond-api && docker compose down # 用完即拆
|
||||||
|
```
|
||||||
|
|
||||||
|
实测脚本沿 M3.5 第一批的 `client_ux_live_test.dart` 先例:驱动**真实 App**(Linux GTK 渲染 + 真实 HTTP + 真实 MinIO),把整棵 App 包一层 `RepaintBoundary` 后 `toImage()` 直出真实渲染像素。**只有选图与压缩两层是桌面替身**(Linux 无 image_picker / flutter_image_compress 原生实现);`createUpload` → 预签名 PUT 直传 → `confirm` → `PATCH` 四段全是生产实现。
|
||||||
|
|
||||||
|
### 4.2 逐步结果
|
||||||
|
|
||||||
|
| # | 步骤 | 结果 | 截图 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | UI 注册(用户名/手机号/密码/确认密码;注册面**不收昵称**,ADR-022 D3.5-5) | ✅ 进主壳 | `01-register.png` |
|
||||||
|
| 2 | 首页问候语(未设昵称) | ✅ 「下午好,plive… 👋」——**回退 username** | `02-home-greeting-username.png` |
|
||||||
|
| — | 借真实会话用 API 种一帖 + 一只宠物 | ✅ | — |
|
||||||
|
| 2.5 | UI 登出 → UI 登录 | ✅ 三个控制器重新预取(登出 reset 是既有纪律) | — |
|
||||||
|
| 3 | 资料页 | ✅ 展示名 = 真实 username,副行 `@username`,四个数字 **0 / 0 / 0 / 1**(刚发 1 帖);demo 文案零残留 | `03-profile-real-username.png` |
|
||||||
|
| 4 | 编辑页 | ✅ 昵称框**空**(未预填 username)+ helperText「未设置,当前展示为用户名「plive…」」;无昵称时不给「清除昵称」入口 | `04-profile-edit-empty.png` |
|
||||||
|
| 5 | 设昵称 → 保存 | ✅ 「资料已更新」+ 头部改昵称 | `05-profile-nickname-set.png` |
|
||||||
|
| 6 | 更换头像 → sheet | ✅ 压缩→直传→confirm 走通,出圆形预览 + 「使用这张」 | `06-avatar-sheet-ready.png` |
|
||||||
|
| 7 | 「使用这张」→ 保存 | ✅ 资料页头像**真的画出上传的图**(不是占位) | `07-profile-avatar-uploaded.png` |
|
||||||
|
| 8 | 回首页 | ✅ 问候语同步变昵称(同一控制器,未手动刷新) | `08-home-greeting-nickname.png` |
|
||||||
|
| 9 | Feed 下拉刷新 | ✅ 我的帖的作者名变昵称、作者头像变新头像——**服务端 `/internal` 回退链的实证**(客户端对作者名零拼装)。⚠️ 有 60s 滞后,见 §4.4 | `09-feed-author-nickname.png` |
|
||||||
|
| 10 | 档案 → 宠物列表 → 详情 | ✅ 上传前爪印占位,owner 见铅笔角标 | `10a-…`、`10-pet-detail-placeholder-avatar.png` |
|
||||||
|
| 11 | 点头像 → 上传 → 「使用这张」 | ✅ 「头像已更新」;详情头像画出真实图;PATCH 只带 `version` + `avatarAssetId` | `11-pet-avatar-uploaded.png` |
|
||||||
|
|
||||||
|
脚本内的机器断言(每次运行都跑):编辑页不预填 username、作品数为服务端聚合值、Feed 作者名 = 昵称、`createUpload` 的 purpose 正确、详情头像 URL 带 `pet_avatar` 前缀且**在应用进程内直取得到 200**、详情头像下有 `Image` 且**没有爪印兜底**(有图就必须画出图)。
|
||||||
|
|
||||||
|
### 4.3 顺带用 HTTP 直连复核的服务端语义
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /me` 初始态 | `nickname: null`、`avatarUrl: null`(键恒在,不回退) |
|
||||||
|
| `GET /me/community-stats` 空数据 | `{receivedLikeCount: 0, publishedPostCount: 0}`,200 不是 404 |
|
||||||
|
| `PATCH /me` 只带 nickname | 200,`avatarUrl` 保持 null(未被顺手清空) |
|
||||||
|
| `PATCH /me` 只带 avatarAssetId | 200,**nickname 原值保留**(三态「不改」生效) |
|
||||||
|
| `PATCH /me` 空 body `{}` | **400 / 40000**「请至少提交一个可更新字段:nickname 或 avatarAssetId」 |
|
||||||
|
| 用户头像预签名 GET | 200,字节与上传**逐字节相同** |
|
||||||
|
| 宠物头像预签名 GET(pet 侧本地 SigV4) | 200,字节相同 |
|
||||||
|
| 发帖后 stats | `publishedPostCount: 1` |
|
||||||
|
|
||||||
|
### 4.4 实测发现的两处「看起来像 bug、其实不是」
|
||||||
|
|
||||||
|
**(a)Feed 作者名有 ≤60s 滞后(服务端设计)。** 设完昵称立刻下拉刷新 Feed,作者名仍是旧的 username。根因不在客户端:community 侧 `AuthorProfileGateway` 把 `/internal/users/profiles` 的结果放在 **60s TTL 的进程内缓存**里(`patbond.author-profile.cache-ttl`,M3 T3-05);首屏 Feed 是设昵称之前拉的,那一次已经把「作者名 = username」写进了缓存。等过 TTL 再刷新即同步(实测确认)。
|
||||||
|
|
||||||
|
- 资料页与首页问候语读的是 `/me`,**没有这层缓存,立即生效**——所以会出现「资料页已变、Feed 还是旧名」的一分钟窗口。
|
||||||
|
- 处置:**不改动服务端缓存**(60s 是合理的读侧优化,改它属后端范畴且需拍板)。已写进实测脚本注释 + 真机清单备注,避免下次实测把它当缺陷重复上报。若产品认为这一分钟不可接受,处置方向是「改昵称成功后由 user 服务发失效通知/缩短 TTL」,属后续里程碑的后端工单。
|
||||||
|
|
||||||
|
**(b)1×1 的极小测试图能上传能下载,但 Flutter 解码器拒绝。** 最初用 M3 e2e 那张 344 字节的 1×1 JPEG 做实测夹具,结果:服务端照收、`curl` 与应用进程内 `HttpClient` 都能取回**逐字节相同**的 344 字节,但 UI 一路显示爪印占位。定位到 `Image.network` 抛 `Codec failed to produce an image, possibly due to invalid image data`——**是图片本身在解码路径上被拒,不是链路问题**(1×1 PNG 也一样)。换成一张 16×16 的棋盘 PNG(87 字节)后像素正常渲染。
|
||||||
|
|
||||||
|
- 这个坑很容易被误判成「头像根本没传上去」,故写进了实测脚本的夹具注释。
|
||||||
|
- **不影响生产**:真机走相册真实照片,不会遇到 1×1。真机项第 1(a)另有「真的画出图」的通过标准兜底。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 顺手修掉的既有缺陷
|
||||||
|
|
||||||
|
### 5.1 ⚠️ `/api/v1/me` 端口错线(先于本单存在,本单第一个消费者才暴露)
|
||||||
|
|
||||||
|
**症状**:桌面实测里资料页始终停在 error 态、问候语始终不称名。
|
||||||
|
|
||||||
|
**根因**:`ApiAuthRepository` 只持有一个 auth 服务(:8081)的 `ApiClient`,而 `/api/v1/me` 由 **user 服务(:8082)的 `MeController`** 提供(ADR-002 分端口直连,无网关)。`curl http://127.0.0.1:8081/api/v1/me` 实测 **404**。
|
||||||
|
|
||||||
|
**为什么一直没被发现**:`AuthRepository.me()` 自 M1 就在接口上,但**此前没有任何页面消费它**(Splash 恢复走 `restoreSession` → `TokenRefresher`,打的是 auth 的 refresh 端点)。本单的 `ProfileController` 是第一个真实消费者。
|
||||||
|
|
||||||
|
**处置**:`ApiAuthRepository` 加一条 `userApi` 线路(缺省回落主客户端,既有测试桩不受影响),`me()` 与 `updateMe()` 走它;`app.dart` 用 `patbondUserApiBaseUrl` 装配。手法与 `ApiCommunityRepository` 的 `mediaApi`(media 端点也在 user 服务)完全同构,类文档写明了「auth 上没有 `/api/v1/me` 路由,走主客户端会得到 404」。
|
||||||
|
|
||||||
|
**教训(值得留档)**:分端口直连模式下,「接口定义在哪个 Repository」与「端点部署在哪个服务」是两件事。凡是 `Api*Repository` 里出现跨服务端点,都应有一条独立的 `ApiClient` 并在类文档里写清线路。目前有此情况的两处(auth 的 `/me`、community 的 `/media`)均已显式接线。
|
||||||
|
|
||||||
|
### 5.2 编辑页保存钮的可用性不刷新
|
||||||
|
|
||||||
|
`TextEditingController` 的监听里原先只在有错误/有清除意图时 `setState`,于是「已经打了字但保存钮还是灰的」。改为每次输入都重建(可用性由 `_hasChanges` 现算)。widget 测试覆盖。
|
||||||
|
|
||||||
|
### 5.3 资料页首屏预取触发时机
|
||||||
|
|
||||||
|
`ProfilePage.initState` 里直接 `refresh()` 会在 `IndexedStack` 挂载阶段同步 notify,而同一控制器的另一个监听者(首页问候语)此时**已构建完成** → 命中 Flutter「build 期间 setState」断言。改为推到帧末(`addPostFrameCallback`)。pets/home 各自只有一个监听者,故它们在 `initState` 里直取无妨——差异写进了注释。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 测试数变化
|
||||||
|
|
||||||
|
| 文件 | 新增 | 覆盖 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `test/core/models/patch_field_test.dart` | 4 | 三态 JSON 表现(**absent 绝不落键**)、absent/clear 不可由 valueOrNull 区分、encode 只作用于有值态 |
|
||||||
|
| `test/features/profile/profile_models_test.dart` | 14 | 展示名回退两路 + nickname 不被回退值污染、`UpdateMeRequest` 五种三态组合、昵称码点边界(32 CJK / **32 emoji** / 33 拒 / btrim / 纯空白)、错误文案分层 |
|
||||||
|
| `test/features/profile/profile_controller_test.dart` | 10 | 四态、`/me` 失败与重试、有副本时刷新失败保留、统计独立降级 + 单独重试、空数据零值、空 patch 短路、save 回显替换、save 失败外抛、reset、follow-stats 主体是本人 |
|
||||||
|
| `test/features/profile/profile_page_test.dart` | 8 | 四态齐备、**展示名回退两路**、demo 文案零残留、空数据四个 0、统计块独立降级、编辑页往返、未装配上传能力时无头像入口 |
|
||||||
|
| `test/features/profile/profile_edit_page_test.dart` | 11 | **三态载荷五格(每格断言未改字段的键不出现)**、同值视为未改、无昵称不预填 username、33 码点字段级错、纯空白不隐式清空、42203/40405 分层、上传接线 purpose + 载荷、一次改两样 |
|
||||||
|
| `test/core/widgets/avatar_upload_sheet_test.dart` | 6 | 打开即拉起选择器、上传中进度 + ready 预览确认、purpose 透传、可重试失败重试成功、不可重试只给重新选择、用户取消不交付 assetId |
|
||||||
|
| `test/features/pets/pet_avatar_wiring_test.dart` | 13 | `Pet.avatarUrl` 解析、`UpdatePetRequest` 三态、错误文案分层、**权限呈现四格(owner/caregiver/viewer/未装配)**、上传后 PATCH 只带两键、移除发显式 null 且 `keys.length == 2`、40902 重取、42203 提示、列表卡真实头像与占位回退 |
|
||||||
|
| `test/features/home/home_greeting_test.dart` | 5 | 有昵称/无昵称两路问候语、资料未到手不称名、改昵称后首页同步、**刻意保留的 demo 占位仍在** |
|
||||||
|
| **合计** | **+71** | |
|
||||||
|
|
||||||
|
| 项 | 基线 | 现在 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `flutter test` | 526(+2 skip) | **597(+2 skip)全绿** |
|
||||||
|
| `flutter analyze` | 0 | **0** |
|
||||||
|
| `dart format` | 无 diff | **无 diff** |
|
||||||
|
| `check-secrets.sh --all` | exit 0 | **exit 0** |
|
||||||
|
|
||||||
|
**既有 526 测试零回归**。三处 helper 层改动(不改断言语义):`FakeAuthRepository.me()` 由抛 `UnimplementedError` 改为缺省返回「无昵称无头像」样本(该类文档本就写着「默认成功空实现」),`FakeCommunityRepository` 的 stats 两法给缺省零值,`samplePetJson` 补 `avatarUrl: null`。`_SwitchableRepository`(smoke 测试的代理)补一个转发方法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 遗留与交接
|
||||||
|
|
||||||
|
### 7.1 本单未做(范围裁剪,均已在代码注释登记)
|
||||||
|
|
||||||
|
- **「我的收藏与草稿」列表页未做**。后端能力早已就位(`GET /me/bookmarks`、`GET /me/posts?status=draft`,M3 T3-05/T3-17),客户端仓库层也有 `listMyBookmarks` / `listMyPosts`;缺的是两个列表页面 + 导航。工单原文允许「若工作量超出则本单只接资料 + 统计」,本单按此裁剪——三单合并本身已是 L + M + S,再加两页列表会挤压实测与测试的完成度。菜单入口仍走演示提示,`ProfilePage.menuItems` 的文档注释里写明了「后端已就位、列表页待做」。**建议作为独立小工单(规模 S~M)**:两页都是既有 `CursorPage` 四态列表的同构复制(可照抄 `WeightRecordsPage` 的翻页骨架 + `PostCard` 的卡片)。
|
||||||
|
- 资料页其余四个菜单项(预约订单 / 健康卡包 / 地址定位 / 设置与关于)保持演示提示,已标注去向(M5 服务域 / 需外部服务 / 待有实际可设项)。
|
||||||
|
- 「恢复演示数据」按钮保留:`AppState` 仍承载首页天气与本地服务的演示数据,这个入口是它唯一的复位口。对话框文案已改为「首页天气与本地服务的演示内容会恢复…(不影响账号资料与宠物档案)」,不再声称会重置宠物档案(那部分自 M2 起已是服务端数据)。
|
||||||
|
- `PetSummaryResponse` 未补 `avatarUrl`(后端本就未做,03 号 §7 已登记);`bio` 字段未开放(同上)。
|
||||||
|
|
||||||
|
### 7.2 真机清单已登记(`docs/development/device-verification.md` 的 M3.5 节,4 项 + 1 备注)
|
||||||
|
|
||||||
|
1. **头像上传弱网表现**(6 格):真机相册 + 原生压缩、弱网中断重试、压缩后仍超限只给重新选择、凭据过期自动换新、中途退出不留引用、HEIC 与方向。
|
||||||
|
2. **头像缓存表现**(4 格):跨页命中不重下、下拉刷新后仍命中、TTL 过期重取、清除头像后不留残影(含重启后仍是占位——URL 不得持久化)。
|
||||||
|
3. **caregiver 改宠物头像**(3 格):WRITE 档实证(桌面只跑了 owner)。
|
||||||
|
4. **获赞数与帖子点赞数对账**(4 格):含自赞、多帖求和、软删回落、草稿不计。
|
||||||
|
5. **备注**:Feed 作者名/头像的 ≤60s 服务端缓存滞后(§4.4a),说明这不是缺陷,避免重复上报。
|
||||||
|
|
||||||
|
该文件按维护约定直接修改(工单授权)。
|
||||||
|
|
||||||
|
### 7.3 给后续波次的提醒
|
||||||
|
|
||||||
|
- **头像上传构造口有两层**:页面侧 `AvatarUploaderBuilder(purpose)`(页面只知道用途)+ App 侧 `AvatarUploaderFactory(repository, purpose)`(拿到已装配的仓库)。桌面实测与集成测试在 App 侧替换选图与压缩层,网络三段永远是生产实现。新增头像场景(如后续的封面图)照此接即可。
|
||||||
|
- **`MediaPurpose` 加值只需改枚举 + 服务端配置白名单**(无 DB 约束,ADR-022);但引用侧会校验用途相符,purpose 给错是 404/40405 而不是 400。
|
||||||
|
- **`PatchField` 是通用件**(在 `lib/core/models/`),后续任何需要「清空」语义的 PATCH 字段直接用它,不要再引入 `clearXxx: true` 伴生布尔或空串哨兵。
|
||||||
|
- 本报告只写不提交;`mkdocs.yml` 本次未动,随波末统一挂导航入档。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 06 M3.5 收口:体验补齐
|
||||||
|
|
||||||
|
**执行日期**:2026-09-10 ~ 2026-09-11
|
||||||
|
**交付形态**:客户端体验修复 + 用户资料与头像全链路 + 契约冻结 v1.4.0;另含一次安全事件的处置与固化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 概要
|
||||||
|
|
||||||
|
M3.5 由用户在 v0.3.0 发布后的桌面实测反馈驱动(6 项问题),分两批交付。
|
||||||
|
|
||||||
|
| 批次 | 工单 | 提交 | 测试 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 第一批(纯客户端) | M3.5-01 中文本地化 / -02 日期录入收口 / -03 花费卡月份 | flutter `6038901`→`7d5c84d` | 502→**526** |
|
||||||
|
| 第一波(后端) | T3.5-04 用户资料读写 / -05 宠物头像 / -06 获赞聚合 | api `a5634c5`→`d98a400` | 334→**379** |
|
||||||
|
| 闸门 | T3.5-07 契约冻结 **v1.4.0** + 四模块快照同步 + 矩阵扩展 | doc `5f02909` / api `3cd8005` | 矩阵 173→**181** 格 |
|
||||||
|
| 第二波(前端) | T3.5-08 资料页+编辑页 / -09 宠物头像接线 / -10 首页问候语 | flutter `a4a97c0`→`6945436` | 526→**597** |
|
||||||
|
|
||||||
|
**波末状态**:patbond-api **379** 测试、patbond-flutter **597** 测试全绿;契约 v1.4.0(32 路径/45 操作/75 schema);**零 Flyway 迁移**(ADR-022)。
|
||||||
|
|
||||||
|
## 1. 用户 6 项反馈的处置结果
|
||||||
|
|
||||||
|
| 反馈 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| ① 日历英文 | ✅ 补 `flutter_localizations` + zh-CN 三件套(此前从未配置,Flutter 静默回退英文);`datePickerTheme` 上品牌色,只复用已审计色对 |
|
||||||
|
| ② 月份只能 `< >` 切 | ✅ 抽 `pickAppDate` 收口 7 处裸调用;保留手输铅笔 + 表单行「今天」快捷(原生 `showDatePicker` 无法注入弹窗内动作,放表单行反而一键落值、绕开月份导航) |
|
||||||
|
| ③ 宠物无头像 | ✅ 铅笔角标接 `MediaUploader`(`purpose=pet_avatar`),列表/详情展示预签名头像 |
|
||||||
|
| ④ 资料页无法改昵称/头像、显示 demo | ✅ 176 行硬编码 demo 退役;新增编辑页(昵称 + 头像 + 各自显式清除入口) |
|
||||||
|
| ⑤ 本月花费 ¥0 | ✅ **非 bug**——记录在 2026-04-09、当天 09-10,9 月确为 0;根因是 ②。改为显示实际月份(「9 月花费」)+ 四张数据卡补可点提示(原本都可点却无提示) |
|
||||||
|
| ⑥ 资料页统计是假数据 | ✅ 关注/我关注/获赞/作品四个数字全部真实(`/me` + `/me/community-stats` + `follow-stats`) |
|
||||||
|
| (未提)首页 demo | ✅ 仅问候语真实化(ADR-022);天气/位置/圈子/促销卡刻意保留并在代码标注去向,另有 widget 用例反向钉住「保留项仍在」 |
|
||||||
|
|
||||||
|
## 2. 开工审计推翻了迁移预估(本迭代最大的省事项)
|
||||||
|
|
||||||
|
原估「需 Flyway V6 加列、规模 L」。逐一核实原始 SQL 后确认**所需列全部早已存在**:
|
||||||
|
|
||||||
|
- `identity.users.nickname`(V1 第 63 行,含 btrim + 1~32 CHECK)、`avatar_asset_id`(V1 第 67 行,含 FK + 索引)
|
||||||
|
- `pet_health.pets.avatar_asset_id`(V3 第 64 行,含索引)——但 pet 模块代码此前**零处读写**
|
||||||
|
- `community.posts.like_count` 等冗余列(V5)——获赞总数 `SUM` 即可
|
||||||
|
- `media.assets.purpose` 无 CHECK 约束,白名单在**配置项** `MediaProperties.allowedPurposes` → 加 `user_avatar`/`pet_avatar` 只改配置
|
||||||
|
|
||||||
|
教训:**转述不可采信**。「`identity.users` 无 nickname」来自 M3 T3-05 报告的一句表述,实际那句说的是「契约未暴露 nickname」。核实原始 SQL 只花几分钟,却把工作量预估降了一档。
|
||||||
|
|
||||||
|
## 3. 关键语义定型
|
||||||
|
|
||||||
|
- **`/me` 不做 username 回退**(返回 DB 原值):回退是展示约定,若放进本人编辑态,编辑页会预填 `llx`,一保存就把它固化成真昵称,`/internal` 的回退链从此永不触发。**回退只在客户端展示层做一层**(`nickname ?? username`)。
|
||||||
|
- **PATCH 三态**(键缺省=不改 / 显式 null=清空 / 给值=设置):客户端以 `PatchField<T>` 类型承载,序列化单一路径。若把未改字段也发成 null,用户只改昵称就会连头像一起被清掉。纯空白昵称为 400 而非隐式清空;空 patch 前置短路。
|
||||||
|
- **宠物头像权限按「本次碰了哪些字段」定档**:仅头像=WRITE(owner+caregiver),碰任一资料字段=MANAGE(仅 owner),混合取更严——堵住 caregiver 把改名夹带进头像请求。客户端纯头像 PATCH 只带 `version` + `avatarAssetId`(测试断言 `keys.length == 2`)。
|
||||||
|
- **`avatarAssetId` 只写不读**:响应不外露,「有头像」等价 `avatarUrl != null`。
|
||||||
|
- **昵称长度按码点计**(`runes.length`):32 个 emoji 的合法昵称 UTF-16 长度为 64,按 `String.length` 会误拒数据库存得下的昵称。
|
||||||
|
|
||||||
|
## 4. 实现期发现与修正
|
||||||
|
|
||||||
|
1. **`/api/v1/me` 错线(先于本迭代存在)**:该端点由 user 服务(:8082) 提供,但 `ApiAuthRepository` 只挂了 auth(:8081),实测 404。此前无人消费 `me()` 故一直未暴露;已加 `userApi` 线路。
|
||||||
|
2. **Feed 作者名 ≤60s 滞后是服务端设计**:`AuthorProfileGateway` 有 60s TTL 进程内缓存;`/me` 无缓存立即生效,故存在一分钟「资料页已变、Feed 还是旧名」的窗口。未改服务端,已写入注释与真机清单。
|
||||||
|
3. **1×1 极小 PNG 能上传能下载但 Flutter 解码器拒绝**(`Codec failed to produce an image`),会被误判成「头像没传上」;夹具改 16×16。
|
||||||
|
4. 中文「9月10日周四」在日期弹窗头部 26px 起折行 → `headerHeadlineStyle` 32→22(只有真跑起来才看得见)。
|
||||||
|
|
||||||
|
## 5. 契约冻结 v1.4.0
|
||||||
|
|
||||||
|
- 规模:路径 31→**32**、操作 43→**45**、schema 72→**75**(多出的 `CommunityStatsEnvelope` 为保持「每个 200 响应都 `$ref` 一个 Envelope」的一致性)
|
||||||
|
- **对 v1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,已写入 `info.description` 作为对既有集成方的承诺
|
||||||
|
- 零新增错误码;四模块快照 md5 与正典一致;11 格「未声明字段即漂移」的红全部转绿;mutation 三处定向注毒自证有效
|
||||||
|
|
||||||
|
## 6. 安全事件(并行处置,已闭环)
|
||||||
|
|
||||||
|
本迭代期间 CI 全面失效,根因为 Gitea 被注入 gitconfig 的 `packObjectsHook`。完整复盘见 [07 号](07-security-incident-20260911.md),处置与纪律固化见新建的 [服务器暴露面清单](../../server-exposure.md)。要点:攻击未达成代码执行、三仓代码经核对未被篡改、无系统层入侵;服务器侧新增「决策与环境同步」等 7 条纪律。
|
||||||
|
|
||||||
|
## 7. 遗留
|
||||||
|
|
||||||
|
1. **「我的收藏与草稿」列表页未做**(工单许可的裁剪):后端与仓库层均已就位,缺两个页面 + 导航,建议独立小工单(S~M,可照抄既有 `CursorPage` 四态列表骨架)
|
||||||
|
2. 月份网格选择器未做(年份网格 + 手输 + 「今天」已覆盖实测痛点)
|
||||||
|
3. `SegmentedButton` 选中态仍是 `fromSeed` 派生粉底(M2 遗留主题债),建议并入后续主题收敛单
|
||||||
|
4. `widthPx/heightPx` 恒 null(M3 观察项,单图帖回落 4:3)、`eventVersion` 口径未定型——两项均未在本迭代处理
|
||||||
|
5. 真机验证:`device-verification.md` 新增 M3.5 节(头像上传弱网、头像缓存等 4 项 + 1 备注)
|
||||||
|
|
||||||
|
## 8. 待发布
|
||||||
|
|
||||||
|
v0.4.0 尚未发布。**main 已受分支保护,须走 PR 流程**(见 [发布记录](../../releases.md)「发布后生效的纪律」):三仓 CI 绿 + E2E 双份回归 → Gitea 建 PR(dev→main)→ CI 状态检查转绿 → 合并 → 打 tag → 登记发布记录。
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 安全事件复盘:Gitea gitconfig 注入(2026-09-11)
|
||||||
|
|
||||||
|
**级别**:中(服务中断,无数据损失,攻击未达成代码执行)
|
||||||
|
**发现方式**:CI 连续失败排查
|
||||||
|
**处置结果**:已闭环,服务恢复
|
||||||
|
**记录人**:主会话(AI 辅助排查,用户执行服务器侧操作)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 时间线(UTC+8)
|
||||||
|
|
||||||
|
| 时间 | 事件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 2026-07-13 | Nacos 部署于服务器并对公网暴露 8848/9848(此前 ADR-002 已将 Nacos 从项目移除,属遗留服务) |
|
||||||
|
| 2026-09-10 18:04 | flutter CI 最后一次成功(task 68) |
|
||||||
|
| 2026-09-10 夜 ~ 09-11 晨 | **攻击发生**:外部 IP 调用 Gitea 内部管理 API 写入 gitconfig |
|
||||||
|
| 2026-09-11 09:27 | doc 仓 CI 首次失败(1 秒,无 step) |
|
||||||
|
| 2026-09-11 10:03 / 10:33 | api 仓 CI 失败;期间另有 4 个提交的 job 完全未上报状态 |
|
||||||
|
| 2026-09-11 下午 | 定位、处置、验证恢复 |
|
||||||
|
|
||||||
|
攻击者来源 IP(gitconfig 注入串中残留):`20.212.233.41`(Azure 段)、`187.15.89.220`(巴西)。
|
||||||
|
|
||||||
|
## 2. 根因链
|
||||||
|
|
||||||
|
1. **Gitea 的 3000 端口对公网开放**(`HTTP_ADDR` 未限制为 127.0.0.1,且轻量服务器防火墙放行 3000),使 nginx 之外存在一条直达通道。
|
||||||
|
2. **`/api/internal/**` 可被外部调用**:攻击者 `POST /api/internal/manager/add-logger`,利用日志路径参数把内容写进 Gitea 的 gitconfig(注入串以 `;#` 收尾,用于把 Gitea 追加的日志行注释掉)。
|
||||||
|
3. **注入项为 `uploadpack.packObjectsHook`**,指向 `/var/lib/gitea/data/home/p0_*.sh` 等文件。该 hook 是 `git upload-pack` 生成 pack 时调用的外部程序。
|
||||||
|
4. **脚本并不存在** → hook 执行失败 → upload-pack 在发出 `NAK` 后无法产出任何 pack 数据 → **所有 HTTPS clone/fetch 失败**,而 Gitea 仍记为 `200 OK in 3ms`。
|
||||||
|
|
||||||
|
被污染的两份文件:`/var/lib/gitea/data/home/.gitconfig`、`/var/lib/gitea/home/.gitconfig`。
|
||||||
|
|
||||||
|
## 3. 影响评估
|
||||||
|
|
||||||
|
| 面 | 结论 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **代码完整性** | ✅ 未被篡改 | 三仓 `git ls-remote` 的 dev/main/tag 与本地权威值逐一核对一致(api dev `3cd8005`、main `8089c06`、flutter dev `6945436`、main `0e87413`、doc main `5f02909`,三个 v0.3.0 tag 全一致) |
|
||||||
|
| **攻击是否达成代码执行** | ✅ 未达成 | hook 指向的 `p0_*.sh` 经确认**不存在**;正因执行失败才暴露事件 |
|
||||||
|
| **系统是否被入侵** | ✅ 未被入侵 | `authorized_keys` 仅 2 个已知 key(腾讯云 skey + 维护者本人);ubuntu 无 crontab、root crontab 仅腾讯云 agent;无挖矿进程(最高 CPU 1.0% 为云监控 agent);登录记录全部来自维护者常用 IP 段;`Failed password` 仅 1 次 |
|
||||||
|
| **开发是否受影响** | ✅ 未受影响 | push 走 SSH 且 `receive-pack`(写入方向)不经 `packObjectsHook`,故两日内所有提交正常落地 |
|
||||||
|
| **CI** | ❌ 中断约 1 天 | checkout 走 HTTPS,全仓失效 |
|
||||||
|
| **凭证泄露风险** | ⚠️ 存在 | 攻击者能调用内部 API,`INTERNAL_TOKEN` 须视为可能泄露并轮换 |
|
||||||
|
|
||||||
|
## 4. 处置动作
|
||||||
|
|
||||||
|
| # | 动作 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Gitea `HTTP_ADDR = 127.0.0.1`(不再对公网监听),重启 | ✅ |
|
||||||
|
| 2 | 防火墙删除 3000、8848、9848、2222 放行规则 | ✅ |
|
||||||
|
| 3 | 停止 Nacos 并确认无监听(`nacos.service` 需一并 disable 防重启自启) | ✅ 停止;⚠️ disable 待确认 |
|
||||||
|
| 4 | 清理两份 gitconfig 的 `uploadpack.packObjectsHook`(原文件已备份至 `/root/gitconfig*.evidence.*.bak`) | ✅ |
|
||||||
|
| 5 | 重启 Gitea 并验证恢复:三仓 HTTPS 浅克隆全成功;upload-pack 响应从 12 字节恢复至 723 KB | ✅ |
|
||||||
|
| 6 | 轮换 `INTERNAL_TOKEN`(及可选 `SECRET_KEY`) | ⬜ 待做 |
|
||||||
|
| 7 | nginx 增加 `location ^~ /api/internal/ { deny all; return 404; }` 作为纵深防御 | ⬜ 待做 |
|
||||||
|
| 8 | 清理 gitconfig 中残留的注入垃圾注释行 | ⬜ 待做(不影响功能) |
|
||||||
|
|
||||||
|
## 5. 诊断过程的教训(比结论更值得记)
|
||||||
|
|
||||||
|
**判断走过两次弯路**,都源于采信推断而非实测:
|
||||||
|
|
||||||
|
1. **第一次误判「act_runner 故障」**:CI 日志显示 job 1~2 秒失败、`steps: null`,据此推断 runner 挂了。实际 runner 容器 Up 6 天、job 镜像在本地、job 容器正常启动。
|
||||||
|
2. **第二次误判「flutter CI 正常所以 runner 活着」**:查到 flutter 最新提交 CI 为 success,却**没核对时间戳**——那是前一天 18:04 的旧记录。跨仓比较必须带时间戳。
|
||||||
|
3. **第三次误判「nginx 代理层」**:绕过 nginx 直连 3000 后同样失败,才排除。
|
||||||
|
|
||||||
|
**真正的转折点是两个动作**:
|
||||||
|
- **在本机复现同样的 git 操作**(`git clone --depth 1 https://...` 立刻复现 EOF)→ 一举把问题从「CI 领域」移到「Gitea 服务端领域」;
|
||||||
|
- **手动执行 Gitea 内部实际调用的命令**(`git upload-pack --stateless-rpc`)→ 手动成功、进程内失败,把差异锁定到执行环境,于是查 gitconfig 时一眼看到注入。
|
||||||
|
|
||||||
|
**沉淀为纪律(已写入 [CI Runner 手册](../../ci-runner-setup.md) 排障表)**:CI 失败时,第一动作是**在本机复现 CI 的第一个 step**(通常是 checkout),而不是先去查 runner。
|
||||||
|
|
||||||
|
## 6. 更深层的问题:环境漂移无人核对
|
||||||
|
|
||||||
|
本次真正的隐患不是「Gitea 有个洞」,而是 **Nacos 在项目已用 ADR-002 明确移除后,其进程与防火墙规则仍在服务器上暴露公网近两个月**。
|
||||||
|
|
||||||
|
代码侧我们有 ADR + 契约冻结 + 契约一致性测试来防止「决策变了、实现没跟上」,**服务器侧却没有任何对应机制**。为此新建 [服务器暴露面清单](../../server-exposure.md):逐项登记开放端口与常驻服务的用途、归属决策、最后确认日期,作为常设文档定期核对。
|
||||||
|
|
||||||
|
## 7. 待办
|
||||||
|
|
||||||
|
- [ ] 轮换 `INTERNAL_TOKEN`
|
||||||
|
- [ ] nginx 拒绝 `/api/internal/`
|
||||||
|
- [ ] `systemctl disable nacos.service`(当前仅停止,仍为 enabled,重启会自启)
|
||||||
|
- [ ] 清理 gitconfig 残留注释垃圾行
|
||||||
|
- [ ] Gitea 升级评估(当前 1.26.4;`/api/internal` 可被外部调用是否属已知漏洞待核,无论如何应保持不对公网监听)
|
||||||
@@ -44,7 +44,16 @@
|
|||||||
|
|
||||||
1. **命名统一**:ADR-011 的 `master` 更正为 `main`(ADR-021);api 本地孤儿 master 已删。
|
1. **命名统一**:ADR-011 的 `master` 更正为 `main`(ADR-021);api 本地孤儿 master 已删。
|
||||||
2. **api main 重建**(方案 A,用户拍板):远端 main 原为建仓自动生成的单提交 `ff876bc "Add README"`,与 dev **无共同祖先**,无法 ff 也不宜缝合孤儿历史。操作:Gitea 默认分支临时切 dev → 删除远端 main → `git push origin dev:refs/heads/main` 重建 → 默认分支切回 main。结果:main 41 提交、与 dev 同点位、零 force push。原孤儿提交保留本地备份 ref `refs/backup/old-main-ff876bc`。
|
2. **api main 重建**(方案 A,用户拍板):远端 main 原为建仓自动生成的单提交 `ff876bc "Add README"`,与 dev **无共同祖先**,无法 ff 也不宜缝合孤儿历史。操作:Gitea 默认分支临时切 dev → 删除远端 main → `git push origin dev:refs/heads/main` 重建 → 默认分支切回 main。结果:main 41 提交、与 dev 同点位、零 force push。原孤儿提交保留本地备份 ref `refs/backup/old-main-ff876bc`。
|
||||||
3. **flutter main 快进**:main 本就是 dev 祖先,用 `git push origin dev:main` 完成——**较 checklist 第 4 步的 `checkout main && merge --ff-only` 改进**:不切换工作区(当时有 E2E 脚本正在该工作区运行),且非快进推送会被 git 自动拒绝,等于内建 ff-only 保护。建议固化此写法。
|
3. **flutter main 快进**:main 本就是 dev 祖先,用 `git push origin dev:main` 完成——**较 checklist 第 4 步的 `checkout main && merge --ff-only` 改进**:不切换工作区(当时有 E2E 脚本正在该工作区运行),且非快进推送会被 git 自动拒绝,等于内建 ff-only 保护。
|
||||||
|
4. **分支保护启用**(checklist 第 6 步,Gitea 平台):api 与 flutter 的 `main` 均已启用,经 Gitea API `GET /repos/{owner}/{repo}/branches/main` 核实:
|
||||||
|
|
||||||
|
| 仓库 | protected | 推送 | 状态检查上下文 | 所需批准 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| patbond-api | `true` | 禁用直接推送 | `CI / backend-test (push)` | 0 |
|
||||||
|
| patbond-flutter | `true` | 禁用直接推送 | `CI / flutter-gates (push)` | 0 |
|
||||||
|
| patbond-doc | 未启用 | —— | —— | —— |
|
||||||
|
|
||||||
|
两仓 `dev` 均保持 `protected=false`(直推流,ADR-021 分层策略);doc 仓 main 即日常分支、不参与发布分支语义,按规划不设保护。**状态检查上下文显式填写而非留空**:留空时 Gitea 语义为「所有上报的检查都通过」,若某次工作流未触发则空集为真反而放行;写死检查名消除该歧义。
|
||||||
|
|
||||||
### 已知遗留(不阻塞发布)
|
### 已知遗留(不阻塞发布)
|
||||||
|
|
||||||
@@ -56,6 +65,14 @@
|
|||||||
|
|
||||||
### 发布后生效的纪律
|
### 发布后生效的纪律
|
||||||
|
|
||||||
- 影响 `main` 的 hotfix 一律走短命分支 + PR(ADR-021 强制情形之二正式生效)
|
- **`main` 已禁止直接推送**——影响 `main` 的一切变更(含发布本身与 hotfix)一律走 PR,CI 状态检查通过方可合并(ADR-021 强制情形之二正式生效)。`dev` 保持直推流不变。
|
||||||
- `main` 分支保护(禁直推、合并需 CI 状态检查通过)在 Gitea 平台启用;`dev` 保持直推流
|
- **⚠️ 下次发布的姿势与本次不同**:本次首发用 `git push origin dev:main` 直推(当时 main 尚未保护);保护启用后该命令会被拒绝。**此后发布流程为**:
|
||||||
- 下次发布 `dev → main` 应能 `--ff-only` 通过;过不了说明 main 被绕过 dev 改动,先查明原因
|
|
||||||
|
1. 完成 checklist 第 1~2 步(三仓 CI 绿 + E2E 双份回归 PASS);
|
||||||
|
2. 在 Gitea 上创建 PR:`dev` → `main`(标题写版本号,正文贴门禁证据链接);
|
||||||
|
3. 等 PR 的 CI 状态检查转绿(即上表的 `status_check_contexts`);
|
||||||
|
4. 在 Gitea 上合并 PR——因 dev 与 main 无分叉,合并应为快进;
|
||||||
|
5. 继续 checklist 第 5、7 步(打 tag、写本页记录)。
|
||||||
|
|
||||||
|
故 checklist 第 4 步的本地 `merge --ff-only && push` 写法**仅适用于首次发布**(保护启用前),后续版本以上述 PR 流程替代;第 3、6 步为一次性项,不再重复。
|
||||||
|
- 若某次 PR 显示 `dev` 与 `main` 有分叉(无法快进),说明 main 被绕过 dev 改动过,**先查明原因再合并**,不要用合并提交掩盖。
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 服务器暴露面清单(常设)
|
||||||
|
|
||||||
|
> **定位**:跨迭代常设文档——服务器上**每个对公网开放的端口**与**每个常驻服务**都必须在此登记:用途、归属决策、谁在用、最后确认日期。
|
||||||
|
> **缘起**:2026-09-11 的[安全事件](iterations/iteration-3.5/07-security-incident-20260911.md)——Nacos 在 ADR-002 已将其从项目移除后,进程与防火墙规则仍暴露公网近两个月。代码侧有 ADR + 契约测试防「决策变了实现没跟上」,服务器侧此前无任何对应机制,本页即为补上这一环。
|
||||||
|
> **维护约定**:① 新开端口/新增常驻服务必须先在此登记;② 每次迭代收官核对一遍,更新「最后确认」;③ 登记不出理由的开放端口,默认删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 对公网开放的端口(2026-09-11 核对)
|
||||||
|
|
||||||
|
| 端口 | 协议 | 用途 | 谁在用 | 归属决策 | 最后确认 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 22 | TCP | SSH 登录与 git push/pull(三仓 remote 均为 SSH) | 维护者、本机 git | —— | 2026-09-11 |
|
||||||
|
| 80 | TCP | HTTP(跳转 443) | nginx | —— | 2026-09-11 |
|
||||||
|
| 443 | TCP | HTTPS:Gitea Web/API、CI checkout、act_runner 回连 | nginx → 127.0.0.1:3000 | [CI Runner 手册](ci-runner-setup.md) | 2026-09-11 |
|
||||||
|
| ICMP | —— | ping 连通性 | 运维排查 | —— | 2026-09-11 |
|
||||||
|
|
||||||
|
**已于 2026-09-11 关闭**(记录在此以防重开):
|
||||||
|
|
||||||
|
| 端口 | 原用途 | 关闭原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 3000 | Gitea HTTP 直连 | **本次安全事件入口**;nginx 已从 127.0.0.1 反代,无需公网暴露 |
|
||||||
|
| 8848 / 9848 | Nacos HTTP / gRPC | 项目已由 **ADR-002** 移除 Nacos;暴露公网近两个月,且 Nacos 历史高危漏洞多(默认凭证、鉴权绕过、反序列化 RCE) |
|
||||||
|
| 2222 | 不明(疑为早期 Gitea 内建 SSH) | `ss -tlnp` 确认无任何服务监听,空规则即无谓攻击面 |
|
||||||
|
|
||||||
|
## 2. 常驻服务
|
||||||
|
|
||||||
|
| 服务 | 监听 | 用途 | 归属决策 | 状态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| gitea | **127.0.0.1:3000** | 代码托管 + Actions 调度 | [CI Runner 手册](ci-runner-setup.md) | ✅ 运行(2026-09-11 起改为仅本机监听) |
|
||||||
|
| nginx | 0.0.0.0:80/443 | 反向代理,按域名分流(`sites-enabled/`:`git.patbond.cn`、`patbond-doc`) | —— | ✅ 运行 |
|
||||||
|
| **文档站(patbond-doc)** | 经 nginx 443 | mkdocs 构建产物,含架构/部署/迭代全部文档 | —— | ✅ 运行;⚠️ **公开可访问,待评估是否加 basic auth 或 IP 白名单**(无凭证内容,但暴露内部架构细节) |
|
||||||
|
| act_runner(容器) | 无监听(主动回连) | Gitea Actions 执行器 | [CI Runner 手册](ci-runner-setup.md) | ✅ 运行 |
|
||||||
|
| dockerd / containerd | 本机 socket | 容器运行时(runner 与 job 容器) | ADR-006 | ✅ 运行 |
|
||||||
|
| sshd | 0.0.0.0:22 | SSH | —— | ✅ 运行 |
|
||||||
|
| 腾讯云 agent(barad_agent / YDEyes / YDLive / stargate) | —— | 云监控与主机安全,云厂商预装 | —— | ✅ 运行(预期存在) |
|
||||||
|
| **nacos** | ~~*:8848 / *:9848~~ | **项目已不使用** | **ADR-002 已移除** | ⛔ 已停止 **且已 `systemctl disable`**(2026-09-11,重启不再自启) |
|
||||||
|
|
||||||
|
## 3. 核对方法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开放端口与监听服务
|
||||||
|
sudo ss -tlnp
|
||||||
|
# 云平台防火墙规则:腾讯云轻量服务器控制台 → 防火墙(轻量无安全组)
|
||||||
|
|
||||||
|
# 常驻服务与自启项
|
||||||
|
systemctl list-unit-files --state=enabled | grep -viE "^(systemd|dbus|network|cloud|snap|apt|unattended|multipathd|open-iscsi|lvm2|rsyslog|cron|ssh)"
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
# 异常检查(安全事件后例行)
|
||||||
|
ps aux --sort=-%cpu | head -15
|
||||||
|
crontab -l; sudo crontab -l
|
||||||
|
cat ~/.ssh/authorized_keys
|
||||||
|
sudo last -20
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 纪律
|
||||||
|
|
||||||
|
1. **默认拒绝**:新服务一律只监听 `127.0.0.1`,需要外部访问时经 nginx 反代 + 域名分流,不直接开端口。
|
||||||
|
2. **内部 API 不得对外**:Gitea 的 `/api/internal/**` 属内部通道(SSH serv 命令等经 127.0.0.1 调用),nginx 层应显式拒绝——本次事件的注入正是走这条路径。
|
||||||
|
3. **决策与环境同步**:ADR 决定移除某组件时,**同一次收口内**必须停服务、禁自启、删防火墙规则,并更新本页。
|
||||||
|
4. **凭证轮换触发条件**:任何「外部可调用内部 API」的迹象,一律视为对应 token 已泄露并轮换(`INTERNAL_TOKEN`、`SECRET_KEY`)。
|
||||||
|
5. **本页与实际不符即为缺陷**:核对时发现未登记的开放端口或常驻服务,按缺陷处理——先查清用途,无正当理由即关闭。
|
||||||
|
6. **凭证不进任何可留存介质**:生成/轮换凭证时一律「直接落盘不回显」(如 `NEW=$(gitea generate secret X); sed -i ...; unset NEW`),**不打印到终端、不粘贴进聊天记录、不写入报告或日志**。协作规则原本只约束「不入库」,2026-09-11 的处置中新 token 被回显并粘贴,故扩展此条。
|
||||||
|
7. **配置备份不留在配置目录**:`sites-available/*.bak.*` 之类应移出(如 `/root/nginx-backups/`)——留在配置目录里,一旦 include 通配符被改宽或软链手误就会激活旧配置。
|
||||||
|
|
||||||
|
## 5. 处置与核对记录
|
||||||
|
|
||||||
|
| 日期 | 动作 | 触发 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-09-11 | Gitea 改仅监听 127.0.0.1;防火墙删 3000/8848/9848/2222;停并 disable Nacos;清理被注入的 gitconfig;nginx 增 `/api/internal/` 全拒;轮换 `INTERNAL_TOKEN`(两次——首次回显后按纪律 6 重做);外部验证四项通过 | [安全事件](iterations/iteration-3.5/07-security-incident-20260911.md) |
|
||||||
@@ -9,6 +9,7 @@ nav:
|
|||||||
- 功能完成清单: development/feature-checklist.md
|
- 功能完成清单: development/feature-checklist.md
|
||||||
- 真机验证清单: development/device-verification.md
|
- 真机验证清单: development/device-verification.md
|
||||||
- 发布记录: development/releases.md
|
- 发布记录: development/releases.md
|
||||||
|
- 服务器暴露面清单: development/server-exposure.md
|
||||||
- CI Runner 部署手册: development/ci-runner-setup.md
|
- CI Runner 部署手册: development/ci-runner-setup.md
|
||||||
- 第一迭代:
|
- 第一迭代:
|
||||||
- 进展看板: development/iterations/iteration-1/index.md
|
- 进展看板: development/iterations/iteration-1/index.md
|
||||||
@@ -96,6 +97,14 @@ nav:
|
|||||||
- 28 E2E 烟囱收官: development/iterations/iteration-3/28-e2e-smoke-report.md
|
- 28 E2E 烟囱收官: development/iterations/iteration-3/28-e2e-smoke-report.md
|
||||||
- 29 M3 收官总结: development/iterations/iteration-3/29-m3-summary.md
|
- 29 M3 收官总结: development/iterations/iteration-3/29-m3-summary.md
|
||||||
- 30 发布 E2E 回归: development/iterations/iteration-3/30-release-e2e-regression.md
|
- 30 发布 E2E 回归: development/iterations/iteration-3/30-release-e2e-regression.md
|
||||||
|
- M3.5 体验补齐:
|
||||||
|
- 01 任务分解: development/iterations/iteration-3.5/01-pm-task-breakdown.md
|
||||||
|
- 02 客户端体验修复: development/iterations/iteration-3.5/02-client-ux-fixes.md
|
||||||
|
- 03 后端资料与头像: development/iterations/iteration-3.5/03-backend-profile-avatar.md
|
||||||
|
- 04 契约冻结 v1.4.0: development/iterations/iteration-3.5/04-contract-freeze-v140.md
|
||||||
|
- 05 资料页与头像 UI: development/iterations/iteration-3.5/05-profile-avatar-ui.md
|
||||||
|
- 06 M3.5 收口: development/iterations/iteration-3.5/06-wave2-closure.md
|
||||||
|
- 07 安全事件复盘: development/iterations/iteration-3.5/07-security-incident-20260911.md
|
||||||
- API:
|
- API:
|
||||||
- 契约说明: api/index.md
|
- 契约说明: api/index.md
|
||||||
- 架构:
|
- 架构:
|
||||||
|
|||||||
Reference in New Issue
Block a user