10 Commits

Author SHA1 Message Date
lixi 3cd8005577 test(contract): v1.4.0 快照四模块同步 + 用户资料与头像矩阵入场(T3.5-07)
CI / backend-test (push) Failing after 2s
doc 仓正典 main@5f02909 冻结 v1.4.0 后的 api 侧收尾:字节级同步快照、守卫
升版、新增/变更操作入契约一致性矩阵。**零生产代码改动**(只改契约快照与测试)。

快照同步(字节级,md5 与正典逐一比对一致 a7081fb84f1207eef579ab94025f5801):
- 四模块 openapi-v1.3.0.yaml → openapi-v1.4.0.yaml,删旧文件(守卫只认一份,
  保留旧快照是死重;历史版本由 git 与 doc 仓承载,沿 T3-19 先例)
- 四份守卫期望升版:1.3.0/31/43/72 → 1.4.0/32/45/75

矩阵扩展(173 → 181 格,豁免仍为 1 格):
- patbond-auth +5 格:PATCH /api/v1/me 全响应矩阵(200 设值 / 200 显式 null
  清空 / 400 空 patch / 401 / 404 双码 40400+40405 / 422 42203),并让 GET
  /api/v1/me 在 nickname 非空分支再走一遍严格校验。注意 /api/v1/me 的守卫与
  矩阵都在 auth 模块(实现在 user,契约测试跨服务发请求),扩契约易漏
- patbond-pet +1 格:PATCH /api/v1/pets/{petId} 新增 422/42203;404 单元格
  补 40405 第二种业务码(幽灵 asset 与用途不符 asset 合并同答),并补一格
  挂 ready 头像的 200
- patbond-community +2 格:GET /api/v1/me/community-stats 200(空数据零值与
  有数据 1 赞/2 作品两分支)+ 401(由全操作循环覆盖)
- patbond-user media 域 2 操作 8 格不变(v1.4.0 未触碰其冻结面)

T3.5-04/05/06 遗留的 11 格契约守卫红(auth 2 + pet 9,根因为新增字段未冻结)
全部转绿。测试 379 → 381(+2 个新增矩阵方法),根反应堆 clean test 全绿,
check-secrets.sh --all exit 0。

mutation 自证(三处定向注毒均红、还原即绿):CommunityStats.required 注入
fakeStatsField → GET /me/community-stats 200 报漂移;Me.required 注入
fakeMeField → GET+PATCH /me 200 报漂移;Pet.required 注入 fakePetAvatarField
→ POST /pets 201 等 9 格报漂移。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:33:19 +08:00
lixi d98a400f47 feat: 获赞聚合——新增 GET /api/v1/me/community-stats 读侧实时聚合(T3.5-06,ADR-022)
CI / backend-test (push) Failing after 1s
- 新增 GET /api/v1/me/community-stats,返回
  {receivedLikeCount, publishedPostCount}:本人「已发布且未软删」帖的
  like_count 之和与帖子数
- 口径:草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、运营态
  hidden/archived 不计(在 M3 契约里对所有人不可见)、他人帖不计;空数据答 0
  不答 null,任何已认证用户都有 stats,从不 404
- 走读侧实时聚合不引冗余列(ADR-022):写侧无按人计数器,也就没有可漂移的
  副本;单次查询压在 ix_posts_author_created 的前导列上
- 端点独立而不并入 /users/{userId}/follow-stats(ADR-022 决策 A):后者主体是
  「某用户的关注数」,混入「我的获赞」会让一个载荷有两个主体;且本端点主体
  恒为 token 里的自己,路径上没有可枚举的 userId
- 测试 +12(空数据零值/多帖求和/自赞与取消赞/草稿与软删与运营态排除/他人帖
  不串味/401 两态/无他人入口/并发与重复读幂等/载荷形态),community 模块
  94 → 106

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:03:07 +08:00
lixi 15c2e66519 feat: 宠物头像读写——PATCH 支持 avatarAssetId、详情与列表补 avatarUrl(T3.5-05,ADR-022)
- PATCH /api/v1/pets/{petId} 支持 avatarAssetId 三态:缺省不改、显式 null
  清空、给值设置。这是 pets 域唯一的三态字段——M2 惯例「null 与缺省同义」
  无法表达「删掉头像」,而 name/species/sex 本就不允许为空,故差异刻意限定
  在本字段
- 权限按「本次请求碰了哪些字段」定档:仅改头像为 WRITE(owner+caregiver,
  ADR-022:头像属日常照护信息,与体重/疫苗同档),碰到任一资料字段仍是
  MANAGE(仅 owner),混合请求按更严的一半判;viewer 一律 403/40300
- 头像与资料共用同一把乐观锁:仅改头像也吃 version,旧版本必答 409/40902
- asset 校验复用 T3-03 引用侧协议 + purpose='pet_avatar':不存在/非本人/
  已删/用途不符答 404/40405,本人未就绪答 422/42203(user_avatar 资源也不能
  当宠物头像)
- 详情与列表响应补 avatarUrl(本地 SigV4 现签预签名 GET,沿用 community 读侧
  先例):仅当 asset 为 ready 才签,指针在而资源退出 ready 时降级为 null,
  不签一个下载必 404 的地址,也不隐式清理指针
- PetRepository 读改为返回 PetRow(含头像存储坐标),签名上移到 PetService,
  与 community 的 PostRow → PostResponse 装配同构——过期 URL 不下沉到仓储层
- pet 模块加入 aws-sdk s3(仅本地签名,不直连对象存储)与读侧 patbond.media
  配置;compose 补同一组 PATBOND_MINIO_* 环境变量
- 测试 +11(成功/清空/缺省保留/降级/caregiver 与 viewer 权限/防枚举 404/
  asset 五态/畸形入参/乐观锁与重放),pet 模块 89 → 100

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:02:50 +08:00
lixi a5634c5f85 feat: 用户资料读写——GET/PATCH /api/v1/me 补昵称与头像 URL(T3.5-04,ADR-022)
- GET /api/v1/me 补 nickname 与 avatarUrl:nickname 为 DB 原值不做 username
  回退(/me 是本人编辑态,回退会让用户误以为已设过昵称;他人视角的回退仍在
  /internal/users/profiles 的 SQL 层,M3 T3-05 已交付);avatarUrl 每次现签
  预签名 GET,非 ready 或存储未配置一律降级为 null 而非 500
- 新增 PATCH /api/v1/me:nickname 与 avatarAssetId 均为三态语义——键缺省即
  不改、显式 null 即清空、给值即设置;空 patch 答 400/40000 而非静默 200
- nickname 校验对齐 ck_users_nickname:btrim 后按码点计 1~32(PostgreSQL
  char_length 数码点,按 UTF-16 长度校验会误拒 32 个 emoji),纯空白答 40000
  而非隐式清空(清空只留显式 null 一种表达)
- avatarAssetId 沿用 T3-03 引用侧协议:不存在/非本人/已删/用途非
  user_avatar 均答 404/40405(防枚举合并),本人 user_avatar 未就绪答
  422/42203
- 写入走列级选择性 UPDATE 而非读-合并-写:并发的昵称与头像 PATCH 互不覆盖,
  故 /me 无需暴露版本号乐观锁
- MediaProperties.allowedPurposes 增 user_avatar/pet_avatar(ADR-022:purpose
  无 CHECK 约束,新用途只改配置,零 Flyway 迁移)
- 测试 +22(19 无存储降级路径 + 3 真实 MinIO 全链路签名/下载),user 模块
  100 → 131

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:02:26 +08:00
lixi 8089c06a73 feat: 事件字典 v3 白名单扩充——community 域 19 事件 + experiment_exposed 字典先行(T3-20 后端,ADR-020)
CI / backend-test (push) Successful in 5m26s
- EventDictionary 增补 20 个事件(iteration-3 报告 06 §1.5 白名单原样落地):
  post 域 8(发布漏斗 started/draft_saved/publish_succeeded/failed、post_deleted、
  媒体上传三段逐文件漏斗);feed 域 2(feed_viewed 浏览段聚合曝光 + feed_load_failed);
  互动 8(like/unlike、favorite/unfavorite、comment 成败对、follow/unfollow);
  platform 域 experiment_exposed(A/B 前置 #5,M4 启用字典先行)
- 字典 javadoc 同步 v3:社区隐私红线增量(§1.3 无正文/无内容 ID/无话题名/无媒体线索)
  与 pageName v3 页面族(§6.1 收编 4 + 新增 9,键级校验不变、后端零代码)
- EventDictionaryTest:每个新事件键集边界锁定 + §1.2/§1.4/§1.6 故意不设事件
  (post_impression/post_viewed/comment_create_started/单点互动 _failed/topic_follow)
- AnalyticsIntegrationTest:feed_viewed 聚合事件入库、post_liked 白名单外
  postId 剥离(红线 2)、post_impression 拒绝(§1.2 裁定 ingest 侧锁死)
- 测试 325 → 334(+9),openapi.yaml 无需变更(events 契约对事件名开放)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 11:51:16 +08:00
lixi 0569585434 test: 契约冻结 v1.3.0 api 侧收尾——四模块字节级快照同步 + community/media 契约矩阵入场
CI / backend-test (push) Successful in 5m4s
- 正典 v1.3.0(doc main@f848476)字节级复制为 pet/auth/community/user 四份
  openapi-v1.3.0.yaml 快照(md5 与正典一致),删除旧 v1.2.0(守卫只认一份,
  历史由 git 承载);pet/auth 守卫期望升版 1.3.0/31 路径/43 操作/72 schemas
- CommunityContractConformanceTest:community 域 17 操作 64 单元格全响应矩阵
  (Feed/帖子/评论/互动/关注,401/403/404/409/422 各格实证,零豁免)
- MediaContractConformanceTest(user 模块):media 两步上传 2 操作 8 单元格
  全矩阵(真实 MinIO 直传,零豁免)
- ContractValidator 四副本加单分支 allOf 展平合并,修复 v1.3.0
  nullable+allOf 模式(coverImage/replyToUser)被静默跳过的校验盲区,
  定向 mutation 自证生效
- 实现与冻结契约零漂移;310 → 325 测试全绿;check-secrets --all 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 11:31:45 +08:00
lixi 7f1dd33097 feat: 单层评论——幂等创建/游标列表/作者软删与 comment_count 同事务维护(T3-07,ADR-019)
CI / backend-test (push) Successful in 7m33s
- POST /api/v1/posts/{postId}/comments:Idempotency-Key 必带,落
  client_request_id + 规范化 request_hash(uq author×key,键按作者隔离);
  同键同 payload 返回首条(201,不重复计数),异 payload 409/40905,
  重试撞已删首评 404/40404(沿 T3-04 §2.4 先例);replyToUserId 可选
  @ 回复,目标须为存活用户(404/40406);content trim 后 1~2000
- GET 评论列表:(created_at DESC, id DESC) 走 ix_comments_post_created
  keyset 游标,仅 status=visible,作者与 @ 目标批量走 AuthorProfileGateway
  (降级 id-only 同构复用)
- DELETE /api/v1/comments/{commentId} 顶层短路径:仅评论作者可删
  (D3-7 拍板,帖主删他人评论不做);可见评论他人删 403/40301,
  不存在/已删/所属帖不可见合并 404/40404;FOR UPDATE 锁定状态迁移,
  comment_count 同事务 -1 恰一次
- 评论域同用互动门禁:帖子公开面之外(含作者本人草稿)一律 404/40403
  逐字节一致
- ErrorCode 新增 40404 COMMENT_NOT_FOUND
- CommentIntegrationTest 14 例:六类路径、幂等矩阵专项、分页不丢不重、
  计数对账专项(删评后列值 = visible 行数 = 列表长度)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 10:50:28 +08:00
lixi 19e8cba59f feat: 点赞/收藏/关注幂等互动与同事务计数——PUT/DELETE 权威终态、我的收藏游标列表、follow-stats(T3-06/T3-08,ADR-018/019)
- PUT/DELETE like|bookmark:复合主键即幂等键(ON CONFLICT DO NOTHING /
  条件 DELETE),计数列按关系写实际变更行数同事务增减,响应回
  {liked,likeCount}/{bookmarked,bookmarkCount} 权威终态;并发重复施加
  恰计 1、PUT+DELETE 竞态终态列值与关系表恒一致(真并发测试锚定)
- 互动门禁定型:只认帖子公开面(published 且未删)——作者本人草稿、
  hidden/archived、软删、不存在合并逐字节一致 404/40403
- GET /api/v1/me/bookmarks:按 (bookmarks.created_at DESC, post_id DESC)
  keyset 游标,卡片复用 FeedCard 装配;失效帖在页查询内静默剔除,
  游标键在关系行上、分页正确性不受剔除影响
- PUT/DELETE /api/v1/users/{userId}/follow + GET follow-stats:
  自关注 422/42204(ck_user_follows_self 库层兜底);目标不存在/注销
  合并 404/40406,存在性走同库只读 identity.users(ADR-017 例外,
  写门禁不适用 Feign 降级语义);计数实时 COUNT 双向索引
- ErrorCode 新增 40406 TARGET_USER_NOT_FOUND、42204 FOLLOW_RULE_VIOLATION
- 集成测试 14 例(LikeBookmark 9 + Follow 5):六类路径、并发幂等专项、
  计数对账专项、收藏列表静默剔除与分页

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 10:50:06 +08:00
lixi 99a3c1f8ab feat: 公共 Feed 游标分页 + 作者公开资料链路(T3-05 / D3-9 方案 B,ADR-002/017)
CI / backend-test (push) Successful in 8m0s
- GET /api/v1/feed:仅 published+public+未删,(published_at DESC, id DESC) keyset
  游标恰合 ix_posts_feed,{items,nextCursor,hasMore},禁 OFFSET
- FeedCard 定型:author/category/title/contentPreview(200 码点截断)/coverImage
  (唯一 is_cover 行)/mediaCount/三计数(posts 冗余列)/likedByMe/bookmarkedByMe/publishedAt
- AuthorSummary 定型并回填 Post 详情(T3-04 偏差① authorId 占位闭环):
  userId+nickname+avatarUrl;Feign 批量调 user /internal/users/profiles,
  60s 进程内 TTL 缓存,avatarAssetId 经 media.assets 只读解析后本地签名
- 降级语义:user 服务不可达/出错时 Feed/详情照常 200,作者摘要退为仅 userId,
  失败不入缓存;Feign 1s/2s 超时兜底
- compose 为 community 注入 PATBOND_USER_SERVICE_URL/PATBOND_INTERNAL_TOKEN
- 新增分页专项 8 例、卡片定型 5 例、作者链路 7 例、Feign 线路 3 例
  (community 32→55;全仓 251→282 全绿,check-secrets --all 通过)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 10:26:16 +08:00
lixi 40bac85543 feat: user 域 /internal 批量公开资料接口——昵称回退/头像指针/静默缺席(D3-9 方案 B,T3-05)
- GET /internal/users/profiles?ids=…:一次最多 50 个,超限/空/非法 UUID 均 400/40000
- 仅暴露 userId/nickname/avatarAssetId;nickname→username 回退在归属侧 SQL 完成
- 不存在与已注销用户静默缺席(墓碑形态 = 消费侧 authorId 保底,不泄露成因)
- InternalAuthFilter 既有 /internal/** 共享密钥保护直接覆盖,无新安全面
- 新增 InternalProfileEndpointTest 8 例(user 模块 88→96)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 10:26:01 +08:00
98 changed files with 19723 additions and 143 deletions
+9
View File
@@ -102,6 +102,11 @@ services:
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
# 媒体读取侧(M3.5 T3.5-05):宠物头像的预签名 GET 与 user 服务同一凭证/
# 同一客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
volumes:
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro
@@ -125,6 +130,10 @@ services:
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
# 作者公开资料(D3-9 方案 B):走 user 服务 /internal 批量接口,
# 服务间共享密钥与 auth/user 同一值。
PATBOND_USER_SERVICE_URL: http://user:8082
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
# 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一
# 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
@@ -20,6 +20,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
@@ -38,16 +39,24 @@ import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
/**
* T3-19D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.2.0(快照
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
* T3-19D3-8):auth 域 M1 操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照
* {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
*
* <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP
* 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内
* 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例),
* 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。
* 因此这操作是跨服务的真实纵切,不是 MockMvc 短路。
*
* <p><b>T3.5-07</b>v1.4.0 把 {@code PATCH /api/v1/me} 与 {@code GET} 的
* nickname/avatarUrl 纳入冻结面,故本类的操作面自 6 增至 7。注意
* {@code /api/v1/me} 的守卫与矩阵都在 **auth 模块**(实现在 patbond-user
* 但契约测试在这里跨服务发请求),扩契约时容易漏。本类不配置对象存储,
* 故 {@code avatarUrl} 恒为 null——正是「存储未配置时资料读取整体降级」的
* nullable 实证;真实签名 URL 的全链路在 patbond-user 的
* MeAvatarSigningIntegrationTest(真实 MinIO)覆盖。
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest(
@@ -63,13 +72,14 @@ class AuthContractConformanceTest {
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** auth 域 6 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
/** auth 域 7 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
private static final List<String> AUTH_OPERATIONS = List.of(
"POST /api/v1/auth/register",
"POST /api/v1/auth/login",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
"GET /api/v1/me",
"PATCH /api/v1/me",
"POST /api/v1/events");
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
@@ -305,6 +315,80 @@ class AuthContractConformanceTest {
null, 423, 42300);
}
// ---- PATCH /api/v1/mev1.4.0 新增操作,T3.5-07--------------------
/**
* `PATCH /api/v1/me` 的全响应矩阵(200/400/401/404/422)与三态语义的形态
* 实证,外加 GET 在 nickname 非空分支上再走一遍严格校验。
*
* <p>422/42203 需要一枚「本人所有、用途 user_avatar、状态 uploading」的
* asset:本上下文未配置对象存储(创建上传会 500),故按 MeProfileIntegrationTest
* 的先例直接写 media.assets 行——测试数据造法,不触碰实现。
*/
@Test
@Order(7)
void meProfileWriteShapes() {
String registered = register("contract_auth_frank", "+8613800000608");
String accessToken = JsonPath.read(registered, "$.data.accessToken");
UUID userId = UUID.fromString(JsonPath.read(registered, "$.data.userId"));
// 200:三态「给值」——设昵称;avatarUrl 为 null(存储未配置时的降级实证)
String patched = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":\"契约昵称\"}", accessToken, 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("契约昵称");
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
// GET 回读:nickname 非空分支同样过严格校验(PATCH 与 GET 同一 Me 形态)
String reread = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, accessToken, 200);
assertThat((String) JsonPath.read(reread, "$.data.nickname")).isEqualTo("契约昵称");
// 200:三态「显式 null = 清空」
String cleared = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":null}", accessToken, 200);
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
// 400/40000:空 patch(不静默 200
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{}", accessToken, 400, 40000);
// 401/40101:无 token
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":\"无票\"}", null, 401, 40101);
// 404:同一单元格的两种业务码——用户已注销 40400 / asset 不可引用 40405
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID().toString(), Duration.ofMinutes(15));
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":\"幽灵\"}", ghostToken, 404, 40400);
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()),
accessToken, 404, 40405);
// 422/42203:本人的 user_avatar asset 仍在 uploading
UUID uploading = insertUserAvatarAsset(userId, "uploading");
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"avatarAssetId\":\"%s\"}".formatted(uploading), accessToken, 422, 42203);
}
/** 一枚本人所有的 user_avatar asset 行(模拟 T3-03 两步上传的中间产物)。 */
private UUID insertUserAvatarAsset(UUID ownerUserId, String status) {
UUID id = UUID.randomUUID();
userApp.getBean(JdbcClient.class).sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status)
VALUES (:id, :owner, 'image', 'user_avatar', 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("objectKey", "user_avatar/2026/09/" + id)
.param("status", status)
.update();
return id;
}
// ---- 快照与覆盖门禁 -------------------------------------------------
/**
@@ -314,17 +398,17 @@ class AuthContractConformanceTest {
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
assertThat(CONTRACT.paths()).hasSize(18);
assertThat(CONTRACT.operations()).hasSize(24);
assertThat(CONTRACT.schemas()).hasSize(45);
assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
}
/**
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。
* 全矩阵覆盖门禁:auth 域 7 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(24 个单元格,无豁免)。
*/
@Test
@Order(99)
@@ -10,6 +10,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -81,7 +82,7 @@ final class ContractValidator {
}
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = contract.resolve(rawSchema);
Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失");
return;
@@ -130,6 +131,32 @@ final class ContractValidator {
}
}
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys — e.g.
* the outer {@code nullable} — win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
@@ -13,27 +13,30 @@ import java.util.Objects;
import java.util.Set;
/**
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.2.0.yaml}.
* The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.4.0.yaml}.
*
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
* snapshot is a byte-identical copy taken at freeze time, and this class is
* the module-local copy of the pet module's contract framework (same
* per-module duplication discipline as BearerAuthFilter). Whenever the
* canonical contract changes, copy it here AND in patbond-pet under the new
* version's file name and update both conformance tests (expected version +
* snapshot counts). The guard test on {@code info.version} makes a forgotten
* canonical contract changes, copy it into every framework-carrying module
* (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
* new version's file name and update each conformance test (expected version
* + snapshot counts). The guard test on {@code info.version} makes a forgotten
* sync fail loudly in CI instead of silently testing against a stale
* contract.
*
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf.
* {@code required}, {@code properties}, {@code items}, and the v1.3.0
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
* {@link ContractValidator}) — no oneOf/anyOf.
*/
final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml";
static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -26,9 +26,12 @@ public enum ErrorCode {
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"),
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
COMMENT_NOT_FOUND(40404, 404, "评论不存在"),
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"),
FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"),
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
+12
View File
@@ -39,6 +39,18 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Author public profiles come from patbond-user's /internal batch
API (D3-9 方案 B), static direct URL per ADR-002. feign-hc5 for
the same reason as patbond-auth: the JDK default client loses
error bodies on some replies. -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hc5</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
@@ -1,17 +1,21 @@
package com.patbond.patbond.community;
import com.patbond.patbond.community.config.CommunityFeignConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* Community feed, posts and interactions service (M3, ADR-017: the community
* domain lives in its own Maven module on :8084). First-wave skeleton:
* configuration wiring, datasource, RS256 bearer auth on /api/v1/** and a
* liveness endpoint — business endpoints follow the contract work in the
* next waves. The module only reads and writes the community schema
* (author profile lookups follow the D3-9 plan later).
* domain lives in its own Maven module on :8084). Configuration wiring,
* datasource, RS256 bearer auth on /api/v1/**, the post lifecycle (T3-04)
* and the public feed (T3-05). The module only reads and writes the
* community schema (plus the ADR-017 read-only media.assets exception);
* author public profiles come from patbond-user's /internal batch API over
* Feign (D3-9 方案 B).
*/
@SpringBootApplication
@EnableFeignClients(defaultConfiguration = CommunityFeignConfig.class)
public class CommunityApplication {
public static void main(String[] args) {
@@ -0,0 +1,34 @@
package com.patbond.patbond.community.access;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* Existence probe into identity.users for write gates that reference a
* user (follow target, comment @-reply target). Same-database read-only
* access under the ADR-017 exception — the user_follows/comments foreign
* keys already bind these schemas together, and a WRITE gate cannot ride
* the Feign profile path, whose degradation deliberately cannot tell
* "absent" from "unreachable". A soft-deleted (注销) user counts as absent.
*/
@Component
public class UserExistenceGateway {
private final JdbcClient jdbcClient;
public UserExistenceGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public boolean existsActive(UUID userId) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM identity.users
WHERE id = :id AND deleted_at IS NULL)
""")
.param("id", userId)
.query(Boolean.class)
.single();
}
}
@@ -0,0 +1,24 @@
package com.patbond.patbond.community.author;
import com.patbond.patbond.common.response.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* Batch public-profile API of patbond-user, the identity schema owner
* (D3-9 方案 B; static direct URL per ADR-002). The X-Internal-Token header
* is attached by the interceptor in CommunityFeignConfig. Unknown or
* deleted ids are silently absent from the reply. {@code primary = false}
* only matters to tests (lets a stub take precedence); in production this
* is the sole candidate.
*/
@FeignClient(name = "patbond-user-profiles", url = "${patbond.user-service.url}", primary = false)
public interface AuthorProfileClient {
/** @param ids comma-separated user ids, at most 50 per call */
@GetMapping("/internal/users/profiles")
ApiResponse<List<AuthorProfileDto>> profiles(@RequestParam("ids") String ids);
}
@@ -0,0 +1,14 @@
package com.patbond.patbond.community.author;
import java.util.UUID;
/**
* Wire shape of one profile in patbond-user's /internal/users/profiles
* reply (D3-9 方案 B): display name (nickname→username fallback already
* applied by the owning service) plus the avatar asset pointer. The avatar
* arrives as an id, not a URL — this service resolves it against
* media.assets (ADR-017 read-only exception) and signs a fresh presigned
* GET per response, so nothing cached here ever holds an expiring URL.
*/
public record AuthorProfileDto(UUID userId, String nickname, UUID avatarAssetId) {
}
@@ -0,0 +1,145 @@
package com.patbond.patbond.community.author;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.media.MediaAssetGateway;
import com.patbond.patbond.community.media.MediaAssetRef;
import com.patbond.patbond.community.media.MediaUrlSigner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Author public-profile lookup (D3-9 方案 B): a batch Feign call to
* patbond-user's /internal/users/profiles behind a short-TTL in-process
* cache, plus local avatar resolution.
*
* <ul>
* <li><b>Batch, never loop</b> — one call per ≤50 distinct cache-missed
* authors (a feed page has ≤20 cards, so normally exactly one call,
* and none on a warm cache).</li>
* <li><b>Avatar</b> — travels as an asset id; resolved to bucket/key via
* the ADR-017 read-only media.assets exception (ready assets only)
* and signed fresh per response, so the cache stores no expiring
* URL.</li>
* <li><b>Degradation</b> — ANY lookup failure (user service down, slow,
* or answering an error) logs one warning and leaves the ids
* unresolved; callers render the id-only summary. Failures are never
* cached, so the next request retries; the feed never 5xxes over a
* profile lookup.</li>
* </ul>
*/
@Component
public class AuthorProfileGateway {
private static final int MAX_BATCH = 50;
/** Expired entries are pruned opportunistically past this size. */
private static final int PRUNE_THRESHOLD = 10_000;
private static final Logger log = LoggerFactory.getLogger(AuthorProfileGateway.class);
private final AuthorProfileClient client;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
private final AuthorProfileProperties properties;
private final ConcurrentHashMap<UUID, CacheEntry> cache = new ConcurrentHashMap<>();
public AuthorProfileGateway(AuthorProfileClient client, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner, AuthorProfileProperties properties) {
this.client = client;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
this.properties = properties;
}
/**
* Summaries for the given authors, avatar URLs signed fresh. Ids that
* could not be resolved (lookup degraded, or the user no longer exists)
* are absent — callers fall back to
* {@link AuthorSummaryResponse#idOnly}.
*/
public Map<UUID, AuthorSummaryResponse> summarize(Collection<UUID> userIds) {
if (userIds.isEmpty()) {
return Map.of();
}
long now = System.nanoTime();
Map<UUID, AuthorRef> resolved = new HashMap<>();
List<UUID> misses = new ArrayList<>();
for (UUID id : new LinkedHashSet<>(userIds)) {
CacheEntry entry = cache.get(id);
if (entry != null && entry.expiresAtNanos() - now > 0) {
resolved.put(id, entry.ref());
} else {
misses.add(id);
}
}
if (!misses.isEmpty()) {
fetchInto(resolved, misses, now);
}
Map<UUID, AuthorSummaryResponse> summaries = new HashMap<>();
resolved.forEach((id, ref) -> summaries.put(id, new AuthorSummaryResponse(
id, ref.nickname(), mediaUrlSigner.signGet(ref.avatarBucket(), ref.avatarObjectKey()))));
return summaries;
}
private void fetchInto(Map<UUID, AuthorRef> resolved, List<UUID> misses, long now) {
List<AuthorProfileDto> profiles = new ArrayList<>();
try {
for (int i = 0; i < misses.size(); i += MAX_BATCH) {
List<UUID> chunk = misses.subList(i, Math.min(i + MAX_BATCH, misses.size()));
ApiResponse<List<AuthorProfileDto>> reply = client.profiles(
chunk.stream().map(UUID::toString).collect(Collectors.joining(",")));
if (reply != null && reply.getData() != null) {
profiles.addAll(reply.getData());
}
}
} catch (RuntimeException e) {
// Chunks fetched before the failure still count below.
log.warn("作者公开资料获取失败,本次响应对未解析作者降级为 authorId 保底: {}",
e.toString());
}
if (profiles.isEmpty()) {
return;
}
Set<UUID> assetIds = profiles.stream()
.map(AuthorProfileDto::avatarAssetId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<UUID, MediaAssetRef> assets = assetIds.isEmpty()
? Map.of()
: mediaAssetGateway.findByIds(assetIds);
long expiresAt = now + properties.getCacheTtl().toNanos();
for (AuthorProfileDto profile : profiles) {
MediaAssetRef asset = profile.avatarAssetId() == null
? null
: assets.get(profile.avatarAssetId());
boolean ready = asset != null && "ready".equals(asset.status());
AuthorRef ref = new AuthorRef(profile.nickname(),
ready ? asset.bucket() : null,
ready ? asset.objectKey() : null);
cache.put(profile.userId(), new CacheEntry(ref, expiresAt));
resolved.put(profile.userId(), ref);
}
if (cache.size() > PRUNE_THRESHOLD) {
cache.values().removeIf(entry -> entry.expiresAtNanos() - now <= 0);
}
}
private record AuthorRef(String nickname, String avatarBucket, String avatarObjectKey) {
}
private record CacheEntry(AuthorRef ref, long expiresAtNanos) {
}
}
@@ -0,0 +1,26 @@
package com.patbond.patbond.community.author;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Knobs of the author-profile lookup (D3-9 方案 B): a short in-process TTL
* cache in front of patbond-user's /internal batch API. 60 s is the frozen
* default — long enough to absorb feed scrolling and refresh bursts,
* short enough that a nickname/avatar change propagates within a minute.
*/
@ConfigurationProperties(prefix = "patbond.author-profile")
public class AuthorProfileProperties {
/** How long one resolved profile stays in the in-process cache. */
private Duration cacheTtl = Duration.ofSeconds(60);
public Duration getCacheTtl() {
return cacheTtl;
}
public void setCacheTtl(Duration value) {
this.cacheTtl = value;
}
}
@@ -0,0 +1,35 @@
package com.patbond.patbond.community.config;
import feign.Request;
import feign.RequestInterceptor;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit;
/**
* Feign child-context beans, registered via
* {@code @EnableFeignClients(defaultConfiguration = …)} — deliberately not
* a @Configuration, same reasoning as patbond-auth's FeignInternalConfig
* (a component-scanned bean would land in the parent context and be
* shadowed by the child's defaults).
*
* <p>No ErrorDecoder on purpose: the only Feign consumer here is the author
* profile lookup, whose gateway degrades on ANY failure instead of
* propagating it — a downstream business error is as much "no profile" as a
* connection refusal. Timeouts are tight because this call sits on the feed
* read path: a hung patbond-user must cost one bounded stall, not an
* unbounded one (connection refused already fails fast on its own).</p>
*/
public class CommunityFeignConfig {
/** Presents the shared service secret on every call to patbond-user. */
@Bean
public RequestInterceptor internalTokenInterceptor(CommunitySecurityProperties properties) {
return template -> template.header("X-Internal-Token", properties.getInternalToken());
}
@Bean
public Request.Options feignOptions() {
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, true);
}
}
@@ -3,16 +3,32 @@ package com.patbond.patbond.community.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Security knobs of the community service: only the RS256 public key for
* Security knobs of the community service: the RS256 public key for
* verifying access tokens issued by patbond-auth (same contract as
* patbond-user/pet's {@code patbond.jwt.public-key}). No /internal routes
* exist here yet, so no service token property.
* patbond-user/pet's {@code patbond.jwt.public-key}), and the shared
* service secret presented on outbound /internal/** calls to patbond-user
* (D3-9 方案 B author-profile lookups — this service still exposes no
* /internal routes of its own).
*/
@ConfigurationProperties(prefix = "patbond")
public class CommunitySecurityProperties {
/**
* Shared secret sent as X-Internal-Token on calls to patbond-user's
* /internal/** API; must equal the value patbond-user expects.
*/
private String internalToken;
private final Jwt jwt = new Jwt();
public String getInternalToken() {
return internalToken;
}
public void setInternalToken(String value) {
this.internalToken = value;
}
public Jwt getJwt() {
return jwt;
}
@@ -1,6 +1,7 @@
package com.patbond.patbond.community.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.community.author.AuthorProfileProperties;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.security.JwtVerifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -15,7 +16,7 @@ import org.springframework.context.annotation.Configuration;
* unauthenticated.
*/
@Configuration
@EnableConfigurationProperties(CommunitySecurityProperties.class)
@EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class})
public class SecurityConfig {
@Bean
@@ -0,0 +1,71 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CommentResponse;
import com.patbond.patbond.community.dto.CreateCommentRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.CommentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Flat comment endpoints (T3-07). Delete rides the top-level short path
* (commentId is globally unique — the pets-domain precedent); create
* carries a MANDATORY Idempotency-Key (ADR-019). All permission and error
* semantics live in CommentService.
*/
@RestController
@Validated
public class CommentController {
private final CommentService commentService;
public CommentController(CommentService commentService) {
this.commentService = commentService;
}
@GetMapping("/api/v1/posts/{postId}/comments")
public ApiResponse<CursorPage<CommentResponse>> list(
@PathVariable UUID postId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(commentService.list(postId, limit, cursor));
}
@PostMapping("/api/v1/posts/{postId}/comments")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<CommentResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateCommentRequest request) {
return ApiResponse.success(commentService.create(userId, postId, idempotencyKey, request));
}
@DeleteMapping("/api/v1/comments/{commentId}")
public ApiResponse<Void> delete(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID commentId) {
commentService.delete(userId, commentId);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.FeedService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Public feed endpoint (T3-05). Authenticated like every /api/v1 route —
* the viewer identity feeds likedByMe/bookmarkedByMe; the feed content
* itself is the same for everyone (published + public only).
*/
@RestController
@Validated
public class FeedController {
private final FeedService feedService;
public FeedController(FeedService feedService) {
this.feedService = feedService;
}
@GetMapping("/api/v1/feed")
public ApiResponse<CursorPage<FeedCardResponse>> feed(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(feedService.list(userId, limit, cursor));
}
}
@@ -0,0 +1,51 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.FollowStateResponse;
import com.patbond.patbond.community.dto.FollowStatsResponse;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.FollowService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* The ADR-018 minimal follow surface (T3-07): idempotent follow/unfollow
* plus the numbers endpoint. Follower/following LISTS are deliberately not
* in M3.
*/
@RestController
public class FollowController {
private final FollowService followService;
public FollowController(FollowService followService) {
this.followService = followService;
}
@PutMapping("/api/v1/users/{userId}/follow")
public ApiResponse<FollowStateResponse> follow(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.follow(callerId, userId));
}
@DeleteMapping("/api/v1/users/{userId}/follow")
public ApiResponse<FollowStateResponse> unfollow(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.unfollow(callerId, userId));
}
@GetMapping("/api/v1/users/{userId}/follow-stats")
public ApiResponse<FollowStatsResponse> stats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.stats(callerId, userId));
}
}
@@ -0,0 +1,79 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.BookmarkStateResponse;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.dto.LikeStateResponse;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.FeedService;
import com.patbond.patbond.community.service.InteractionService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Binary post interactions (T3-06): PUT/DELETE idempotent like and
* bookmark, each answering the authoritative terminal state, plus the
* my-bookmarks list whose items reuse the feed card shape.
*/
@RestController
@Validated
public class InteractionController {
private final InteractionService interactionService;
private final FeedService feedService;
public InteractionController(InteractionService interactionService, FeedService feedService) {
this.interactionService = interactionService;
this.feedService = feedService;
}
@PutMapping("/api/v1/posts/{postId}/like")
public ApiResponse<LikeStateResponse> like(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.like(userId, postId));
}
@DeleteMapping("/api/v1/posts/{postId}/like")
public ApiResponse<LikeStateResponse> unlike(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.unlike(userId, postId));
}
@PutMapping("/api/v1/posts/{postId}/bookmark")
public ApiResponse<BookmarkStateResponse> bookmark(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.bookmark(userId, postId));
}
@DeleteMapping("/api/v1/posts/{postId}/bookmark")
public ApiResponse<BookmarkStateResponse> unbookmark(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.unbookmark(userId, postId));
}
@GetMapping("/api/v1/me/bookmarks")
public ApiResponse<CursorPage<FeedCardResponse>> myBookmarks(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(feedService.listBookmarked(userId, limit, cursor));
}
}
@@ -1,6 +1,7 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostResponse;
@@ -86,4 +87,17 @@ public class PostController {
@RequestParam(required = false) String cursor) {
return ApiResponse.success(postService.listMine(userId, status, limit, cursor));
}
/**
* The caller's own community numbers (T3.5-06, ADR-022 决策 A: a dedicated
* endpoint rather than an addition to /users/{userId}/follow-stats, whose
* subject is "some user's follow counts" — mixing "my likes received" in
* would give one payload two subjects). Lives here because posts are the
* sole source of both numbers, next to the other /me posts read.
*/
@GetMapping("/api/v1/me/community-stats")
public ApiResponse<CommunityStatsResponse> communityStats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(postService.communityStats(userId));
}
}
@@ -0,0 +1,21 @@
package com.patbond.patbond.community.dto;
import java.util.UUID;
/**
* Author public summary embedded in post/feed/comment responses (D3-9).
* {@code nickname} carries the nickname→username fallback applied by
* patbond-user, so clients never assemble a display name themselves;
* {@code avatarUrl} is a fresh presigned GET (null when the author has no
* ready avatar, or when object storage is unconfigured — clients show a
* placeholder). The degraded shape — profile service unreachable, or the
* author since deleted — keeps only {@code userId} and nulls the rest
* (authorId 保底:the feed never 5xxes over a profile lookup).
*/
public record AuthorSummaryResponse(UUID userId, String nickname, String avatarUrl) {
/** The degraded / tombstone shape: id only, client renders placeholders. */
public static AuthorSummaryResponse idOnly(UUID userId) {
return new AuthorSummaryResponse(userId, null, null);
}
}
@@ -0,0 +1,5 @@
package com.patbond.patbond.community.dto;
/** Authoritative post-write bookmark state — isomorphic to {@link LikeStateResponse}. */
public record BookmarkStateResponse(boolean bookmarked, long bookmarkCount) {
}
@@ -0,0 +1,19 @@
package com.patbond.patbond.community.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One flat comment (T3-07 定型): the author and the optional @-reply target
* both travel as the D3-9 AuthorSummary shape, resolved through the same
* batch profile gateway as posts, so a degraded profile service renders
* id-only summaries here too and never fails the request.
*/
public record CommentResponse(
UUID id,
UUID postId,
AuthorSummaryResponse author,
AuthorSummaryResponse replyToUser,
String content,
OffsetDateTime createdAt) {
}
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.dto;
/**
* GET /api/v1/me/community-stats (T3.5-06, ADR-022 决策 A) — the caller's own
* community numbers, read-side aggregates over community.posts with no new
* denormalized column.
*
* <p>Scope, frozen with this ticket: both numbers count ONLY the caller's own
* posts that are {@code status='published'} and not soft-deleted. Drafts are
* excluded (they are not works yet, and their likes cannot exist anyway),
* soft-deleted posts are excluded (deleting a post removes its numbers), and
* the operational states hidden/archived are excluded for the same reason
* they are invisible everywhere else in the M3 contract. Empty data yields
* {@code 0}, never null.</p>
*
* <p>{@code receivedLikeCount} is {@code SUM(posts.like_count)} — the counter
* the write side maintains in the same transaction as the like row (T3-07),
* so this is exact, not an estimate. A user's own likes on their own posts are
* counted, exactly as the per-post number shows them.</p>
*/
public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) {
}
@@ -0,0 +1,38 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* POST /api/v1/posts/{postId}/comments. Content width mirrors
* ck_comments_content (1~2000 after trim); {@code replyToUserId} is the
* optional flat @-reply target (single level, no parentCommentId — ADR-018
* rules out nested threads).
*/
public class CreateCommentRequest {
@NotBlank(message = "content 不能为空")
@Size(max = 2000, message = "content 最长 2000 字符")
private String content;
/** Optional @-reply target; must be an existing active user (40406). */
private UUID replyToUserId;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public UUID getReplyToUserId() {
return replyToUserId;
}
public void setReplyToUserId(UUID replyToUserId) {
this.replyToUserId = replyToUserId;
}
}
@@ -0,0 +1,29 @@
package com.patbond.patbond.community.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One public-feed card (T3-05 定型, the FeedCard freeze input): the Post
* shape trimmed for list rendering — content cut to a 200-code-point
* preview, the media set reduced to the cover item plus a count, counts
* read from the posts table's denormalized columns. {@code coverImage} is
* null exactly for text-only posts (T3-04 guarantees a unique is_cover row
* whenever media exist); {@code publishedAt} is never null here (the feed
* predicate admits published posts only).
*/
public record FeedCardResponse(
UUID id,
AuthorSummaryResponse author,
String category,
String title,
String contentPreview,
PostMediaItemResponse coverImage,
int mediaCount,
long likeCount,
long commentCount,
long bookmarkCount,
boolean likedByMe,
boolean bookmarkedByMe,
OffsetDateTime publishedAt) {
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.community.dto;
/**
* Authoritative post-write follow state; {@code followerCount} is the
* TARGET user's follower count (real-time COUNT — user_follows has no
* denormalized counter column, and the double index keeps both directions
* cheap).
*/
public record FollowStateResponse(boolean following, long followerCount) {
}
@@ -0,0 +1,9 @@
package com.patbond.patbond.community.dto;
/**
* GET /api/v1/users/{userId}/follow-stats — the ADR-018 minimal "numbers"
* endpoint. {@code followedByMe} is the caller's view; asking about oneself
* yields false (a self-follow row cannot exist, ck_user_follows_self).
*/
public record FollowStatsResponse(long followerCount, long followingCount, boolean followedByMe) {
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.community.dto;
/**
* Authoritative post-write like state (草案定型): a PUT answers
* {@code liked=true} and a DELETE {@code liked=false} regardless of whether
* the call changed anything; {@code likeCount} is the count as of this
* write's transaction, the value optimistic clients reconcile against.
*/
public record LikeStateResponse(boolean liked, long likeCount) {
}
@@ -5,16 +5,16 @@ import java.util.List;
import java.util.UUID;
/**
* Full post shape (detail / my-posts list / write responses). Deviation from
* the contract draft, recorded for the T3-10 freeze: the draft's
* {@code author: AuthorSummary} is placeheld by {@code authorId} until T3-05
* lands the public-profile aggregation (工单口径:author 字段可先占位
* authorId). Trimmed fields (region/generationJob/topics …) do not appear at
* all (ADR-018 + ADR-010 precedent).
* Full post shape (detail / my-posts list / write responses). The T3-04
* {@code authorId} placeholder is gone: {@code author} is the D3-9
* AuthorSummary, degraded to its id-only shape when the profile lookup is
* unavailable (contract deviation #1 closed by T3-05). Trimmed fields
* (region/generationJob/topics …) do not appear at all (ADR-018 + ADR-010
* precedent).
*/
public record PostResponse(
UUID id,
UUID authorId,
AuthorSummaryResponse author,
UUID petId,
String category,
String title,
@@ -0,0 +1,145 @@
package com.patbond.patbond.community.repository;
import com.patbond.patbond.community.support.CommentCursor;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* community.comments access. Post visibility and authorship decisions live
* in CommentService; every query here filters on the comment's own state
* only (status='visible' is the single liveness predicate — 'hidden' has no
* producing endpoint in M3 and 'deleted' pairs with deleted_at,
* ck_comments_deleted).
*/
@Repository
public class CommentRepository {
private static final String SELECT_COMMENT = """
SELECT c.id, c.post_id, c.author_user_id, c.reply_to_user_id, c.content,
c.status, c.request_hash, c.created_at, c.deleted_at
FROM community.comments c
""";
private final JdbcClient jdbcClient;
public CommentRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one comment; the conflict target is the (author_user_id,
* client_request_id) unique constraint, so a keyed replay is a no-op and
* the caller settles retry-vs-mismatch on the stored request_hash
* (ADR-019, same shape as posts).
*
* @return rows inserted — 0 means this author already used the key
*/
public int insertComment(UUID id, UUID postId, UUID authorUserId, UUID replyToUserId,
String content, String clientRequestId, byte[] requestHash) {
return jdbcClient.sql("""
INSERT INTO community.comments
(id, post_id, author_user_id, reply_to_user_id, content,
client_request_id, request_hash)
VALUES (:id, :postId, :authorUserId, :replyToUserId, :content,
:clientRequestId, :requestHash)
ON CONFLICT (author_user_id, client_request_id) DO NOTHING
""")
.param("id", id)
.param("postId", postId)
.param("authorUserId", authorUserId)
.param("replyToUserId", replyToUserId)
.param("content", content)
.param("clientRequestId", clientRequestId)
.param("requestHash", requestHash)
.update();
}
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
public Optional<CommentRow> findByAuthorAndClientRequestId(UUID authorUserId,
String clientRequestId) {
return jdbcClient.sql(SELECT_COMMENT
+ " WHERE c.author_user_id = :authorUserId"
+ " AND c.client_request_id = :clientRequestId")
.param("authorUserId", authorUserId)
.param("clientRequestId", clientRequestId)
.query(CommentRepository::mapComment)
.optional();
}
/**
* Locks the visible row for the delete transition: concurrent deletes
* of the same comment serialize here, so the status flip — and with it
* the comment_count decrement — happens exactly once.
*/
public Optional<CommentRow> lockVisibleById(UUID id) {
return jdbcClient.sql(SELECT_COMMENT + " WHERE c.id = :id AND c.status = 'visible' FOR UPDATE")
.param("id", id)
.query(CommentRepository::mapComment)
.optional();
}
/** The soft-delete transition; deleted_at pairs with status (ck_comments_deleted). */
public int softDelete(UUID id) {
return jdbcClient.sql("""
UPDATE community.comments
SET status = 'deleted', deleted_at = now()
WHERE id = :id AND status = 'visible'
""")
.param("id", id)
.update();
}
/**
* One page of a post's visible comments in (created_at DESC, id DESC) —
* the exact key of ix_comments_post_created. The caller asks for
* limit+1 rows to learn whether more exist.
*/
public List<CommentRow> pageByPost(UUID postId, CommentCursor after, int limitPlusOne) {
String sql = SELECT_COMMENT + " WHERE c.post_id = :postId AND c.status = 'visible'";
if (after != null) {
sql += " AND (c.created_at, c.id) < (:cursorCreatedAt, :cursorId)";
}
sql += " ORDER BY c.created_at DESC, c.id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("postId", postId)
.param("limit", limitPlusOne);
if (after != null) {
spec = spec.param("cursorCreatedAt", after.createdAt())
.param("cursorId", after.id());
}
return spec.query(CommentRepository::mapComment).list();
}
private static CommentRow mapComment(ResultSet rs, int rowNum) throws SQLException {
return new CommentRow(
rs.getObject("id", UUID.class),
rs.getObject("post_id", UUID.class),
rs.getObject("author_user_id", UUID.class),
rs.getObject("reply_to_user_id", UUID.class),
rs.getString("content"),
rs.getString("status"),
rs.getBytes("request_hash"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("deleted_at", OffsetDateTime.class));
}
/** One comments row; requestHash carries the ADR-019 replay comparison. */
public record CommentRow(
UUID id,
UUID postId,
UUID authorUserId,
UUID replyToUserId,
String content,
String status,
byte[] requestHash,
OffsetDateTime createdAt,
OffsetDateTime deletedAt) {
}
}
@@ -0,0 +1,202 @@
package com.patbond.patbond.community.repository;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.UUID;
/**
* community.post_likes / post_bookmarks / user_follows access, plus the
* denormalized counter writes on community.posts. The invariant every
* caller must hold (工单验收硬项): a counter column moves IN THE SAME
* TRANSACTION as its relation row, and only by the number of rows the
* relation write actually changed — {@code ON CONFLICT DO NOTHING} inserts
* and conditional deletes report that number, so concurrent duplicates
* converge on the composite primary key and never double-count.
*/
@Repository
public class InteractionRepository {
private final JdbcClient jdbcClient;
public InteractionRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* The interaction gate: likes, bookmarks and comments attach to the
* PUBLIC face of a post only — published and live. Drafts (the
* author's own included), hidden/archived and soft-deleted posts all
* fail this probe and answer the byte-identical 404/40403.
*/
public boolean isInteractable(UUID postId) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM community.posts
WHERE id = :id AND status = 'published'
AND deleted_at IS NULL)
""")
.param("id", postId)
.query(Boolean.class)
.single();
}
/** @return rows inserted — 0 when the like already existed */
public int insertLike(UUID postId, UUID userId) {
return jdbcClient.sql("""
INSERT INTO community.post_likes (post_id, user_id)
VALUES (:postId, :userId)
ON CONFLICT (post_id, user_id) DO NOTHING
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/** @return rows deleted — 0 when there was nothing to cancel */
public int deleteLike(UUID postId, UUID userId) {
return jdbcClient.sql("""
DELETE FROM community.post_likes
WHERE post_id = :postId AND user_id = :userId
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/** @return rows inserted — 0 when the bookmark already existed */
public int insertBookmark(UUID postId, UUID userId) {
return jdbcClient.sql("""
INSERT INTO community.post_bookmarks (post_id, user_id)
VALUES (:postId, :userId)
ON CONFLICT (post_id, user_id) DO NOTHING
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/** @return rows deleted — 0 when there was nothing to cancel */
public int deleteBookmark(UUID postId, UUID userId) {
return jdbcClient.sql("""
DELETE FROM community.post_bookmarks
WHERE post_id = :postId AND user_id = :userId
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/**
* Moves like_count by delta and returns the resulting value — the
* authoritative count the write response carries. Callers pass the row
* count their relation write reported; a zero delta must instead read
* via {@link #likeCount} so a no-op replay takes no row lock and does
* not touch updated_at.
*/
public long bumpLikeCount(UUID postId, int delta) {
return jdbcClient.sql("""
UPDATE community.posts SET like_count = like_count + :delta
WHERE id = :id
RETURNING like_count
""")
.param("id", postId)
.param("delta", delta)
.query(Long.class)
.single();
}
public long bumpBookmarkCount(UUID postId, int delta) {
return jdbcClient.sql("""
UPDATE community.posts SET bookmark_count = bookmark_count + :delta
WHERE id = :id
RETURNING bookmark_count
""")
.param("id", postId)
.param("delta", delta)
.query(Long.class)
.single();
}
public long bumpCommentCount(UUID postId, int delta) {
return jdbcClient.sql("""
UPDATE community.posts SET comment_count = comment_count + :delta
WHERE id = :id
RETURNING comment_count
""")
.param("id", postId)
.param("delta", delta)
.query(Long.class)
.single();
}
public long likeCount(UUID postId) {
return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id")
.param("id", postId)
.query(Long.class)
.single();
}
public long bookmarkCount(UUID postId) {
return jdbcClient.sql("SELECT bookmark_count FROM community.posts WHERE id = :id")
.param("id", postId)
.query(Long.class)
.single();
}
/** @return rows inserted — 0 when the follow already existed */
public int insertFollow(UUID followerUserId, UUID followeeUserId) {
return jdbcClient.sql("""
INSERT INTO community.user_follows (follower_user_id, followee_user_id)
VALUES (:follower, :followee)
ON CONFLICT (follower_user_id, followee_user_id) DO NOTHING
""")
.param("follower", followerUserId)
.param("followee", followeeUserId)
.update();
}
/** @return rows deleted — 0 when there was nothing to cancel */
public int deleteFollow(UUID followerUserId, UUID followeeUserId) {
return jdbcClient.sql("""
DELETE FROM community.user_follows
WHERE follower_user_id = :follower AND followee_user_id = :followee
""")
.param("follower", followerUserId)
.param("followee", followeeUserId)
.update();
}
/** Real-time follower count of a user — ix_user_follows_followee. */
public long countFollowers(UUID userId) {
return jdbcClient.sql("""
SELECT count(*) FROM community.user_follows
WHERE followee_user_id = :userId
""")
.param("userId", userId)
.query(Long.class)
.single();
}
/** Real-time following count of a user — the primary key prefix. */
public long countFollowing(UUID userId) {
return jdbcClient.sql("""
SELECT count(*) FROM community.user_follows
WHERE follower_user_id = :userId
""")
.param("userId", userId)
.query(Long.class)
.single();
}
public boolean followExists(UUID followerUserId, UUID followeeUserId) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM community.user_follows
WHERE follower_user_id = :follower
AND followee_user_id = :followee)
""")
.param("follower", followerUserId)
.param("followee", followeeUserId)
.query(Boolean.class)
.single();
}
}
@@ -1,5 +1,8 @@
package com.patbond.patbond.community.repository;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.support.BookmarkCursor;
import com.patbond.patbond.community.support.FeedCursor;
import com.patbond.patbond.community.support.PostCursor;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@@ -167,6 +170,31 @@ public class PostRepository {
.update();
}
/**
* The author's own community aggregates in one indexed pass over
* ix_posts_author_created's leading column (T3.5-06, ADR-022: read-side
* aggregation, no denormalized column). {@code COALESCE} turns the empty
* SUM into 0 so the endpoint never answers null; the
* {@code deleted_at IS NULL} predicate is belt-and-braces — softDelete
* parks published rows as 'archived', so status='published' already
* implies live (ck_posts_publish_state).
*/
public CommunityStatsResponse aggregateByAuthor(UUID authorUserId) {
return jdbcClient.sql("""
SELECT COALESCE(SUM(like_count), 0) AS received_like_count,
COUNT(*) AS published_post_count
FROM community.posts
WHERE author_user_id = :authorUserId
AND status = 'published'
AND deleted_at IS NULL
""")
.param("authorUserId", authorUserId)
.query((rs, rowNum) -> new CommunityStatsResponse(
rs.getLong("received_like_count"),
rs.getLong("published_post_count")))
.single();
}
/**
* One page of the author's own posts in (created_at DESC, id DESC) — the
* exact key of ix_posts_author_created. Soft-deleted rows never appear;
@@ -200,6 +228,74 @@ public class PostRepository {
return spec.query(PostRepository::mapPost).list();
}
/**
* One public-feed page in (published_at DESC, id DESC) — the exact key
* and predicate of the ix_posts_feed partial index. The explicit
* {@code deleted_at IS NULL} is belt-and-braces: softDelete parks
* published rows as 'archived', so status='published' already implies
* live (ck_posts_publish_state), and the planner still matches the
* partial index. The caller asks for limit+1 rows to learn whether more
* exist.
*/
public List<PostRow> pageFeed(UUID viewerId, FeedCursor after, int limitPlusOne) {
String sql = SELECT_POST + """
WHERE p.status = 'published' AND p.visibility = 'public'
AND p.deleted_at IS NULL
""";
if (after != null) {
sql += " AND (p.published_at, p.id) < (:cursorPublishedAt, :cursorId)";
}
sql += " ORDER BY p.published_at DESC, p.id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("viewerId", viewerId)
.param("limit", limitPlusOne);
if (after != null) {
spec = spec.param("cursorPublishedAt", after.publishedAt())
.param("cursorId", after.id());
}
return spec.query(PostRepository::mapPost).list();
}
/**
* One my-bookmarks page in (bookmarks.created_at DESC, post_id DESC) —
* the exact key of ix_post_bookmarks_user_created. Bookmarked posts
* that turned invisible (deleted, hidden/archived, non-public) are
* filtered INSIDE the keyset query(草案「静默剔除」定型): the cursor
* keys on the relation row, so dropped posts cost nothing to
* pagination correctness. The caller asks for limit+1 rows to learn
* whether more exist.
*/
public List<BookmarkedPostRow> pageBookmarked(UUID userId, BookmarkCursor after,
int limitPlusOne) {
String sql = """
SELECT p.id, p.author_user_id, p.pet_id, p.category, p.title, p.content,
p.status, p.visibility, p.like_count, p.comment_count, p.bookmark_count,
p.created_at, p.updated_at, p.published_at, p.deleted_at, p.version, p.request_hash,
EXISTS (SELECT 1 FROM community.post_likes pl
WHERE pl.post_id = p.id AND pl.user_id = :viewerId) AS liked_by_me,
true AS bookmarked_by_me,
b.created_at AS bookmarked_at
FROM community.post_bookmarks b
JOIN community.posts p ON p.id = b.post_id
WHERE b.user_id = :viewerId
AND p.status = 'published' AND p.visibility = 'public' AND p.deleted_at IS NULL
""";
if (after != null) {
sql += " AND (b.created_at, b.post_id) < (:cursorBookmarkedAt, :cursorPostId)";
}
sql += " ORDER BY b.created_at DESC, b.post_id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("viewerId", userId)
.param("limit", limitPlusOne);
if (after != null) {
spec = spec.param("cursorBookmarkedAt", after.bookmarkedAt())
.param("cursorPostId", after.postId());
}
return spec.query((rs, rowNum) -> new BookmarkedPostRow(
mapPost(rs, rowNum),
rs.getObject("bookmarked_at", OffsetDateTime.class))).list();
}
public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
jdbcClient.sql("""
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
@@ -321,4 +417,8 @@ public class PostRepository {
Integer widthPx,
Integer heightPx) {
}
/** A bookmarked post plus the relation row's timestamp (the page key). */
public record BookmarkedPostRow(PostRow post, OffsetDateTime bookmarkedAt) {
}
}
@@ -0,0 +1,177 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.UserExistenceGateway;
import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.dto.CommentResponse;
import com.patbond.patbond.community.dto.CreateCommentRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.repository.CommentRepository;
import com.patbond.patbond.community.repository.CommentRepository.CommentRow;
import com.patbond.patbond.community.repository.InteractionRepository;
import com.patbond.patbond.community.support.CommentCursor;
import com.patbond.patbond.community.support.RequestHashes;
import com.patbond.patbond.community.support.UuidV7;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Flat comments (T3-07 定型). The semantics fixed here are T3-10 freeze
* input:
*
* <ul>
* <li><b>Interaction surface</b> — comments attach to the PUBLIC face of
* a post only: published and live. A draft (its author included),
* hidden/archived or soft-deleted post answers the byte-identical
* 404/40403 on every comment path — 互动域不区分「作者的草稿」.</li>
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory,
* stored as client_request_id next to the normalized request hash;
* same key + same payload returns the first comment (201 again),
* different payload 40905, keys scoped per author. A replay hitting
* a since-deleted first comment answers 404/40404 (T3-04 §2.4
* 同一先例).</li>
* <li><b>Delete</b> — the comment's author onlyD3-7 拍板:帖主删他人
* 评论首版不做); a non-author on a visible comment gets 403/40301,
* everything invisible (absent, deleted, its post invisible) merges
* into 404/40404. comment_count moves -1 in the same transaction,
* exactly once — the FOR UPDATE lock serializes double deletes.</li>
* </ul>
*/
@Service
public class CommentService {
private final CommentRepository commentRepository;
private final InteractionRepository interactionRepository;
private final UserExistenceGateway userExistenceGateway;
private final AuthorProfileGateway authorProfileGateway;
public CommentService(CommentRepository commentRepository,
InteractionRepository interactionRepository,
UserExistenceGateway userExistenceGateway,
AuthorProfileGateway authorProfileGateway) {
this.commentRepository = commentRepository;
this.interactionRepository = interactionRepository;
this.userExistenceGateway = userExistenceGateway;
this.authorProfileGateway = authorProfileGateway;
}
@Transactional(readOnly = true)
public CursorPage<CommentResponse> list(UUID postId, int limit, String cursor) {
requireInteractable(postId);
CommentCursor after = cursor == null ? null : CommentCursor.decode(cursor);
List<CommentRow> rows = commentRepository.pageByPost(postId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<CommentRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new CommentCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(assemble(page), nextCursor, hasMore);
}
@Transactional
public CommentResponse create(UUID userId, UUID postId, String idempotencyKey,
CreateCommentRequest request) {
String key = normalizeIdempotencyKey(idempotencyKey);
String content = requireContent(request.getContent());
requireInteractable(postId);
if (request.getReplyToUserId() != null
&& !userExistenceGateway.existsActive(request.getReplyToUserId())) {
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
}
byte[] requestHash = RequestHashes.sha256(
canonicalize(postId, content, request.getReplyToUserId()));
UUID id = UuidV7.generate();
int inserted = commentRepository.insertComment(id, postId, userId,
request.getReplyToUserId(), content, key, requestHash);
if (inserted == 0) {
CommentRow first = commentRepository.findByAuthorAndClientRequestId(userId, key)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
if (!Arrays.equals(first.requestHash(), requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
}
if (first.deletedAt() != null) {
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
}
return assemble(List.of(first)).get(0);
}
interactionRepository.bumpCommentCount(postId, 1);
CommentRow row = commentRepository.lockVisibleById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return assemble(List.of(row)).get(0);
}
@Transactional
public void delete(UUID userId, UUID commentId) {
CommentRow comment = commentRepository.lockVisibleById(commentId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND));
if (!interactionRepository.isInteractable(comment.postId())) {
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
}
if (!comment.authorUserId().equals(userId)) {
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
}
commentRepository.softDelete(commentId);
interactionRepository.bumpCommentCount(comment.postId(), -1);
}
private void requireInteractable(UUID postId) {
if (!interactionRepository.isInteractable(postId)) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
}
private static String normalizeIdempotencyKey(String idempotencyKey) {
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
if (key.isEmpty() || key.length() > 128) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"Idempotency-Key 必带且长度须在 1~128 字符");
}
return key;
}
private static String requireContent(String content) {
String trimmed = content == null ? "" : content.trim();
if (trimmed.isEmpty() || trimmed.length() > 2000) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~2000 字符");
}
return trimmed;
}
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
private static String canonicalize(UUID postId, String content, UUID replyToUserId) {
return "comment.v1\n" + postId + '\n'
+ (replyToUserId == null ? "" : replyToUserId) + '\n'
+ content + '\n';
}
private List<CommentResponse> assemble(List<CommentRow> rows) {
Set<UUID> userIds = new HashSet<>();
for (CommentRow row : rows) {
userIds.add(row.authorUserId());
if (row.replyToUserId() != null) {
userIds.add(row.replyToUserId());
}
}
Map<UUID, AuthorSummaryResponse> profiles = authorProfileGateway.summarize(userIds);
return rows.stream().map(row -> new CommentResponse(
row.id(),
row.postId(),
profiles.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.replyToUserId() == null ? null
: profiles.getOrDefault(row.replyToUserId(),
AuthorSummaryResponse.idOnly(row.replyToUserId())),
row.content(),
row.createdAt())).toList();
}
}
@@ -0,0 +1,142 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.dto.PostMediaItemResponse;
import com.patbond.patbond.community.media.MediaUrlSigner;
import com.patbond.patbond.community.repository.PostRepository;
import com.patbond.patbond.community.repository.PostRepository.BookmarkedPostRow;
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
import com.patbond.patbond.community.repository.PostRepository.PostRow;
import com.patbond.patbond.community.support.BookmarkCursor;
import com.patbond.patbond.community.support.FeedCursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* The public feed (T3-05): keyset pagination over the ix_posts_feed key
* (published_at DESC, id DESC), cards assembled from the posts row (counts
* come from the denormalized like/comment/bookmark_count columns — the
* writers of T3-06/T3-07 maintain them in the same transaction as the
* relation rows), the cover media item, the viewer's liked/bookmarked flags
* and the D3-9 author summary. Everything is batch: one page query, one
* media query, at most one profile call — no per-card work.
*/
@Service
public class FeedService {
/** Frozen preview rule: the first 200 Unicode code points, verbatim. */
static final int PREVIEW_CODE_POINTS = 200;
private final PostRepository postRepository;
private final MediaUrlSigner mediaUrlSigner;
private final AuthorProfileGateway authorProfileGateway;
public FeedService(PostRepository postRepository, MediaUrlSigner mediaUrlSigner,
AuthorProfileGateway authorProfileGateway) {
this.postRepository = postRepository;
this.mediaUrlSigner = mediaUrlSigner;
this.authorProfileGateway = authorProfileGateway;
}
@Transactional(readOnly = true)
public CursorPage<FeedCardResponse> list(UUID viewerId, int limit, String cursor) {
FeedCursor after = cursor == null ? null : FeedCursor.decode(cursor);
List<PostRow> rows = postRepository.pageFeed(viewerId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new FeedCursor(page.get(limit - 1).publishedAt(), page.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(assembleCards(page), nextCursor, hasMore);
}
/**
* My-bookmarks page (T3-07): the item IS the feed card(草案定型:项
* 形态复用 Feed 卡片), the order and cursor key on the bookmark
* relation row, and posts that turned invisible since bookmarking are
* silently dropped inside the page query — the same public-face
* predicate the feed uses, so a card here never breaks the
* publishedAt-non-null invariant.
*/
@Transactional(readOnly = true)
public CursorPage<FeedCardResponse> listBookmarked(UUID userId, int limit, String cursor) {
BookmarkCursor after = cursor == null ? null : BookmarkCursor.decode(cursor);
List<BookmarkedPostRow> rows = postRepository.pageBookmarked(userId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<BookmarkedPostRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new BookmarkCursor(page.get(limit - 1).bookmarkedAt(),
page.get(limit - 1).post().id()).encode()
: null;
return new CursorPage<>(assembleCards(page.stream().map(BookmarkedPostRow::post).toList()),
nextCursor, hasMore);
}
private List<FeedCardResponse> assembleCards(List<PostRow> rows) {
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
.stream()
.collect(Collectors.groupingBy(PostMediaRow::postId));
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
return rows.stream().map(row -> {
List<PostMediaRow> media = mediaByPost.getOrDefault(row.id(), List.of());
return new FeedCardResponse(
row.id(),
authors.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.category(),
row.title(),
preview(row.content()),
coverOf(media),
media.size(),
row.likeCount(),
row.commentCount(),
row.bookmarkCount(),
row.likedByMe(),
row.bookmarkedByMe(),
row.publishedAt());
}).toList();
}
/**
* The is_cover row (unique per post, and present whenever media exist —
* T3-04 §2.6 sets it on position 0 when the author picked none).
*/
private PostMediaItemResponse coverOf(List<PostMediaRow> media) {
return media.stream()
.filter(PostMediaRow::isCover)
.findFirst()
.map(m -> new PostMediaItemResponse(
m.assetId(),
m.position(),
m.isCover(),
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
m.widthPx(),
m.heightPx(),
m.caption()))
.orElse(null);
}
/**
* Preview = the first {@value #PREVIEW_CODE_POINTS} code points of the
* stored content, cut on a code-point boundary (no surrogate is ever
* split), no ellipsis appended — whether the card is a truncation is
* the client's call via {@code contentPreview.length} vs its own
* rendering, and the full text always comes from the detail endpoint.
*/
static String preview(String content) {
if (content.codePointCount(0, content.length()) <= PREVIEW_CODE_POINTS) {
return content;
}
return content.substring(0, content.offsetByCodePoints(0, PREVIEW_CODE_POINTS));
}
}
@@ -0,0 +1,67 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.UserExistenceGateway;
import com.patbond.patbond.community.dto.FollowStateResponse;
import com.patbond.patbond.community.dto.FollowStatsResponse;
import com.patbond.patbond.community.repository.InteractionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* The ADR-018 minimal follow surface: follow/unfollow (PUT/DELETE
* idempotent on the composite primary key, ADR-019) plus the follow-stats
* numbers. Counts are real-time COUNTs — user_follows carries no
* denormalized counters, and both directions ride an index. The target
* must be an existing active user (404/40406, absent and 注销 merged);
* following oneself is 422/42204 on PUT (ck_user_follows_self is the
* database backstop), while DELETE stays a plain idempotent no-op — a
* self-follow row cannot exist, so the authoritative false is the truth.
*/
@Service
public class FollowService {
private final InteractionRepository interactionRepository;
private final UserExistenceGateway userExistenceGateway;
public FollowService(InteractionRepository interactionRepository,
UserExistenceGateway userExistenceGateway) {
this.interactionRepository = interactionRepository;
this.userExistenceGateway = userExistenceGateway;
}
@Transactional
public FollowStateResponse follow(UUID userId, UUID targetUserId) {
if (userId.equals(targetUserId)) {
throw new BusinessException(ErrorCode.FOLLOW_RULE_VIOLATION);
}
requireActive(targetUserId);
interactionRepository.insertFollow(userId, targetUserId);
return new FollowStateResponse(true, interactionRepository.countFollowers(targetUserId));
}
@Transactional
public FollowStateResponse unfollow(UUID userId, UUID targetUserId) {
requireActive(targetUserId);
interactionRepository.deleteFollow(userId, targetUserId);
return new FollowStateResponse(false, interactionRepository.countFollowers(targetUserId));
}
@Transactional(readOnly = true)
public FollowStatsResponse stats(UUID viewerId, UUID targetUserId) {
requireActive(targetUserId);
return new FollowStatsResponse(
interactionRepository.countFollowers(targetUserId),
interactionRepository.countFollowing(targetUserId),
interactionRepository.followExists(viewerId, targetUserId));
}
private void requireActive(UUID targetUserId) {
if (!userExistenceGateway.existsActive(targetUserId)) {
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
}
}
}
@@ -0,0 +1,78 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.dto.BookmarkStateResponse;
import com.patbond.patbond.community.dto.LikeStateResponse;
import com.patbond.patbond.community.repository.InteractionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* Binary post interactions (T3-06, ADR-019): PUT/DELETE are idempotent by
* construction — the relation row's composite primary key is the
* idempotency key, the counter column moves in the same transaction and
* only by the number of rows the relation write actually changed, so
* concurrent duplicates converge (N concurrent PUTs land exactly one row
* and exactly +1) and every response carries the authoritative terminal
* state. The interaction gate is the post's public face: anything not
* published-and-live answers the byte-identical 404/40403 on PUT and
* DELETE alike.
*/
@Service
public class InteractionService {
private final InteractionRepository interactionRepository;
public InteractionService(InteractionRepository interactionRepository) {
this.interactionRepository = interactionRepository;
}
@Transactional
public LikeStateResponse like(UUID userId, UUID postId) {
requireInteractable(postId);
int inserted = interactionRepository.insertLike(postId, userId);
long count = inserted > 0
? interactionRepository.bumpLikeCount(postId, inserted)
: interactionRepository.likeCount(postId);
return new LikeStateResponse(true, count);
}
@Transactional
public LikeStateResponse unlike(UUID userId, UUID postId) {
requireInteractable(postId);
int deleted = interactionRepository.deleteLike(postId, userId);
long count = deleted > 0
? interactionRepository.bumpLikeCount(postId, -deleted)
: interactionRepository.likeCount(postId);
return new LikeStateResponse(false, count);
}
@Transactional
public BookmarkStateResponse bookmark(UUID userId, UUID postId) {
requireInteractable(postId);
int inserted = interactionRepository.insertBookmark(postId, userId);
long count = inserted > 0
? interactionRepository.bumpBookmarkCount(postId, inserted)
: interactionRepository.bookmarkCount(postId);
return new BookmarkStateResponse(true, count);
}
@Transactional
public BookmarkStateResponse unbookmark(UUID userId, UUID postId) {
requireInteractable(postId);
int deleted = interactionRepository.deleteBookmark(postId, userId);
long count = deleted > 0
? interactionRepository.bumpBookmarkCount(postId, -deleted)
: interactionRepository.bookmarkCount(postId);
return new BookmarkStateResponse(false, count);
}
private void requireInteractable(UUID postId) {
if (!interactionRepository.isInteractable(postId)) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
}
}
@@ -3,6 +3,9 @@ package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.PetVisibilityGateway;
import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
@@ -65,13 +68,16 @@ public class PostService {
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
private final PetVisibilityGateway petVisibilityGateway;
private final AuthorProfileGateway authorProfileGateway;
public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway) {
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway,
AuthorProfileGateway authorProfileGateway) {
this.postRepository = postRepository;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
this.petVisibilityGateway = petVisibilityGateway;
this.authorProfileGateway = authorProfileGateway;
}
@Transactional
@@ -200,6 +206,19 @@ public class PostService {
return new CursorPage<>(assemble(page), nextCursor, hasMore);
}
/**
* The caller's own community numbers (T3.5-06): total likes received on
* published, live posts plus the count of those posts. Aggregated on read
* (ADR-022) — the write side keeps no per-user counter, so there is
* nothing that can drift out of sync. Empty data is a legitimate answer of
* zeros, never a 404: every authenticated user has stats, even a brand-new
* one with nothing published.
*/
@Transactional(readOnly = true)
public CommunityStatsResponse communityStats(UUID userId) {
return postRepository.aggregateByAuthor(userId);
}
/**
* The shared write gate of PATCH/DELETE: locks the live row, then walks
* the 403/404 boundary — invisible (absent, deleted, hidden/archived,
@@ -374,9 +393,12 @@ public class PostService {
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
.stream()
.collect(Collectors.groupingBy(PostMediaRow::postId));
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
return rows.stream().map(row -> new PostResponse(
row.id(),
row.authorUserId(),
authors.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.petId(),
row.category(),
row.title(),
@@ -0,0 +1,46 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor of the my-bookmarks list (bookmarks.created_at DESC,
* post_id DESC — the exact key of ix_post_bookmarks_user_created). The key
* lives on the RELATION row, not the post: a bookmarked post that later
* turns invisible is filtered inside the same keyset query, so pages stay
* complete and the cursor never points at a value the client saw filtered.
* Encoding is the shared base64url("epochMicros:id") shape.
*/
public record BookmarkCursor(OffsetDateTime bookmarkedAt, UUID postId) {
public String encode() {
long micros = Math.multiplyExact(bookmarkedAt.toInstant().getEpochSecond(), 1_000_000L)
+ bookmarkedAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + postId).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static BookmarkCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID postId = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime bookmarkedAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new BookmarkCursor(bookmarkedAt, postId);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,45 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor of a post's comment list (created_at DESC, id DESC — the
* exact key of ix_comments_post_created), same encoding as
* {@link PostCursor}: base64url("epochMicros:id"), next page selects
* {@code (created_at, id) < (cursor)} so ties on created_at are broken by
* id and rows are neither lost nor repeated across page boundaries.
*/
public record CommentCursor(OffsetDateTime createdAt, UUID id) {
public String encode() {
long micros = Math.multiplyExact(createdAt.toInstant().getEpochSecond(), 1_000_000L)
+ createdAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static CommentCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID id = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime createdAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new CommentCursor(createdAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,46 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor of the public feed (published_at DESC, id DESC — the exact
* key of ix_posts_feed), same encoding as {@link PostCursor}:
* base64url("epochMicros:id"), next page selects
* {@code (published_at, id) < (cursor)} so ties on published_at are broken
* by id and rows are neither lost nor repeated across page boundaries.
* timestamptz carries microseconds, so the micros encoding is lossless.
*/
public record FeedCursor(OffsetDateTime publishedAt, UUID id) {
public String encode() {
long micros = Math.multiplyExact(publishedAt.toInstant().getEpochSecond(), 1_000_000L)
+ publishedAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static FeedCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID id = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime publishedAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new FeedCursor(publishedAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -20,6 +20,16 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
# 作者公开资料来源(D3-9 方案 B):patbond-user 的 /internal 批量接口,
# ADR-002 静态直连。不可达时 Feed/详情照常返回,作者摘要降级为仅 userId。
user-service:
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
# /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
author-profile:
# 作者公开资料的进程内缓存 TTL:昵称/头像变更最迟一分钟可见。
cache-ttl: ${PATBOND_AUTHOR_PROFILE_CACHE_TTL:60s}
media:
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
# 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。
@@ -0,0 +1,146 @@
package com.patbond.patbond.community.author;
import com.patbond.patbond.community.TestcontainersConfiguration;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.TestJwtKeys;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The real Feign wiring against an in-test HTTP server standing in for
* patbond-user: static URL resolution, the X-Internal-Token interceptor,
* query-string batching, envelope decoding, avatar resolution through
* media.assets plus URL signing — and degradation when the downstream
* answers an error. (The /internal endpoint itself is tested in the
* patbond-user module; the DB-backed stub covers the service-level tests.)
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class AuthorProfileClientWireTest {
private static final HttpServer SERVER;
private static final AtomicReference<String> RESPONSE_BODY = new AtomicReference<>("");
private static final AtomicInteger RESPONSE_STATUS = new AtomicInteger(200);
private static final AtomicReference<String> SEEN_TOKEN = new AtomicReference<>();
private static final AtomicReference<String> SEEN_QUERY = new AtomicReference<>();
static {
try {
SERVER = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
} catch (IOException e) {
throw new IllegalStateException(e);
}
SERVER.createContext("/internal/users/profiles", exchange -> {
SEEN_TOKEN.set(exchange.getRequestHeaders().getFirst("X-Internal-Token"));
SEEN_QUERY.set(exchange.getRequestURI().getRawQuery());
byte[] body = RESPONSE_BODY.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(RESPONSE_STATUS.get(), body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
SERVER.start();
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.user-service.url",
() -> "http://127.0.0.1:" + SERVER.getAddress().getPort());
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
@AfterAll
static void stopServer() {
SERVER.stop(0);
}
@Autowired
private AuthorProfileGateway gateway;
@Autowired
private JdbcClient jdbcClient;
@BeforeEach
void resetServer() {
RESPONSE_STATUS.set(200);
RESPONSE_BODY.set("{\"code\":0,\"message\":\"success\",\"data\":[]}");
SEEN_TOKEN.set(null);
SEEN_QUERY.set(null);
}
private UUID newUser() {
return CommunityTestData.insertUser(jdbcClient,
"w" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
}
@Test
void presentsTheServiceSecretAndBatchesIdsIntoOneQuery() {
UUID userA = UUID.randomUUID();
UUID userB = UUID.randomUUID();
RESPONSE_BODY.set("""
{"code":0,"message":"success","data":[
{"userId":"%s","nickname":"小白","avatarAssetId":null}
]}""".formatted(userA));
Map<UUID, AuthorSummaryResponse> summaries =
gateway.summarize(java.util.List.of(userA, userB));
assertThat(SEEN_TOKEN.get()).isEqualTo("test-internal-token");
String ids = URLDecoder.decode(SEEN_QUERY.get(), StandardCharsets.UTF_8)
.replaceFirst("^ids=", "");
assertThat(ids.split(",")).containsExactlyInAnyOrder(
userA.toString(), userB.toString());
assertThat(summaries).containsOnlyKeys(userA);
assertThat(summaries.get(userA).nickname()).isEqualTo("小白");
assertThat(summaries.get(userA).avatarUrl()).isNull();
}
@Test
void resolvesTheAvatarAssetLocallyAndSignsTheUrl() {
UUID owner = newUser();
UUID assetId = CommunityTestData.insertReadyAsset(jdbcClient, owner);
RESPONSE_BODY.set("""
{"code":0,"message":"success","data":[
{"userId":"%s","nickname":"有头像","avatarAssetId":"%s"}
]}""".formatted(owner, assetId));
AuthorSummaryResponse summary = gateway.summarize(java.util.List.of(owner)).get(owner);
assertThat(summary.nickname()).isEqualTo("有头像");
assertThat(summary.avatarUrl())
.contains(assetId.toString())
.contains("X-Amz-Signature");
}
@Test
void aDownstreamErrorDegradesToNoSummaries() {
RESPONSE_STATUS.set(500);
RESPONSE_BODY.set("{\"code\":50000,\"message\":\"boom\",\"data\":null}");
assertThat(gateway.summarize(java.util.List.of(UUID.randomUUID()))).isEmpty();
}
}
@@ -0,0 +1,551 @@
package com.patbond.patbond.community.contract;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.community.post.PostApiTestBase;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/**
* T3-20M3 第二波收尾):community 域 18 个操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照
* {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用
* {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名
* 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
*
* <p>覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()}
* 断言契约为这 18 个操作声明的每一个 (操作, 状态码) 单元格(共 66 格)都被
* 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁
* 冲突、422 均为业务规则拒绝,单线程即可确定性触发。
*
* <p>media 域 2 个操作属 patbond-user 模块,由该模块的
* MediaContractConformanceTest 覆盖;auth/user 域 7 操作在 patbond-auth
* (含 v1.4.0 新增的 PATCH /api/v1/me)。快照四模块同一份。
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CommunityContractConformanceTest extends PostApiTestBase {
private static final OpenApiContract CONTRACT = OpenApiContract.load();
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
/** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/feed 200"。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** community 域 18 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */
private static final List<String> COMMUNITY_OPERATIONS = List.of(
"POST /api/v1/posts",
"GET /api/v1/posts/{postId}",
"PATCH /api/v1/posts/{postId}",
"DELETE /api/v1/posts/{postId}",
"GET /api/v1/me/posts",
"GET /api/v1/feed",
"GET /api/v1/posts/{postId}/comments",
"POST /api/v1/posts/{postId}/comments",
"DELETE /api/v1/comments/{commentId}",
"PUT /api/v1/posts/{postId}/like",
"DELETE /api/v1/posts/{postId}/like",
"PUT /api/v1/posts/{postId}/bookmark",
"DELETE /api/v1/posts/{postId}/bookmark",
"GET /api/v1/me/bookmarks",
"PUT /api/v1/users/{userId}/follow",
"DELETE /api/v1/users/{userId}/follow",
"GET /api/v1/users/{userId}/follow-stats",
"GET /api/v1/me/community-stats");
private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
// ---- 校验骨架 ------------------------------------------------------
/**
* 执行请求,断言 HTTP 状态,并将响应体对照冻结契约严格校验;通过后把
* (操作, 状态码) 记入覆盖表。返回响应体供取 id/cursor。
*/
private String verified(MockHttpServletRequestBuilder rq, String method,
String pathTemplate, int expectedStatus) throws Exception {
MvcResult result = mockMvc.perform(rq).andReturn();
int actual = result.getResponse().getStatus();
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(actual)
.as("%s %s 的 HTTP 状态(响应体: %s", method, pathTemplate, body)
.isEqualTo(expectedStatus);
List<String> drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body);
assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty();
COVERED.add(method + " " + pathTemplate + " " + actual);
return body;
}
/** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
private String verifiedError(MockHttpServletRequestBuilder rq, String method,
String pathTemplate, int status, int bizCode) throws Exception {
String body = verified(rq, method, pathTemplate, status);
assertThat((Integer) JsonPath.read(body, "$.code"))
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
.isEqualTo(bizCode);
return body;
}
/** 经 verified 的创建(响应同样被契约校验),返回帖子 id。 */
private String newPost(UUID author, String body) throws Exception {
String created = verified(
createPostRequest(author, UUID.randomUUID().toString(), body),
"POST", "/api/v1/posts", 201);
return JsonPath.read(created, "$.data.id");
}
private String newPublishedPost(UUID author, String content) throws Exception {
return newPost(author, """
{"content":"%s","status":"published"}
""".formatted(content));
}
private String newComment(UUID author, String postId, String content) throws Exception {
String created = verified(
authed(post("/api/v1/posts/{postId}/comments", postId), author)
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
.content("{\"content\":\"%s\"}".formatted(content)),
"POST", "/api/v1/posts/{postId}/comments", 201);
return JsonPath.read(created, "$.data.id");
}
// ---- 成功路径:18 操作全覆盖 ---------------------------------------
@Test
@Order(1)
void postLifecycleSuccessShapes() throws Exception {
UUID author = newUser();
UUID petId = CommunityTestData.insertPetOwnedBy(jdbcClient, author);
UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author);
// 全字段草稿(petId + 单图封面 + caption
String draftId = newPost(author, """
{"title":"契约帖","content":"全字段草稿正文","category":"help",
"status":"draft","petId":"%s",
"media":[{"assetId":"%s","position":0,"isCover":true,"caption":"封面图"}]}
""".formatted(petId, asset));
// 可空字段全空的纯文字直接发布形态(nullable 声明的实证)
newPublishedPost(author, "契约纯文字发布帖");
verified(get("/api/v1/posts/{postId}", draftId)
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/posts/{postId}", 200);
// 发布草稿(draft→published 唯一开放迁移)
String published = verified(authed(patch("/api/v1/posts/{postId}", draftId), author)
.content("{\"version\":0,\"status\":\"published\"}"),
"PATCH", "/api/v1/posts/{postId}", 200);
assertThat((String) JsonPath.read(published, "$.data.status")).isEqualTo("published");
assertThat((Object) JsonPath.read(published, "$.data.publishedAt")).isNotNull();
// 我的帖子列表:keyset 翻页两态 + status 过滤
String page1 = verified(get("/api/v1/me/posts").param("limit", "1")
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/posts", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
assertThat(cursor).as("hasMore=true 时 nextCursor 非空").isNotNull();
verified(get("/api/v1/me/posts").param("limit", "1").param("cursor", cursor)
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/posts", 200);
verified(get("/api/v1/me/posts").param("status", "draft")
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/posts", 200);
// 软删(VoidEnvelope
String victim = newPublishedPost(author, "契约待删帖");
verified(authed(delete("/api/v1/posts/{postId}", victim), author),
"DELETE", "/api/v1/posts/{postId}", 200);
}
@Test
@Order(2)
void feedSuccessShapes() throws Exception {
UUID author = newUser();
UUID reader = newUser();
UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author);
// 有封面与纯文字两种卡片形态(coverImage 的 allOf 非空/null 两分支)
newPost(author, """
{"title":"契约图帖","content":"Feed 封面卡片","status":"published",
"media":[{"assetId":"%s","isCover":true}]}
""".formatted(asset));
newPublishedPost(author, "Feed 纯文字卡片");
String page1 = verified(get("/api/v1/feed").param("limit", "1")
.header("Authorization", "Bearer " + token(reader)),
"GET", "/api/v1/feed", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
verified(get("/api/v1/feed").param("cursor", cursor)
.header("Authorization", "Bearer " + token(reader)),
"GET", "/api/v1/feed", 200);
}
@Test
@Order(3)
void commentSuccessShapes() throws Exception {
UUID author = newUser();
UUID commenter = newUser();
String postId = newPublishedPost(author, "契约评论帖");
// 普通评论与 @ 回复(replyToUser 的 allOf null/非空两分支)
newComment(commenter, postId, "普通评论");
verified(authed(post("/api/v1/posts/{postId}/comments", postId), author)
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
.content("""
{"content":"@ 回复","replyToUserId":"%s"}
""".formatted(commenter)),
"POST", "/api/v1/posts/{postId}/comments", 201);
String page1 = verified(get("/api/v1/posts/{postId}/comments", postId)
.param("limit", "1")
.header("Authorization", "Bearer " + token(commenter)),
"GET", "/api/v1/posts/{postId}/comments", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
verified(get("/api/v1/posts/{postId}/comments", postId)
.param("cursor", cursor)
.header("Authorization", "Bearer " + token(commenter)),
"GET", "/api/v1/posts/{postId}/comments", 200);
// 作者软删自己的评论(VoidEnvelope
String commentId = newComment(commenter, postId, "待删评论");
verified(authed(delete("/api/v1/comments/{commentId}", commentId), commenter),
"DELETE", "/api/v1/comments/{commentId}", 200);
}
@Test
@Order(4)
void interactionSuccessShapes() throws Exception {
UUID author = newUser();
UUID actor = newUser();
String postA = newPublishedPost(author, "契约互动帖 A");
String postB = newPublishedPost(author, "契约互动帖 B");
// PUT/DELETE 权威终态(重复 PUT 同格,幂等语义顺带实证)
verified(authed(put("/api/v1/posts/{postId}/like", postA), actor),
"PUT", "/api/v1/posts/{postId}/like", 200);
String likedAgain = verified(authed(put("/api/v1/posts/{postId}/like", postA), actor),
"PUT", "/api/v1/posts/{postId}/like", 200);
assertThat((Boolean) JsonPath.read(likedAgain, "$.data.liked")).isTrue();
assertThat((Integer) JsonPath.read(likedAgain, "$.data.likeCount")).isEqualTo(1);
verified(authed(delete("/api/v1/posts/{postId}/like", postA), actor),
"DELETE", "/api/v1/posts/{postId}/like", 200);
verified(authed(put("/api/v1/posts/{postId}/bookmark", postA), actor),
"PUT", "/api/v1/posts/{postId}/bookmark", 200);
verified(authed(put("/api/v1/posts/{postId}/bookmark", postB), actor),
"PUT", "/api/v1/posts/{postId}/bookmark", 200);
String page1 = verified(get("/api/v1/me/bookmarks").param("limit", "1")
.header("Authorization", "Bearer " + token(actor)),
"GET", "/api/v1/me/bookmarks", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
verified(get("/api/v1/me/bookmarks").param("cursor", cursor)
.header("Authorization", "Bearer " + token(actor)),
"GET", "/api/v1/me/bookmarks", 200);
verified(authed(delete("/api/v1/posts/{postId}/bookmark", postB), actor),
"DELETE", "/api/v1/posts/{postId}/bookmark", 200);
}
@Test
@Order(5)
void followSuccessShapes() throws Exception {
UUID follower = newUser();
UUID followee = newUser();
String followed = verified(authed(put("/api/v1/users/{userId}/follow", followee), follower),
"PUT", "/api/v1/users/{userId}/follow", 200);
assertThat((Boolean) JsonPath.read(followed, "$.data.following")).isTrue();
String stats = verified(get("/api/v1/users/{userId}/follow-stats", followee)
.header("Authorization", "Bearer " + token(follower)),
"GET", "/api/v1/users/{userId}/follow-stats", 200);
assertThat((Boolean) JsonPath.read(stats, "$.data.followedByMe")).isTrue();
// 查自己:followedByMe 恒 false 分支
verified(get("/api/v1/users/{userId}/follow-stats", follower)
.header("Authorization", "Bearer " + token(follower)),
"GET", "/api/v1/users/{userId}/follow-stats", 200);
verified(authed(delete("/api/v1/users/{userId}/follow", followee), follower),
"DELETE", "/api/v1/users/{userId}/follow", 200);
// 取消不存在的关注:幂等 no-op 仍 200 权威 false
String unfollowedAgain = verified(
authed(delete("/api/v1/users/{userId}/follow", followee), follower),
"DELETE", "/api/v1/users/{userId}/follow", 200);
assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse();
}
/**
* `GET /api/v1/me/community-stats`v1.4.0 新增操作,T3.5-07):空数据零值
* 与有数据两个分支都过严格校验。**永不 404**,故本操作只有 200/401 两格
* 401 由 {@link #unauthenticatedRequestsAnswer40101OnAllOperations()} 的
* 全操作循环覆盖)。
*/
@Test
@Order(51)
void myCommunityStatsSuccessShapes() throws Exception {
UUID fresh = newUser();
// 空数据:两数为 0 而非 null,且不是 404
String zeros = verified(get("/api/v1/me/community-stats")
.header("Authorization", "Bearer " + token(fresh)),
"GET", "/api/v1/me/community-stats", 200);
assertThat(((Number) JsonPath.read(zeros, "$.data.receivedLikeCount")).longValue())
.isZero();
assertThat(((Number) JsonPath.read(zeros, "$.data.publishedPostCount")).longValue())
.isZero();
// 有数据:两篇已发布帖,其中一篇被他人赞一次 → 1 赞 / 2 作品
UUID author = newUser();
UUID fan = newUser();
String postA = newPublishedPost(author, "契约统计帖甲");
newPublishedPost(author, "契约统计帖乙");
verified(authed(put("/api/v1/posts/{postId}/like", postA), fan),
"PUT", "/api/v1/posts/{postId}/like", 200);
String counted = verified(get("/api/v1/me/community-stats")
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/community-stats", 200);
assertThat(((Number) JsonPath.read(counted, "$.data.receivedLikeCount")).longValue())
.isEqualTo(1L);
assertThat(((Number) JsonPath.read(counted, "$.data.publishedPostCount")).longValue())
.isEqualTo(2L);
}
// ---- 错误信封 ------------------------------------------------------
@Test
@Order(6)
void unauthenticatedRequestsAnswer40101OnAllOperations() throws Exception {
for (String op : COMMUNITY_OPERATIONS) {
String[] parts = op.split(" ", 2);
String url = parts[1].replaceAll("\\{[^}]+}", UUID.randomUUID().toString());
MockHttpServletRequestBuilder rq = request(HttpMethod.valueOf(parts[0]), url);
if (!"GET".equals(parts[0])) {
rq = rq.contentType(MediaType.APPLICATION_JSON).content("{}");
}
verifiedError(rq, parts[0], parts[1], 401, 40101);
}
}
@Test
@Order(7)
void validationErrorsAnswer40000() throws Exception {
UUID user = newUser();
String postId = newPublishedPost(user, "契约校验帖");
// 创建:缺 Idempotency-Key 与空 body 两种 40000
verifiedError(authed(post("/api/v1/posts"), user).content("{\"content\":\"无幂等键\"}"),
"POST", "/api/v1/posts", 400, 40000);
verifiedError(createPostRequest(user, UUID.randomUUID().toString(), "{}"),
"POST", "/api/v1/posts", 400, 40000);
// PATCH:缺 version
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), user)
.content("{\"content\":\"缺版本\"}"),
"PATCH", "/api/v1/posts/{postId}", 400, 40000);
verifiedError(get("/api/v1/me/posts").param("limit", "0")
.header("Authorization", "Bearer " + token(user)),
"GET", "/api/v1/me/posts", 400, 40000);
verifiedError(get("/api/v1/feed").param("cursor", "not-a-cursor")
.header("Authorization", "Bearer " + token(user)),
"GET", "/api/v1/feed", 400, 40000);
verifiedError(get("/api/v1/posts/{postId}/comments", postId).param("limit", "101")
.header("Authorization", "Bearer " + token(user)),
"GET", "/api/v1/posts/{postId}/comments", 400, 40000);
verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), user)
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
.content("{}"),
"POST", "/api/v1/posts/{postId}/comments", 400, 40000);
verifiedError(get("/api/v1/me/bookmarks").param("cursor", "broken")
.header("Authorization", "Bearer " + token(user)),
"GET", "/api/v1/me/bookmarks", 400, 40000);
}
@Test
@Order(8)
void antiEnumerationAndPermissionErrorsMatchContract() throws Exception {
UUID author = newUser();
UUID other = newUser();
String ghost = UUID.randomUUID().toString();
// -- 40403:帖子防枚举(不存在 / 他人 draft 同响应)--
verifiedError(get("/api/v1/posts/{postId}", ghost)
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/posts/{postId}", 404, 40403);
String draftId = newPost(author, "{\"content\":\"他人不可见草稿\"}");
verifiedError(authed(patch("/api/v1/posts/{postId}", draftId), other)
.content("{\"version\":0,\"content\":\"越权\"}"),
"PATCH", "/api/v1/posts/{postId}", 404, 40403);
verifiedError(authed(delete("/api/v1/posts/{postId}", ghost), author),
"DELETE", "/api/v1/posts/{postId}", 404, 40403);
verifiedError(get("/api/v1/posts/{postId}/comments", draftId)
.header("Authorization", "Bearer " + token(other)),
"GET", "/api/v1/posts/{postId}/comments", 404, 40403);
// 互动面 = 帖子公开面:作者本人草稿同样 40403
verifiedError(authed(put("/api/v1/posts/{postId}/like", draftId), author),
"PUT", "/api/v1/posts/{postId}/like", 404, 40403);
verifiedError(authed(delete("/api/v1/posts/{postId}/like", draftId), author),
"DELETE", "/api/v1/posts/{postId}/like", 404, 40403);
verifiedError(authed(put("/api/v1/posts/{postId}/bookmark", ghost), author),
"PUT", "/api/v1/posts/{postId}/bookmark", 404, 40403);
verifiedError(authed(delete("/api/v1/posts/{postId}/bookmark", ghost), author),
"DELETE", "/api/v1/posts/{postId}/bookmark", 404, 40403);
// -- 创建帖子的 404 双业务码:40401 幽灵宠物 / 40405 幽灵 asset --
verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """
{"content":"幽灵宠物","petId":"%s"}
""".formatted(ghost)),
"POST", "/api/v1/posts", 404, 40401);
verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """
{"content":"幽灵媒体","media":[{"assetId":"%s"}]}
""".formatted(ghost)),
"POST", "/api/v1/posts", 404, 40405);
// -- 评论的 404 双业务码:40403 帖子不可见 / 40406 幽灵 @ 目标 --
String postId = newPublishedPost(author, "契约错误评论帖");
verifiedError(authed(post("/api/v1/posts/{postId}/comments", draftId), other)
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
.content("{\"content\":\"评论他人草稿\"}"),
"POST", "/api/v1/posts/{postId}/comments", 404, 40403);
verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), other)
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
.content("""
{"content":"@ 幽灵","replyToUserId":"%s"}
""".formatted(ghost)),
"POST", "/api/v1/posts/{postId}/comments", 404, 40406);
verifiedError(authed(delete("/api/v1/comments/{commentId}", ghost), author),
"DELETE", "/api/v1/comments/{commentId}", 404, 40404);
// -- 40406:关注三端点的幽灵目标 --
verifiedError(authed(put("/api/v1/users/{userId}/follow", ghost), author),
"PUT", "/api/v1/users/{userId}/follow", 404, 40406);
verifiedError(authed(delete("/api/v1/users/{userId}/follow", ghost), author),
"DELETE", "/api/v1/users/{userId}/follow", 404, 40406);
verifiedError(get("/api/v1/users/{userId}/follow-stats", ghost)
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/users/{userId}/follow-stats", 404, 40406);
// -- 40301:可见但无权限(他人已发布帖改/删、他人可见评论删——含帖主)--
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), other)
.content("{\"version\":0,\"content\":\"越权改\"}"),
"PATCH", "/api/v1/posts/{postId}", 403, 40301);
verifiedError(authed(delete("/api/v1/posts/{postId}", postId), other),
"DELETE", "/api/v1/posts/{postId}", 403, 40301);
String commentId = newComment(other, postId, "帖主也删不得");
verifiedError(authed(delete("/api/v1/comments/{commentId}", commentId), author),
"DELETE", "/api/v1/comments/{commentId}", 403, 40301);
}
@Test
@Order(9)
void conflictAndRuleErrorsMatchContract() throws Exception {
UUID author = newUser();
UUID self = author;
// -- 409/40905:同幂等键不同 payload(帖子与评论)--
String key = UUID.randomUUID().toString();
verified(createPostRequest(author, key, "{\"content\":\"首次提交\"}"),
"POST", "/api/v1/posts", 201);
verifiedError(createPostRequest(author, key, "{\"content\":\"同键不同内容\"}"),
"POST", "/api/v1/posts", 409, 40905);
String postId = newPublishedPost(author, "契约冲突帖");
String commentKey = UUID.randomUUID().toString();
verified(authed(post("/api/v1/posts/{postId}/comments", postId), author)
.header(IDEMPOTENCY_KEY, commentKey)
.content("{\"content\":\"首次评论\"}"),
"POST", "/api/v1/posts/{postId}/comments", 201);
verifiedError(authed(post("/api/v1/posts/{postId}/comments", postId), author)
.header(IDEMPOTENCY_KEY, commentKey)
.content("{\"content\":\"同键不同评论\"}"),
"POST", "/api/v1/posts/{postId}/comments", 409, 40905);
// -- 409/40902:乐观锁过期(先成功一次把 version 顶到 1)--
verified(authed(patch("/api/v1/posts/{postId}", postId), author)
.content("{\"version\":0,\"content\":\"第一次改\"}"),
"PATCH", "/api/v1/posts/{postId}", 200);
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), author)
.content("{\"version\":0,\"content\":\"过期版本\"}"),
"PATCH", "/api/v1/posts/{postId}", 409, 40902);
// -- 422/42203:引用本人 uploading asset(创建与编辑)--
UUID uploading = CommunityTestData.insertAsset(jdbcClient, author, "uploading");
verifiedError(createPostRequest(author, UUID.randomUUID().toString(), """
{"content":"未就绪媒体","media":[{"assetId":"%s"}]}
""".formatted(uploading)),
"POST", "/api/v1/posts", 422, 42203);
verifiedError(authed(patch("/api/v1/posts/{postId}", postId), author)
.content("""
{"version":1,"media":[{"assetId":"%s"}]}
""".formatted(uploading)),
"PATCH", "/api/v1/posts/{postId}", 422, 42203);
// -- 422/42204:自关注(仅 PUT;自取关 200 已在 Order(5) 语义内)--
verifiedError(authed(put("/api/v1/users/{userId}/follow", self), self),
"PUT", "/api/v1/users/{userId}/follow", 422, 42204);
}
// ---- 快照与覆盖门禁 -------------------------------------------------
/**
* 冻结快照守卫:与 pet/auth 侧同一纪律——正典契约升版时必须同步复制新快照
* 并更新期望值,忘记同步在 CI 立即变红。
*/
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(
Set.of("posts", "feed", "comments", "interactions", "follows")))
.containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS);
}
/**
* 全矩阵覆盖门禁:community 域 18 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(66 个单元格,无豁免)。
*/
@Test
@Order(99)
void everyDeclaredResponseCellIsExercised() {
List<String> missing = new ArrayList<>();
for (String op : COMMUNITY_OPERATIONS) {
for (int status : CONTRACT.responseStatuses(op)) {
String cell = op + " " + status;
if (!COVERED.contains(cell)) {
missing.add(cell);
}
}
}
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
}
}
@@ -0,0 +1,256 @@
package com.patbond.patbond.community.contract;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.patbond.patbond.community.contract.OpenApiContract.cast;
import static com.patbond.patbond.community.contract.OpenApiContract.list;
import static com.patbond.patbond.community.contract.OpenApiContract.map;
/**
* Validates an actual HTTP response against the frozen contract, strictly:
*
* <ul>
* <li>the operation and the status must be declared;</li>
* <li>required fields must be present; a null value needs {@code nullable};</li>
* <li>fields the schema does not declare are rejected (this is what catches
* a renamed or newly leaked field — plain OpenAPI semantics would allow
* extra properties, but the frozen contract is "exactly these fields");</li>
* <li>types, enum membership, uuid / date-time / date formats and
* min/max(Length) bounds are checked.</li>
* </ul>
*
* Behavioural semantics (state machines, anti-enumeration, permission logic)
* stay with the existing integration tests — this class only pins structure.
*/
final class ContractValidator {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final OpenApiContract contract;
ContractValidator(OpenApiContract contract) {
this.contract = contract;
}
/**
* @return drift findings, empty when the response conforms; each entry is
* a human-readable "where: what" line
*/
List<String> validateResponse(String method, String pathTemplate, int status, String body) {
List<String> errors = new ArrayList<>();
String opKey = method + " " + pathTemplate;
Map<String, Object> op = contract.operation(opKey);
if (op == null) {
errors.add("契约未声明该操作: " + opKey);
return errors;
}
Object respNode = map(op, "responses").get(String.valueOf(status));
if (respNode == null) {
errors.add("契约未为 " + opKey + " 声明状态码 " + status);
return errors;
}
Map<String, Object> content = map(contract.resolve(cast(respNode)), "content");
if (content == null) {
return errors; // response declared without a body
}
Map<String, Object> schema = map(map(content, "application/json"), "schema");
if (schema == null) {
errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
return errors;
}
JsonNode node;
try {
node = MAPPER.readTree(body);
} catch (JsonProcessingException e) {
errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
return errors;
}
validate(schema, node, "$", errors);
return errors;
}
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失");
return;
}
if (node.isNull()) {
if (!Boolean.TRUE.equals(schema.get("nullable"))) {
errors.add(loc + ": 为 null,但契约未声明 nullable");
}
return;
}
List<Object> allowed = list(schema, "enum");
if (allowed != null && !enumMatches(allowed, node)) {
errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + "");
}
String type = (String) schema.get("type");
if (type == null) {
type = schema.containsKey("properties") ? "object" : null;
}
if (type == null) {
return;
}
switch (type) {
case "object" -> validateObject(schema, node, loc, errors);
case "array" -> validateArray(schema, node, loc, errors);
case "string" -> validateString(schema, node, loc, errors);
case "integer" -> {
if (!node.isIntegralNumber()) {
errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "number" -> {
if (!node.isNumber()) {
errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "boolean" -> {
if (!node.isBoolean()) {
errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
}
}
default -> errors.add(loc + ": 契约测试不支持的 type " + type);
}
}
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys — e.g.
* the outer {@code nullable} — win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
return;
}
Map<String, Object> props = map(schema, "properties");
List<Object> required = list(schema, "required");
if (required != null) {
for (Object r : required) {
if (!node.has((String) r)) {
errors.add(loc + "." + r + ": 契约必填字段缺失");
}
}
}
Object additional = schema.get("additionalProperties");
boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
Map<String, Object> propSchema = props == null ? null : cast(props.get(field.getKey()));
if (propSchema != null) {
validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
} else if (!open) {
errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
}
}
}
private void validateArray(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isArray()) {
errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
return;
}
Map<String, Object> items = map(schema, "items");
if (items == null) {
return;
}
int i = 0;
for (JsonNode element : node) {
validate(items, element, loc + "[" + i++ + "]", errors);
}
}
private void validateString(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isTextual()) {
errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
return;
}
String value = node.asText();
String format = (String) schema.get("format");
if (format != null) {
try {
switch (format) {
case "uuid" -> {
if (value.length() != 36) {
throw new IllegalArgumentException("非规范 UUID 长度");
}
java.util.UUID.fromString(value);
}
case "date-time" -> OffsetDateTime.parse(value);
case "date" -> LocalDate.parse(value);
default -> { /* password 等纯标注格式不校验 */ }
}
} catch (IllegalArgumentException | DateTimeParseException e) {
errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
}
}
if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
}
if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
}
}
private static void checkRange(Map<String, Object> schema, BigDecimal value, String loc, List<String> errors) {
if (schema.get("minimum") instanceof Number min
&& value.compareTo(new BigDecimal(min.toString())) < 0) {
errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
}
if (schema.get("maximum") instanceof Number max
&& value.compareTo(new BigDecimal(max.toString())) > 0) {
errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
}
}
private static boolean enumMatches(List<Object> allowed, JsonNode node) {
if (node.isTextual()) {
return allowed.contains(node.asText());
}
if (node.isIntegralNumber()) {
long v = node.longValue();
return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
}
return false;
}
}
@@ -0,0 +1,151 @@
package com.patbond.patbond.community.contract;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.4.0.yaml}.
*
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
* snapshot is a byte-identical copy taken at freeze time, and this class is
* the module-local copy of the pet module's contract framework (same
* per-module duplication discipline as BearerAuthFilter). Whenever the
* canonical contract changes, copy it into every framework-carrying module
* (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
* new version's file name and update each conformance test (expected version
* + snapshot counts). The guard test on {@code info.version} makes a forgotten
* sync fail loudly in CI instead of silently testing against a stale
* contract.
*
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items}, and the v1.3.0
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
* {@link ContractValidator}) — no oneOf/anyOf.
*/
final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
private final Map<String, Object> root;
private OpenApiContract(Map<String, Object> root) {
this.root = root;
}
static OpenApiContract load() {
try (InputStream in = Objects.requireNonNull(
OpenApiContract.class.getResourceAsStream(RESOURCE),
"契约快照缺失: " + RESOURCE)) {
return new OpenApiContract(new Yaml().load(in));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
String version() {
return (String) map(root, "info").get("version");
}
Map<String, Object> paths() {
return map(root, "paths");
}
Map<String, Object> schemas() {
return map(map(root, "components"), "schemas");
}
/** All declared operations as "METHOD pathTemplate" (insertion order). */
Set<String> operations() {
Set<String> ops = new LinkedHashSet<>();
paths().forEach((path, item) -> cast(item).forEach((method, op) -> {
if (HTTP_METHODS.contains(method)) {
ops.add(method.toUpperCase(Locale.ROOT) + " " + path);
}
}));
return ops;
}
/** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */
Set<String> operationsTagged(Set<String> tags) {
Set<String> ops = new LinkedHashSet<>();
for (String key : operations()) {
List<Object> opTags = list(operation(key), "tags");
if (opTags != null && opTags.stream().anyMatch(tags::contains)) {
ops.add(key);
}
}
return ops;
}
/** Declared response statuses of an operation, as ints. */
Set<Integer> responseStatuses(String operationKey) {
Set<Integer> statuses = new LinkedHashSet<>();
map(operation(operationKey), "responses")
.keySet().forEach(s -> statuses.add(Integer.parseInt(s)));
return statuses;
}
/** The single 2xx status the operation declares. */
int successStatus(String operationKey) {
return responseStatuses(operationKey).stream()
.filter(s -> s >= 200 && s < 300)
.reduce((a, b) -> {
throw new IllegalStateException("多个 2xx 响应: " + operationKey);
})
.orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey));
}
/** Operation object for "METHOD pathTemplate", or null when undeclared. */
Map<String, Object> operation(String operationKey) {
String[] parts = operationKey.split(" ", 2);
Map<String, Object> pathItem = map(paths(), parts[1]);
return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT));
}
/** Follows local $ref chains; non-ref maps come back unchanged. */
Map<String, Object> resolve(Map<String, Object> node) {
while (node != null && node.get("$ref") instanceof String ref) {
if (!ref.startsWith("#/")) {
throw new IllegalStateException("仅支持本地 $ref: " + ref);
}
Map<String, Object> cur = root;
for (String seg : ref.substring(2).split("/")) {
cur = map(cur, seg);
if (cur == null) {
throw new IllegalStateException("$ref 指向不存在的节点: " + ref);
}
}
node = cur;
}
return node;
}
@SuppressWarnings("unchecked")
static Map<String, Object> cast(Object o) {
return (Map<String, Object>) o;
}
static Map<String, Object> map(Map<String, Object> m, String key) {
return m == null ? null : cast(m.get(key));
}
@SuppressWarnings("unchecked")
static List<Object> list(Map<String, Object> m, String key) {
return m == null ? null : (List<Object>) m.get(key);
}
}
@@ -0,0 +1,363 @@
package com.patbond.patbond.community.interaction;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.post.PostApiTestBase;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-07 flat comments on the real database: the six canonical paths, the
* ADR-019 keyed-idempotency matrix, the interaction-surface 40403 merge,
* DESC keyset pagination and the same-transaction comment_count invariant.
* These assertions are T3-10 freeze input for the comment domain.
*/
class CommentIntegrationTest extends PostApiTestBase {
@Test
void createCommentReturnsFullShapeAndBumpsCount() throws Exception {
UUID author = newUser();
UUID commenter = newUser();
CommunityTestData.setNickname(jdbcClient, commenter, "毛豆妈");
String postId = publishPost(author);
mockMvc.perform(commentRequest(commenter, postId, UUID.randomUUID().toString(),
"{\"content\": \" 说得好! \"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.postId").value(postId))
.andExpect(jsonPath("$.data.author.userId").value(commenter.toString()))
.andExpect(jsonPath("$.data.author.nickname").value("毛豆妈"))
.andExpect(jsonPath("$.data.replyToUser").isEmpty())
.andExpect(jsonPath("$.data.content").value("说得好!"))
.andExpect(jsonPath("$.data.createdAt").isNotEmpty());
mockMvc.perform(authed(get("/api/v1/posts/" + postId), author))
.andExpect(jsonPath("$.data.commentCount").value(1));
}
@Test
void createWithReplyToUserCarriesReplySummary() throws Exception {
UUID author = newUser();
UUID replyTarget = newUser();
CommunityTestData.setNickname(jdbcClient, replyTarget, "被@的人");
String postId = publishPost(author);
mockMvc.perform(commentRequest(author, postId, UUID.randomUUID().toString(),
"{\"content\": \"回复你\", \"replyToUserId\": \"" + replyTarget + "\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.replyToUser.userId").value(replyTarget.toString()))
.andExpect(jsonPath("$.data.replyToUser.nickname").value("被@的人"));
}
@Test
void contentAndKeyValidationAnswer40000() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
"{\"content\": \" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
"{\"content\": \"" + "".repeat(2001) + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// Idempotency-Key: missing header, blank, oversized
mockMvc.perform(authed(post("/api/v1/posts/" + postId + "/comments"), user)
.content("{\"content\": \"没带键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(commentRequest(user, postId, " ", "{\"content\": \"空白键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(commentRequest(user, postId, "k".repeat(129), "{\"content\": \"超长键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void replyToAbsentOrDeletedUserAnswers40406() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
UUID ghost = UUID.randomUUID();
UUID cancelled = newUser();
jdbcClient.sql("UPDATE identity.users SET status = 'deleted', deleted_at = now()"
+ " WHERE id = :id")
.param("id", cancelled)
.update();
for (UUID target : List.of(ghost, cancelled)) {
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
"{\"content\": \"@不存在\", \"replyToUserId\": \"" + target + "\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406))
.andExpect(jsonPath("$.message").value("用户不存在"));
}
}
@Test
void commentPathsOnInvisiblePostsAnswerIdentical40403() throws Exception {
UUID author = newUser();
UUID stranger = newUser();
String ownDraft = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
String hidden = publishPost(author);
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(hidden))
.update();
String deleted = publishPost(author);
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
Set<String> bodies = new LinkedHashSet<>();
// own draft (the interaction surface is the PUBLIC face — the
// author's own draft is not commentable), hidden, deleted, absent
for (String target : List.of(ownDraft, hidden, deleted, UUID.randomUUID().toString())) {
MvcResult postResult = mockMvc.perform(
commentRequest(author, target, UUID.randomUUID().toString(),
"{\"content\": \"评一下\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
bodies.add(postResult.getResponse().getContentAsString());
MvcResult listResult = mockMvc.perform(
authed(get("/api/v1/posts/" + target + "/comments"), stranger))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
bodies.add(listResult.getResponse().getContentAsString());
}
// anti-enumeration: every invisible case is byte-identical
assertThat(bodies).hasSize(1);
}
@Test
void keyedReplayReturnsFirstCommentWithoutDoubleCounting() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String key = UUID.randomUUID().toString();
String first = data(mockMvc.perform(commentRequest(user, postId, key,
"{\"content\": \"就一条\"}"))
.andExpect(status().isCreated())
.andReturn()).get("id").asText();
String replay = data(mockMvc.perform(commentRequest(user, postId, key,
"{\"content\": \"就一条\"}"))
.andExpect(status().isCreated())
.andReturn()).get("id").asText();
assertThat(replay).isEqualTo(first);
assertThat(countRows("community.comments", "post_id", postId)).isEqualTo(1);
mockMvc.perform(authed(get("/api/v1/posts/" + postId), user))
.andExpect(jsonPath("$.data.commentCount").value(1));
}
@Test
void sameKeyDifferentPayloadAnswers40905() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String key = UUID.randomUUID().toString();
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"\"}"))
.andExpect(status().isCreated());
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40905));
}
@Test
void idempotencyKeysAreScopedPerAuthor() throws Exception {
UUID one = newUser();
UUID two = newUser();
String postId = publishPost(one);
String shared = UUID.randomUUID().toString();
mockMvc.perform(commentRequest(one, postId, shared, "{\"content\": \"同键\"}"))
.andExpect(status().isCreated());
mockMvc.perform(commentRequest(two, postId, shared, "{\"content\": \"同键\"}"))
.andExpect(status().isCreated());
assertThat(countRows("community.comments", "post_id", postId)).isEqualTo(2);
}
@Test
void replayAfterFirstCommentDeletedAnswers40404() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String key = UUID.randomUUID().toString();
String commentId = data(mockMvc.perform(commentRequest(user, postId, key,
"{\"content\": \"将被删\"}"))
.andReturn()).get("id").asText();
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), user))
.andExpect(status().isOk());
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"将被删\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
}
@Test
void listPagesNewestFirstWithoutLossOrOverlap() throws Exception {
UUID author = newUser();
UUID reader = newUser();
String postId = publishPost(author);
List<String> created = new ArrayList<>();
for (int i = 0; i < 7; i++) {
created.add(data(mockMvc.perform(commentRequest(author, postId,
UUID.randomUUID().toString(), "{\"content\": \"评论" + i + "\"}"))
.andReturn()).get("id").asText());
}
String deletedId = created.get(3);
mockMvc.perform(authed(delete("/api/v1/comments/" + deletedId), author))
.andExpect(status().isOk());
List<String> seen = new ArrayList<>();
String cursor = null;
for (int page = 0; page < 3; page++) {
String url = "/api/v1/posts/" + postId + "/comments?limit=3"
+ (cursor == null ? "" : "&cursor=" + cursor);
JsonNode body = data(mockMvc.perform(authed(get(url), reader))
.andExpect(status().isOk())
.andReturn());
body.get("items").forEach(item -> seen.add(item.get("id").asText()));
if (!body.get("hasMore").asBoolean()) {
assertThat(body.get("nextCursor").isNull()).isTrue();
break;
}
cursor = body.get("nextCursor").asText();
}
List<String> expected = new ArrayList<>(created);
java.util.Collections.reverse(expected);
expected.remove(deletedId);
assertThat(seen).containsExactlyElementsOf(expected);
}
@Test
void listRejectsBadPagingInput() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments?limit=0"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments?cursor=不是游标"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/posts/不是UUID/comments"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void deleteWalksThePermissionBoundary() throws Exception {
UUID author = newUser();
UUID commenter = newUser();
UUID stranger = newUser();
String postId = publishPost(author);
String commentId = data(mockMvc.perform(commentRequest(commenter, postId,
UUID.randomUUID().toString(), "{\"content\": \"别人的评论\"}"))
.andReturn()).get("id").asText();
// a visible comment deleted by a non-author (the post's owner
// included — D3-7: 帖主删他人评论首版不做) is 403/40301
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), stranger))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), author))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), commenter))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
String state = jdbcClient.sql(
"SELECT status || ':' || (deleted_at IS NOT NULL) FROM community.comments"
+ " WHERE id = :id")
.param("id", UUID.fromString(commentId))
.query(String.class)
.single();
assertThat(state).isEqualTo("deleted:true");
// repeat delete and absent id merge into 404/40404
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), commenter))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
mockMvc.perform(authed(delete("/api/v1/comments/" + UUID.randomUUID()), commenter))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
}
@Test
void deleteOnCommentOfDeletedPostAnswers40404() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String commentId = data(mockMvc.perform(commentRequest(user, postId,
UUID.randomUUID().toString(), "{\"content\": \"帖没了\"}"))
.andReturn()).get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + postId), user))
.andExpect(status().isOk());
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), user))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
}
@Test
void commentCountReconcilesWithVisibleRows() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
List<String> ids = new ArrayList<>();
for (int i = 0; i < 3; i++) {
ids.add(data(mockMvc.perform(commentRequest(user, postId,
UUID.randomUUID().toString(), "{\"content\": \"" + i + "\"}"))
.andReturn()).get("id").asText());
}
mockMvc.perform(authed(delete("/api/v1/comments/" + ids.get(0)), user))
.andExpect(status().isOk());
long column = jdbcClient.sql("SELECT comment_count FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
long visible = jdbcClient.sql("""
SELECT count(*) FROM community.comments
WHERE post_id = :id AND status = 'visible'
""")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
assertThat(column).isEqualTo(2).isEqualTo(visible);
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments"), user))
.andExpect(jsonPath("$.data.items.length()").value(2));
}
private String publishPost(UUID author) throws Exception {
return createPost(author, "{\"content\": \"被评论的帖子\", \"status\": \"published\"}")
.get("id").asText();
}
private MockHttpServletRequestBuilder commentRequest(UUID userId, String postId, String key,
String body) {
return authed(post("/api/v1/posts/" + postId + "/comments"), userId)
.header("Idempotency-Key", key)
.content(body);
}
private long countRows(String table, String column, String value) {
return jdbcClient.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :value")
.param("value", UUID.fromString(value))
.query(Long.class)
.single();
}
}
@@ -0,0 +1,156 @@
package com.patbond.patbond.community.interaction;
import com.patbond.patbond.community.post.PostApiTestBase;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-07 minimal follow surface on the real database: idempotent
* follow/unfollow with authoritative state, the 42204 self-follow gate,
* the 40406 target gate (absent and 注销 merged), the numbers endpoint
* and true concurrent convergence on the composite primary key.
*/
class FollowIntegrationTest extends PostApiTestBase {
@Test
void followLifecycleIsIdempotentWithAuthoritativeState() throws Exception {
UUID follower = newUser();
UUID target = newUser();
mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), follower))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.following").value(true))
.andExpect(jsonPath("$.data.followerCount").value(1));
mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), follower))
.andExpect(jsonPath("$.data.following").value(true))
.andExpect(jsonPath("$.data.followerCount").value(1));
mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), follower))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.following").value(false))
.andExpect(jsonPath("$.data.followerCount").value(0));
mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), follower))
.andExpect(jsonPath("$.data.following").value(false))
.andExpect(jsonPath("$.data.followerCount").value(0));
assertThat(followRows(target)).isEqualTo(0);
}
@Test
void selfFollowIsRejectedWith42204() throws Exception {
UUID user = newUser();
mockMvc.perform(authed(put("/api/v1/users/" + user + "/follow"), user))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42204))
.andExpect(jsonPath("$.message").value("不能关注自己"));
// DELETE stays a plain idempotent no-op — the row cannot exist
mockMvc.perform(authed(delete("/api/v1/users/" + user + "/follow"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.following").value(false));
}
@Test
void absentOrCancelledTargetAnswers40406OnEveryPath() throws Exception {
UUID caller = newUser();
UUID ghost = UUID.randomUUID();
UUID cancelled = newUser();
jdbcClient.sql("UPDATE identity.users SET status = 'deleted', deleted_at = now()"
+ " WHERE id = :id")
.param("id", cancelled)
.update();
for (UUID target : List.of(ghost, cancelled)) {
mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), caller))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406));
mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), caller))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406));
mockMvc.perform(authed(get("/api/v1/users/" + target + "/follow-stats"), caller))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406));
}
mockMvc.perform(authed(put("/api/v1/users/不是UUID/follow"), caller))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void followStatsCountBothDirectionsWithViewerFlag() throws Exception {
UUID alice = newUser();
UUID bob = newUser();
UUID carol = newUser();
// alice→bob, carol→bob, bob→alice
mockMvc.perform(authed(put("/api/v1/users/" + bob + "/follow"), alice))
.andExpect(status().isOk());
mockMvc.perform(authed(put("/api/v1/users/" + bob + "/follow"), carol))
.andExpect(status().isOk());
mockMvc.perform(authed(put("/api/v1/users/" + alice + "/follow"), bob))
.andExpect(status().isOk());
mockMvc.perform(authed(get("/api/v1/users/" + bob + "/follow-stats"), alice))
.andExpect(jsonPath("$.data.followerCount").value(2))
.andExpect(jsonPath("$.data.followingCount").value(1))
.andExpect(jsonPath("$.data.followedByMe").value(true));
mockMvc.perform(authed(get("/api/v1/users/" + alice + "/follow-stats"), carol))
.andExpect(jsonPath("$.data.followerCount").value(1))
.andExpect(jsonPath("$.data.followingCount").value(1))
.andExpect(jsonPath("$.data.followedByMe").value(false));
// asking about oneself: followedByMe is definitionally false
mockMvc.perform(authed(get("/api/v1/users/" + bob + "/follow-stats"), bob))
.andExpect(jsonPath("$.data.followerCount").value(2))
.andExpect(jsonPath("$.data.followedByMe").value(false));
}
@Test
void concurrentDuplicateFollowsLandExactlyOneRow() throws Exception {
UUID follower = newUser();
UUID target = newUser();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(3);
try {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < 3; i++) {
results.add(pool.submit(() -> {
start.await();
return mockMvc.perform(
authed(put("/api/v1/users/" + target + "/follow"), follower))
.andReturn().getResponse().getStatus();
}));
}
start.countDown();
for (Future<Integer> result : results) {
assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(200);
}
} finally {
pool.shutdownNow();
}
assertThat(followRows(target)).isEqualTo(1);
mockMvc.perform(authed(get("/api/v1/users/" + target + "/follow-stats"), follower))
.andExpect(jsonPath("$.data.followerCount").value(1));
}
private long followRows(UUID followee) {
return jdbcClient.sql("""
SELECT count(*) FROM community.user_follows
WHERE followee_user_id = :id
""")
.param("id", followee)
.query(Long.class)
.single();
}
}
@@ -0,0 +1,295 @@
package com.patbond.patbond.community.interaction;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.post.PostApiTestBase;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-06 idempotent like/bookmark on the real database: the authoritative
* terminal-state responses, TRUE concurrent convergence on the composite
* primary key (the M3 acceptance criterion: N concurrent PUTs count
* exactly 1), the 40403 interaction gate, the my-bookmarks keyset list
* with silent removal, and column-vs-relation reconciliation.
*/
class LikeBookmarkIntegrationTest extends PostApiTestBase {
@Test
void likeLifecycleIsIdempotentWithAuthoritativeState() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(true))
.andExpect(jsonPath("$.data.likeCount").value(1));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(true))
.andExpect(jsonPath("$.data.likeCount").value(1));
assertThat(likeRows(postId)).isEqualTo(1);
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(false))
.andExpect(jsonPath("$.data.likeCount").value(0));
// cancelling a like that does not exist neither errors nor
// decrements (工单验收)
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(false))
.andExpect(jsonPath("$.data.likeCount").value(0));
assertThat(likeRows(postId)).isEqualTo(0);
}
@Test
void bookmarkLifecycleIsIdempotentWithAuthoritativeState() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.bookmarked").value(true))
.andExpect(jsonPath("$.data.bookmarkCount").value(1));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(jsonPath("$.data.bookmarkCount").value(1));
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(jsonPath("$.data.bookmarked").value(false))
.andExpect(jsonPath("$.data.bookmarkCount").value(0));
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(jsonPath("$.data.bookmarkCount").value(0));
}
@Test
void distinctUsersAccumulateAndSurfaceInDetail() throws Exception {
UUID author = newUser();
UUID other = newUser();
String postId = publishPost(author);
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), author))
.andExpect(jsonPath("$.data.likeCount").value(1));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), other))
.andExpect(jsonPath("$.data.likeCount").value(2));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), other))
.andExpect(jsonPath("$.data.bookmarkCount").value(1));
mockMvc.perform(authed(get("/api/v1/posts/" + postId), other))
.andExpect(jsonPath("$.data.likeCount").value(2))
.andExpect(jsonPath("$.data.bookmarkCount").value(1))
.andExpect(jsonPath("$.data.likedByMe").value(true))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(true));
mockMvc.perform(authed(get("/api/v1/posts/" + postId), author))
.andExpect(jsonPath("$.data.likedByMe").value(true))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false));
}
@Test
void interactionsOnInvisiblePostsAnswerIdentical40403() throws Exception {
UUID author = newUser();
String ownDraft = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
String hidden = publishPost(author);
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(hidden))
.update();
String deleted = publishPost(author);
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
Set<String> bodies = new LinkedHashSet<>();
for (String target : List.of(ownDraft, hidden, deleted, UUID.randomUUID().toString())) {
for (String action : List.of("like", "bookmark")) {
MvcResult puts = mockMvc.perform(
authed(put("/api/v1/posts/" + target + "/" + action), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
MvcResult deletes = mockMvc.perform(
authed(delete("/api/v1/posts/" + target + "/" + action), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
bodies.add(puts.getResponse().getContentAsString());
bodies.add(deletes.getResponse().getContentAsString());
}
}
assertThat(bodies).hasSize(1);
}
@Test
void concurrentDuplicatePutsCountExactlyOne() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < 4; i++) {
results.add(pool.submit(() -> {
start.await();
return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
}));
}
start.countDown();
for (Future<Integer> result : results) {
assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(200);
}
} finally {
pool.shutdownNow();
}
assertThat(likeRows(postId)).isEqualTo(1);
assertThat(likeColumn(postId)).isEqualTo(1);
}
@Test
void concurrentPutAndDeleteConvergeOnConsistentTerminalState() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<Integer> putting = pool.submit(() -> {
start.await();
return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
});
Future<Integer> deleting = pool.submit(() -> {
start.await();
return mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
});
start.countDown();
assertThat(putting.get(30, TimeUnit.SECONDS)).isEqualTo(200);
assertThat(deleting.get(30, TimeUnit.SECONDS)).isEqualTo(200);
} finally {
pool.shutdownNow();
}
// whichever order the race resolved in, the column agrees with the
// relation table — never a phantom count
assertThat(likeColumn(postId)).isEqualTo(likeRows(postId));
}
@Test
void countColumnsReconcileWithRelationRowsAfterMixedOps() throws Exception {
UUID author = newUser();
UUID second = newUser();
UUID third = newUser();
String postId = publishPost(author);
for (UUID user : List.of(author, second, third)) {
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk());
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(status().isOk());
}
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), second))
.andExpect(status().isOk());
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), third))
.andExpect(status().isOk());
assertThat(likeColumn(postId)).isEqualTo(2).isEqualTo(likeRows(postId));
long bookmarkColumn = jdbcClient.sql(
"SELECT bookmark_count FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
long bookmarkRows = jdbcClient.sql(
"SELECT count(*) FROM community.post_bookmarks WHERE post_id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
assertThat(bookmarkColumn).isEqualTo(2).isEqualTo(bookmarkRows);
}
@Test
void myBookmarksPagesByBookmarkTimeAndDropsInvisible() throws Exception {
UUID author = newUser();
UUID reader = newUser();
List<String> posts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
posts.add(publishPost(author));
}
for (String postId : posts) {
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), reader))
.andExpect(status().isOk());
}
// one bookmarked post soft-deleted, one hidden → silently dropped
mockMvc.perform(authed(delete("/api/v1/posts/" + posts.get(1)), author))
.andExpect(status().isOk());
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(posts.get(3)))
.update();
List<String> seen = new ArrayList<>();
String cursor = null;
for (int page = 0; page < 3; page++) {
String url = "/api/v1/me/bookmarks?limit=2"
+ (cursor == null ? "" : "&cursor=" + cursor);
JsonNode body = data(mockMvc.perform(authed(get(url), reader))
.andExpect(status().isOk())
.andReturn());
for (JsonNode item : body.get("items")) {
seen.add(item.get("id").asText());
// the item IS the feed card: cover-less text post, counts,
// viewer flags, non-null publishedAt
assertThat(item.get("bookmarkedByMe").asBoolean()).isTrue();
assertThat(item.get("publishedAt").isNull()).isFalse();
assertThat(item.has("contentPreview")).isTrue();
assertThat(item.has("content")).isFalse();
}
if (!body.get("hasMore").asBoolean()) {
break;
}
cursor = body.get("nextCursor").asText();
}
// bookmark order DESC (posts were bookmarked 0→4), invisible dropped
assertThat(seen).containsExactly(posts.get(4), posts.get(2), posts.get(0));
}
@Test
void myBookmarksRejectsBadPagingInput() throws Exception {
UUID user = newUser();
mockMvc.perform(authed(get("/api/v1/me/bookmarks?limit=101"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/me/bookmarks?cursor=损坏"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
private String publishPost(UUID author) throws Exception {
return createPost(author, "{\"content\": \"被互动的帖子\", \"status\": \"published\"}")
.get("id").asText();
}
private long likeRows(String postId) {
return jdbcClient.sql("SELECT count(*) FROM community.post_likes WHERE post_id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
}
private long likeColumn(String postId) {
return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
}
}
@@ -0,0 +1,150 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.StubAuthorProfileClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MvcResult;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* AuthorSummary 定型 (D3-9 方案 B / T3-05): the detail response's author
* backfill (closes T3-04 contract deviation #1), the server-side
* nickname→username fallback, avatar URL signing, the short-TTL cache and
* the degrade-don't-5xx semantics when the user service is unreachable.
* The Feign transport itself is covered by AuthorProfileClientWireTest.
*/
class AuthorProfileIntegrationTest extends PostApiTestBase {
@Autowired
private StubAuthorProfileClient stubClient;
@AfterEach
void restoreUserService() {
stubClient.setUnavailable(false);
}
private JsonNode detail(UUID viewer, String postId) throws Exception {
MvcResult result = mockMvc.perform(authed(get("/api/v1/posts/" + postId), viewer))
.andExpect(status().isOk())
.andReturn();
return data(result);
}
@Test
void detailBackfillsTheAuthorSummaryWithTheNickname() throws Exception {
UUID author = newUser();
CommunityTestData.setNickname(jdbcClient, author, "毛毛的铲屎官");
JsonNode post = createPost(author, "{\"content\": \"作者摘要\", \"status\": \"published\"}");
JsonNode summary = detail(newUser(), post.get("id").asText()).get("author");
assertThat(summary.get("userId").asText()).isEqualTo(author.toString());
assertThat(summary.get("nickname").asText()).isEqualTo("毛毛的铲屎官");
assertThat(summary.get("avatarUrl").isNull()).isTrue();
}
@Test
void nicknameFallsBackToUsernameServerSide() throws Exception {
UUID author = newUser();
String username = jdbcClient.sql("SELECT username::text FROM identity.users WHERE id = :id")
.param("id", author)
.query(String.class)
.single();
JsonNode post = createPost(author, "{\"content\": \"回退昵称\", \"status\": \"published\"}");
JsonNode summary = detail(newUser(), post.get("id").asText()).get("author");
assertThat(summary.get("nickname").asText()).isEqualTo(username);
}
@Test
void readyAvatarBecomesASignedUrlAndUnreadyStaysNull() throws Exception {
UUID withReady = newUser();
UUID readyAsset = CommunityTestData.attachAvatar(jdbcClient, withReady, "ready");
UUID withUploading = newUser();
CommunityTestData.attachAvatar(jdbcClient, withUploading, "uploading");
JsonNode readyPost = createPost(withReady, "{\"content\": \"有头像\", \"status\": \"published\"}");
JsonNode uploadingPost = createPost(withUploading, "{\"content\": \"头像未就绪\", \"status\": \"published\"}");
UUID viewer = newUser();
JsonNode readySummary = detail(viewer, readyPost.get("id").asText()).get("author");
assertThat(readySummary.get("avatarUrl").asText())
.contains(readyAsset.toString())
.contains("X-Amz-Signature");
JsonNode uploadingSummary = detail(viewer, uploadingPost.get("id").asText()).get("author");
assertThat(uploadingSummary.get("avatarUrl").isNull()).isTrue();
}
@Test
void secondLookupWithinTheTtlIsServedFromTheCache() throws Exception {
UUID author = newUser();
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "缓存命中");
UUID viewer = newUser();
int before = stubClient.invocationCount();
detail(viewer, postId.toString());
int afterFirst = stubClient.invocationCount();
detail(viewer, postId.toString());
int afterSecond = stubClient.invocationCount();
assertThat(afterFirst - before).isEqualTo(1);
assertThat(afterSecond - afterFirst).isZero();
}
@Test
void unreachableUserServiceDegradesToIdOnlyInsteadOf5xx() throws Exception {
UUID author = newUser();
CommunityTestData.setNickname(jdbcClient, author, "看不见的昵称");
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "降级帖");
stubClient.setUnavailable(true);
JsonNode summary = detail(newUser(), postId.toString()).get("author");
assertThat(summary.get("userId").asText()).isEqualTo(author.toString());
assertThat(summary.get("nickname").isNull()).isTrue();
assertThat(summary.get("avatarUrl").isNull()).isTrue();
}
@Test
void degradedFeedStillServesEveryCard() throws Exception {
UUID author = newUser();
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "降级 Feed");
stubClient.setUnavailable(true);
MvcResult result = mockMvc.perform(
authed(get("/api/v1/feed").queryParam("limit", "100"), newUser()))
.andExpect(status().isOk())
.andReturn();
JsonNode items = data(result).get("items");
JsonNode card = null;
for (JsonNode item : items) {
if (item.get("id").asText().equals(postId.toString())) {
card = item;
}
}
assertThat(card).isNotNull();
assertThat(card.get("author").get("userId").asText()).isEqualTo(author.toString());
assertThat(card.get("author").get("nickname").isNull()).isTrue();
}
@Test
void aFailedLookupIsNotCachedSoTheNextRequestRecovers() throws Exception {
UUID author = newUser();
CommunityTestData.setNickname(jdbcClient, author, "恢复后的昵称");
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "降级不缓存");
UUID viewer = newUser();
stubClient.setUnavailable(true);
JsonNode degraded = detail(viewer, postId.toString()).get("author");
assertThat(degraded.get("nickname").isNull()).isTrue();
stubClient.setUnavailable(false);
JsonNode recovered = detail(viewer, postId.toString()).get("author");
assertThat(recovered.get("nickname").asText()).isEqualTo("恢复后的昵称");
}
}
@@ -0,0 +1,133 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Feed card field 定型 (T3-05, the FeedCard freeze input): the 200-code-point
* preview rule, cover selection from the unique is_cover row, mediaCount,
* counts read from the posts table's denormalized columns, and the
* viewer-relative flags.
*/
class FeedCardIntegrationTest extends PostApiTestBase {
private UUID viewer;
@BeforeEach
void wipeFeed() {
jdbcClient.sql("DELETE FROM community.posts").update();
viewer = newUser();
}
private JsonNode firstCard() throws Exception {
MvcResult result = mockMvc.perform(authed(get("/api/v1/feed"), viewer))
.andExpect(status().isOk())
.andReturn();
JsonNode items = data(result).get("items");
assertThat(items).hasSize(1);
return items.get(0);
}
@Test
void cardCarriesTheFrozenFieldSetForATextOnlyPost() throws Exception {
UUID author = newUser();
JsonNode post = createPost(author, """
{"title": "卡片字段", "content": "纯文字帖", "category": "help",
"status": "published"}
""");
JsonNode card = firstCard();
assertThat(card.get("id").asText()).isEqualTo(post.get("id").asText());
assertThat(card.get("author").get("userId").asText()).isEqualTo(author.toString());
assertThat(card.get("category").asText()).isEqualTo("help");
assertThat(card.get("title").asText()).isEqualTo("卡片字段");
assertThat(card.get("contentPreview").asText()).isEqualTo("纯文字帖");
assertThat(card.get("coverImage").isNull()).isTrue();
assertThat(card.get("mediaCount").asInt()).isZero();
assertThat(card.get("likeCount").asLong()).isZero();
assertThat(card.get("commentCount").asLong()).isZero();
assertThat(card.get("bookmarkCount").asLong()).isZero();
assertThat(card.get("likedByMe").asBoolean()).isFalse();
assertThat(card.get("bookmarkedByMe").asBoolean()).isFalse();
assertThat(card.get("publishedAt").asText()).contains("T");
// Trimmed relative to Post: no full content, no version, no visibility.
assertThat(card.has("content")).isFalse();
assertThat(card.has("version")).isFalse();
}
@Test
void previewCutsAtTwoHundredCodePointsWithoutSplittingSurrogates() throws Exception {
UUID author = newUser();
String content = "".repeat(199) + "🐱" + "这些字符必须被截掉";
createPost(author,
"{\"content\": \"%s\", \"status\": \"published\"}".formatted(content));
String preview = firstCard().get("contentPreview").asText();
assertThat(preview.codePointCount(0, preview.length())).isEqualTo(200);
assertThat(preview).isEqualTo("".repeat(199) + "🐱");
}
@Test
void shortContentIsPassedThroughVerbatim() throws Exception {
UUID author = newUser();
createPost(author, "{\"content\": \"刚好不截断\", \"status\": \"published\"}");
assertThat(firstCard().get("contentPreview").asText()).isEqualTo("刚好不截断");
}
@Test
void coverIsTheIsCoverRowAndMediaCountTheWholeSet() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
createPost(author, """
{"content": "两图帖", "status": "published",
"media": [{"assetId": "%s"}, {"assetId": "%s", "isCover": true}]}
""".formatted(assetA, assetB));
JsonNode card = firstCard();
assertThat(card.get("mediaCount").asInt()).isEqualTo(2);
JsonNode cover = card.get("coverImage");
assertThat(cover.get("assetId").asText()).isEqualTo(assetB.toString());
assertThat(cover.get("isCover").asBoolean()).isTrue();
assertThat(cover.get("url").asText())
.contains(assetB.toString())
.contains("X-Amz-Signature");
}
@Test
void countsComeFromTheDenormalizedColumnsAndFlagsFromTheRelationTables() throws Exception {
UUID author = newUser();
JsonNode post = createPost(author, "{\"content\": \"计数帖\", \"status\": \"published\"}");
UUID postId = UUID.fromString(post.get("id").asText());
jdbcClient.sql("""
UPDATE community.posts
SET like_count = 5, comment_count = 3, bookmark_count = 2
WHERE id = :id
""")
.param("id", postId)
.update();
jdbcClient.sql("""
INSERT INTO community.post_likes (post_id, user_id)
VALUES (:postId, :userId)
""")
.param("postId", postId)
.param("userId", viewer)
.update();
JsonNode card = firstCard();
assertThat(card.get("likeCount").asLong()).isEqualTo(5);
assertThat(card.get("commentCount").asLong()).isEqualTo(3);
assertThat(card.get("bookmarkCount").asLong()).isEqualTo(2);
assertThat(card.get("likedByMe").asBoolean()).isTrue();
assertThat(card.get("bookmarkedByMe").asBoolean()).isFalse();
}
}
@@ -0,0 +1,207 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Feed pagination 专项 (T3-05 工单要求): empty feed, single page, page
* walking with no loss and no duplication (including published_at ties and
* inserts/deletes between page fetches), cursor validity, and the
* visibility predicate. The feed is global state, so every test starts
* from a wiped community.posts (the FK cascades clear media/likes/
* bookmarks); other test classes create their own rows per test and run
* sequentially, so the wipe races nothing.
*/
class FeedPaginationIntegrationTest extends PostApiTestBase {
private UUID viewer;
@BeforeEach
void wipeFeed() {
jdbcClient.sql("DELETE FROM community.posts").update();
viewer = newUser();
}
private MockHttpServletRequestBuilder feed(UUID userId, Integer limit, String cursor) {
MockHttpServletRequestBuilder builder = authed(get("/api/v1/feed"), userId);
if (limit != null) {
builder = builder.queryParam("limit", String.valueOf(limit));
}
if (cursor != null) {
builder = builder.queryParam("cursor", cursor);
}
return builder;
}
private JsonNode feedPage(UUID userId, Integer limit, String cursor) throws Exception {
MvcResult result = mockMvc.perform(feed(userId, limit, cursor))
.andExpect(status().isOk())
.andReturn();
return data(result);
}
private UUID publish(UUID author, String content) throws Exception {
JsonNode post = createPost(author,
"{\"content\": \"%s\", \"status\": \"published\"}".formatted(content));
return UUID.fromString(post.get("id").asText());
}
private List<String> idsOf(JsonNode page) {
List<String> ids = new ArrayList<>();
page.get("items").forEach(item -> ids.add(item.get("id").asText()));
return ids;
}
@Test
void emptyFeedIsAnEmptyPage() throws Exception {
JsonNode page = feedPage(viewer, null, null);
assertThat(page.get("items")).isEmpty();
assertThat(page.get("hasMore").asBoolean()).isFalse();
assertThat(page.get("nextCursor").isNull()).isTrue();
}
@Test
void singlePageListsNewestFirstWithoutACursor() throws Exception {
UUID author = newUser();
UUID first = publish(author, "一号帖");
UUID second = publish(author, "二号帖");
JsonNode page = feedPage(viewer, null, null);
assertThat(idsOf(page)).containsExactly(second.toString(), first.toString());
assertThat(page.get("hasMore").asBoolean()).isFalse();
assertThat(page.get("nextCursor").isNull()).isTrue();
}
@Test
void onlyLivePublishedPublicPostsAppear() throws Exception {
UUID author = newUser();
UUID visible = publish(author, "可见的帖子");
createPost(author, "{\"content\": \"草稿不进 Feed\"}");
UUID deleted = publish(author, "删除后不进 Feed");
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
UUID hidden = publish(author, "hidden 不进 Feed");
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", hidden)
.update();
UUID nonPublic = publish(author, "followers 可见性不进 Feed");
jdbcClient.sql("UPDATE community.posts SET visibility = 'followers' WHERE id = :id")
.param("id", nonPublic)
.update();
JsonNode page = feedPage(viewer, null, null);
assertThat(idsOf(page)).containsExactly(visible.toString());
}
@Test
void pageWalkLosesNothingAndRepeatsNothing() throws Exception {
UUID author = newUser();
List<String> published = new ArrayList<>();
for (int i = 0; i < 7; i++) {
published.add(publish(author, "翻页帖 " + i).toString());
}
List<String> expected = new ArrayList<>(published);
java.util.Collections.reverse(expected);
List<String> crawled = new ArrayList<>();
String cursor = null;
int pages = 0;
while (true) {
JsonNode page = feedPage(viewer, 3, cursor);
crawled.addAll(idsOf(page));
pages++;
if (!page.get("hasMore").asBoolean()) {
assertThat(page.get("nextCursor").isNull()).isTrue();
break;
}
cursor = page.get("nextCursor").asText();
}
assertThat(pages).isEqualTo(3);
assertThat(crawled).containsExactlyElementsOf(expected);
}
@Test
void publishedAtTiesAreBrokenByIdWithoutLossOrDuplication() throws Exception {
UUID author = newUser();
List<UUID> ids = new ArrayList<>();
for (int i = 0; i < 3; i++) {
ids.add(publish(author, "同刻帖 " + i));
}
OffsetDateTime sameInstant = OffsetDateTime.now();
for (UUID id : ids) {
jdbcClient.sql("UPDATE community.posts SET published_at = :ts WHERE id = :id")
.param("ts", sameInstant)
.param("id", id)
.update();
}
List<String> expected = ids.stream()
.map(UUID::toString)
.sorted(java.util.Comparator.reverseOrder())
.toList();
JsonNode page1 = feedPage(viewer, 2, null);
JsonNode page2 = feedPage(viewer, 2, page1.get("nextCursor").asText());
List<String> crawled = new ArrayList<>(idsOf(page1));
crawled.addAll(idsOf(page2));
assertThat(crawled).containsExactlyElementsOf(expected);
assertThat(page2.get("hasMore").asBoolean()).isFalse();
}
@Test
void insertsAndDeletesBetweenPagesNeitherShiftNorRepeat() throws Exception {
UUID author = newUser();
List<UUID> ids = new ArrayList<>();
for (int i = 0; i < 5; i++) {
ids.add(publish(author, "间隙帖 " + i));
}
// Oldest→newest is ids[0..4]; page 1 (limit 2) shows ids[4], ids[3].
JsonNode page1 = feedPage(viewer, 2, null);
assertThat(idsOf(page1)).containsExactly(ids.get(4).toString(), ids.get(3).toString());
// Between the fetches: a new post lands (newer than the cursor — must
// NOT shift page 2) and one page-2 candidate is deleted (must vanish
// without repeating anything).
publish(author, "翻页间隙新发布");
mockMvc.perform(authed(delete("/api/v1/posts/" + ids.get(2)), author))
.andExpect(status().isOk());
JsonNode page2 = feedPage(viewer, 2, page1.get("nextCursor").asText());
assertThat(idsOf(page2)).containsExactly(ids.get(1).toString(), ids.get(0).toString());
assertThat(page2.get("hasMore").asBoolean()).isFalse();
}
@Test
void invalidCursorsAreA400() throws Exception {
mockMvc.perform(feed(viewer, null, "not-base64url!!"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(feed(viewer, null,
java.util.Base64.getUrlEncoder().encodeToString("garbage".getBytes())))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void limitOutOfBoundsIsA400() throws Exception {
mockMvc.perform(feed(viewer, 0, null))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(feed(viewer, 101, null))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
@@ -0,0 +1,237 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-06 获赞聚合:GET /api/v1/me/community-stats。口径(ADR-022,读侧实时
* 聚合,不引冗余列):
*
* <ul>
* <li>{@code receivedLikeCount} = 本人「已发布且未软删」帖的 like_count 之和;</li>
* <li>{@code publishedPostCount} = 同一集合的帖子数;</li>
* <li>草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、他人的帖不计;</li>
* <li>空数据答 0 而非 null,任何已认证用户都有 stats,从不 404。</li>
* </ul>
*/
class MeCommunityStatsIntegrationTest extends PostApiTestBase {
private static final String DRAFT = "{\"content\":\"草稿内容\"}";
private static final String PUBLISHED = "{\"content\":\"已发布内容\",\"status\":\"published\"}";
private JsonNode stats(UUID userId) throws Exception {
return data(mockMvc.perform(authed(get("/api/v1/me/community-stats"), userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn());
}
private void like(UUID actor, String postId) throws Exception {
mockMvc.perform(authed(put("/api/v1/posts/{postId}/like", postId), actor))
.andExpect(status().isOk());
}
// ---- 成功路径 + 空数据 ----------------------------------------------
@Test
void freshUserGetsZerosNotNullsAndNever404() throws Exception {
JsonNode stats = stats(newUser());
assertThat(stats.get("receivedLikeCount").isNull()).isFalse();
assertThat(stats.get("publishedPostCount").isNull()).isFalse();
assertThat(stats.get("receivedLikeCount").asLong()).isZero();
assertThat(stats.get("publishedPostCount").asLong()).isZero();
}
@Test
void sumsLikesAcrossThePublishedPostsOfTheCaller() throws Exception {
UUID author = newUser();
UUID fanA = newUser();
UUID fanB = newUser();
String first = createPost(author, PUBLISHED).get("id").asText();
String second = createPost(author, PUBLISHED).get("id").asText();
like(fanA, first);
like(fanB, first);
like(fanA, second);
JsonNode stats = stats(author);
assertThat(stats.get("receivedLikeCount").asLong()).isEqualTo(3);
assertThat(stats.get("publishedPostCount").asLong()).isEqualTo(2);
}
@Test
void countsSelfLikesExactlyAsThePerPostNumberDoes() throws Exception {
UUID author = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
like(author, postId);
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
}
@Test
void unlikingBringsTheNumberBackDown() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
like(fan, postId);
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
mockMvc.perform(authed(delete("/api/v1/posts/{postId}/like", postId), fan))
.andExpect(status().isOk());
assertThat(stats(author).get("receivedLikeCount").asLong()).isZero();
}
// ---- 口径边界:草稿 / 软删 / 他人 -----------------------------------
@Test
void draftsAreExcludedFromBothNumbers() throws Exception {
UUID author = newUser();
createPost(author, DRAFT);
createPost(author, DRAFT);
JsonNode before = stats(author);
assertThat(before.get("publishedPostCount").asLong()).isZero();
assertThat(before.get("receivedLikeCount").asLong()).isZero();
// 发布其中一篇后才计入
String draftId = createPost(author, DRAFT).get("id").asText();
mockMvc.perform(authed(patch("/api/v1/posts/{postId}", draftId), author)
.content("{\"version\":0,\"status\":\"published\"}"))
.andExpect(status().isOk());
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
}
@Test
void softDeletedPostsDropOutOfBothNumbers() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String keep = createPost(author, PUBLISHED).get("id").asText();
String doomed = createPost(author, PUBLISHED).get("id").asText();
like(fan, keep);
like(fan, doomed);
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(2);
mockMvc.perform(authed(delete("/api/v1/posts/{postId}", doomed), author))
.andExpect(status().isOk());
JsonNode after = stats(author);
assertThat(after.get("receivedLikeCount").asLong()).isEqualTo(1);
assertThat(after.get("publishedPostCount").asLong()).isEqualTo(1);
}
@Test
void otherPeoplesPostsNeverLeakIntoMyStats() throws Exception {
UUID me = newUser();
UUID other = newUser();
UUID fan = newUser();
String theirs = createPost(other, PUBLISHED).get("id").asText();
like(fan, theirs);
like(me, theirs);
JsonNode mine = stats(me);
assertThat(mine.get("receivedLikeCount").asLong()).isZero();
assertThat(mine.get("publishedPostCount").asLong()).isZero();
// 对方的数字是对方的
assertThat(stats(other).get("receivedLikeCount").asLong()).isEqualTo(2);
}
/**
* hidden/archived 是运营态(D3-7),在 M3 契约里对所有人不可见,因此也不计
* 入作品数——「看不到的帖不该出现在我的作品计数里」。
*/
@Test
void operationalStatesAreExcluded() throws Exception {
UUID author = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(postId))
.update();
assertThat(stats(author).get("publishedPostCount").asLong()).isZero();
}
// ---- 无权限 / 不存在 -------------------------------------------------
@Test
void requiresAValidAccessToken() throws Exception {
mockMvc.perform(get("/api/v1/me/community-stats"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(get("/api/v1/me/community-stats")
.header("Authorization", "Bearer not.a.jwt"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
/**
* 主体永远是 token 里的自己,路径上没有可枚举的 userId —— 「查不到别人的
* 获赞」不靠权限判断,而靠端点形态本身就没有别人的入口。
*/
@Test
void hasNoPathParameterToProbeSomeoneElse() throws Exception {
UUID me = newUser();
mockMvc.perform(authed(get("/api/v1/me/community-stats/{userId}", newUser()), me))
.andExpect(status().isNotFound());
}
// ---- 并发与重放 -----------------------------------------------------
/**
* 读侧聚合天然幂等:并发重复读必须给出同一答案,且不产生任何副作用
* (连续两次读的数字完全相同)。
*/
@Test
void concurrentAndRepeatedReadsAreIdenticalAndSideEffectFree() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
like(fan, postId);
CyclicBarrier startTogether = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<Long> read = () -> {
startTogether.await();
return stats(author).get("receivedLikeCount").asLong();
};
Future<Long> first = pool.submit(read);
Future<Long> second = pool.submit(read);
assertThat(first.get()).isEqualTo(1);
assertThat(second.get()).isEqualTo(1);
} finally {
pool.shutdownNow();
}
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
}
// ---- 形态回归 -------------------------------------------------------
@Test
void payloadCarriesExactlyTheTwoNumbers() throws Exception {
UUID author = newUser();
mockMvc.perform(authed(get("/api/v1/me/community-stats"), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.receivedLikeCount").value(0))
.andExpect(jsonPath("$.data.publishedPostCount").value(0))
.andExpect(jsonPath("$.data.followerCount").doesNotExist())
.andExpect(jsonPath("$.data.userId").doesNotExist());
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.community.TestcontainersConfiguration;
import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.StubAuthorProfileConfig;
import com.patbond.patbond.community.support.TestJwtKeys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -32,7 +33,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
@Import({TestcontainersConfiguration.class, StubAuthorProfileConfig.class})
public abstract class PostApiTestBase {
@Autowired
@@ -38,7 +38,7 @@ class PostLifecycleIntegrationTest extends PostApiTestBase {
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.authorId").value(author.toString()))
.andExpect(jsonPath("$.data.author.userId").value(author.toString()))
.andExpect(jsonPath("$.data.title").value("第一帖"))
.andExpect(jsonPath("$.data.content").value("大家好"))
.andExpect(jsonPath("$.data.category").value("general"))
@@ -25,6 +25,23 @@ public final class CommunityTestData {
return id;
}
public static void setNickname(JdbcClient jdbc, UUID userId, String nickname) {
jdbc.sql("UPDATE identity.users SET nickname = :nickname WHERE id = :id")
.param("nickname", nickname)
.param("id", userId)
.update();
}
/** Gives the user an avatar asset in the given status; returns the asset id. */
public static UUID attachAvatar(JdbcClient jdbc, UUID userId, String status) {
UUID assetId = insertAsset(jdbc, userId, status);
jdbc.sql("UPDATE identity.users SET avatar_asset_id = :assetId WHERE id = :id")
.param("assetId", assetId)
.param("id", userId)
.update();
return assetId;
}
/** One ready image asset owned by the given user, as T3-03 would leave it. */
public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) {
return insertAsset(jdbc, ownerUserId, "ready");
@@ -48,6 +65,24 @@ public final class CommunityTestData {
return id;
}
/**
* A published post inserted straight into community.posts — used when a
* test must NOT go through the create API (whose response assembly
* would already resolve and cache the author's profile).
*/
public static UUID insertPublishedPost(JdbcClient jdbc, UUID authorUserId, String content) {
UUID id = UuidV7.generate();
jdbc.sql("""
INSERT INTO community.posts (id, author_user_id, content, status, published_at)
VALUES (:id, :author, :content, 'published', now())
""")
.param("id", id)
.param("author", authorUserId)
.param("content", content)
.update();
return id;
}
public static UUID insertPetOwnedBy(JdbcClient jdbc, UUID ownerUserId) {
UUID id = UuidV7.generate();
jdbc.sql("""
@@ -0,0 +1,61 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.author.AuthorProfileClient;
import com.patbond.patbond.community.author.AuthorProfileDto;
import org.springframework.jdbc.core.simple.JdbcClient;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
/**
* In-process stand-in for patbond-user's /internal/users/profiles, wired in
* place of the Feign proxy (工单许可:Feign 层用替身,/internal 端点自身在
* patbond-user 模块测全;两服务同 JVM 的 AuthE2e 先例成本过高)。 It answers
* from identity.users with the same query the real endpoint runs — including
* the nickname→username fallback — so profile tests seed users exactly like
* every other cross-schema fixture. {@link #unavailable} simulates the user
* service being down (the gateway must degrade, not 5xx);
* {@link #invocations} makes the cache observable.
*/
public class StubAuthorProfileClient implements AuthorProfileClient {
private final JdbcClient jdbcClient;
private final AtomicInteger invocations = new AtomicInteger();
private volatile boolean unavailable;
public StubAuthorProfileClient(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public ApiResponse<List<AuthorProfileDto>> profiles(String ids) {
invocations.incrementAndGet();
if (unavailable) {
throw new IllegalStateException("stub: user service unavailable");
}
List<UUID> parsed = Arrays.stream(ids.split(",")).map(UUID::fromString).toList();
List<AuthorProfileDto> profiles = jdbcClient.sql("""
SELECT id, COALESCE(nickname, username::text) AS nickname, avatar_asset_id
FROM identity.users
WHERE id IN (:ids) AND deleted_at IS NULL
""")
.param("ids", parsed)
.query((rs, rowNum) -> new AuthorProfileDto(
rs.getObject("id", UUID.class),
rs.getString("nickname"),
rs.getObject("avatar_asset_id", UUID.class)))
.list();
return ApiResponse.success(profiles);
}
public void setUnavailable(boolean value) {
this.unavailable = value;
}
public int invocationCount() {
return invocations.get();
}
}
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.support;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* Replaces the AuthorProfileClient Feign proxy with the DB-backed stub for
* the shared post/feed test context. The Feign machinery itself (URL, token
* interceptor, envelope decoding) is exercised separately by
* AuthorProfileClientWireTest against a real HTTP server.
*/
@TestConfiguration(proxyBeanMethods = false)
public class StubAuthorProfileConfig {
@Bean
@Primary
public StubAuthorProfileClient stubAuthorProfileClient(JdbcClient jdbcClient) {
return new StubAuthorProfileClient(jdbcClient);
}
}
@@ -4,3 +4,12 @@
spring:
application:
name: patbond-community
patbond:
# Feign client wiring must resolve at context start. Author-profile tests
# either replace the client bean with a DB-backed stub or (the wire test)
# override this URL with an in-test HTTP server; nothing ever calls this
# unroutable address.
user-service:
url: http://127.0.0.1:1
internal-token: test-internal-token
+9
View File
@@ -43,6 +43,15 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Read-side media URL signing only (presigned GET is a local SigV4
computation): this service never talks to the object store, the
media write flow stays in patbond-user (ADR-016/017). Same
precedent as patbond-community's read side. Version managed by
the root pom's awssdk bom. -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned in step with patbond-user/auth. -->
<dependency>
@@ -0,0 +1,22 @@
package com.patbond.patbond.pet.config;
import com.patbond.patbond.pet.media.MediaUrlSigner;
import com.patbond.patbond.pet.media.PetMediaProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Read-side media wiring (T3.5-05): a presigned-GET signer over the same
* MinIO configuration patbond-user uses (ADR-016). Bean destruction closes
* the underlying presigner.
*/
@Configuration
@EnableConfigurationProperties(PetMediaProperties.class)
public class MediaConfig {
@Bean(destroyMethod = "close")
public MediaUrlSigner mediaUrlSigner(PetMediaProperties properties) {
return new MediaUrlSigner(properties);
}
}
@@ -9,6 +9,13 @@ import java.util.UUID;
* dictionary when {@code breedId} is set; exactly one of {@code breedId} /
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
* calling user's own pet_owners role — the client uses it to gate write UI.
*
* <p>{@code avatarUrl} (T3.5-05) is a freshly signed presigned GET against a
* private bucket: it EXPIRES and must never be persisted client-side (the
* client's image cache key strips the signature parameters). It is null both
* when the pet has no avatar and when the referenced asset is not (or no
* longer) ready, so "has an avatar" is exactly {@code avatarUrl != null}. The
* asset id is not echoed — the client only ever writes it.</p>
*/
public record PetResponse(
UUID id,
@@ -24,6 +31,7 @@ public record PetResponse(
String microchipNo,
LocalDate sterilizedOn,
String status,
String avatarUrl,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
@@ -16,6 +16,21 @@ import java.util.UUID;
* replaces the pair as a whole (they are mutually exclusive per
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
* the whole endpoint exists to enforce.
*
* <p><b>{@code avatarAssetId} is the one three-state field</b> (T3.5-05):
* absent = unchanged, explicit {@code null} = remove the avatar, value = set
* it. Removing an avatar is a first-class user action with no other way to
* express it, whereas the M2 fields either cannot be empty at all (name,
* species, sex) or are edited, not erased — so the asymmetry is deliberate
* and confined to this field. Presence is tracked in the setter: Jackson
* calls it exactly when the JSON key is present, including for an explicit
* null.</p>
*
* <p>Permission note: this endpoint is MANAGE (owner only), but an
* avatar-ONLY patch is WRITE (owner + caregiver) per ADR-022 — the avatar is
* day-to-day care information, same tier as weights and vaccinations. The
* required level is therefore computed from which fields the body touches;
* see PetService.</p>
*/
public class UpdatePetRequest {
@@ -56,6 +71,33 @@ public class UpdatePetRequest {
message = "status 仅支持 active/lost/deceased/archived")
private String status;
private UUID avatarAssetId;
private boolean avatarAssetIdPresent;
public UUID getAvatarAssetId() {
return avatarAssetId;
}
public void setAvatarAssetId(UUID avatarAssetId) {
this.avatarAssetId = avatarAssetId;
this.avatarAssetIdPresent = true;
}
public boolean isAvatarAssetIdPresent() {
return avatarAssetIdPresent;
}
/**
* True when the body touches any pet-profile field, i.e. anything beyond
* the avatar. {@code version} does not count — it is the lock, not an
* edit. Drives the MANAGE-vs-WRITE decision in PetService.
*/
public boolean touchesProfileFields() {
return name != null || breedId != null || customBreedName != null || sex != null
|| birthDate != null || birthDateEstimated != null || personality != null
|| microchipNo != null || sterilizedOn != null || status != null;
}
public Integer getVersion() {
return version;
}
@@ -0,0 +1,42 @@
package com.patbond.patbond.pet.media;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Read-only cross-schema access to media.assets — the pet side of the T3-03
* 联调协议 (business references accept only assets owned by the caller with
* status='ready' and the matching purpose). Same-database read was chosen
* over an internal HTTP call to patbond-user, exactly as patbond-community
* did (ADR-017 precedent: while the schemas share one database this is a
* cross-schema read; splitting the database later moves every such gateway to
* an internal API together). This class never writes media.assets — the media
* state machine belongs to patbond-user.
*/
@Repository
public class MediaAssetGateway {
private final JdbcClient jdbcClient;
public MediaAssetGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public Optional<MediaAssetRef> findById(UUID assetId) {
return jdbcClient.sql("""
SELECT id, owner_user_id, purpose, status
FROM media.assets
WHERE id = :id
""")
.param("id", assetId)
.query((rs, rowNum) -> new MediaAssetRef(
rs.getObject("id", UUID.class),
rs.getObject("owner_user_id", UUID.class),
rs.getString("purpose"),
rs.getString("status")))
.optional();
}
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.pet.media;
import java.util.UUID;
/**
* Read-only view of one media.assets row — exactly the columns the pet
* avatar flow needs for attach validation (owner, purpose, status).
*/
public record MediaAssetRef(UUID id, UUID ownerUserId, String purpose, String status) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.pet.media;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
/**
* Signs presigned GET URLs for pet avatars (T3-03 定型:private bucket +
* presigned GET, TTL configurable, signed fresh on every response — clients
* never persist the URL). Presigning is a local SigV4 computation against the
* public endpoint; this service never talks to the object store itself.
* Path-style addressing is forced because MinIO has no wildcard DNS for
* virtual-host-style buckets (same as patbond-user's S3ObjectStorage and
* patbond-community's signer). When unconfigured, {@link #signGet} returns
* null and pet responses degrade to {@code avatarUrl: null}.
*/
public class MediaUrlSigner implements AutoCloseable {
private final PetMediaProperties properties;
private final S3Presigner presigner;
public MediaUrlSigner(PetMediaProperties properties) {
this.properties = properties;
if (properties.getPublicEndpoint().isBlank()) {
this.presigner = null;
return;
}
this.presigner = S3Presigner.builder()
.endpointOverride(URI.create(properties.getPublicEndpoint()))
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
/** @return a presigned GET URL, or null when storage is unconfigured */
public String signGet(String bucket, String objectKey) {
if (presigner == null || bucket == null || objectKey == null) {
return null;
}
return presigner.presignGetObject(GetObjectPresignRequest.builder()
.signatureDuration(properties.getDownloadTtl())
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
.build())
.url()
.toString();
}
@Override
public void close() {
if (presigner != null) {
presigner.close();
}
}
}
@@ -0,0 +1,80 @@
package com.patbond.patbond.pet.media;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Read-side subset of the media object-storage configuration (T3.5-05). The
* write side — upload flow, mime/purpose whitelists, bucket init — lives in
* patbond-user's MediaProperties; this service only signs presigned GET URLs
* for pet avatars, a purely local SigV4 computation, so no S3 client is
* needed. Values reuse the same PATBOND_MINIO_* / PATBOND_MEDIA_*
* environment variables as patbond-user and patbond-community, keeping one
* set of knobs per deployment (ADR-016/021).
*/
@ConfigurationProperties(prefix = "patbond.media")
public class PetMediaProperties {
/**
* Endpoint presigned GET URLs are issued against — the address CLIENTS
* can reach. Empty means media is unconfigured for this service: pet
* responses carry {@code avatarUrl: null} (same degradation precedent as
* the missing JWT public key).
*/
private String publicEndpoint = "";
/** S3 access key; injected via environment, never committed (ADR-021). */
private String accessKey = "";
/** S3 secret key; injected via environment, never committed (ADR-021). */
private String secretKey = "";
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
private String region = "us-east-1";
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
private Duration downloadTtl = Duration.ofHours(1);
public String getPublicEndpoint() {
return publicEndpoint;
}
public void setPublicEndpoint(String publicEndpoint) {
this.publicEndpoint = publicEndpoint;
}
public String getAccessKey() {
return accessKey;
}
// setter 形参名取 valuecheck-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
public void setAccessKey(String value) {
this.accessKey = value;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String value) {
this.secretKey = value;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public Duration getDownloadTtl() {
return downloadTtl;
}
public void setDownloadTtl(Duration downloadTtl) {
this.downloadTtl = downloadTtl;
}
}
@@ -1,6 +1,5 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.PetResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@@ -16,6 +15,12 @@ import java.util.UUID;
* pet_health.pets + pet_owners access. All reads join pet_owners on the
* calling user so a row only comes back when a relationship exists — the
* repository layer itself never exposes another user's pet.
*
* <p>Reads return {@link PetRow}, not the API DTO: the avatar travels as
* storage coordinates (bucket + object key of a READY media asset) and the
* presigned URL is produced one layer up, in PetService — same split as
* patbond-community's PostRow → PostResponse assembly, and the reason a
* signed, expiring URL never leaks into a repository-level cache.</p>
*/
@Repository
public class PetRepository {
@@ -24,10 +29,13 @@ public class PetRepository {
SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name,
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
p.personality, p.microchip_no, p.sterilized_on, p.status,
p.avatar_asset_id,
av.bucket AS avatar_bucket, av.object_key AS avatar_object_key,
po.role, p.created_at, p.updated_at, p.version
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
LEFT JOIN pet_health.breeds b ON b.id = p.breed_id
LEFT JOIN media.assets av ON av.id = p.avatar_asset_id AND av.status = 'ready'
WHERE p.status <> 'deleted'
""";
@@ -37,6 +45,37 @@ public class PetRepository {
this.jdbcClient = jdbcClient;
}
/**
* One pet as stored, from the calling user's perspective.
* {@code avatarAssetId} is the raw column (so a PATCH that does not touch
* the avatar can carry it through unchanged), while the two storage
* columns are already narrowed to a READY asset — a dangling or
* still-uploading avatar yields nulls there (→ {@code avatarUrl: null})
* rather than a signed URL that would 404 at the object store.
*/
public record PetRow(
UUID id,
String name,
String species,
UUID breedId,
String breedDisplayName,
String customBreedName,
String sex,
LocalDate birthDate,
Boolean birthDateEstimated,
String personality,
String microchipNo,
LocalDate sterilizedOn,
String status,
UUID avatarAssetId,
String avatarBucket,
String avatarObjectKey,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
Integer version) {
}
public void insertPet(UUID petId, String name, String species, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
@@ -72,14 +111,14 @@ public class PetRepository {
.update();
}
public List<PetResponse> listByUser(UUID userId) {
public List<PetRow> listByUser(UUID userId) {
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
.param("userId", userId)
.query(PetRepository::mapPet)
.list();
}
public Optional<PetResponse> findByIdForUser(UUID petId, UUID userId) {
public Optional<PetRow> findByIdForUser(UUID petId, UUID userId) {
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
.param("userId", userId)
.param("petId", petId)
@@ -109,14 +148,16 @@ public class PetRepository {
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
String microchipNo, LocalDate sterilizedOn, String status) {
String microchipNo, LocalDate sterilizedOn, String status,
UUID avatarAssetId) {
return jdbcClient.sql("""
UPDATE pet_health.pets
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
sex = :sex, birth_date = :birthDate,
birth_date_estimated = :birthDateEstimated, personality = :personality,
microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
status = :status, version = version + 1
status = :status, avatar_asset_id = :avatarAssetId,
version = version + 1
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
""")
.param("petId", petId)
@@ -131,11 +172,12 @@ public class PetRepository {
.param("microchipNo", microchipNo)
.param("sterilizedOn", sterilizedOn)
.param("status", status)
.param("avatarAssetId", avatarAssetId)
.update();
}
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetResponse(
private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetRow(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("species"),
@@ -149,6 +191,9 @@ public class PetRepository {
rs.getString("microchip_no"),
rs.getObject("sterilized_on", LocalDate.class),
rs.getString("status"),
rs.getObject("avatar_asset_id", UUID.class),
rs.getString("avatar_bucket"),
rs.getString("avatar_object_key"),
rs.getString("role"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
@@ -7,8 +7,12 @@ import com.patbond.patbond.pet.access.PetAccessService;
import com.patbond.patbond.pet.dto.CreatePetRequest;
import com.patbond.patbond.pet.dto.PetResponse;
import com.patbond.patbond.pet.dto.UpdatePetRequest;
import com.patbond.patbond.pet.media.MediaAssetGateway;
import com.patbond.patbond.pet.media.MediaAssetRef;
import com.patbond.patbond.pet.media.MediaUrlSigner;
import com.patbond.patbond.pet.repository.BreedRepository;
import com.patbond.patbond.pet.repository.PetRepository;
import com.patbond.patbond.pet.repository.PetRepository.PetRow;
import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -23,19 +27,47 @@ import java.util.UUID;
* uq_pets_microchip) so clients get a stable business error instead of a
* constraint-violation 500 — the constraints stay as the last line of
* defense.
*
* <p>Avatar semantics (T3.5-05, ADR-022):
* <ul>
* <li>The write level is decided per REQUEST, not per endpoint: an
* avatar-only PATCH needs {@link AccessLevel#WRITE} (owner + caregiver
* — the avatar is day-to-day care information, same tier as weights and
* vaccinations), everything else stays {@link AccessLevel#MANAGE}
* (owner only). A body touching both is judged by the stricter half.
* Viewers are refused either way (403/40300).</li>
* <li>The referenced asset must exist, belong to the CALLER, carry
* {@code purpose='pet_avatar'} and be {@code ready} — the T3-03
* referencing protocol: unknown / someone else's / deleted → 404/40405
* (merged, anti-enumeration), wrong purpose → 404/40405 (a post image
* is not an avatar; reachable only for the caller's own assets, so the
* message may be specific), own pet_avatar asset still uploading or
* failed → 422/42203.</li>
* <li>The optimistic lock is unchanged: the avatar rides the same
* version-guarded UPDATE, so a stale version loses with 409/40902 even
* when only the avatar changes.</li>
* </ul>
*/
@Service
public class PetService {
/** The only media purpose acceptable as a pet avatar (ADR-022). */
private static final String AVATAR_PURPOSE = "pet_avatar";
private final PetRepository petRepository;
private final BreedRepository breedRepository;
private final PetAccessService petAccessService;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
public PetService(PetRepository petRepository, BreedRepository breedRepository,
PetAccessService petAccessService) {
PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner) {
this.petRepository = petRepository;
this.breedRepository = breedRepository;
this.petAccessService = petAccessService;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
}
/**
@@ -63,18 +95,18 @@ public class PetService {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
petRepository.insertPrimaryOwner(petId, userId);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
}
public List<PetResponse> list(UUID userId) {
return petRepository.listByUser(userId);
return petRepository.listByUser(userId).stream().map(this::toResponse).toList();
}
public PetResponse get(UUID userId, UUID petId) {
petAccessService.require(userId, petId, AccessLevel.READ);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)));
}
/**
@@ -85,8 +117,8 @@ public class PetService {
*/
@Transactional
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
petAccessService.require(userId, petId, AccessLevel.MANAGE);
PetResponse current = petRepository.findByIdForUser(petId, userId)
petAccessService.require(userId, petId, requiredLevel(request));
PetRow current = petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
UUID breedId = current.breedId();
@@ -102,6 +134,16 @@ public class PetService {
String sex = request.getSex() != null ? request.getSex() : current.sex();
String status = request.getStatus() != null ? request.getStatus() : current.status();
// Three-state avatar: absent → carry the stored id through; explicit
// null → clear; value → validate then set.
UUID avatarAssetId = current.avatarAssetId();
if (request.isAvatarAssetIdPresent()) {
avatarAssetId = request.getAvatarAssetId();
if (avatarAssetId != null) {
requireOwnReadyAvatarAsset(userId, avatarAssetId);
}
}
int updated;
try {
updated = petRepository.updateWithVersion(
@@ -120,7 +162,8 @@ public class PetService {
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
request.getSterilizedOn() != null
? request.getSterilizedOn() : current.sterilizedOn(),
status);
status,
avatarAssetId);
} catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
@@ -129,8 +172,34 @@ public class PetService {
// missed conditional update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
}
/**
* MANAGE for anything that edits the pet profile, WRITE when the request
* touches nothing but the avatar (ADR-022). A body carrying only
* {@code version} keeps the historical MANAGE level — it is a
* profile-shaped no-op, not an avatar edit.
*/
private static AccessLevel requiredLevel(UpdatePetRequest request) {
boolean avatarOnly = request.isAvatarAssetIdPresent() && !request.touchesProfileFields();
return avatarOnly ? AccessLevel.WRITE : AccessLevel.MANAGE;
}
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
MediaAssetRef asset = mediaAssetGateway.findById(assetId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
if (!userId.equals(asset.ownerUserId()) || "deleted".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为宠物头像");
}
if (!"ready".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
}
/**
@@ -156,6 +225,33 @@ public class PetService {
}
}
/**
* Signs the avatar URL fresh on every response (never cached, never
* persisted) and drops the storage coordinates — the DTO exposes a URL,
* not a bucket layout.
*/
private PetResponse toResponse(PetRow row) {
return new PetResponse(
row.id(),
row.name(),
row.species(),
row.breedId(),
row.breedDisplayName(),
row.customBreedName(),
row.sex(),
row.birthDate(),
row.birthDateEstimated(),
row.personality(),
row.microchipNo(),
row.sterilizedOn(),
row.status(),
mediaUrlSigner.signGet(row.avatarBucket(), row.avatarObjectKey()),
row.myRole(),
row.createdAt(),
row.updatedAt(),
row.version());
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
@@ -19,3 +19,12 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
media:
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
# 签名计算生成宠物头像访问 URL,从不直连对象存储;写入流程在 patbond-user。
# 环境变量与 patbond-user/patbond-community 共用同一组(一套部署一套旋钮)。
# public-endpoint 为空时服务照常启动,宠物响应中 avatarUrl 为 null。
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -27,8 +28,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/**
* T2-09 契约一致性保障:对冻结契约 v1.2.0(快照
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
* T2-09 契约一致性保障:对冻结契约 v1.4.0(快照
* {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
@@ -38,8 +39,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
* 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程
* MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。
*
* <p>auth 域 6既有操作(register/login/refresh/logout/me/trackEvents
* 不在本单范围(M1 交付无契约测试,补齐另立工单)
* <p>auth/user7 个操作(register/login/refresh/logout/me 读写/trackEvents
* 的契约测试在 patbond-authT3-19 补齐);快照同一份
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ContractConformanceTest extends PetIntegrationTestSupport {
@@ -122,6 +123,29 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
return JsonPath.read(body, "$.data.id");
}
/**
* 一枚 media.assets 行,用途/状态/归属可控(T3.5-07:头像引用侧的四态校验
* 需要,造法与 PetAvatarIntegrationTest 同构——测试数据,不触碰实现)。
*/
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
// ---- 成功路径:18 操作全覆盖 ---------------------------------------
@Test
@@ -594,6 +618,33 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
.content("{\"version\":0,\"personality\":\"\"}"),
"PATCH", "/api/v1/pets/{petId}", 409, 40902);
// -- 宠物头像(v1.4.0 新增两格,T3.5-07--
// 404/40405:与 40401 同一单元格的第二种业务码——幽灵 asset 与用途不符
// 的 asset 合并同答(防枚举)
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(UUID.randomUUID())),
"PATCH", "/api/v1/pets/{petId}", 404, 40405);
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(insertAsset(owner, "post_image", "ready"))),
"PATCH", "/api/v1/pets/{petId}", 404, 40405);
// 422/42203:本人的 pet_avatar asset 仍在 uploading(新增状态码单元格)
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(insertAsset(owner, "pet_avatar", "uploading"))),
"PATCH", "/api/v1/pets/{petId}", 422, 42203);
// 200:挂上 ready 头像(avatarUrl 的非 null 分支不在本模块——未配置对象
// 存储时恒 null,真实签名 URL 由 PetAvatarIntegrationTest 覆盖)
verified(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(insertAsset(owner, "pet_avatar", "ready"))),
"PATCH", "/api/v1/pets/{petId}", 200);
// -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 --
verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
@@ -703,10 +754,10 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
assertThat(CONTRACT.paths()).hasSize(18);
assertThat(CONTRACT.operations()).hasSize(24);
assertThat(CONTRACT.schemas()).hasSize(45);
assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
.containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
}
@@ -10,6 +10,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -81,7 +82,7 @@ final class ContractValidator {
}
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = contract.resolve(rawSchema);
Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失");
return;
@@ -130,6 +131,32 @@ final class ContractValidator {
}
}
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys — e.g.
* the outer {@code nullable} — win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
@@ -13,8 +13,8 @@ import java.util.Objects;
import java.util.Set;
/**
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.2.0.yaml}.
* The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.4.0.yaml}.
*
* <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc
* repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical
@@ -26,11 +26,13 @@ import java.util.Set;
*
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items} no allOf/oneOf.
* {@code required}, {@code properties}, {@code items}, and the v1.3.0
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
* {@link ContractValidator}) no oneOf/anyOf.
*/
final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml";
static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -0,0 +1,335 @@
package com.patbond.patbond.pet.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-05 宠物头像读写PATCH /api/v1/pets/{petId} {@code avatarAssetId}
* 三态缺省不改 / 显式 null 清空 / 赋值设置详情与列表的 {@code avatarUrl}
* 预签名 GET以及六类路径 成功 / 参数错asset 非法四态/ 不存在防枚举
* 404/ 无权限viewer 拒写caregiver 只能改头像/ 并发冲突乐观锁 40902
* / 重放同版本重放必冲突新版本重放幂等
*
* <p>预签名 GET 是纯本地 SigV4 计算故这里用占位端点与占位凭证即可断言 URL
* 形态 patbond-community PostApiTestBase 同先例无需 MinIO 容器
* 签名真能下载的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担</p>
*/
class PetAvatarIntegrationTest extends PetIntegrationTestSupport {
private static final String SIGNED_PREFIX = "http://127.0.0.1:9000/patbond-media/pet_avatar/";
@Autowired
private MockMvc mockMvc;
@DynamicPropertySource
static void wireMediaSigning(DynamicPropertyRegistry registry) {
// 占位值dummy仅用于本地 SigV4 计算不连任何真实存储
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
// ---- helpers -------------------------------------------------------
private String createPetAs(UUID ownerId) throws Exception {
String body = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"头像猫","species":"cat","sex":"female",
"customBreedName":"狸花"}
"""))
.andExpect(status().isCreated())
// 新建宠物尚无头像
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andReturn().getResponse().getContentAsString();
return JsonPath.read(body, "$.data.id");
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 上传的产物)。 */
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private UUID readyPetAvatar(UUID ownerUserId) {
return insertAsset(ownerUserId, "pet_avatar", "ready");
}
private String patchPet(UUID actor, String petId, String body, int expectedStatus)
throws Exception {
return mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchPetExpectingCode(UUID actor, String petId, String body,
int httpStatus, int bizCode) throws Exception {
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private static String setAvatarBody(int version, UUID assetId) {
return "{\"version\":%d,\"avatarAssetId\":\"%s\"}".formatted(version, assetId);
}
private String detail(UUID actor, String petId) throws Exception {
return mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
private UUID dbAvatarAssetId(String petId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM pet_health.pets WHERE id = :id")
.param("id", UUID.fromString(petId))
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void ownerSetsAvatarAndDetailAndListBothCarryASignedUrl() throws Exception {
UUID owner = newUser("pet_avatar_owner");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
String patched = patchPet(owner, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX)
.contains("X-Amz-Signature=");
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
// 头像也吃乐观锁写入后 version 前进
assertThat((int) JsonPath.read(patched, "$.data.version")).isEqualTo(1);
assertThat((String) JsonPath.read(detail(owner, petId), "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX);
String listBody = mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> pets = JsonPath.read(listBody, "$.data");
assertThat((String) pets.get(0).get("avatarUrl")).startsWith(SIGNED_PREFIX);
}
@Test
void clearsAvatarWithAnExplicitNull() throws Exception {
UUID owner = newUser("pet_avatar_clear");
String petId = createPetAs(owner);
patchPet(owner, petId, setAvatarBody(0, readyPetAvatar(owner)), 200);
String cleared = patchPet(owner, petId, "{\"version\":1,\"avatarAssetId\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isNull();
}
/**
* 缺省即不改只改名字的 PATCH 不得把头像顺手清掉这正是三态语义存在的
* 理由 null 与缺省同义就无法既保留又能清空
*/
@Test
void absentAvatarFieldLeavesItUntouched() throws Exception {
UUID owner = newUser("pet_avatar_absent");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
String renamed = patchPet(owner, petId, "{\"version\":1,\"name\":\"改个名\"}", 200);
assertThat((String) JsonPath.read(renamed, "$.data.name")).isEqualTo("改个名");
assertThat((String) JsonPath.read(renamed, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
/**
* 指针在资源却退出 ready如后台清理置 failedURL 降级为 null 而不是
* 签一个下载必 404 的地址数据库指针本身保留不做隐式清理
*/
@Test
void avatarUrlDegradesToNullWhenTheAssetLeavesReady() throws Exception {
UUID owner = newUser("pet_avatar_degrade");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
jdbcClient.sql("UPDATE media.assets SET status = 'failed' WHERE id = :id")
.param("id", asset)
.update();
assertThat((Object) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
// ---- 权限WRITE ADR-022 ---------------------------------------
/**
* caregiver 可改头像WRITE 头像属日常照护信息与体重/疫苗同档
* 但资料本体仍是 MANAGE 同一端点按本次请求碰了哪些字段定档
*/
@Test
void caregiverMayChangeTheAvatarButNotTheProfile() throws Exception {
UUID owner = newUser("pet_avatar_owner2");
UUID caregiver = newUser("pet_avatar_caregiver");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), caregiver, "caregiver");
UUID asset = readyPetAvatar(caregiver);
String patched = patchPet(caregiver, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
// 资料字段仍需 MANAGE
patchPetExpectingCode(caregiver, petId, "{\"version\":1,\"name\":\"照护人改名\"}", 403, 40300);
// 头像 + 资料混合按更严的那一半判MANAGE
patchPetExpectingCode(caregiver, petId,
"{\"version\":1,\"name\":\"夹带改名\",\"avatarAssetId\":null}", 403, 40300);
}
@Test
void viewerCannotChangeTheAvatar() throws Exception {
UUID owner = newUser("pet_avatar_owner3");
UUID viewer = newUser("pet_avatar_viewer");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), viewer, "viewer");
UUID asset = readyPetAvatar(viewer);
patchPetExpectingCode(viewer, petId, setAvatarBody(0, asset), 403, 40300);
assertThat(dbAvatarAssetId(petId)).isNull();
// 只读仍可见
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk());
}
// ---- 不存在路径防枚举 --------------------------------------------
@Test
void strangerAndGhostPetAnswerTheSame404() throws Exception {
UUID owner = newUser("pet_avatar_owner4");
UUID stranger = newUser("pet_avatar_stranger");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(stranger);
patchPetExpectingCode(stranger, petId, setAvatarBody(0, asset), 404, 40401);
patchPetExpectingCode(stranger, UUID.randomUUID().toString(),
setAvatarBody(0, asset), 404, 40401);
}
// ---- 参数错asset 非法四态 ------------------------------------------
@Test
void rejectsUnknownForeignOrWrongPurposeAsset() throws Exception {
UUID owner = newUser("pet_avatar_asset");
UUID stranger = newUser("pet_avatar_assetowner");
String petId = createPetAs(owner);
// 幽灵 id 与他人 asset 同答 40405防枚举合并
patchPetExpectingCode(owner, petId, setAvatarBody(0, UUID.randomUUID()), 404, 40405);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(stranger, "pet_avatar", "ready")), 404, 40405);
// 用途不符帖子配图不能当宠物头像
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "post_image", "ready")), 404, 40405);
// 用户头像也不行两种头像用途各归各
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "user_avatar", "ready")), 404, 40405);
// 已删资源对引用方即不存在
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "deleted")), 404, 40405);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsAssetThatIsNotReadyYet() throws Exception {
UUID owner = newUser("pet_avatar_state");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "uploading")), 422, 42203);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "failed")), 422, 42203);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsMalformedAvatarAssetIdAndMissingVersion() throws Exception {
UUID owner = newUser("pet_avatar_malformed");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
"{\"version\":0,\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
// version 仍是必填乐观锁不可绕过哪怕只改头像
patchPetExpectingCode(owner, petId,
"{\"avatarAssetId\":\"%s\"}".formatted(readyPetAvatar(owner)), 400, 40000);
}
// ---- 并发冲突与重放 --------------------------------------------------
/**
* 头像写入走同一把乐观锁拿旧 version 的第二个写者必败 40902并发冲突
* 而带同一 body 的重放正是 version 再来一次因此必须同样冲突
* pets 域自 M2 起的一致语义头像不另开后门
*/
@Test
void staleVersionLosesAndReplayOfTheSameBodyConflicts() throws Exception {
UUID owner = newUser("pet_avatar_version");
String petId = createPetAs(owner);
UUID first = readyPetAvatar(owner);
UUID second = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, first), 200);
// 重放 body同旧 version 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, first), 409, 40902);
// 另一个写者拿旧 version 抢改 同样 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, second), 409, 40902);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
// 用新 version 重放同一头像 幂等地仍是这张图version 继续前进
String again = patchPet(owner, petId, setAvatarBody(1, first), 200);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
assertThat((int) JsonPath.read(again, "$.data.version")).isEqualTo(2);
}
}
File diff suppressed because it is too large Load Diff
@@ -5,20 +5,32 @@ import java.util.Set;
import java.util.regex.Pattern;
/**
* Event dictionary v2 (report 06 §1.4/§1.5): v1 auth funnel (report 13 §4)
* plus the M2 increment pet domain (3 events) and health_record domain
* (7 events) and page_viewed formalized (report 06 §5.2, was report 19
* ad-hoc addition; same props keys pageName/referrer).
* Event dictionary v3 (iteration-3 report 06 §1.4/§1.5, ADR-020): v1 auth
* funnel + v2 pet/health_record domains + page_viewed 正稿, plus the M3
* community increment post domain (8: publish funnel, draft, delete, and
* the per-file media upload funnel), feed domain (2: feed_viewed as an
* aggregated browse-segment exposure event per §1.2 no per-card
* post_impression and feed_load_failed), interactions (8: like/unlike,
* favorite/unfavorite, comment success/failure pair, follow/unfollow)
* and experiment_exposed (platform domain, A/B prerequisite #5, dictionary
* ahead of its M4 first use).
* ADR-013: health_record_action removed (client zero-reference), replaced by
* the per-action health_record_* events below.
* Unknown event names reject the whole event; props outside the per-event
* whitelist are stripped (kept event, counted warning); props whose KEY
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
* Community privacy red-lines (report 06 §1.3): no free text, no content or
* counterpart IDs (postId/commentId/topicId/target userId), no topic names,
* no file names/paths/URLs only behavior counts and buckets
* (textLengthBucket, sizeBucket) ever reach props.
* Value-level enum conformance (recordType: weight/vaccine/health_event/
* reminder; failureReason incl. permission_denied/conflict/not_found;
* pageName: login/register/home/profile/pet_list/pet_detail/pet_form/
* record_form/record_detail) is enforced client-side (compile-time enums)
* and patrolled offline (report 06 §6.4); ingest validates keys only.
* reminder; failureReason incl. media_too_large/unsupported_format;
* pageName v3 family: login/register/home/profile/pet_list/pet_detail/
* pet_form/record_form/record_detail + create/pet_archive/services/
* post_detail (收编) + post_form/topic_list/topic_detail/user_profile/
* follower_list/following_list/favorite_list/draft_list) is enforced
* client-side (compile-time enums) and patrolled offline (report 06 §6.4);
* ingest validates keys only pageName growth needs no code change here.
*/
public final class EventDictionary {
@@ -59,7 +71,36 @@ public final class EventDictionary {
Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")),
Map.entry("health_record_edit_failed",
Set.of("recordType", "failureReason", "errorCode", "httpStatus")),
Map.entry("health_record_deleted", Set.of("recordType"))
Map.entry("health_record_deleted", Set.of("recordType")),
// v3 增量 post iteration-3 报告 06 §1.4
Map.entry("post_create_started", Set.of("entryPoint")),
Map.entry("post_draft_saved", Set.of("trigger", "mediaCount")),
Map.entry("post_publish_succeeded",
Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft")),
Map.entry("post_publish_failed",
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("post_deleted", Set.of()),
Map.entry("post_media_upload_started", Set.of("mediaType", "sizeBucket")),
Map.entry("post_media_upload_succeeded", Set.of("mediaType", "sizeBucket", "durationMs")),
Map.entry("post_media_upload_failed",
Set.of("mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq")),
// v3 增量 feed 聚合曝光设计§1.2 裁定
Map.entry("feed_viewed",
Set.of("feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount")),
Map.entry("feed_load_failed",
Set.of("feedTab", "loadType", "failureReason", "errorCode", "httpStatus")),
// v3 增量互动
Map.entry("post_liked", Set.of("source")),
Map.entry("post_unliked", Set.of("source")),
Map.entry("post_favorited", Set.of("source")),
Map.entry("post_unfavorited", Set.of("source")),
Map.entry("comment_create_succeeded", Set.of("durationMs", "isReply", "textLengthBucket")),
Map.entry("comment_create_failed",
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("user_followed", Set.of("source")),
Map.entry("user_unfollowed", Set.of("source")),
// A/B 前置 #5曝光事件字典先行M4 启用§1.4
Map.entry("experiment_exposed", Set.of("experimentKey", "variant"))
);
public static boolean isKnownEvent(String eventName) {
@@ -0,0 +1,77 @@
package com.patbond.patbond.user.controller;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.user.dto.PublicProfileResponse;
import com.patbond.patbond.user.repository.UserRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Internal batch public-profile API (D3-9 方案 B, T3-05), consumed by
* patbond-community for feed/comment author summaries. Guarded by
* InternalAuthFilter like every /internal/** route.
*
* <p>Semantics: {@code ids} is a mandatory comma-separated list of user ids,
* at most {@value #MAX_BATCH} per call (one feed page's worth of authors
* with headroom) more is a 400, matching the batch-not-loop contract the
* consumer's cache is built around. Ids that do not resolve (unknown, or the
* user is soft-deleted) are silently absent from the reply; the caller
* renders its id-only fallback for them, so absence leaks nothing about
* which of the two cases it was.</p>
*/
@RestController
@RequestMapping("/internal/users")
public class InternalProfileController {
static final int MAX_BATCH = 50;
private final UserRepository userRepository;
public InternalProfileController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/profiles")
public ApiResponse<List<PublicProfileResponse>> profiles(
@RequestParam(required = false) String ids) {
List<PublicProfileResponse> profiles = userRepository.findPublicProfiles(parse(ids))
.stream()
.map(row -> new PublicProfileResponse(row.id(), row.nickname(), row.avatarAssetId()))
.toList();
return ApiResponse.success(profiles);
}
/**
* Parsed by hand (not bound as List&lt;UUID&gt;) so every malformed shape
* answers a deterministic 400/40000 instead of falling through to the
* generic handler.
*/
private static Set<UUID> parse(String ids) {
if (ids == null || ids.isBlank()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "ids 必带且不得为空");
}
String[] parts = ids.split(",", -1);
if (parts.length > MAX_BATCH) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"ids 一次最多 " + MAX_BATCH + "");
}
Set<UUID> parsed = new LinkedHashSet<>(parts.length);
for (String part : parts) {
try {
parsed.add(UUID.fromString(part.trim()));
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "ids 含非法 UUID");
}
}
return parsed;
}
}
@@ -1,34 +1,50 @@
package com.patbond.patbond.user.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.user.dto.MeResponse;
import com.patbond.patbond.user.dto.UpdateMeRequest;
import com.patbond.patbond.user.security.BearerAuthFilter;
import com.patbond.patbond.user.service.UserService;
import com.patbond.patbond.user.service.MeProfileService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Public profile endpoint. Authentication happens in BearerAuthFilter (RS256
* verification against the auth service's public key); by the time this
* controller runs, the user id attribute is guaranteed to be present.
* The account owner's own profile (T3.5-04). Authentication happens in
* BearerAuthFilter (RS256 verification against the auth service's public
* key); by the time this controller runs, the user id attribute is guaranteed
* to be present, so there is no "other user" case here the resource is
* always the caller's own.
*
* <p>PATCH is intentionally not {@code @Valid}-annotated: the three-state
* fields of {@link UpdateMeRequest} (absent / null / value) need
* presence-aware checks that bean validation cannot express, so all rules
* live in {@link MeProfileService} and answer 400/40000 with a precise
* message.</p>
*/
@RestController
public class MeController {
private final UserService userService;
private final MeProfileService meProfileService;
public MeController(UserService userService) {
this.userService = userService;
public MeController(MeProfileService meProfileService) {
this.meProfileService = meProfileService;
}
@GetMapping("/api/v1/me")
public ApiResponse<MeResponse> me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
UserProfile profile = userService.getById(userId);
return ApiResponse.success(new MeResponse(
profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt()));
public ApiResponse<MeResponse> me(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(meProfileService.get(userId));
}
@PatchMapping("/api/v1/me")
public ApiResponse<MeResponse> updateMe(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestBody UpdateMeRequest request) {
return ApiResponse.success(meProfileService.update(userId, request));
}
}
@@ -4,8 +4,25 @@ import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Public /api/v1/me payload exactly the frozen contract fields
* {userId, username, phone, createdAt}; nothing else leaks out.
* The owner's own profile payload of both GET and PATCH /api/v1/me
* (T3.5-04).
*
* <p>{@code nickname} is the RAW stored value and is null when the user never
* set one: unlike {@link PublicProfileResponse}, this endpoint deliberately
* does NOT apply the nicknameusername fallback. /me is the editing surface
* of the account owner, so it must report what is actually stored; a fallback
* here would prefill the edit form with a username the user never chose and
* the next save would silently promote it into a real nickname. The display
* fallback belongs where display happens /internal/users/profiles for other
* people's view (SQL COALESCE, T3-05) and the client's own greeting.</p>
*
* <p>{@code avatarUrl} is a freshly signed presigned GET (private bucket,
* T3-03 定型): it EXPIRES and must never be persisted client-side. It is null
* both when no avatar is set and when the avatar asset is not (or no longer)
* ready, so the client's "has an avatar" test is exactly "avatarUrl != null".
* The asset id itself is deliberately not echoed the client only ever
* writes it (PATCH) and renders the URL.</p>
*/
public record MeResponse(UUID userId, String username, String phone, OffsetDateTime createdAt) {
public record MeResponse(UUID userId, String username, String nickname, String phone,
String avatarUrl, OffsetDateTime createdAt) {
}
@@ -0,0 +1,16 @@
package com.patbond.patbond.user.dto;
import java.util.UUID;
/**
* One public profile in the /internal/users/profiles batch reply (D3-9 方案 B):
* exactly the fields another service may see display name and the avatar
* asset pointer, nothing else (no phone, no username-vs-nickname distinction,
* no timestamps). The nicknameusername fallback is applied HERE, on the
* owning side, so consumers never see or need the raw username. The avatar
* travels as an asset id, not a URL: URL signing is the consumer's read-side
* concern (community signs presigned GETs locally, T3-03 定型), and a signed
* URL would go stale inside the consumer's cache.
*/
public record PublicProfileResponse(UUID userId, String nickname, UUID avatarAssetId) {
}
@@ -0,0 +1,67 @@
package com.patbond.patbond.user.dto;
import java.util.UUID;
/**
* PATCH /api/v1/me (T3.5-04). Partial update with an EXPLICIT三态 semantics
* per field, which is what the avatar and nickname features need and what the
* pets domain's "absent-or-null means unchanged" convention (M2, see
* UpdatePetRequest) cannot express:
*
* <ul>
* <li><b>key absent</b> leave the column untouched;</li>
* <li><b>key present with null</b> clear the column (remove the nickname /
* remove the avatar);</li>
* <li><b>key present with a value</b> set it.</li>
* </ul>
*
* <p>Why the difference from pets: name/species/sex have no meaningful empty
* state (their CHECK constraints forbid it), so M2 could afford to conflate
* null with absent. A nickname and an avatar are genuinely optional and
* "remove what I set" is a first-class user action with only two states
* there would be no way to express it at all. Presence is tracked by the
* setters: Jackson calls a setter exactly when the JSON key is present,
* including when its value is null.</p>
*
* <p>A body that touches neither field is rejected with 400/40000 rather than
* answering a silent 200 an empty PATCH is a client bug, not an intent.</p>
*/
public class UpdateMeRequest {
private String nickname;
private boolean nicknamePresent;
private UUID avatarAssetId;
private boolean avatarAssetIdPresent;
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
this.nicknamePresent = true;
}
public boolean isNicknamePresent() {
return nicknamePresent;
}
public UUID getAvatarAssetId() {
return avatarAssetId;
}
public void setAvatarAssetId(UUID avatarAssetId) {
this.avatarAssetId = avatarAssetId;
this.avatarAssetIdPresent = true;
}
public boolean isAvatarAssetIdPresent() {
return avatarAssetIdPresent;
}
/** True when the body carries no updatable field at all. */
public boolean isEmptyPatch() {
return !nicknamePresent && !avatarAssetIdPresent;
}
}
@@ -54,8 +54,16 @@ public class MediaProperties {
/** Mime whitelist for kind=image (M3: jpeg/png/webp). */
private List<String> allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
/** Purpose whitelist; decides the objectKey prefix. M3: post_image. */
private List<String> allowedPurposes = List.of("post_image");
/**
* Purpose whitelist; decides the objectKey prefix. M3 shipped
* {@code post_image}; M3.5 adds the two avatar purposes (ADR-022
* media.assets.purpose has no CHECK constraint, so a new use case is a
* configuration + contract-enum change, never a migration). The purpose
* is also the referencing side's type check: only a {@code user_avatar}
* asset may become a user avatar, only a {@code pet_avatar} asset a pet
* avatar, so a post image can never be silently reused as an avatar.
*/
private List<String> allowedPurposes = List.of("post_image", "user_avatar", "pet_avatar");
public String getEndpoint() {
return endpoint;
@@ -4,6 +4,9 @@ import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -36,6 +39,20 @@ public class UserRepository {
OffsetDateTime lockedUntil) {
}
/** Public-profile projection; nickname already carries the username fallback. */
public record PublicProfileRow(UUID id, String nickname, UUID avatarAssetId) {
}
/**
* Owner's-own-profile projection for /api/v1/me (T3.5-04). {@code nickname}
* is the RAW column no username fallback here (see MeResponse for why);
* {@code avatarObjectKey} is already narrowed to a READY asset, so the
* caller only has to sign it.
*/
public record MeRow(UUID id, String username, String nickname, String phone,
String avatarObjectKey, OffsetDateTime createdAt) {
}
/** Inserts the user row; created_at/updated_at come from the DB defaults. */
public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) {
return jdbcClient.sql("""
@@ -86,6 +103,94 @@ public class UserRepository {
.optional();
}
/**
* Batch public-profile lookup for /internal/users/profiles (D3-9 方案 B).
* The nicknameusername fallback happens in SQL (ck_users_nickname
* guarantees a stored nickname is trimmed and non-empty, so plain
* COALESCE suffices); soft-deleted users are simply absent their
* tombstone shape is the caller's id-only fallback, indistinguishable
* from a user it failed to resolve.
*/
public List<PublicProfileRow> findPublicProfiles(Collection<UUID> ids) {
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient.sql("""
SELECT id, COALESCE(nickname, username::text) AS nickname, avatar_asset_id
FROM identity.users
WHERE id IN (:ids) AND deleted_at IS NULL
""")
.param("ids", List.copyOf(ids))
.query((rs, rowNum) -> new PublicProfileRow(
rs.getObject("id", UUID.class),
rs.getString("nickname"),
rs.getObject("avatar_asset_id", UUID.class)))
.list();
}
/**
* The owner's own profile plus the object key of a READY avatar asset.
* The LEFT JOIN carries the readiness condition, so a dangling or
* still-uploading avatar simply yields a null key ( {@code avatarUrl:
* null}) instead of a broken signed URL.
*/
public Optional<MeRow> findMeById(UUID id) {
return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, u.phone_e164,
u.created_at, a.object_key AS avatar_object_key
FROM identity.users u
LEFT JOIN media.assets a
ON a.id = u.avatar_asset_id AND a.status = 'ready'
WHERE u.id = :id AND u.deleted_at IS NULL
""")
.param("id", id)
.query((rs, rowNum) -> new MeRow(
rs.getObject("id", UUID.class),
rs.getString("username"),
rs.getString("nickname"),
rs.getString("phone_e164"),
rs.getString("avatar_object_key"),
rs.getObject("created_at", OffsetDateTime.class)))
.optional();
}
/**
* Column-selective profile update for PATCH /api/v1/me: only the columns
* the request actually carried appear in the SET list. This is
* deliberately NOT a read-merge-write two concurrent PATCHes, one
* changing the nickname and one the avatar, both survive, whereas a
* merged full-row write would let the later one silently revert the
* other's field. /me has a single legitimate writer (the account owner),
* so no optimistic-lock version is exposed; last write wins per column.
*
* @return rows updated 0 means the user is gone (or soft-deleted)
*/
public int updateOwnProfile(UUID id, boolean setNickname, String nickname,
boolean setAvatarAssetId, UUID avatarAssetId) {
List<String> assignments = new ArrayList<>(2);
if (setNickname) {
assignments.add("nickname = :nickname");
}
if (setAvatarAssetId) {
assignments.add("avatar_asset_id = :avatarAssetId");
}
if (assignments.isEmpty()) {
throw new IllegalArgumentException("updateOwnProfile 需至少一个待更新列");
}
JdbcClient.StatementSpec spec = jdbcClient.sql("""
UPDATE identity.users SET %s
WHERE id = :id AND deleted_at IS NULL
""".formatted(String.join(", ", assignments)))
.param("id", id);
if (setNickname) {
spec = spec.param("nickname", nickname);
}
if (setAvatarAssetId) {
spec = spec.param("avatarAssetId", avatarAssetId);
}
return spec.update();
}
public Optional<AuthRow> findAuthByUsername(String username) {
return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
@@ -0,0 +1,152 @@
package com.patbond.patbond.user.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.user.dto.MeResponse;
import com.patbond.patbond.user.dto.UpdateMeRequest;
import com.patbond.patbond.user.media.MediaAssetRepository;
import com.patbond.patbond.user.media.MediaProperties;
import com.patbond.patbond.user.media.ObjectStorage;
import com.patbond.patbond.user.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* The account owner's own profile: GET and PATCH /api/v1/me (T3.5-04).
*
* <p>Semantics frozen with this ticket:
* <ul>
* <li><b>No nickname fallback on /me</b> the raw stored value, null when
* unset (rationale in {@link MeResponse}). The fallback stays in
* /internal/users/profiles' SQL, where other people's display name is
* produced.</li>
* <li><b>PATCH is三态 per field</b> absent = unchanged, explicit null =
* clear, value = set (see {@link UpdateMeRequest}). An empty patch is
* 400/40000.</li>
* <li><b>nickname validation mirrors ck_users_nickname</b> trimmed, 1..32
* CODE POINTS (PostgreSQL char_length counts code points, so a Java
* String.length() bound would reject 32 emoji the database accepts);
* whitespace-only is 400/40000, never an implicit clear, so clearing is
* expressible exactly one way.</li>
* <li><b>avatarAssetId validation mirrors the T3-03 referencing protocol</b>
* unknown / someone else's / deleted asset 404/40405 (one merged
* anti-enumeration answer); the caller's own asset with the wrong
* purpose 404/40405 as well (from the avatar domain's point of view a
* post image is not an avatar; no enumeration risk, since the branch is
* only reachable for assets the caller owns, so the message may be
* specific); the caller's own user_avatar asset still uploading or
* failed 422/42203.</li>
* </ul>
*
* <p>No optimistic lock is exposed: /me has one legitimate writer and the
* update is column-selective, so concurrent nickname/avatar patches cannot
* clobber each other (see {@link UserRepository#updateOwnProfile}).</p>
*/
@Service
public class MeProfileService {
/** The only media purpose acceptable as a user avatar (ADR-022). */
private static final String AVATAR_PURPOSE = "user_avatar";
private static final int NICKNAME_MAX_CODE_POINTS = 32;
private final UserRepository userRepository;
private final MediaAssetRepository mediaAssetRepository;
private final ObjectStorage objectStorage;
private final MediaProperties mediaProperties;
public MeProfileService(UserRepository userRepository,
MediaAssetRepository mediaAssetRepository,
ObjectStorage objectStorage,
MediaProperties mediaProperties) {
this.userRepository = userRepository;
this.mediaAssetRepository = mediaAssetRepository;
this.objectStorage = objectStorage;
this.mediaProperties = mediaProperties;
}
@Transactional(readOnly = true)
public MeResponse get(UUID userId) {
return toResponse(userRepository.findMeById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)));
}
@Transactional
public MeResponse update(UUID userId, UpdateMeRequest request) {
if (request.isEmptyPatch()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"请至少提交一个可更新字段:nickname 或 avatarAssetId");
}
String nickname = null;
if (request.isNicknamePresent() && request.getNickname() != null) {
nickname = requireNickname(request.getNickname());
}
if (request.isAvatarAssetIdPresent() && request.getAvatarAssetId() != null) {
requireOwnReadyAvatarAsset(userId, request.getAvatarAssetId());
}
int updated = userRepository.updateOwnProfile(userId,
request.isNicknamePresent(), nickname,
request.isAvatarAssetIdPresent(), request.getAvatarAssetId());
if (updated == 0) {
// The token is valid but the account is gone (or 注销) same
// answer as a GET of a soft-deleted user.
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
return get(userId);
}
/**
* Trims like {@code btrim} and enforces the ck_users_nickname width. A
* blank-after-trim value is a validation error rather than a clear: an
* explicit JSON null is the single, unambiguous way to remove a nickname.
*/
private static String requireNickname(String raw) {
String trimmed = raw.trim();
int codePoints = trimmed.codePointCount(0, trimmed.length());
if (codePoints < 1 || codePoints > NICKNAME_MAX_CODE_POINTS) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"nickname 去除首尾空白后长度须在 1~" + NICKNAME_MAX_CODE_POINTS
+ " 字符;如需清空请显式提交 null");
}
return trimmed;
}
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
MediaAssetRepository.AssetRow asset = mediaAssetRepository
.findByIdAndOwner(assetId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
if ("deleted".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为头像");
}
if (!"ready".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
}
private MeResponse toResponse(UserRepository.MeRow row) {
return new MeResponse(row.id(), row.username(), row.nickname(), row.phone(),
signAvatar(row.avatarObjectKey()), row.createdAt());
}
/**
* Signs a short-lived GET for the avatar object. Storage being
* unconfigured degrades to {@code avatarUrl: null} (same precedent as the
* community read side and the missing JWT public key) instead of failing
* the whole profile read the condition mirrors MediaStorageConfig's.
*/
private String signAvatar(String objectKey) {
String endpoint = mediaProperties.getEndpoint();
if (objectKey == null || endpoint == null || endpoint.isBlank()) {
return null;
}
return objectStorage.presignGet(objectKey, mediaProperties.getDownloadTtl());
}
}
@@ -53,10 +53,10 @@ patbond:
# 预签名 PUT 凭据与 GET URL 的有效期
upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
# 单文件上限(字节)与 mime/purpose 白名单(M3 首版:图片、帖子配图
# 单文件上限(字节)与 mime/purpose 白名单(M3.5:帖子配图 + 用户/宠物头像
max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760}
allowed-mime-types: image/jpeg,image/png,image/webp
allowed-purposes: post_image
allowed-purposes: post_image,user_avatar,pet_avatar
# Development seed data (regions reference rows) is opt-in. To load it,
# activate a dev profile that widens the Flyway locations:
@@ -360,4 +360,115 @@ class AnalyticsIntegrationTest {
.andExpect(jsonPath("$.data.rejected").value(1))
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
}
@Test
void acceptsV3FeedViewedAggregateEvent() throws Exception {
String eventId = UUID.randomUUID().toString();
String body = """
{
"events": [{
"eventId": "%s",
"eventName": "feed_viewed",
"eventVersion": 1,
"anonymousId": "019212aa-0000-7000-8000-000000000001",
"sessionId": "019212aa-1111-7000-8000-000000000001",
"clientTs": "%s",
"appVersion": "1.2.0",
"platform": "android",
"osVersion": "android-14",
"props": {
"feedTab": "home",
"durationMs": 45000,
"impressionCount": 18,
"loadMoreCount": 2,
"refreshCount": 1
}
}]
}
""".formatted(eventId, OffsetDateTime.now());
mockMvc.perform(post("/api/v1/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.accepted").value(1))
.andExpect(jsonPath("$.data.results[0].status").value("accepted"));
String storedName = jdbcClient.sql(
"SELECT event_name FROM platform.product_events WHERE event_id = :id")
.param("id", UUID.fromString(eventId))
.query(String.class)
.single();
assertThat(storedName).isEqualTo("feed_viewed");
}
@Test
void stripsContentIdPropsFromV3InteractionEvent() throws Exception {
String eventId = UUID.randomUUID().toString();
// 红线 2report 06 §1.3行为客体的内容标识不进 props白名单外的 postId 必须被剥离
String body = """
{
"events": [{
"eventId": "%s",
"eventName": "post_liked",
"eventVersion": 1,
"anonymousId": "019212aa-0000-7000-8000-000000000001",
"sessionId": "019212aa-1111-7000-8000-000000000001",
"clientTs": "%s",
"appVersion": "1.2.0",
"platform": "ios",
"osVersion": "ios-17",
"props": {"source": "feed", "postId": "should_be_stripped"}
}]
}
""".formatted(eventId, OffsetDateTime.now());
mockMvc.perform(post("/api/v1/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.accepted").value(1));
Map<String, Object> storedProps = jdbcClient.sql(
"SELECT props::text FROM platform.product_events WHERE event_id = :id")
.param("id", UUID.fromString(eventId))
.query((rs, rowNum) -> {
try {
return new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(rs.getString(1), Map.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.single();
assertThat(storedProps).containsEntry("source", "feed");
assertThat(storedProps).doesNotContainKey("postId");
}
@Test
void rejectedPerCardImpressionStaysOutOfDictionary() throws Exception {
// report 06 §1.2 裁定逐卡曝光 post_impression 被否决接收端按未知事件拒绝
String body = """
{
"events": [{
"eventId": "019212aa-5555-7000-8000-000000000001",
"eventName": "post_impression",
"eventVersion": 1,
"anonymousId": "019212aa-0000-7000-8000-000000000001",
"sessionId": "019212aa-1111-7000-8000-000000000001",
"clientTs": "%s",
"appVersion": "1.2.0",
"platform": "android",
"osVersion": "android-14"
}]
}
""".formatted(OffsetDateTime.now());
mockMvc.perform(post("/api/v1/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.rejected").value(1))
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
}
}
@@ -5,8 +5,10 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Locks the dictionary v2 whitelist boundaries (report 06 §1.4/§1.5):
* v1 auth funnel + page_viewed 正稿 + pet 3 事件 + health_record 7 事件.
* Locks the dictionary v3 whitelist boundaries (iteration-3 report 06
* §1.4/§1.5, ADR-020): v1 auth funnel + page_viewed 正稿 + v2 pet/
* health_record domains + M3 community increment (post 8, feed 2,
* interactions 8) + experiment_exposed (dictionary ahead of M4 use).
* ADR-013's health_record_action stays removed the per-action events
* below replace it.
*/
@@ -77,4 +79,77 @@ class EventDictionaryTest {
assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse();
assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse();
}
@Test
void v3PostPublishFunnelMatchesDictionary() {
assertThat(EventDictionary.allowedProps("post_create_started"))
.containsExactlyInAnyOrder("entryPoint");
assertThat(EventDictionary.allowedProps("post_draft_saved"))
.containsExactlyInAnyOrder("trigger", "mediaCount");
assertThat(EventDictionary.allowedProps("post_publish_succeeded"))
.containsExactlyInAnyOrder(
"durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft");
assertThat(EventDictionary.allowedProps("post_publish_failed"))
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
// post_deleted 单事件风格无专有属性report 06 §1.4
assertThat(EventDictionary.isKnownEvent("post_deleted")).isTrue();
assertThat(EventDictionary.allowedProps("post_deleted")).isEmpty();
}
@Test
void v3MediaUploadFunnelMatchesDictionary() {
assertThat(EventDictionary.allowedProps("post_media_upload_started"))
.containsExactlyInAnyOrder("mediaType", "sizeBucket");
assertThat(EventDictionary.allowedProps("post_media_upload_succeeded"))
.containsExactlyInAnyOrder("mediaType", "sizeBucket", "durationMs");
assertThat(EventDictionary.allowedProps("post_media_upload_failed"))
.containsExactlyInAnyOrder(
"mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq");
}
@Test
void v3FeedDomainMatchesDictionary() {
// feed_viewed 是浏览段聚合曝光事件report 06 §1.2只有计数与时长绝无 postId 类内容标识
assertThat(EventDictionary.allowedProps("feed_viewed"))
.containsExactlyInAnyOrder(
"feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount");
assertThat(EventDictionary.allowedProps("feed_load_failed"))
.containsExactlyInAnyOrder("feedTab", "loadType", "failureReason", "errorCode", "httpStatus");
}
@Test
void v3InteractionEventsMatchDictionary() {
// like/unlikefavorite/unfavoritefollow/unfollow 分立事件名v2 废弃 action 属性同一逻辑
assertThat(EventDictionary.allowedProps("post_liked")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("post_unliked")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("post_favorited")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("post_unfavorited")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("user_followed")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("user_unfollowed")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("comment_create_succeeded"))
.containsExactlyInAnyOrder("durationMs", "isReply", "textLengthBucket");
assertThat(EventDictionary.allowedProps("comment_create_failed"))
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
}
@Test
void v3ExperimentExposedRegisteredAheadOfM4Use() {
// A/B 前置 #5report 06 §1.4M4 首实验才启用字典与白名单本迭代一次进
assertThat(EventDictionary.allowedProps("experiment_exposed"))
.containsExactlyInAnyOrder("experimentKey", "variant");
}
@Test
void v3DeliberatelyAbsentEventsStayUnknown() {
// report 06 §1.2逐卡曝光被否决不设 post_impression§1.4 取舍
// 帖子浏览由 page_viewed(post_detail) 覆盖评论不设 started单点互动不埋失败
// §1.6 缺口 3话题关注 UI 定稿前挂起
assertThat(EventDictionary.isKnownEvent("post_impression")).isFalse();
assertThat(EventDictionary.isKnownEvent("post_viewed")).isFalse();
assertThat(EventDictionary.isKnownEvent("comment_create_started")).isFalse();
assertThat(EventDictionary.isKnownEvent("post_like_failed")).isFalse();
assertThat(EventDictionary.isKnownEvent("user_follow_failed")).isFalse();
assertThat(EventDictionary.isKnownEvent("topic_followed")).isFalse();
assertThat(EventDictionary.isKnownEvent("topic_unfollowed")).isFalse();
}
}
@@ -0,0 +1,256 @@
package com.patbond.patbond.user.contract;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.patbond.patbond.user.contract.OpenApiContract.cast;
import static com.patbond.patbond.user.contract.OpenApiContract.list;
import static com.patbond.patbond.user.contract.OpenApiContract.map;
/**
* Validates an actual HTTP response against the frozen contract, strictly:
*
* <ul>
* <li>the operation and the status must be declared;</li>
* <li>required fields must be present; a null value needs {@code nullable};</li>
* <li>fields the schema does not declare are rejected (this is what catches
* a renamed or newly leaked field plain OpenAPI semantics would allow
* extra properties, but the frozen contract is "exactly these fields");</li>
* <li>types, enum membership, uuid / date-time / date formats and
* min/max(Length) bounds are checked.</li>
* </ul>
*
* Behavioural semantics (state machines, anti-enumeration, permission logic)
* stay with the existing integration tests this class only pins structure.
*/
final class ContractValidator {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final OpenApiContract contract;
ContractValidator(OpenApiContract contract) {
this.contract = contract;
}
/**
* @return drift findings, empty when the response conforms; each entry is
* a human-readable "where: what" line
*/
List<String> validateResponse(String method, String pathTemplate, int status, String body) {
List<String> errors = new ArrayList<>();
String opKey = method + " " + pathTemplate;
Map<String, Object> op = contract.operation(opKey);
if (op == null) {
errors.add("契约未声明该操作: " + opKey);
return errors;
}
Object respNode = map(op, "responses").get(String.valueOf(status));
if (respNode == null) {
errors.add("契约未为 " + opKey + " 声明状态码 " + status);
return errors;
}
Map<String, Object> content = map(contract.resolve(cast(respNode)), "content");
if (content == null) {
return errors; // response declared without a body
}
Map<String, Object> schema = map(map(content, "application/json"), "schema");
if (schema == null) {
errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
return errors;
}
JsonNode node;
try {
node = MAPPER.readTree(body);
} catch (JsonProcessingException e) {
errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
return errors;
}
validate(schema, node, "$", errors);
return errors;
}
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失");
return;
}
if (node.isNull()) {
if (!Boolean.TRUE.equals(schema.get("nullable"))) {
errors.add(loc + ": 为 null,但契约未声明 nullable");
}
return;
}
List<Object> allowed = list(schema, "enum");
if (allowed != null && !enumMatches(allowed, node)) {
errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + "");
}
String type = (String) schema.get("type");
if (type == null) {
type = schema.containsKey("properties") ? "object" : null;
}
if (type == null) {
return;
}
switch (type) {
case "object" -> validateObject(schema, node, loc, errors);
case "array" -> validateArray(schema, node, loc, errors);
case "string" -> validateString(schema, node, loc, errors);
case "integer" -> {
if (!node.isIntegralNumber()) {
errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "number" -> {
if (!node.isNumber()) {
errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "boolean" -> {
if (!node.isBoolean()) {
errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
}
}
default -> errors.add(loc + ": 契约测试不支持的 type " + type);
}
}
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys e.g.
* the outer {@code nullable} win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
return;
}
Map<String, Object> props = map(schema, "properties");
List<Object> required = list(schema, "required");
if (required != null) {
for (Object r : required) {
if (!node.has((String) r)) {
errors.add(loc + "." + r + ": 契约必填字段缺失");
}
}
}
Object additional = schema.get("additionalProperties");
boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
Map<String, Object> propSchema = props == null ? null : cast(props.get(field.getKey()));
if (propSchema != null) {
validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
} else if (!open) {
errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
}
}
}
private void validateArray(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isArray()) {
errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
return;
}
Map<String, Object> items = map(schema, "items");
if (items == null) {
return;
}
int i = 0;
for (JsonNode element : node) {
validate(items, element, loc + "[" + i++ + "]", errors);
}
}
private void validateString(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isTextual()) {
errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
return;
}
String value = node.asText();
String format = (String) schema.get("format");
if (format != null) {
try {
switch (format) {
case "uuid" -> {
if (value.length() != 36) {
throw new IllegalArgumentException("非规范 UUID 长度");
}
java.util.UUID.fromString(value);
}
case "date-time" -> OffsetDateTime.parse(value);
case "date" -> LocalDate.parse(value);
default -> { /* password 等纯标注格式不校验 */ }
}
} catch (IllegalArgumentException | DateTimeParseException e) {
errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
}
}
if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
}
if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
}
}
private static void checkRange(Map<String, Object> schema, BigDecimal value, String loc, List<String> errors) {
if (schema.get("minimum") instanceof Number min
&& value.compareTo(new BigDecimal(min.toString())) < 0) {
errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
}
if (schema.get("maximum") instanceof Number max
&& value.compareTo(new BigDecimal(max.toString())) > 0) {
errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
}
}
private static boolean enumMatches(List<Object> allowed, JsonNode node) {
if (node.isTextual()) {
return allowed.contains(node.asText());
}
if (node.isIntegralNumber()) {
long v = node.longValue();
return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
}
return false;
}
}
@@ -0,0 +1,274 @@
package com.patbond.patbond.user.contract;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.support.TestJwtKeys;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.utility.DockerImageName;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* T3-20M3 第二波收尾media 2 个操作两步上传 user 模块补进契约
* 一致性保障机制与 patbond-pet ContractConformanceTest 同构对冻结契约
* v1.4.0快照 {@code src/test/resources/contract/openapi-v1.4.0.yaml}正典在
* doc {@code docs/api/openapi.yaml}逐操作真实起服务发请求真实 MinIO
* Testcontainer直传走真实 HTTP PUT {@link ContractValidator} 严格校验
* 响应结构最后以全响应矩阵门禁兜底8 个单元格无豁免
*
* <p>auth/user 7 操作在 patbond-authpets 18 操作在 patbond-petcommunity
* 18 操作在 patbond-community 的同构测试内快照同一份
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MediaContractConformanceTest {
private static final OpenApiContract CONTRACT = OpenApiContract.load();
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** media 域 2 个操作(= 契约中 tags ∈ {media})。 */
private static final List<String> MEDIA_OPERATIONS = List.of(
"POST /api/v1/media/uploads",
"POST /api/v1/media/uploads/{assetId}/complete");
/** 与 docker-compose.yml 的 minio 服务钉同一 tagADR-016 三环境零分叉)。 */
private static final MinIOContainer MINIO = new MinIOContainer(
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
// 值仅为测试占位dummy非真实凭证
.withUserName("minio-dummy-access")
.withPassword("minio-dummy-secret");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final byte[] FAKE_JPEG = fakeJpeg();
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void wireMedia(DynamicPropertyRegistry registry) {
MINIO.start();
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.media.endpoint", MINIO::getS3URL);
registry.add("patbond.media.access-key", MINIO::getUserName);
registry.add("patbond.media.secret-key", MINIO::getPassword);
}
// ---- 校验骨架 ------------------------------------------------------
private String verified(MockHttpServletRequestBuilder rq, String method,
String pathTemplate, int expectedStatus) throws Exception {
MvcResult result = mockMvc.perform(rq).andReturn();
int actual = result.getResponse().getStatus();
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(actual)
.as("%s %s 的 HTTP 状态(响应体: %s", method, pathTemplate, body)
.isEqualTo(expectedStatus);
List<String> drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body);
assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty();
COVERED.add(method + " " + pathTemplate + " " + actual);
return body;
}
private String verifiedError(MockHttpServletRequestBuilder rq, String method,
String pathTemplate, int status, int bizCode) throws Exception {
String body = verified(rq, method, pathTemplate, status);
assertThat((Integer) JsonPath.read(body, "$.code"))
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
.isEqualTo(bizCode);
return body;
}
private static byte[] fakeJpeg() {
byte[] bytes = new byte[2048];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i * 31);
}
bytes[0] = (byte) 0xFF;
bytes[1] = (byte) 0xD8; // JPEG SOI凑个像样的文件头
return bytes;
}
private UUID newUser(String username) {
UUID id = UUID.randomUUID();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 创建上传(经 verified,201 凭据形态即被契约校验),返回响应体。 */
private String createUpload(UUID user, long byteSize) throws Exception {
return verified(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"post_image",
"mimeType":"image/jpeg","byteSize":%d}
""".formatted(byteSize)),
"POST", "/api/v1/media/uploads", 201);
}
/** 按凭据把字节真实 PUT 到 MinIO。 */
private void directPut(String createdBody, byte[] bytes) throws Exception {
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
.PUT(HttpRequest.BodyPublishers.ofByteArray(bytes));
headers.forEach(put::header);
int status = HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode();
assertThat(status).as("预签名直传应被 MinIO 接受").isEqualTo(200);
}
private MockHttpServletRequestBuilder completeRequest(UUID user, String assetId) {
return post("/api/v1/media/uploads/{assetId}/complete", assetId)
.header("Authorization", bearer(user));
}
// ---- 成功路径 ------------------------------------------------------
@Test
@Order(1)
void twoStepUploadSuccessShapes() throws Exception {
UUID user = newUser("contract_media_owner");
String created = createUpload(user, FAKE_JPEG.length);
String assetId = JsonPath.read(created, "$.data.assetId");
directPut(created, FAKE_JPEG);
String completed = verified(completeRequest(user, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 200);
assertThat((String) JsonPath.read(completed, "$.data.status")).isEqualTo("ready");
assertThat((String) JsonPath.read(completed, "$.data.url"))
.as("ready 资产必须带预签名 GET URL").isNotNull();
// 幂等重复确认同格 200同一 asset
String again = verified(completeRequest(user, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 200);
assertThat((String) JsonPath.read(again, "$.data.id")).isEqualTo(assetId);
}
// ---- 错误信封 ------------------------------------------------------
@Test
@Order(2)
void errorEnvelopesMatchContract() throws Exception {
UUID user = newUser("contract_media_err");
// 400/40000mime 白名单外complete 的畸形 assetId
verifiedError(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"post_image",
"mimeType":"image/gif","byteSize":1024}
"""),
"POST", "/api/v1/media/uploads", 400, 40000);
verifiedError(completeRequest(user, "not-a-uuid"),
"POST", "/api/v1/media/uploads/{assetId}/complete", 400, 40000);
// 401/40101两操作均缺 token
verifiedError(post("/api/v1/media/uploads")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"post_image",
"mimeType":"image/jpeg","byteSize":1024}
"""),
"POST", "/api/v1/media/uploads", 401, 40101);
verifiedError(post("/api/v1/media/uploads/{assetId}/complete", UUID.randomUUID()),
"POST", "/api/v1/media/uploads/{assetId}/complete", 401, 40101);
// 404/40405他人 asset 与不存在 asset 防枚举合并
UUID intruder = newUser("contract_media_intruder");
String created = createUpload(user, FAKE_JPEG.length);
String assetId = JsonPath.read(created, "$.data.assetId");
verifiedError(completeRequest(intruder, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405);
verifiedError(completeRequest(user, UUID.randomUUID().toString()),
"POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405);
// 422/42205直传完成前确认asset 保持 uploading 可重试
verifiedError(completeRequest(user, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 422, 42205);
}
// ---- 快照与覆盖门禁 -------------------------------------------------
/**
* 冻结快照守卫 pet/auth/community 侧同一纪律正典契约升版时必须同步
* 复制新快照并更新期望值忘记同步在 CI 立即变红
*/
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(Set.of("media")))
.containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS);
}
/**
* 全矩阵覆盖门禁media 2 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验8 个单元格无豁免
*/
@Test
@Order(99)
void everyDeclaredResponseCellIsExercised() {
List<String> missing = new ArrayList<>();
for (String op : MEDIA_OPERATIONS) {
for (int status : CONTRACT.responseStatuses(op)) {
String cell = op + " " + status;
if (!COVERED.contains(cell)) {
missing.add(cell);
}
}
}
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
}
}
@@ -0,0 +1,151 @@
package com.patbond.patbond.user.contract;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.4.0.yaml}.
*
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
* snapshot is a byte-identical copy taken at freeze time, and this class is
* the module-local copy of the pet module's contract framework (same
* per-module duplication discipline as BearerAuthFilter). Whenever the
* canonical contract changes, copy it into every framework-carrying module
* (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
* new version's file name and update each conformance test (expected version
* + snapshot counts). The guard test on {@code info.version} makes a forgotten
* sync fail loudly in CI instead of silently testing against a stale
* contract.
*
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items}, and the v1.3.0
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
* {@link ContractValidator}) no oneOf/anyOf.
*/
final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
private final Map<String, Object> root;
private OpenApiContract(Map<String, Object> root) {
this.root = root;
}
static OpenApiContract load() {
try (InputStream in = Objects.requireNonNull(
OpenApiContract.class.getResourceAsStream(RESOURCE),
"契约快照缺失: " + RESOURCE)) {
return new OpenApiContract(new Yaml().load(in));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
String version() {
return (String) map(root, "info").get("version");
}
Map<String, Object> paths() {
return map(root, "paths");
}
Map<String, Object> schemas() {
return map(map(root, "components"), "schemas");
}
/** All declared operations as "METHOD pathTemplate" (insertion order). */
Set<String> operations() {
Set<String> ops = new LinkedHashSet<>();
paths().forEach((path, item) -> cast(item).forEach((method, op) -> {
if (HTTP_METHODS.contains(method)) {
ops.add(method.toUpperCase(Locale.ROOT) + " " + path);
}
}));
return ops;
}
/** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */
Set<String> operationsTagged(Set<String> tags) {
Set<String> ops = new LinkedHashSet<>();
for (String key : operations()) {
List<Object> opTags = list(operation(key), "tags");
if (opTags != null && opTags.stream().anyMatch(tags::contains)) {
ops.add(key);
}
}
return ops;
}
/** Declared response statuses of an operation, as ints. */
Set<Integer> responseStatuses(String operationKey) {
Set<Integer> statuses = new LinkedHashSet<>();
map(operation(operationKey), "responses")
.keySet().forEach(s -> statuses.add(Integer.parseInt(s)));
return statuses;
}
/** The single 2xx status the operation declares. */
int successStatus(String operationKey) {
return responseStatuses(operationKey).stream()
.filter(s -> s >= 200 && s < 300)
.reduce((a, b) -> {
throw new IllegalStateException("多个 2xx 响应: " + operationKey);
})
.orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey));
}
/** Operation object for "METHOD pathTemplate", or null when undeclared. */
Map<String, Object> operation(String operationKey) {
String[] parts = operationKey.split(" ", 2);
Map<String, Object> pathItem = map(paths(), parts[1]);
return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT));
}
/** Follows local $ref chains; non-ref maps come back unchanged. */
Map<String, Object> resolve(Map<String, Object> node) {
while (node != null && node.get("$ref") instanceof String ref) {
if (!ref.startsWith("#/")) {
throw new IllegalStateException("仅支持本地 $ref: " + ref);
}
Map<String, Object> cur = root;
for (String seg : ref.substring(2).split("/")) {
cur = map(cur, seg);
if (cur == null) {
throw new IllegalStateException("$ref 指向不存在的节点: " + ref);
}
}
node = cur;
}
return node;
}
@SuppressWarnings("unchecked")
static Map<String, Object> cast(Object o) {
return (Map<String, Object>) o;
}
static Map<String, Object> map(Map<String, Object> m, String key) {
return m == null ? null : cast(m.get(key));
}
@SuppressWarnings("unchecked")
static List<Object> list(Map<String, Object> m, String key) {
return m == null ? null : (List<Object>) m.get(key);
}
}
@@ -0,0 +1,190 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.security.InternalAuthFilter;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* /internal/users/profiles (D3-9 方案 B, T3-05): service auth, batch limits,
* the nicknameusername fallback, avatar pointer passthrough, and the
* silent-absence semantics for unknown or soft-deleted users.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class InternalProfileEndpointTest {
private static final String INTERNAL_TOKEN = "test-internal-token";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
private MockHttpServletRequestBuilder profiles(String ids) {
return get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.queryParam("ids", ids);
}
private String registerUser(String username) throws Exception {
String body = mockMvc.perform(post("/internal/users")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
return JsonPath.read(body, "$.data.id");
}
private String freshUsername() {
return "prof" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL);
}
/** Profiles of a successful reply, keyed by userId. */
private Map<String, Map<String, Object>> fetch(String ids) throws Exception {
String body = mockMvc.perform(profiles(ids))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
return rows.stream().collect(Collectors.toMap(
row -> (String) row.get("userId"), Function.identity()));
}
private UUID insertReadyAvatarAsset(UUID ownerId) {
UUID assetId = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at)
VALUES (:id, :owner, 'image', 'user_avatar', 'object', 'patbond-media',
:objectKey, 'image/jpeg', 123, 'ready', :readyAt)
""")
.param("id", assetId)
.param("owner", ownerId)
.param("objectKey", "user_avatar/2026/09/" + assetId + ".jpg")
.param("readyAt", OffsetDateTime.now())
.update();
jdbcClient.sql("UPDATE identity.users SET avatar_asset_id = :assetId WHERE id = :id")
.param("assetId", assetId)
.param("id", ownerId)
.update();
return assetId;
}
@Test
void rejectsCallsWithoutTheServiceSecret() throws Exception {
mockMvc.perform(get("/internal/users/profiles").queryParam("ids", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, "wrong-token")
.queryParam("ids", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void returnsNicknameWhenSetAndFallsBackToUsernameWhenNot() throws Exception {
String withNickname = registerUser(freshUsername());
String withoutNickname = freshUsername();
String withoutNicknameId = registerUser(withoutNickname);
jdbcClient.sql("UPDATE identity.users SET nickname = '毛毛的铲屎官' WHERE id = :id")
.param("id", UUID.fromString(withNickname))
.update();
Map<String, Map<String, Object>> profiles = fetch(withNickname + "," + withoutNicknameId);
assertThat(profiles).hasSize(2);
assertThat(profiles.get(withNickname).get("nickname")).isEqualTo("毛毛的铲屎官");
assertThat(profiles.get(withoutNicknameId).get("nickname")).isEqualTo(withoutNickname);
}
@Test
void passesTheAvatarAssetPointerThroughAndNullWithoutAvatar() throws Exception {
String withAvatar = registerUser(freshUsername());
String withoutAvatar = registerUser(freshUsername());
UUID assetId = insertReadyAvatarAsset(UUID.fromString(withAvatar));
Map<String, Map<String, Object>> profiles = fetch(withAvatar + "," + withoutAvatar);
assertThat(profiles.get(withAvatar).get("avatarAssetId")).isEqualTo(assetId.toString());
assertThat(profiles.get(withoutAvatar).get("avatarAssetId")).isNull();
}
@Test
void unknownAndSoftDeletedIdsAreSilentlyAbsent() throws Exception {
String live = registerUser(freshUsername());
String deleted = registerUser(freshUsername());
jdbcClient.sql("UPDATE identity.users SET deleted_at = now(), status = 'deleted' WHERE id = :id")
.param("id", UUID.fromString(deleted))
.update();
Map<String, Map<String, Object>> profiles =
fetch(live + "," + deleted + "," + UUID.randomUUID());
assertThat(profiles).containsOnlyKeys(live);
}
@Test
void missingOrBlankIdsIsA400() throws Exception {
mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(profiles(" "))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void malformedUuidIsA400() throws Exception {
mockMvc.perform(profiles(UUID.randomUUID() + ",not-a-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void moreThanFiftyIdsIsA400AndExactlyFiftyIsNot() throws Exception {
String fifty = IntStream.range(0, 50)
.mapToObj(i -> UUID.randomUUID().toString())
.collect(Collectors.joining(","));
mockMvc.perform(profiles(fifty))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(0));
mockMvc.perform(profiles(fifty + "," + UUID.randomUUID()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void duplicateIdsCollapseToOneRow() throws Exception {
String user = registerUser(freshUsername());
mockMvc.perform(profiles(user + "," + user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1));
}
}
@@ -0,0 +1,217 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.support.TestJwtKeys;
import com.patbond.patbond.user.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.utility.DockerImageName;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-04 头像全链路真实 MinIO Testcontainer镜像 tag compose 一致
* {@code purpose=user_avatar} 创建上传 凭据直传 complete ready
* PATCH /me 挂头像 GET /me {@code avatarUrl} 是可真实下载的预签名 GET
* 清空后回到 null同时实证新加入白名单的 user_avatar 用途端到端可用以及
* 真实未就绪 asset 被拒 42203 SQL 造数据的那一版
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MeAvatarSigningIntegrationTest {
/** 与 docker-compose.yml 的 minio 服务钉同一 tagADR-016 三环境零分叉)。 */
private static final MinIOContainer MINIO = new MinIOContainer(
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
// 值仅为测试占位dummy非真实凭证
.withUserName("minio-dummy-access")
.withPassword("minio-dummy-secret");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final byte[] FAKE_JPEG = fakeJpeg();
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void wireMedia(DynamicPropertyRegistry registry) {
MINIO.start();
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.media.endpoint", MINIO::getS3URL);
registry.add("patbond.media.access-key", MINIO::getUserName);
registry.add("patbond.media.secret-key", MINIO::getPassword);
}
private static byte[] fakeJpeg() {
byte[] bytes = new byte[1024];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i * 17);
}
bytes[0] = (byte) 0xFF;
bytes[1] = (byte) 0xD8; // JPEG SOI
return bytes;
}
private UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 以 user_avatar 用途申请上传凭据(本单新加入白名单)。 */
private String createAvatarUpload(UUID user) throws Exception {
return mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"user_avatar",
"mimeType":"image/jpeg","byteSize":%d}
""".formatted(FAKE_JPEG.length)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.assetId").isNotEmpty())
.andReturn().getResponse().getContentAsString();
}
private void directPut(String createdBody) throws Exception {
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
.PUT(HttpRequest.BodyPublishers.ofByteArray(FAKE_JPEG));
headers.forEach(put::header);
assertThat(HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode())
.isEqualTo(200);
}
/** 走完 T3-03 两步上传,返回 ready 的 assetId。 */
private String uploadReadyAvatar(UUID user) throws Exception {
String created = createAvatarUpload(user);
String assetId = JsonPath.read(created, "$.data.assetId");
directPut(created);
mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
.header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ready"));
return assetId;
}
@Test
void avatarUrlIsAFreshPresignedGetThatActuallyDownloads() throws Exception {
UUID user = newUser("avatar_signed");
String assetId = uploadReadyAvatar(user);
String patched = mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
String avatarUrl = JsonPath.read(patched, "$.data.avatarUrl");
assertThat(avatarUrl)
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/")
.contains("X-Amz-Signature=");
// 真实下载签名有效私有桶靠签名而非公开读
HttpResponse<byte[]> download = HTTP.send(
HttpRequest.newBuilder(URI.create(avatarUrl)).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
assertThat(download.statusCode()).isEqualTo(200);
assertThat(download.body()).isEqualTo(FAKE_JPEG);
// GET /me 每次重新签发URL 会过期客户端不得持久化
String fetched = mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat((String) JsonPath.read(fetched, "$.data.avatarUrl"))
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/");
// 清空后回到 null
String cleared = mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":null}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
}
/**
* 真实的凭据已发但还没直传状态asset 存在属本人用途正确但仍是
* uploading 引用侧必须 422/42203不能挂上一个下载会 404 的头像
*/
@Test
void refusesAnAvatarAssetWhoseUploadNeverCompleted() throws Exception {
UUID user = newUser("avatar_pending");
String assetId = JsonPath.read(createAvatarUpload(user), "$.data.assetId");
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
}
/** purpose 白名单:M3.5 之后 pet_avatar 同样可申请(宠物侧引用方在 pet 服务)。 */
@Test
void petAvatarPurposeIsAlsoWhitelisted() throws Exception {
UUID user = newUser("avatar_purposes");
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"pet_avatar",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0));
// 白名单外的用途仍是 400/40000
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"avatar",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
@@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.util.UUID;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -25,8 +26,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/**
* GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally
* against the configured public key (generated per test run no committed
* key material). Response shape is the frozen contract:
* {userId, username, phone, createdAt} and nothing else.
* key material). Response shape is the M3.5 surface
* {userId, username, nickname, phone, avatarUrl, createdAt} and nothing else;
* the profile-write semantics live in MeProfileIntegrationTest.
*/
@SpringBootTest
@AutoConfigureMockMvc
@@ -65,9 +67,14 @@ class MeEndpointTest {
.andExpect(jsonPath("$.data.username").value("me_happy"))
.andExpect(jsonPath("$.data.phone").value("+8613800000401"))
.andExpect(jsonPath("$.data.createdAt").isNotEmpty())
// Frozen contract: no other identity fields leak out.
// 注册不收昵称ADR-022 决策 D3.5-5 /me 不做 username 回退
// 本人编辑态必须如实反映我还没设过昵称
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
// No other identity field leaks out.
.andExpect(jsonPath("$.data.id").doesNotExist())
.andExpect(jsonPath("$.data.nickname").doesNotExist());
.andExpect(jsonPath("$.data.avatarAssetId").doesNotExist())
.andExpect(jsonPath("$.data.bio").doesNotExist());
}
@Test
@@ -0,0 +1,430 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.security.InternalAuthFilter;
import com.patbond.patbond.user.support.TestJwtKeys;
import com.patbond.patbond.user.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-04 用户资料读写GET/PATCH /api/v1/me 的六类路径成功 / 参数错 /
* 不存在 / 无权限 / 并发 / 重放与三项专项昵称边界值与清空头像 asset
* 非法三态/me 不回退 /internal 回退
*
* <p>本类不配置对象存储因此断言 {@code avatarUrl} 恒为 null这正是
* 存储未配置时资料读取整体降级而不失败的实证真实签名 URL 的全链路
* 创建上传 直传 complete 挂头像 URL 可访问
* MeAvatarSigningIntegrationTest 用真实 MinIO 覆盖</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MeProfileIntegrationTest {
private static final String INTERNAL_TOKEN = "test-internal-token";
/** 32 个 CJK 码点:恰好压在 ck_users_nickname 的上界上。 */
private static final String NICKNAME_32_CJK = "".repeat(32);
/**
* 32 emoji 码点UTF-16 长度 64证明长度校验按码点而非 Java
* String.length() PostgreSQL char_length 数的是码点若按 UTF-16
* 长度校验这个数据库能存的昵称会被应用层误拒
*/
private static final String NICKNAME_32_EMOJI = "🐶".repeat(32);
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void jwtPublicKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
}
// ---- helpers -------------------------------------------------------
private UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 的产物)。 */
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UuidV7.generate();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private String patchMe(UUID userId, String body, int expectedStatus) throws Exception {
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchMeExpectingCode(UUID userId, String body, int httpStatus, int bizCode)
throws Exception {
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private String getMe(UUID userId) throws Exception {
return mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(userId)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
/** 他人视角的展示名(/internal 批量接口,回退在 SQL 层)。 */
private String publicNickname(UUID userId) throws Exception {
String body = mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.queryParam("ids", userId.toString()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
return (String) rows.get(0).get("nickname");
}
private UUID dbAvatarAssetId(UUID userId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM identity.users WHERE id = :id")
.param("id", userId)
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void setsNicknameAndBothSidesAgree() throws Exception {
UUID user = newUser("me_nick_set");
String patched = patchMe(user, "{\"nickname\":\"豆豆家长\"}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆家长");
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("豆豆家长");
// 双端一致他人看到的展示名也立即是昵称Feed 作者名同链路
assertThat(publicNickname(user)).isEqualTo("豆豆家长");
}
@Test
void trimsNicknameLikeBtrim() throws Exception {
UUID user = newUser("me_nick_trim");
String patched = patchMe(user, "{\"nickname\":\" 豆豆 \"}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆");
}
/**
* /me 是本人编辑态 nickname DB 原值清空后为 null他人视角的
* /internal 才回退 username这两者的差异是本单的核心语义定型
*/
@Test
void clearingNicknameIsNullOnMeButFallsBackForOtherPeople() throws Exception {
UUID user = newUser("me_nick_clear");
patchMe(user, "{\"nickname\":\"临时昵称\"}", 200);
String cleared = patchMe(user, "{\"nickname\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
assertThat(publicNickname(user)).isEqualTo("me_nick_clear");
}
@Test
void absentFieldIsLeftUnchanged() throws Exception {
UUID user = newUser("me_absent");
patchMe(user, "{\"nickname\":\"保持不动\"}", 200);
// 只提交 avatarAssetId=nullnickname 未出现在 body 必须不受影响
String patched = patchMe(user, "{\"avatarAssetId\":null}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("保持不动");
}
@Test
void setsAndClearsAvatar() throws Exception {
UUID user = newUser("me_avatar_ok");
UUID asset = insertAsset(user, "user_avatar", "ready");
String patched = patchMe(user, "{\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
// 对象存储未配置 整体降级为 null URL而不是 500
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
patchMe(user, "{\"avatarAssetId\":null}", 200);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void acceptsNicknameAndAvatarInOneRequest() throws Exception {
UUID user = newUser("me_both");
UUID asset = insertAsset(user, "user_avatar", "ready");
String patched = patchMe(user,
"{\"nickname\":\"一次改两样\",\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("一次改两样");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- 参数错路径昵称边界值 + 畸形入参 + patch -------------------
@Test
void acceptsNicknameAtBothBoundaries() throws Exception {
UUID user = newUser("me_nick_bounds");
assertThat((String) JsonPath.read(patchMe(user, "{\"nickname\":\"\"}", 200),
"$.data.nickname")).isEqualTo("");
assertThat((String) JsonPath.read(
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_CJK), 200),
"$.data.nickname")).isEqualTo(NICKNAME_32_CJK);
assertThat((String) JsonPath.read(
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_EMOJI), 200),
"$.data.nickname")).isEqualTo(NICKNAME_32_EMOJI);
}
@Test
void rejectsNicknameOverThirtyTwoCodePoints() throws Exception {
UUID user = newUser("me_nick_long");
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("".repeat(33)), 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("🐶".repeat(33)), 400, 40000);
// 拒绝后 DB 未被写入
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
}
/**
* 空白昵称是参数错不是隐式清空清空只有显式 null 一种表达
* 否则用户不小心提交了空格用户想删昵称无法区分
*/
@Test
void rejectsWhitespaceOnlyNicknameInsteadOfClearing() throws Exception {
UUID user = newUser("me_nick_blank");
patchMe(user, "{\"nickname\":\"原昵称\"}", 200);
patchMeExpectingCode(user, "{\"nickname\":\" \"}", 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":\"\"}", 400, 40000);
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("原昵称");
}
@Test
void rejectsPatchThatTouchesNothing() throws Exception {
UUID user = newUser("me_empty_patch");
patchMeExpectingCode(user, "{}", 400, 40000);
// 只带未声明字段同样等于什么都没改
patchMeExpectingCode(user, "{\"unknownField\":\"x\"}", 400, 40000);
}
@Test
void rejectsMalformedBody() throws Exception {
UUID user = newUser("me_malformed");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":", 400, 40000);
patchMeExpectingCode(user, "{\"avatarAssetId\":42}", 400, 40000);
}
// ---- 头像 asset 非法三态不存在/非本人/错用途/未就绪 ---------------
@Test
void rejectsUnknownOrForeignAvatarAssetWithMergedNotFound() throws Exception {
UUID user = newUser("me_asset_foreign");
UUID stranger = newUser("me_asset_stranger");
UUID strangerAsset = insertAsset(stranger, "user_avatar", "ready");
// 幽灵 id 与他人 asset 同答 40405防枚举合并
patchMeExpectingCode(user,
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), 404, 40405);
patchMeExpectingCode(user,
"{\"avatarAssetId\":\"%s\"}".formatted(strangerAsset), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void rejectsAssetWithWrongPurpose() throws Exception {
UUID user = newUser("me_asset_purpose");
UUID postImage = insertAsset(user, "post_image", "ready");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(postImage), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void rejectsAvatarAssetThatIsNotReady() throws Exception {
UUID user = newUser("me_asset_state");
UUID uploading = insertAsset(user, "user_avatar", "uploading");
UUID failed = insertAsset(user, "user_avatar", "failed");
UUID deleted = insertAsset(user, "user_avatar", "deleted");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(uploading), 422, 42203);
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(failed), 422, 42203);
// deleted 归入防枚举合并的 40405已删资源对引用方就是不存在
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(deleted), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
// ---- 无权限路径 -----------------------------------------------------
@Test
void patchWithoutOrWithBadTokenIs40101() throws Exception {
mockMvc.perform(patch("/api/v1/me")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"无票乘车\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", "Bearer not.a.jwt")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"伪票\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
// ---- 不存在路径 -----------------------------------------------------
@Test
void softDeletedUserGetsUserNotFoundOnBothVerbs() throws Exception {
UUID user = newUser("me_gone");
jdbcClient.sql("""
UPDATE identity.users
SET status = 'deleted', deleted_at = now()
WHERE id = :id
""")
.param("id", user)
.update();
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
patchMeExpectingCode(user, "{\"nickname\":\"亡者昵称\"}", 404, 40400);
}
// ---- 并发路径 -------------------------------------------------------
/**
* 两个并发 PATCH 各改一个字段都必须留下这是列级选择性 UPDATE 而非
* -合并-的实证若走整行合并写后到的那个会把对方刚写的字段
* 悄悄还原丢失更新/me 无版本号乐观锁靠的正是这个性质
*/
@Test
void concurrentDisjointPatchesBothSurvive() throws Exception {
UUID user = newUser("me_concurrent");
UUID asset = insertAsset(user, "user_avatar", "ready");
CyclicBarrier startTogether = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<Integer> nicknameWriter = () -> {
startTogether.await();
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"并发昵称\"}"))
.andReturn().getResponse().getStatus();
};
Callable<Integer> avatarWriter = () -> {
startTogether.await();
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(asset)))
.andReturn().getResponse().getStatus();
};
Future<Integer> first = pool.submit(nicknameWriter);
Future<Integer> second = pool.submit(avatarWriter);
assertThat(first.get()).isEqualTo(200);
assertThat(second.get()).isEqualTo(200);
} finally {
pool.shutdownNow();
}
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("并发昵称");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- 重放路径 -------------------------------------------------------
/**
* PATCH /me 是幂等的无版本号无幂等键同一请求重放两次第二次同样
* 200 且状态与首次完全一致重复点保存不会产生二次副作用
*/
@Test
void repeatingTheSamePatchIsStable() throws Exception {
UUID user = newUser("me_replay");
UUID asset = insertAsset(user, "user_avatar", "ready");
String body = "{\"nickname\":\"重放昵称\",\"avatarAssetId\":\"%s\"}".formatted(asset);
String firstNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
String secondNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
assertThat(secondNickname).isEqualTo(firstNickname).isEqualTo("重放昵称");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- GET 形态回归 ---------------------------------------------------
@Test
void meCarriesNickAndAvatarUrlFieldsAlways() throws Exception {
UUID user = newUser("me_shape");
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andExpect(jsonPath("$.data.username").value("me_shape"));
}
}
@@ -241,6 +241,8 @@ class MediaUploadIntegrationTest {
@Test
void rejectsKindAndPurposeOutsideWhitelist() throws Exception {
UUID user = newUser("media_bad_enum");
// M3.5 user_avatar/pet_avatar 已进白名单ADR-022反例改用
// 一个仍未开放的用途保持本用例白名单外必拒的语义
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
@@ -254,7 +256,7 @@ class MediaUploadIntegrationTest {
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"pet_avatar",
{"kind":"image","purpose":"id_card",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isBadRequest())
File diff suppressed because it is too large Load Diff