docs: 2026-09-11 安全事件复盘 + 新建服务器暴露面清单 + M3.5 报告 03/04/05 入档
CI / docs-build (push) Successful in 1m59s
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:
@@ -0,0 +1,299 @@
|
||||
# 03 M3.5 后端第一波:用户资料读写 + 宠物头像 + 获赞聚合
|
||||
|
||||
> 作者:Senior Developer(后端)
|
||||
> 日期:2026-09-11
|
||||
> 工单:T3.5-04(用户资料读写)+ T3.5-05(宠物头像)+ T3.5-06(获赞聚合)三单合并
|
||||
> 输入:patbond-api dev@8089c06(334 测试基线);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` 非法 UUID;body 非法 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/40405(message 具体) | 从头像域看,一张帖子配图"不是头像"。**未新增错误码**(工单要求沿用 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` | **新增**,签名器 bean(destroy 关闭 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/MANAGE;asset 校验 |
|
||||
| `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` | (无入参可错——端点无参数,形态断言代之) |
|
||||
| 不存在 | 用户软删 → 40400(GET 与 PATCH 双动词);asset 幽灵/他人 → 40405 | 陌生人与幽灵 petId 同答 40401;asset 五态 | 永不 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` 本次不动,随波末统一挂导航入档。
|
||||
Reference in New Issue
Block a user