docs: 2026-09-11 安全事件复盘 + 新建服务器暴露面清单 + M3.5 报告 03/04/05 入档
CI / docs-build (push) Successful in 1m59s

安全事件(已闭环,服务恢复):
- 根因链: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>
This commit is contained in:
2026-09-11 17:45:35 +08:00
parent 5f02909af6
commit f9b1358b37
8 changed files with 1064 additions and 0 deletions
@@ -0,0 +1,244 @@
# 04 M3.5 契约冻结 v1.3.0 → v1.4.0doc 正典 + api 四模块快照与矩阵,一单连贯)
> 作者:API Platform Engineer(契约)
> 日期:2026-09-11
> 工单:T3.5-07 契约冻结 + api 侧快照/矩阵同步(M3 分两单,本次规模小故连贯执行,避免 api 侧 CI 长时间红)
> 输入:iteration-3.5/03 号报告 §1 定型表与 §6 机械化清单(**实现定型表 > 推断**);ADR-022;正典 v1.3.0doc main@6e1ab8e);patbond-api dev@d98a400379 测试,其中契约守卫 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-communitytag 选择理由见 §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`int64required,非 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 SUCCESS381 测试全绿
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(波末一并入档)。