# 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`: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 首页问候语 - 「下午好,豆豆」→ `下午好, 👋`。数据源是**与资料页同一个 `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.absent() // 键不出现 → 不改 PatchField.clear() // 键出现为 null → 清空 PatchField.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` 本次未动,随波末统一挂导航入档。