Files
patbond-doc/docs/development/iterations/iteration-3.5/04-contract-freeze-v140.md
T
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

21 KiB
Raw Blame History

04 M3.5 契约冻结 v1.3.0 → v1.4.0doc 正典 + api 四模块快照与矩阵,一单连贯)

作者:API Platform Engineer(契约) 日期:2026-09-11 工单:T3.5-07 契约冻结 + api 侧快照/矩阵同步(M3 分两单,本次规模小故连贯执行,避免 api 侧 CI 长时间红) 输入:iteration-3.5/03 号报告 §1 定型表与 §6 机械化清单(实现定型表 > 推断);ADR-022;正典 v1.3.0doc main@6e1ab8e);patbond-api dev@d98a400379 测试,其中契约守卫 11 格红) 提交:doc 5f02909origin/main)→ api 3cd8005origin/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-communitytag 选择理由见 §6.2
变更(扩响应字段) GET /api/v1/me 200 nicknameavatarUrl
变更(扩响应字段) GET /api/v1/petsGET/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 / publishedPostCountint64required,非 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.PetWriteDeniedresponses.MediaNotFound 的 description 随之更新。

1.4 与 03 号 §6 预估的唯一偏差:schemas 74 → 75

03 号预估 72 + UpdateMeRequest + CommunityStats = 74,并注明「最终以正典实际计数为准」。实际为 75,多出的一个是 CommunityStatsEnvelope

理由是一致性而非必要性:全 API 的每一个 200 响应都 $ref 一个 XxxEnvelope schemaMeEnvelope/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@5f02909docs/api/openapi.yaml):

md5    a7081fb84f1207eef579ab94025f5801   ← 正典与四份快照,五处完全相同
sha256 0ba7bd53f4937d33dfbbf0c6d70aff79000fabeb5178eaea700e845332fcab5b   ← 正典
  • 旧 v1.3.0 快照删除而非保留:沿 T3-19 先例——每个模块的 OpenApiContract.RESOURCE 只认一份快照,守卫锁 info.version,保留旧文件只是死重;历史版本由 git 历史与 doc 仓承载。
  • 四份守卫期望同步升版:1.3.0 / 31 路径 / 43 操作 / 72 schemas1.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 触碰的实证)。
  • ContractValidatorallOf 展平注释里那句「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-statscommunity 模块,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 2GET /api/v1/me 200$.data.nickname$.data.avatarUrl 契约未声明;连带 everyDeclaredResponseCellIsExercised v1.3.0 的 Me 冻结面不含两字段 Me 补两字段进 properties + required,快照升版 绿
patbond-pet ContractConformanceTest 9POST /api/v1/pets 201$.data.avatarUrl 契约未声明,经 newCat() 连锁到 6 个用例;连带矩阵门禁) v1.3.0 的 Pet 冻结面不含 avatarUrl PetavatarUrl 进 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 +1meProfileWriteShapes
patbond-pet 100 100 —(新格并入既有方法)
patbond-community 106 107 +1myCommunityStatsSuccessShapes
合计 379 381 +2

5. mutation 自证(注毒应红、还原应绿)

三处注毒定向打本单新增的冻结面,而不是随便找一处已有字段——目的是证明新增的声明真的在校验路径上,不是写在契约里没人读的死字。

轮次 注毒点(快照) 预期 实测
1 authMe.required 追加 fakeMeField 3 失败:GET /api/v1/me 200PATCH /api/v1/me 200 均报 $.data.fakeMeField: 契约必填字段缺失,矩阵门禁连带红
2 petPet.required 追加 fakePetAvatarField(紧邻新加的 avatarUrl 9 失败:POST /api/v1/pets 201 等报 $.data.fakePetAvatarField: 契约必填字段缺失(与 §4 的 9 格连锁同形,反向印证根因判断)
3 communityCommunityStats.required 追加 fakeStatsField 2 失败:GET /api/v1/me/community-stats 200$.data.fakeStatsField: 契约必填字段缺失,矩阵门禁连带红
还原 四快照 cp 回正典 + md5 复核 绿 五处 md5 一致;根反应堆 clean test BUILD SUCCESS381 测试全绿

第 1 与第 3 轮的失败点分别落在 PATCH /api/v1/me 200GET /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 标志与 isEmptyPatchPetResponseavatarUrlstatus 之后、CommunityStatsResponse 两个 longMeProfileService 的错误码序列、PetService.requiredLevel 的按字段分档、ErrorCode.USER_NOT_FOUND=40400MediaProperties.allowedPurposes 三值)——定型表与实现一致,与 ADR-022 一致,内部无矛盾。冻结按定型表照单全收,未作任何自行裁量的语义改动。

6.2 需要拍板者知晓的三处判断(均不改变定型语义)

  1. CommunityStatsEnvelopeschemas 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 devmain 受保护,禁直推) 3cd8005 四模块快照替换 + 四份守卫升版 + 矩阵扩展;12 文件,src/main 改动

7.2 本地门禁(两侧全通)

# 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 SUCCESS381 测试全绿
bash scripts/check-secrets.sh --all                        # exit 0

7.3 ⚠️ Gitea CI:红,但先于本单存在(runner 级故障,非测试失败)

3cd8005 的 commit statusfailureCI / backend-test (push)"Failing after 2s"

判定为基础设施故障而非本单引入,三条证据:

  1. 前一个提交同症d98a400T3.5-06)同样 failure"Failing after 1s"。最后一次 CI 绿是 M3 末的 8089c06"Successful in 5m26s")——即 M3.5 第一波开始后 CI 就没再绿过。
  2. job 无任何 step 执行Gitea 的 run 详情返回 currentJob.steps: nulllogs.stepsLog: nullduration: 2s。工作流第一步是手动 checkout,连它都没跑起来,说明失败发生在 job 装配阶段(runs-on: ubuntu-latest 无匹配 runner,或 runner 拉不起容器镜像)。真实的 ./mvnw -B clean test 需要数分钟,1~2 秒不可能是测试红。
  3. 同一条命令本地绿CI 跑的是 ./mvnw -B clean testsh 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 纪律 R2SignedNetworkImage 的缓存 key 已剥签名参数);「是否有头像」判 avatarUrl != null(响应里没有 avatarAssetId,别去找);本人昵称展示做 nickname ?? username/me 不回退是定型,不是遗漏)。
  • v1.4.0 之后的新增仍走纯增量:新增可选字段/新增端点/枚举追加不需要新主版本;任何字段删改、类型变更、必填收紧都需要 v2 + 迁移指南 + 日落期,不得在 v1 内静默进行(info.description 的纯增量承诺已把这条写给集成方看)。
  • 本报告只写不提交(随波末统一入档);mkdocs.yml 本次未动;03 号报告仍未 commit(波末一并入档)。