- 15~17 社区后端纵切三单(帖子/Feed 作者链路/评论互动关注,226→310) - 18 契约冻结 v1.3.0(31 路径/43 操作,26 项修正照单全收) - 19 快照同步与全仓契约矩阵(173 格零漂移,修 allOf 校验盲区,→325) - 20 收口总表:定型语义汇总(第三波接入依据)与质量事件记录 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 KiB
M3 第二波帖子生命周期施工报告(T3-04:草稿/编辑/发布/删除)
作者:Senior Developer(后端) 日期:2026-09-09 工单:T3-04(帖子生命周期,第二波关键路径首单) 代码基线:patbond-api
263cd88(226 测试全绿)→ 交付101ac0f(251 测试全绿) 结论先行:帖子域五端点(创建/详情/编辑与发布/软删/我的列表)全落地 patbond-community;权限与错误语义定型(T3-10 冻结输入之一):403/40301 只发给「可见但无权」,一切不可见合并 404/40403 防枚举,hidden/archived 对作者同样 404;创建型幂等按 ADR-019 落uq_posts_author_idempotency+ 规范化 request_hash 实证;asset 校验取「同库只读 media.assets」(ADR-017 同一先例);与契约草案偏差 6 项逐条记录;全套 251 测试全绿(+25)。
1. 提交清单
按工单一逻辑提交,已推送 origin/dev:
| 提交 | 内容 |
|---|---|
101ac0f |
T3-04:帖子生命周期五端点 + 幂等/乐观锁/可见性矩阵集成测试(含 §5 两处附带修正) |
2. 端点与错误/权限语义定型表(T3-10 契约冻结输入)
2.1 端点清单(全部在 patbond-community :8084,强制 Bearer 鉴权)
| 端点 | 成功 | 语义要点 |
|---|---|---|
POST /api/v1/posts |
201 + Post | 创建草稿或直接发布(status: published 时服务端写 publishedAt);Idempotency-Key 必带;纯文字帖合法(D3-4,图片不必填) |
GET /api/v1/posts/{postId} |
200 + Post | published 对全部登录用户开放;draft 仅作者;响应含 likedByMe/bookmarkedByMe |
PATCH /api/v1/posts/{postId} |
200 + Post(新 version) | 部分更新 + version 乐观锁,仅作者;发布 = status: published 状态迁移,无独立端点;media 出现即整组替换 |
DELETE /api/v1/posts/{postId} |
200 + VoidEnvelope | 软删 deleted_at,仅作者;删除后一切读路径 404 |
GET /api/v1/me/posts |
200 + {items,nextCursor,hasMore} |
作者视角含草稿;(created_at DESC, id DESC) 走 ix_posts_author_created,keyset 游标;status 过滤可选(draft|published) |
2.2 权限矩阵定型(有测试逐格锚定)
| 帖子状态 \ 调用者 | 作者读 | 他人读 | 作者写(PATCH/DELETE) | 他人写 |
|---|---|---|---|---|
| draft | 200 | 404/40403 | 200 | 404/40403(不可见,非 403) |
| published | 200 | 200 | 200 | 403/40301 |
| hidden / archived(运营态,D3-7) | 404/40403(作者同样) | 404/40403 | 404/40403 | 404/40403 |
| 软删 / 不存在 | 404/40403 | 404/40403 | 404/40403 | 404/40403 |
定型原则:403/40301 只发给对资源「可见」的调用者(不泄露新信息);一切不可见情形(不存在/软删/hidden/archived/他人 draft)响应逐字节一致(防枚举)。hidden 对作者也不露——M3 无任何端点能产生或解除 hidden,契约 status 枚举保持 [draft, published] 两值,不为运营态开读侧口子(草案预设「不露」的定型,读侧同样适用)。
2.3 错误码定型(本单启用 4 码,均按草案取值,无新码位)
| 业务码 | HTTP | 稳定名 | 本单触发场景(全部有测试) |
|---|---|---|---|
| 40301 | 403 | POST_ACCESS_DENIED | 非作者改/删他人已发布帖 |
| 40403 | 404 | POST_NOT_FOUND | §2.2 全部不可见情形合并 |
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH | 同 Idempotency-Key 不同规范化 payload |
| 42203 | 422 | MEDIA_NOT_READY | 引用本人 uploading/failed 态 asset |
复用既有码(行为实证):40000(content 缺失/超长、category=ai_creation、status 非法值或非法迁移、Idempotency-Key 缺失/空白/超 128、position 不连续/重复、isCover 多于一、assetId 重复、空白 title、limit 越界、游标非法、非 UUID 路径参数、version 缺失)、40101(无/坏 token)、40401(petId 引用不可见宠物,沿 pets 域防枚举语义:他人宠物与不存在同响应)、40405(asset 不存在/非本人/deleted 合并,T3-03 已入 ErrorCode)、40902(version 过期)。
2.4 发布与幂等语义定型
- 发布:
PATCH {status: "published"}是唯一开放迁移(draft→published),publishedAt 恰写一次,ck_posts_publish_state库层兜底;对已发布帖重复提交status: published为幂等 no-op(200,version 照常 +1),不是 400——同态提交不是迁移,弱网重试友好;published→draft与 hidden/archived 目标值被请求枚举拒为 400/40000。发布时内容非空由构造保证(content 全程必填 1~10000,无「空草稿」可发布)。 - 创建型幂等(ADR-019 实证):
Idempotency-Key必带(1~128,trim 后计),落uq_posts_author_idempotency,ON CONFLICT DO NOTHING+ 回读比对 request_hash——同键同 hash 返回首帖(同样 201,库中恰一行);同键异 hash 409/40905;键按作者隔离(跨用户同键各自成帖,有测试);并发同键重试由唯一约束收敛,输家回读赢家行。 - request_hash 规范化定型:hash 对象是规范化后的创建命令(title/content/caption trim、category/status 缺省展开、media position/isCover 解析完成后的规范串 SHA-256,32 字节合
ck_posts_idempotency),非请求原始字节——语义相同、仅格式不同(空白、缺省写全)的重试仍命中首帖(有测试)。 - 幂等重试撞已删首帖(草案未覆盖的边界,本单定型):同键同 hash 但首帖已被删 → 404/40403(重试询问的资源已消亡,沿防枚举合并;不复活、不另建)。
2.5 软删语义定型(D3-7)
deleted_at 是全域唯一删除判定基准(一切读路径过滤)。已发布帖软删时 status 同步归档为 archived 以满足 ck_posts_publish_state(published 行不得带 deleted_at),草稿保持原 status——归档后的 status 值纯属内部记账,对外恒 404。重复删除与「删不存在的帖」同响应 404/40403。删除不提供恢复端点(M3 无回收站)。
2.6 media 挂接定型
- position:全给或全不给——全给须恰为 0..n-1 不重复;全不给按数组序。混合 400/40000。
- isCover:至多一个 true(
uq_post_media_cover库层兜底);全 false 时服务端把 position 0 行落库置 is_cover=true(比草案「展示层取 position 0」更强:库内恒有唯一封面行,T3-05 取封面免特判)。 - PATCH media 整组替换(草案预设定型):字段出现即删旧插新;
[]清空为纯文字帖;缺席不动。 - ≤9 图(D3-4);caption trim 后 ≤300;同帖 assetId 不重复(
UNIQUE (post_id, asset_id)兜底)。
3. asset 校验取舍说明(13 号报告联调协议的引用侧落地)
**定型:同库只读 media.assets(MediaAssetGateway,JdbcClient 单查询),不调 user 内部接口。**理由:
- ADR-017 同一先例——作者公开信息即为「community 跨 schema 只读 identity」,asset 校验同构;拆库时两者一起切内部批量接口,同一演进逻辑。
post_media.asset_id → media.assets的外键本就要求同库,网络接口不消除该耦合,只添故障面与延迟。- 13 号报告交接明言本模块「只做 asset 只读校验」;media 状态机的一切写操作仍归 patbond-user,本模块零写入。
校验语义(每项有测试):不存在 / 非本人 / status='deleted' → 404/40405(防枚举合并,与 media 域自身语义一致);本人所有但 uploading/failed → 422/42203。
读取侧 URL:media[].url 为预签名 GET(T3-03 定型「私有桶 + 签名读」,TTL 同 download-ttl 配置),由 community 侧 MediaUrlSigner 本地 SigV4 计算生成——预签名不联网,本服务不与对象存储建立任何连接。配置与 user 共用同组环境变量(PATBOND_MINIO_PUBLIC_ENDPOINT/ACCESS_KEY/SECRET_KEY,compose 已为 community 服务注入,无需 depends_on minio);未配置时服务照常启动、url 为 null(与 JWT 公钥未配置同一降级先例)。
4. 与契约草案(openapi-community-draft.yaml)偏差清单
| # | 草案 | 实现定型 | 理由 / 冻结动作 |
|---|---|---|---|
| 1 | Post.author 为 AuthorSummary(required) |
占位 authorId(裸 UUID 字符串) |
工单口径:作者公开资料随 T3-05 的 /internal 批量接口落地;冻结前须由 T3-05 回填 AuthorSummary,本单不预造假数据(R10) |
| 2 | Post.status 对作者是否露 hidden 留白(草案预设不露) |
定型不露:hidden/archived 对作者读写一律 404/40403 | M3 无端点能产生 hidden,枚举保持两值;运营台账属 M4+ |
| 3 | 「其余迁移 400/40000」 | published→published 为幂等 no-op(200),非 400 | 同态提交不是迁移;弱网重发 PATCH 不应报错。400 保留给真非法目标值(draft/hidden/archived) |
| 4 | 幂等重试语义未覆盖「首帖已删」 | 同键同 hash 撞已删首帖 → 404/40403 | §2.4;冻结时在 IdempotencyKeyRequiredHeader 描述补一句 |
| 5 | 「request_hash(请求体规范化 SHA-256)」未定规范化细则 | 规范化 = trim + 缺省展开 + media 解析后的规范串(§2.4) | 冻结时把「语义等价即命中」写入头参数描述 |
| 6 | PostMediaItem.url required |
保持事实 required(生产恒配置),但对象存储未配置时为 null | 降级路径与 user 模块同规;冻结时在 url 描述注明「未配置降级」或维持 required + 运维前提,建议后者 |
另两处为草案预设的确认(非偏差):PATCH media 整组替换成立(草案决策 5 删除「草案态」标注即可);isCover 全 false 取 position 0 成立(实现落库置真,见 §2.6)。
5. 测试数变化:226 → 251(+25,0 回归)
| 模块 | 基线 | 交付 | 新增内容 |
|---|---|---|---|
| patbond-common | 3 | 3 | ErrorCode 增 4 码(40301/40403/40905/42203),无行为变化 |
| patbond-user | 88 | 88 | — |
| patbond-auth | 39 | 39 | — |
| patbond-pet | 89 | 89 | — |
| patbond-community | 7 | 32 | 帖子域 25 例(postgres:18 Testcontainers 真库,V1..V5 全链) |
| 合计 | 226 | 251 | JAVA_HOME=<你的 JDK17 路径> ./mvnw clean test 一次通过,BUILD SUCCESS |
新增 25 例按工单六类路径 + 专项覆盖:
PostLifecycleIntegrationTest(14):全形态创建(草稿/直接发布/挂宠物)、六类路径——成功/参数错(content 缺失、ai_creation、hidden、空白 title、幂等键缺失/空白/超长)/不存在(随机 UUID、畸形 UUID)/无权限(403/404 边界四格)/并发冲突(真双线程并发 PATCH,恰一个 200 一个 40902,外加串行过期 version)/幂等重试(见下);草稿可见性矩阵(作者/他人 × draft/published/hidden/软删逐格);软删墓碑落库实证(archived + deleted_at);我的列表 keyset 翻页不丢不重 + status 过滤 + 三种非法入参;likedByMe/bookmarkedByMe 视角实证。PostIdempotencyIntegrationTest(5,幂等专项):同键同 hash(库中恰一行)/同键异 hash 40905/跨用户同键各自成帖/语义等价异格式仍命中/重试撞已删首帖 404。PostMediaAttachIntegrationTest(6):数组序 + 封面缺省、显式 position/isCover、他人与不存在 asset 合并 40405、uploading/failed 42203、四种形态违规 40000、PATCH 整组替换(换图/缺席不动/清空)。预签名 GET URL 形态在位断言(指向 public-endpoint、含 X-Amz-Signature)——签名是本地计算,测试注入假凭证即可,无需 MinIO 容器。
契约一致性测试按工单暂不加,冻结后统一入 patbond-community 契约矩阵(T3-10/T3-11)。
附带修正两处:
- 骨架期
BearerAuthIntegrationTest.validTokenPassesTheFilter的探针路径由/api/v1/posts(现已是真实路由)改为未映射路径,测试意图不变。 check-secrets.sh --all(CI 兜底门禁)的 KEY-ASSIGN 规则会把配置类 setter 的「字段 = 同名形参」自赋值误报为凭证字面量——patbond-userMediaProperties两处属基线既有误报,新增CommunityMediaProperties同形态再中两处。按脚本处置指引第 2 条最小化解:四处 setter 形参改名value(行为零变化,@ConfigurationProperties绑定按 setter 名不按形参名),不单方面改三仓同构的规则表;是否给规则加自赋值豁免留待三仓同步时定。修正后--all全仓通过。
6. 遗留与交接
- T3-10 冻结回填:§2 四张定型表 + §4 偏差 6 项即帖子域冻结输入;偏差 #1(AuthorSummary)等 T3-05 落地后一并回填。
- T3-05/06/07 衔接:可见性谓词(
status='published' AND deleted_at IS NULL)与「不可见一律 40403」语义直接复用;MediaUrlSigner/MediaAssetGateway即 Feed 封面签名与校验的现成件;likedByMe 批量查询模式已在列表路径验证。 - P9 共享设施:UuidV7/游标/幂等件已是第三份复制,下沉 common 的拍板仍悬置。
- 405(方法不匹配路由)目前落通用 500——全部四个服务同现状,属横切收口项,不在本单发明新语义。