Files
lixi f9b1358b37
CI / docs-build (push) Successful in 1m59s
docs: 2026-09-11 安全事件复盘 + 新建服务器暴露面清单 + M3.5 报告 03/04/05 入档
安全事件(已闭环,服务恢复):
- 根因链:Gitea 3000 对公网开放 → 外部调用 /api/internal/manager/add-logger
  注入 gitconfig 的 uploadpack.packObjectsHook → 指向不存在的脚本 →
  upload-pack 发 NAK 后无法产出 pack → 全仓 HTTPS clone 失败(CI 全挂)
- 攻击未达成代码执行(hook 目标脚本不存在);三仓 ref 与本地逐一核对未被篡改;
  无系统层入侵(无陌生 key/crontab/挖矿进程/陌生登录)
- 新建常设「服务器暴露面清单」:补上服务器侧「决策变了环境没跟上」的核对机制
  (Nacos 在 ADR-002 移除后仍暴露公网近两个月)
- CI Runner 手册排障表增三条:CI 秒失败先在本机复现 checkout、跨仓比 CI
  须核对时间戳、clone 坏而 push 正常时查 packObjectsHook 注入

M3.5 交付报告:03 后端资料与头像(api 334→379)、04 契约冻结 v1.4.0
(31→32 路径、矩阵 173→181 格、11 格红转绿)、05 资料页与头像 UI(flutter 526→597)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 17:45:35 +08:00

300 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 03 M3.5 后端第一波:用户资料读写 + 宠物头像 + 获赞聚合
> 作者:Senior Developer(后端)
> 日期:2026-09-11
> 工单:T3.5-04(用户资料读写)+ T3.5-05(宠物头像)+ T3.5-06(获赞聚合)三单合并
> 输入:patbond-api dev@8089c06334 测试基线);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` 非法 UUIDbody 非法 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/40405message 具体) | 从头像域看,一张帖子配图"不是头像"。**未新增错误码**(工单要求沿用 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` | **新增**,签名器 beandestroy 关闭 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/MANAGEasset 校验 |
| `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` | (无入参可错——端点无参数,形态断言代之) |
| 不存在 | 用户软删 → 40400GET 与 PATCH 双动词);asset 幽灵/他人 → 40405 | 陌生人与幽灵 petId 同答 40401asset 五态 | 永不 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` 本次不动,随波末统一挂导航入档。