- 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>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# M3 第二波公共 Feed 与作者公开资料链路施工报告(T3-05 / D3-9 方案 B)
|
||||
|
||||
> 作者:Senior Developer(后端)
|
||||
> 日期:2026-09-09
|
||||
> 工单:T3-05(公共 Feed 游标分页与帖子卡片聚合)+ D3-9 方案 B 落地(作者公开资料链路)
|
||||
> 代码基线:patbond-api `101ac0f`(251 测试全绿)→ 交付 `99a3c1f`(282 测试全绿)
|
||||
> 结论先行:**契约冻结(T3-10)的最后两个待定型点就位——FeedCard 与 AuthorSummary 均已按实现定型(§2/§3 两张定型表即冻结输入);user 侧 `/internal/users/profiles` 批量公开资料接口落地(≤50/次,昵称回退归属侧完成,注销静默缺席);community 侧 Feign 批量取 + 60s 进程内缓存 + 头像本地解析签名;user 服务不可达时 Feed/详情照常 200、作者摘要退为仅 userId(降级有专项测试,绝不 5xx);T3-04 偏差①(authorId 占位)闭环;计数取 posts 冗余列(§4 取舍);与草案偏差 7 项逐条记录;全套 282 测试全绿(+31),`check-secrets --all` 通过。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 提交清单
|
||||
|
||||
按 user 侧 / community 侧两个逻辑提交,已推送 `origin/dev`:
|
||||
|
||||
| 提交 | 内容 |
|
||||
| --- | --- |
|
||||
| `40bac85` | user 域 `/internal/users/profiles` 批量公开资料接口 + 8 例端点测试 |
|
||||
| `99a3c1f` | 公共 Feed 游标分页 + FeedCard/AuthorSummary 定型 + Feign 链路/缓存/降级 + 23 例测试 + compose 注入 |
|
||||
|
||||
## 2. FeedCard 定型表(T3-10 冻结输入之一)
|
||||
|
||||
`GET /api/v1/feed`(强制 Bearer 鉴权;`limit` 1~100 缺省 20,`cursor` 可选)。谓词恒为 `status='published' AND visibility='public' AND deleted_at IS NULL`,恰合 `ix_posts_feed` 部分索引;复合游标 `(published_at DESC, id DESC)`,keyset 翻页(`(published_at, id) < (cursor)`),禁 OFFSET;信封 `{items, nextCursor, hasMore}`(`hasMore=false` 时 `nextCursor` 恒 null)。游标编码与我的列表同构:base64url("epochMicros:id"),timestamptz 微秒精度无损往返。
|
||||
|
||||
| 字段 | 类型 | 定型语义 |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | 帖子 id |
|
||||
| `author` | AuthorSummary | §3;降级时退为 id-only 形态 |
|
||||
| `category` | enum | general \| help \| ai_creation |
|
||||
| `title` | string?(nullable) | 原样透传,无标题为 null |
|
||||
| `contentPreview` | string | **正文前 200 个 Unicode 码点,码点边界截断(emoji 等增补面字符绝不劈开),不追加省略号**;短于 200 码点原样透传。全文恒走详情端点 |
|
||||
| `coverImage` | PostMediaItem?(nullable) | **库中唯一 `is_cover` 行**(T3-04 §2.6 保证有图必有唯一封面行,读侧零特判);纯文字帖为 null;`url` 为现签预签名 GET,对象存储未配置时 null(沿 T3-04 偏差 #6 同规) |
|
||||
| `mediaCount` | int | 帖子图片总数(0~9),卡片角标用 |
|
||||
| `likeCount` / `commentCount` / `bookmarkCount` | int64 | 取自 posts 冗余列(§4) |
|
||||
| `likedByMe` / `bookmarkedByMe` | bool | 当前用户视角,页查询内联 EXISTS 主键探针(无 N+1,无二次往返) |
|
||||
| `publishedAt` | date-time | 恒非空(谓词只放行 published) |
|
||||
|
||||
较 Post 裁剪掉的字段:`content` 全文、`petId`、`visibility`、`version`、`media` 整组、时间戳对(created/updated)。卡片不带 coverImage 之外的图列表(草案 TODO 就此定型:只有封面 + 计数)。
|
||||
|
||||
## 3. AuthorSummary 定型表(T3-10 冻结输入之二,D3-9 方案 B)
|
||||
|
||||
嵌入位置:FeedCard.author、Post.author(详情/我的列表/写响应——**T3-04 偏差①闭环,`authorId` 裸字段已删除**)、后续 T3-07 评论作者同构复用。
|
||||
|
||||
| 字段 | 类型 | 定型语义 |
|
||||
| --- | --- | --- |
|
||||
| `userId` | uuid(required,唯一必选字段) | 恒等于 posts.author_user_id,任何情形都在 |
|
||||
| `nickname` | string?(**nullable,较草案放宽**) | 正常路径恒非空:**nickname→username 回退在 user 侧 SQL 完成**(COALESCE,消费侧与客户端都不做拼装);null 当且仅当降级/墓碑(见下) |
|
||||
| `avatarUrl` | string?(nullable) | ready 头像 asset 的现签预签名 GET;无头像 / asset 非 ready / 对象存储未配置 / 降级 → null,客户端出占位 |
|
||||
|
||||
**链路形态(三段)**:
|
||||
1. **user 侧** `GET /internal/users/profiles?ids=…`:一次最多 50 个(超限/空/非法 UUID 均 400/40000,重复 id 去重),仅回 `userId/nickname/avatarAssetId` 三字段;不存在与已注销(deleted_at)用户**静默缺席**——缺席不泄露成因。既有 InternalAuthFilter(X-Internal-Token 共享密钥)直接覆盖,无新安全面。
|
||||
2. **community 侧 Feign**(ADR-002 静态直连,`patbond.user-service.url`):按缓存未命中 id 去重分批(≤50/批,一页 20 卡通常恰一批、热缓存零批);`avatarAssetId` 经 **media.assets 同库只读**(ADR-017 既有豁免,与帖图校验同构)解析为 bucket/object_key(仅 `status='ready'` 计入),URL 由 `MediaUrlSigner` 每次响应现签——**缓存里永远不存会过期的 URL**。
|
||||
3. **缓存**:进程内 ConcurrentHashMap,TTL 60s(`patbond.author-profile.cache-ttl` 可配),条目为 (nickname, bucket, objectKey);超 1 万条时顺手清理过期项。昵称/头像变更最迟一分钟全站可见。
|
||||
|
||||
**注销用户墓碑形态**(草案 TODO 定型):/internal 缺席 → 消费侧渲染 id-only AuthorSummary(`{userId, nickname: null, avatarUrl: null}`),与降级同形——客户端只需要一种占位逻辑。不补 bio、不露 username(回退后的展示名不标注来源)。
|
||||
|
||||
## 4. 计数策略取舍
|
||||
|
||||
**定型:三计数读 posts 冗余列(V5 的 like_count/comment_count/bookmark_count),不实时 COUNT(*)。**
|
||||
|
||||
1. V5 结构本就为此建列(含 `ck_posts_counts` 非负兜底),T3-06(点赞/收藏)与 T3-07(评论)的工单已明确写侧**同事务**维护关系行 + 冗余列——同库同事务,读侧不存在滞后窗口,只有普通的并发读写序问题。
|
||||
2. 实时 COUNT 是每页 20 帖 × 3 计数的聚合扫描,随互动量线性劣化;冗余列是页查询顺读,代价 O(页)。
|
||||
3. 当前基线互动写侧未落地,列值恒 0——卡片计数透传列值的正确性已用 SQL 置值实证(FeedCardIntegrationTest),T3-06/07 落地后无需回改读侧。
|
||||
|
||||
一致性兜底记录:若未来出现列与关系表漂移(如运维手改),修复口径为以关系表 COUNT 重算列(一条 UPDATE … FROM 聚合),属运维手册项,不做常驻对账任务。`likedByMe/bookmarkedByMe` 不走冗余列,恒查关系表主键,天然精确。
|
||||
|
||||
## 5. 降级语义定型(有专项测试逐条锚定)
|
||||
|
||||
| 情形 | 行为 |
|
||||
| --- | --- |
|
||||
| user 服务连接拒绝 / 超时(Feign connect 1s / read 2s 兜底)/ 回 4xx/5xx | 一条 WARN 日志,该批 id 不解析;**Feed/详情照常 200**,未解析作者退为 id-only AuthorSummary;分批场景失败前已成功的批次照常生效 |
|
||||
| 失败结果 | **不写缓存**(无负缓存)——下一请求自动重试,恢复即回满摘要(有测试:降级→恢复两连请求) |
|
||||
| /internal 回包缺席某 id(不存在/注销) | 同上 id-only 形态,不缓存缺席 |
|
||||
| 头像 asset 非 ready / 已删 / 对象存储未配置 | 仅 `avatarUrl: null`,昵称照常 |
|
||||
| 缓存命中 | 零下游调用(有调用计数测试) |
|
||||
|
||||
设计要点:降级判定在 `AuthorProfileGateway` 单点收口(catch 一切 RuntimeException),Feign 层不配 ErrorDecoder——对这条链路,下游业务错误与网络故障同义(都是"拿不到资料"),没有需要透传的错误语义。
|
||||
|
||||
## 6. 与契约草案(openapi-community-draft.yaml)偏差清单
|
||||
|
||||
| # | 草案 | 实现定型 | 理由 / 冻结动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `AuthorSummary` required `[userId, nickname]` | **required 收为 `[userId]`,`nickname` nullable** | 降级与墓碑形态需要合法的 id-only 摘要;正常路径 nickname 恒非空的语义写入字段描述 |
|
||||
| 2 | contentPreview「200 字符 + 完整边界截断」 | **200 Unicode 码点,码点边界截断,不加省略号** | 「字符」口径歧义(UTF-16 单元会劈开 emoji);码点是最小不破字形单位,词边界截断对中文无意义。冻结时把码点口径写死 |
|
||||
| 3 | FeedCard TODO「是否带 mediaCount 之外的图列表」 | **只带 coverImage + mediaCount** | 卡片是列表形态,整组图属详情;封面行库层唯一(T3-04),读侧零歧义 |
|
||||
| 4 | AuthorSummary TODO(bio/username/墓碑) | **不补 bio;不露 username;墓碑 = /internal 静默缺席 → id-only** | 最小泄露面(D3-9 候选 C 的否决理由同源);bio 字段库里尚不存在 |
|
||||
| 5 | 封面「isCover 优先→position 0 兜底」(读侧规则) | 读侧**只认 is_cover 行**,无兜底分支 | 兜底已在写侧完成(T3-04 §2.6 落库置真),读侧兜底是死代码;冻结时封面描述改为「唯一 is_cover 行」 |
|
||||
| 6 | 「likedByMe/bookmarkedByMe 批量查询」 | 页查询**内联 EXISTS 主键探针**(单 SQL,非独立批量查询) | 语义与性能目标一致(无 N+1、无二次往返),实现形态更简;契约无感知,仅记录 |
|
||||
| 7 | `Post.author` 占位 `authorId`(T3-04 偏差①) | **已回填 AuthorSummary,`authorId` 字段删除** | 本单交付;冻结时 Post.author 按 §3 收编,T3-04 偏差①销项 |
|
||||
|
||||
另两处为草案预设的确认(非偏差):Feed 谓词/游标/信封与草案逐字一致;`PageLimitParam`(1~100 缺省 20,越界 400/40000)与实现一致。**`/internal/users/profiles` 不入公网 openapi.yaml**(服务间接口,非客户端契约),形态以本报告 §3 为准。
|
||||
|
||||
## 7. 测试数变化:251 → 282(+31,0 回归)
|
||||
|
||||
| 模块 | 基线 | 交付 | 新增内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| patbond-common | 3 | 3 | — |
|
||||
| patbond-user | 88 | 96 | `InternalProfileEndpointTest` 8 例:无/错密钥 401、昵称回退、头像指针透传、注销与不存在静默缺席、ids 缺失/空白/非法 UUID/超 50 各 400、恰 50 放行、重复 id 去重 |
|
||||
| patbond-auth | 39 | 39 | — |
|
||||
| patbond-pet | 89 | 89 | — |
|
||||
| patbond-community | 32 | 55 | 见下 |
|
||||
| **合计** | **251** | **282** | `JAVA_HOME=<你的 JDK17 路径> ./mvnw clean test` 一次通过,BUILD SUCCESS;`<repo>/scripts/check-secrets.sh --all` 通过 |
|
||||
|
||||
community 新增 23 例,按工单六类路径 + 两个专项:
|
||||
|
||||
- `FeedPaginationIntegrationTest`(8,分页专项):空 Feed / 单页无游标 / **翻页不丢不重**(7 帖 3 页整走)/ **published_at 同刻并列按 id 破序**(SQL 置同刻实证)/ **翻页间隙增删不移位不重复**(页间新发布不挤入下页、下页候选被删除干净消失)/ 可见性谓词(draft/hidden/软删/followers 可见性一律不出 Feed)/ 非法游标两形态 400 / limit 越界 400。Feed 是全局态,每例先清 posts 表保证断言确定性。
|
||||
- `FeedCardIntegrationTest`(5,卡片定型):纯文字帖全字段形态(含「不带 content/version」的裁剪断言)/ **200 码点截断(199 汉字 + emoji 恰好 200,增补面字符不劈)**/ 短文原样透传 / 封面取 is_cover 行 + mediaCount + 签名 URL 在位 / 计数透传冗余列 + likedByMe 关系表实证。
|
||||
- `AuthorProfileIntegrationTest`(7,作者链路):详情回填昵称(偏差①闭环)/ username 回退 / ready 头像签名 URL + uploading 头像 null / **缓存命中零下游调用**(调用计数)/ 详情降级 id-only / **Feed 降级整页照常 200** / **失败不入缓存、恢复即回满摘要**。
|
||||
- `AuthorProfileClientWireTest`(3,Feign 线路):真实 Feign 客户端打在测试内 JDK HttpServer 上——X-Internal-Token 拦截器在位 + ids 批量成单请求 + 信封解码 / avatarAssetId 经 media.assets 解析并签名 / 下游 500 降级为空结果。
|
||||
|
||||
**测试替身取舍说明(工单许可项)**:作者链路测试未起 user+community 双服务同 JVM(AuthE2e 先例成本高),采用**读同一真库的 DB-backed stub** 顶替 Feign 代理(与真端点跑同一条 SQL,含昵称回退),Feign 传输层另由线路测试用真实客户端 + 真 HTTP 服务器覆盖,`/internal` 端点自身在 user 模块测全——三层拼起来无未测缝隙。为让 stub 可置换,`@FeignClient` 显式 `primary = false`(生产唯一候选,行为无差)。
|
||||
|
||||
## 8. 遗留与交接
|
||||
|
||||
- **T3-10 冻结回填**:§2/§3 两张定型表 + §6 偏差 7 项即 Feed/作者域冻结输入;至此 T3-03 凭据形态、T3-04 权限/错误语义、T3-05 卡片字段三项冻结条件齐备,可开冻结单。
|
||||
- **T3-06/07 衔接**:互动写侧同事务维护三计数列即可,读侧零改动;评论作者摘要直接复用 `AuthorProfileGateway.summarize`(批量 + 缓存现成)。
|
||||
- **compose**:community 服务已注入 `PATBOND_USER_SERVICE_URL` / `PATBOND_INTERNAL_TOKEN`(与 auth/user 同一密钥),容器内直连 user 服务。
|
||||
- **技术债记录**:/internal 仍为共享密钥(mTLS 债项在 01 号报告已记,Feign 面扩大后权重再升);进程内缓存是单实例视角,多副本部署时各副本独立 60s 窗口(可接受,无一致性要求);游标/UuidV7 等共享件已是第四份复制,P9 下沉拍板仍悬置。
|
||||
- **头像上传口子**:链路已通但 `user_avatar` purpose 尚无上传入口(media 域 M3 只开 post_image),全库头像数据为空时 `avatarUrl` 恒 null——前端占位即可,purpose 扩展随 P6 拍板另立工单;identity.users 的 nickname 字段已存在(V1),无需表变更,设置昵称的公开端点亦属后续工单。
|
||||
Reference in New Issue
Block a user