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` 本次不动,随波末统一挂导航入档。
|
||||
@@ -0,0 +1,244 @@
|
||||
# 04 M3.5 契约冻结 v1.3.0 → v1.4.0(doc 正典 + api 四模块快照与矩阵,一单连贯)
|
||||
|
||||
> 作者:API Platform Engineer(契约)
|
||||
> 日期:2026-09-11
|
||||
> 工单:T3.5-07 契约冻结 + api 侧快照/矩阵同步(M3 分两单,本次规模小故连贯执行,避免 api 侧 CI 长时间红)
|
||||
> 输入:iteration-3.5/03 号报告 §1 定型表与 §6 机械化清单(**实现定型表 > 推断**);ADR-022;正典 v1.3.0(doc main@6e1ab8e);patbond-api dev@d98a400(379 测试,其中契约守卫 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-community(tag 选择理由见 §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`(int64,required,非 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 SUCCESS,381 测试全绿
|
||||
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(波末一并入档)。
|
||||
@@ -0,0 +1,315 @@
|
||||
# 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<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`**,一个资料字段都不带。这不是节省字节:服务端按「本次请求触及了哪些字段」定档,夹带任一资料字段就会把档位抬到 MANAGE,caregiver 立刻 403(03 号 §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,字节与上传**逐字节相同** |
|
||||
| 宠物头像预签名 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` 本次未动,随波末统一挂导航入档。
|
||||
@@ -0,0 +1,82 @@
|
||||
# 安全事件复盘:Gitea gitconfig 注入(2026-09-11)
|
||||
|
||||
**级别**:中(服务中断,无数据损失,攻击未达成代码执行)
|
||||
**发现方式**:CI 连续失败排查
|
||||
**处置结果**:已闭环,服务恢复
|
||||
**记录人**:主会话(AI 辅助排查,用户执行服务器侧操作)
|
||||
|
||||
---
|
||||
|
||||
## 1. 时间线(UTC+8)
|
||||
|
||||
| 时间 | 事件 |
|
||||
| --- | --- |
|
||||
| 2026-07-13 | Nacos 部署于服务器并对公网暴露 8848/9848(此前 ADR-002 已将 Nacos 从项目移除,属遗留服务) |
|
||||
| 2026-09-10 18:04 | flutter CI 最后一次成功(task 68) |
|
||||
| 2026-09-10 夜 ~ 09-11 晨 | **攻击发生**:外部 IP 调用 Gitea 内部管理 API 写入 gitconfig |
|
||||
| 2026-09-11 09:27 | doc 仓 CI 首次失败(1 秒,无 step) |
|
||||
| 2026-09-11 10:03 / 10:33 | api 仓 CI 失败;期间另有 4 个提交的 job 完全未上报状态 |
|
||||
| 2026-09-11 下午 | 定位、处置、验证恢复 |
|
||||
|
||||
攻击者来源 IP(gitconfig 注入串中残留):`20.212.233.41`(Azure 段)、`187.15.89.220`(巴西)。
|
||||
|
||||
## 2. 根因链
|
||||
|
||||
1. **Gitea 的 3000 端口对公网开放**(`HTTP_ADDR` 未限制为 127.0.0.1,且轻量服务器防火墙放行 3000),使 nginx 之外存在一条直达通道。
|
||||
2. **`/api/internal/**` 可被外部调用**:攻击者 `POST /api/internal/manager/add-logger`,利用日志路径参数把内容写进 Gitea 的 gitconfig(注入串以 `;#` 收尾,用于把 Gitea 追加的日志行注释掉)。
|
||||
3. **注入项为 `uploadpack.packObjectsHook`**,指向 `/var/lib/gitea/data/home/p0_*.sh` 等文件。该 hook 是 `git upload-pack` 生成 pack 时调用的外部程序。
|
||||
4. **脚本并不存在** → hook 执行失败 → upload-pack 在发出 `NAK` 后无法产出任何 pack 数据 → **所有 HTTPS clone/fetch 失败**,而 Gitea 仍记为 `200 OK in 3ms`。
|
||||
|
||||
被污染的两份文件:`/var/lib/gitea/data/home/.gitconfig`、`/var/lib/gitea/home/.gitconfig`。
|
||||
|
||||
## 3. 影响评估
|
||||
|
||||
| 面 | 结论 | 依据 |
|
||||
| --- | --- | --- |
|
||||
| **代码完整性** | ✅ 未被篡改 | 三仓 `git ls-remote` 的 dev/main/tag 与本地权威值逐一核对一致(api dev `3cd8005`、main `8089c06`、flutter dev `6945436`、main `0e87413`、doc main `5f02909`,三个 v0.3.0 tag 全一致) |
|
||||
| **攻击是否达成代码执行** | ✅ 未达成 | hook 指向的 `p0_*.sh` 经确认**不存在**;正因执行失败才暴露事件 |
|
||||
| **系统是否被入侵** | ✅ 未被入侵 | `authorized_keys` 仅 2 个已知 key(腾讯云 skey + 维护者本人);ubuntu 无 crontab、root crontab 仅腾讯云 agent;无挖矿进程(最高 CPU 1.0% 为云监控 agent);登录记录全部来自维护者常用 IP 段;`Failed password` 仅 1 次 |
|
||||
| **开发是否受影响** | ✅ 未受影响 | push 走 SSH 且 `receive-pack`(写入方向)不经 `packObjectsHook`,故两日内所有提交正常落地 |
|
||||
| **CI** | ❌ 中断约 1 天 | checkout 走 HTTPS,全仓失效 |
|
||||
| **凭证泄露风险** | ⚠️ 存在 | 攻击者能调用内部 API,`INTERNAL_TOKEN` 须视为可能泄露并轮换 |
|
||||
|
||||
## 4. 处置动作
|
||||
|
||||
| # | 动作 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 1 | Gitea `HTTP_ADDR = 127.0.0.1`(不再对公网监听),重启 | ✅ |
|
||||
| 2 | 防火墙删除 3000、8848、9848、2222 放行规则 | ✅ |
|
||||
| 3 | 停止 Nacos 并确认无监听(`nacos.service` 需一并 disable 防重启自启) | ✅ 停止;⚠️ disable 待确认 |
|
||||
| 4 | 清理两份 gitconfig 的 `uploadpack.packObjectsHook`(原文件已备份至 `/root/gitconfig*.evidence.*.bak`) | ✅ |
|
||||
| 5 | 重启 Gitea 并验证恢复:三仓 HTTPS 浅克隆全成功;upload-pack 响应从 12 字节恢复至 723 KB | ✅ |
|
||||
| 6 | 轮换 `INTERNAL_TOKEN`(及可选 `SECRET_KEY`) | ⬜ 待做 |
|
||||
| 7 | nginx 增加 `location ^~ /api/internal/ { deny all; return 404; }` 作为纵深防御 | ⬜ 待做 |
|
||||
| 8 | 清理 gitconfig 中残留的注入垃圾注释行 | ⬜ 待做(不影响功能) |
|
||||
|
||||
## 5. 诊断过程的教训(比结论更值得记)
|
||||
|
||||
**判断走过两次弯路**,都源于采信推断而非实测:
|
||||
|
||||
1. **第一次误判「act_runner 故障」**:CI 日志显示 job 1~2 秒失败、`steps: null`,据此推断 runner 挂了。实际 runner 容器 Up 6 天、job 镜像在本地、job 容器正常启动。
|
||||
2. **第二次误判「flutter CI 正常所以 runner 活着」**:查到 flutter 最新提交 CI 为 success,却**没核对时间戳**——那是前一天 18:04 的旧记录。跨仓比较必须带时间戳。
|
||||
3. **第三次误判「nginx 代理层」**:绕过 nginx 直连 3000 后同样失败,才排除。
|
||||
|
||||
**真正的转折点是两个动作**:
|
||||
- **在本机复现同样的 git 操作**(`git clone --depth 1 https://...` 立刻复现 EOF)→ 一举把问题从「CI 领域」移到「Gitea 服务端领域」;
|
||||
- **手动执行 Gitea 内部实际调用的命令**(`git upload-pack --stateless-rpc`)→ 手动成功、进程内失败,把差异锁定到执行环境,于是查 gitconfig 时一眼看到注入。
|
||||
|
||||
**沉淀为纪律(已写入 [CI Runner 手册](../../ci-runner-setup.md) 排障表)**:CI 失败时,第一动作是**在本机复现 CI 的第一个 step**(通常是 checkout),而不是先去查 runner。
|
||||
|
||||
## 6. 更深层的问题:环境漂移无人核对
|
||||
|
||||
本次真正的隐患不是「Gitea 有个洞」,而是 **Nacos 在项目已用 ADR-002 明确移除后,其进程与防火墙规则仍在服务器上暴露公网近两个月**。
|
||||
|
||||
代码侧我们有 ADR + 契约冻结 + 契约一致性测试来防止「决策变了、实现没跟上」,**服务器侧却没有任何对应机制**。为此新建 [服务器暴露面清单](../../server-exposure.md):逐项登记开放端口与常驻服务的用途、归属决策、最后确认日期,作为常设文档定期核对。
|
||||
|
||||
## 7. 待办
|
||||
|
||||
- [ ] 轮换 `INTERNAL_TOKEN`
|
||||
- [ ] nginx 拒绝 `/api/internal/`
|
||||
- [ ] `systemctl disable nacos.service`(当前仅停止,仍为 enabled,重启会自启)
|
||||
- [ ] 清理 gitconfig 残留注释垃圾行
|
||||
- [ ] Gitea 升级评估(当前 1.26.4;`/api/internal` 可被外部调用是否属已知漏洞待核,无论如何应保持不对公网监听)
|
||||
Reference in New Issue
Block a user