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

26 KiB
Raw Permalink Blame History

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 数据模型审计实情 提交:a5634c5T3.5-04)→ 15c2e66T3.5-05)→ d98a400T3.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) 不变
  • 响应状态:200401/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 不改 清除头像 设置(校验见下)
  • 成功:200data 为与 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 仍在 uploadingfailed
  • 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/40000403/40300404/40401409/40902409/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 里的调用者。
  • 成功 200data
字段 类型 必填 nullable 说明
receivedLikeCount integer(int64) 获赞总数;本人「已发布且未软删」帖的 like_count 之和;空数据为 0
publishedPostCount integer(int64) 作品数;同一集合的帖子数;空数据为 0
  • 错误谱:仅 401/40101永不 404——任何已认证用户都有 stats。

1.6 media purpose 枚举(契约里 CreateMediaUploadRequest.purpose

post_imagepost_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 而 /internalme_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 例,含 draftsAreExcludedFromBothNumberssoftDeletedPostsDropOutOfBothNumbersoperationalStatesAreExcludedotherPeoplesPostsNeverLeakIntoMyStatscountsSelfLikesExactlyAsThePerPostNumberDoesfreshUserGetsZerosNotNullsAndNever404

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-endpointMediaUrlSigner 返回 null)。资料读取不会因为存储没配就 500——与"JWT 公钥缺失"的既有降级先例一致。
  • 每次响应现签,从不缓存 URLMeAvatarSigningIntegrationTest 里 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;改依赖 MeProfileServiceUserService 仍服务 /internal 注册与验密)
patbond-user/.../repository/UserRepository.java MeRow 投影 + findMeByIdLEFT 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);updateWithVersionavatar_asset_id
patbond-pet/.../service/PetService.java 装配 PetResponse 并现签 URLrequiredLevel(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-purposespet 新增整段 patbond.media 读侧配置

分层纪律:预签名 URL 一律在 Service 层生成,仓储层只出存储坐标(bucket + object_key)。pet 侧因此把 PetRepository 的返回类型从 DTO 改成了 PetRow——与 community 的 PostRow → PostResponse 装配同构。理由:会过期的 URL 绝不能沉到可能被缓存的仓储返回值里。

零迁移复核ADR-022 的前提逐条成立——identity.users.nickname/avatar_asset_idV1 第 63/67 行)、pet_health.pets.avatar_asset_idV3 第 64 行)、community.posts.like_countV5)、media.assets.purpose 无 CHECK。未新增任何 Flyway 版本,V6 仍留给后续真正建表的迭代。


4. purpose 白名单变更

变更前 变更后
MediaProperties.allowedPurposes 默认值 ["post_image"] ["post_image", "user_avatar", "pet_avatar"]
patbond-user/.../application.yml.sampleallowed-purposes post_image post_image,user_avatar,pet_avatar
objectKey 前缀 post_image/yyyy/MM/{assetId} 同规则,前缀随 purposeuser_avatar/…pet_avatar/…
DB 约束 无(varchar(32) 无 CHECK 不变(这正是本变更为零迁移的原因)
  • 配置项也是引用侧的类型检查:引用时校验 purpose 相符,所以帖子配图永远不会被当成头像挂上去,两种头像也各归各。
  • 既有测试 MediaUploadIntegrationTest.rejectsKindAndPurposeOutsideWhitelist 原以 pet_avatar 作反例——已改用仍未开放的 id_card,保住"白名单外必拒"的语义(本单唯一一处必须改的既有断言)。
  • 部署侧注意:patbond-user 的实际 application.ymlgit-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 五态 永不 404freshUserGetsZerosNotNullsAndNever404
无权限 无 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 同先例),不额外起容器。

门禁

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 --allexit 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.yamlinfo.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.yamlopenapi-v1.4.0.yaml字节级复制正典 + md5 逐一比对,删旧文件),并更新四份 OpenApiContract.RESOURCE 与守卫的 info.version / 路径数 / 操作数 / schemas 数期望:路径 31 → 32(仅 /api/v1/me/community-stats 是新路径;PATCH /api/v1/me 挂在既有路径上)、操作 43 → 45schemas 72 → 74UpdateMeRequest + CommunityStats),最终以正典实际计数为准。
  3. 各域 operationsTagged 断言随之更新;新操作入契约矩阵(PATCH /meGET /me/community-stats 全响应格)。
  4. 操作序列可照抄 iteration-3/19 §1。

7. 其余遗留与交接

  • 契约矩阵本单不加(工单要求,契约未冻结):PATCH /api/v1/meGET /api/v1/me/community-stats 的契约一致性测试待 T3.5-07 统一入场。
  • 第二波(客户端)可依赖的定型:§1 全表即为 T3.5-08/09/10 的接口面。特别提醒:avatarUrl 会过期,禁止入本地存储(沿用 M3 纪律 R2SignedNetworkImage 缓存 key 已剥签名参数);"是否有头像"判 avatarUrl != null;本人昵称展示需客户端做 nickname ?? username(见 §2.1)。
  • 真机验证清单待补项M3.5 收口时登记进 development/device-verification.md):弱网上传头像、头像预签名 URL 过期后的重取、caregiver 账号改宠物头像、资料页获赞数与帖子详情点赞数对账。
  • 未做(不在本单范围)PetSummaryResponse 未补 avatarUrlbio 字段(V1 已有列)未开放读写;昵称唯一性不加约束(ADR-022 决策 D3.5-4,允许重名);注册不收昵称(决策 D3.5-5)。
  • 本报告只写不提交;mkdocs.yml 本次不动,随波末统一挂导航入档。