安全事件(已闭环,服务恢复): - 根因链: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>
21 KiB
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)→ api3cd8005(origin/dev) 结论先行:v1.4.0 已冻结并两侧同步。规模 31→32 路径 / 43→45 操作 / 72→75 schemas(比 03 号预估的 74 多一个,理由见 §1.4)。四模块快照字节级一致(md5a7081f…5801五处相同)。矩阵 173→181 格(+8),豁免仍 1 格。T3.5-04/05/06 遗留的 11 格守卫红全部转绿**,本地根反应堆clean test全绿(379→381),mutation 三处定向注毒均红、还原即绿,check-secrets.sh --allexit 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):
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 需要拍板者知晓的三处判断(均不改变定型语义)
CommunityStatsEnvelope(schemas 75 而非 74)——见 §1.4。定型表授权「以正典实际计数为准」,此处按一致性优先。GET /api/v1/me/community-stats的 tag 选posts,而非新开一个statstag。约束前提:四个守卫用 tag 集合划分模块归属(auth 模块认{auth,user,analytics}、community 模块认{posts,feed,comments,interactions,follows}),所以该操作必须带一个 community 侧的 tag,不能用user。在posts与新 tag 之间选了posts:两个数字都由帖子派生(SUM(posts.like_count)与帖数),端点也住在PostController里/me/posts旁边;而新开一个statstag 会让门户上出现两个 stats 分区(follow-stats按主体归在follows),对翻文档的人是无来由的意外。posts的 tag description 已相应补一句「含由帖子派生的『我的社区数字』聚合」。若拍板者更倾向独立 tag,改动面是 1 行 tag + community 守卫的 tag 集合 + 本行说明。- 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 本地门禁(两侧全通)
# 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"。
判定为基础设施故障而非本单引入,三条证据:
- 前一个提交同症:
d98a400(T3.5-06)同样failure,"Failing after 1s"。最后一次 CI 绿是 M3 末的8089c06("Successful in 5m26s")——即 M3.5 第一波开始后 CI 就没再绿过。 - 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 秒不可能是测试红。 - 同一条命令本地绿: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(波末一并入档)。