- 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>
14 KiB
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,客户端出占位 |
链路形态(三段):
- user 侧
GET /internal/users/profiles?ids=…:一次最多 50 个(超限/空/非法 UUID 均 400/40000,重复 id 去重),仅回userId/nickname/avatarAssetId三字段;不存在与已注销(deleted_at)用户静默缺席——缺席不泄露成因。既有 InternalAuthFilter(X-Internal-Token 共享密钥)直接覆盖,无新安全面。 - community 侧 Feign(ADR-002 静态直连,
patbond.user-service.url):按缓存未命中 id 去重分批(≤50/批,一页 20 卡通常恰一批、热缓存零批);avatarAssetId经 media.assets 同库只读(ADR-017 既有豁免,与帖图校验同构)解析为 bucket/object_key(仅status='ready'计入),URL 由MediaUrlSigner每次响应现签——缓存里永远不存会过期的 URL。 - 缓存:进程内 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(*)。
- V5 结构本就为此建列(含
ck_posts_counts非负兜底),T3-06(点赞/收藏)与 T3-07(评论)的工单已明确写侧同事务维护关系行 + 冗余列——同库同事务,读侧不存在滞后窗口,只有普通的并发读写序问题。 - 实时 COUNT 是每页 20 帖 × 3 计数的聚合扫描,随互动量线性劣化;冗余列是页查询顺读,代价 O(页)。
- 当前基线互动写侧未落地,列值恒 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_avatarpurpose 尚无上传入口(media 域 M3 只开 post_image),全库头像数据为空时avatarUrl恒 null——前端占位即可,purpose 扩展随 P6 拍板另立工单;identity.users 的 nickname 字段已存在(V1),无需表变更,设置昵称的公开端点亦属后续工单。