Files
patbond-doc/docs/development/iterations/iteration-3/16-feed-author-report.md
T
lixi a611acb358
CI / docs-build (push) Successful in 32s
docs: M3 第二波收口——报告 15~20 入档挂导航
- 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>
2026-09-09 11:38:42 +08:00

14 KiB
Raw Blame History

M3 第二波公共 Feed 与作者公开资料链路施工报告(T3-05 / D3-9 方案 B

作者:Senior Developer(后端) 日期:2026-09-09 工单:T3-05(公共 Feed 游标分页与帖子卡片聚合)+ D3-9 方案 B 落地(作者公开资料链路) 代码基线:patbond-api 101ac0f251 测试全绿)→ 交付 99a3c1f282 测试全绿) 结论先行:契约冻结(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 缺省 20cursor 可选)。谓词恒为 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=falsenextCursor 恒 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 全文、petIdvisibilityversionmedia 整组、时间戳对(created/updated)。卡片不带 coverImage 之外的图列表(草案 TODO 就此定型:只有封面 + 计数)。

3. AuthorSummary 定型表(T3-10 冻结输入之二,D3-9 方案 B)

嵌入位置:FeedCard.author、Post.author(详情/我的列表/写响应——T3-04 偏差①闭环,authorId 裸字段已删除)、后续 T3-07 评论作者同构复用。

字段 类型 定型语义
userId uuidrequired,唯一必选字段) 恒等于 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)用户静默缺席——缺席不泄露成因。既有 InternalAuthFilterX-Internal-Token 共享密钥)直接覆盖,无新安全面。
  2. community 侧 FeignADR-002 静态直连,patbond.user-service.url):按缓存未命中 id 去重分批(≤50/批,一页 20 卡通常恰一批、热缓存零批);avatarAssetIdmedia.assets 同库只读(ADR-017 既有豁免,与帖图校验同构)解析为 bucket/object_key(仅 status='ready' 计入),URL 由 MediaUrlSigner 每次响应现签——缓存里永远不存会过期的 URL
  3. 缓存:进程内 ConcurrentHashMapTTL 60spatbond.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 TODObio/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 占位 authorIdT3-04 偏差①) 已回填 AuthorSummaryauthorId 字段删除 本单交付;冻结时 Post.author 按 §3 收编,T3-04 偏差①销项

另两处为草案预设的确认(非偏差):Feed 谓词/游标/信封与草案逐字一致;PageLimitParam1~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 / 失败不入缓存、恢复即回满摘要
  • AuthorProfileClientWireTest3Feign 线路):真实 Feign 客户端打在测试内 JDK HttpServer 上——X-Internal-Token 拦截器在位 + ids 批量成单请求 + 信封解码 / avatarAssetId 经 media.assets 解析并签名 / 下游 500 降级为空结果。

测试替身取舍说明(工单许可项):作者链路测试未起 user+community 双服务同 JVMAuthE2e 先例成本高),采用读同一真库的 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(批量 + 缓存现成)。
  • composecommunity 服务已注入 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),无需表变更,设置昵称的公开端点亦属后续工单。