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

316 lines
28 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.
# 05 M3.5 第二波:资料页真实化 + 编辑页 + 宠物头像 + 首页问候语
> 作者:Frontend DeveloperFlutter
> 日期:2026-09-11
> 工单:T3.5-08(资料页真实化 + 编辑页)+ T3.5-09(宠物头像接线)+ T3.5-10(首页问候语)三单合并(同仓串行)
> 输入:patbond-flutter dev@7d5c84d526 测试基线);冻结契约 **openapi v1.4.0**doc main@5f02909);ADR-02203 号定型表;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`**,一个资料字段都不带。这不是节省字节:服务端按「本次请求触及了哪些字段」定档,夹带任一资料字段就会把档位抬到 MANAGEcaregiver 立刻 40303 号 §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,字节与上传**逐字节相同** |
| 宠物头像预签名 GETpet 侧本地 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` 本次未动,随波末统一挂导航入档。