23 Commits

Author SHA1 Message Date
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
lixi 101ac0fbbc feat: 帖子生命周期——草稿/编辑/发布/软删/详情/我的列表(ADR-019,T3-04)
CI / backend-test (push) Successful in 6m33s
- patbond-community 帖子域五端点:POST/GET/PATCH/DELETE /api/v1/posts(/{postId}) + GET /api/v1/me/posts
- 创建型幂等按 ADR-019:Idempotency-Key 必带(1~128)落 uq_posts_author_idempotency,
  规范化 request_hash 比对——同键同 hash 返回首帖、异 hash 40905、键按作者隔离
- 权限/错误语义定型(T3-10 冻结输入):403/40301 仅发给可见者,一切不可见合并
  404/40403 防枚举(hidden/archived 对作者同样 404);version 乐观锁 40902
- 发布 = PATCH 状态迁移 draft→published(publishedAt 恰写一次,重复发布幂等 no-op)
- 软删 deleted_at 为唯一判定基准,published 行归档为 archived 满足 ck_posts_publish_state
- post_media:仅本人 ready asset(同库只读 media.assets,ADR-017 先例;40405/42203),
  position 全给或全不给、isCover 至多一、封面缺省落 position 0;PATCH media 整组替换
- 读取侧图片 URL 由 community 本地预签名 GET(与 user 共用 PATBOND_MINIO_* 配置,
  compose 已注入;未配置降级 url=null)
- ErrorCode 增 40301/40403/40905/42203;异常处理补 MissingRequestHeaderException→40000
- 集成测试 +25(六类路径/幂等专项/草稿可见性矩阵/并发 PATCH 真竞争),全套 251 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 09:59:46 +08:00
lixi 263cd88451 test: auth 域 6 操作契约一致性测试全响应矩阵(D3-8,T3-19)
CI / backend-test (push) Failing after 8s
- 复制 pet 模块契约框架(OpenApiContract/ContractValidator + v1.2.0
  字节级快照)入 patbond-auth,沿每模块复制纪律;快照守卫防漏同步
- AuthContractConformanceTest:沿 AuthE2eIntegrationTest 编排同 JVM 真实
  拉起 user 服务,register/login/refresh/logout 打 auth、me/trackEvents
  打 user;6 操作 19 个 (操作,状态码) 单元格全矩阵零豁免(含 409 双业务
  码、423 锁定、40102 重放、me 404 幽灵用户、events 匿名 202/无效 token 401)
- 修契约测试首轮抓到的真实漂移:EventResult.reason 在 accepted/duplicate
  条目序列化出 null,契约声明仅 rejected 时出现——reason 加
  @JsonInclude(NON_NULL),既有埋点测试零回归

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:11:58 +08:00
lixi 10a43f876c feat: media 域最小闭环——存储适配层 + MinIO 预签名两步上传(ADR-016/017,T3-03)
- ObjectStorage 适配层(供应商唯一缝)+ S3ObjectStorage(AWS SDK v2,
  path-style,内网/公网双端点分离签发);endpoint 未配置时服务可启动、
  media 端点 500(沿 JWT 公钥先例)
- POST /api/v1/media/uploads:白名单校验(purpose/mime/byteSize 全配置项)
  → uploading 行(objectKey 服务端生成不含用户输入)→ 预签名 PUT 凭据
  (requiredHeaders 定型仅 Content-Type,TTL 10m)
- complete:HEAD 校验存在性/大小/类型 → uploading→ready(guarded UPDATE
  并发幂等);对象缺失保持 uploading 可重试,内容不符置 failed 终态;
  防枚举 40405;新错误码 40405/42205
- 读取侧预签名 GET(TTL 1h),桶保持私有
- compose 纳入 minio 第六容器(镜像与 Testcontainer 同 tag,桶初始化走
  应用启动 ensureBucket,三环境零分叉);init-secrets.sh 幂等追加 MinIO
  根凭证到 .env(凭证零入库,ADR-021)
- MediaUploadIntegrationTest 12 例:MinIO Testcontainer 全链路直传取回 +
  六类失败路径 + 库层约束一致性;uploading 超时清理只记方案(报告 13 §6)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 17:11:44 +08:00
lixi 8330885b06 chore: 凭证防泄漏检查落地——check-secrets 脚本入库 + CI 兜底 step(ADR-021)
CI / backend-test (push) Successful in 6m47s
- 新增 scripts/check-secrets.sh:8 条内容规则 + 文件名黑名单 + 占位/注入允许清单,纯 shell 零外部依赖
- 新增 scripts/hooks/pre-commit:git config core.hooksPath scripts/hooks 启用,扫暂存区
- ci.yml 在 checkout 后新增 Secret scan step(同一脚本 --all),既有 step 未动
- 验收:全仓 194 个已跟踪文件扫描零误报;构造假 AK/SK/私钥/.env 自测 9 类命中全拦截

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:28:04 +08:00
lixi 3c671fcaf5 feat: 新建 patbond-community 模块骨架(ADR-017,T3-02)
CI / backend-test (push) Successful in 7m15s
社区服务 :8084 挂入父 pom 与 compose(第五容器,依赖 postgres 健康 + user
先起保证 V5 已执行);/api/v1/** 自骨架起接 RS256 资源侧校验(无 token/畸形/
错签/过期均 401+40101,user/pet 同款复制,P9 下沉待拍板);GET /health 探活
在 /api/v1 之外;.sample 配置模式;生产 classpath 无 Flyway,测试经 user jar
跑全链 V1..V5(pet 先例)。模块只读写 community schema。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:16:26 +08:00
lixi a97814ac1c feat: Flyway V5 community schema 基线 + 迁移验证集成测试(T3-01)
从目标模型 718~875 行提取 community 全部 8 表(含 topics,ADR-018 表建功能剪、
无种子进生产链);启用 pg_trgm(02 号发现 V1 漏建,trgm 索引照建);剥离 2 条
跨 schema FK:posts.generation_job_id→creation(M4 补回)、posts.region_id→
platform.regions(M5 地区体系补回),裸可空 uuid 列与索引保留,照 V3 剪
marketplace FK 先例。CommunityMigrationIntegrationTest 8 例验证结构与裁剪。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:16:16 +08:00
lixi 64c9b72fd1 feat: 事件字典 v2 白名单扩充 pet/health_record 域 10 事件(T2-17 后端)
CI / backend-test (push) Successful in 5m18s
- EventDictionary 增补 pet 域 3 事件(pet_create_started/succeeded/failed)
  与 health_record 域 7 事件(create_started/succeeded/failed、viewed、
  edit_succeeded/failed、deleted),props 键集按 06 号报告 §1.5 直抄
- page_viewed 转正为 v2 正稿:props 键集 pageName/referrer 与正稿一致,
  pageName 枚举(含 pet_form)在客户端编译期约束 + §6.4 值级巡检兜底
- 字典边界测试 5 例:三域 props 键集全矩阵、page_viewed 正稿键集、
  刻意不设事件(pet_viewed/edit_started/delete_failed)保持 unknown
- 接收端集成测试 4 例:v2 事件端到端接受、v2 白名单外 props 剥离、
  枚举外 recordType 值过 ingest 交巡检、废弃 health_record_action 仍拒绝
- 测试 182 → 191 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:30:44 +08:00
lixi d026f2f63d test: 契约一致性测试对冻结契约 v1.2.0 全矩阵校验(T2-09)
CI / backend-test (push) Successful in 5m14s
- 新增 ContractConformanceTest:pets 域 18 操作真实起服务(Testcontainers +
  MockMvc)逐一发请求,对照冻结快照 src/test/resources/contract/
  openapi-v1.2.0.yaml 严格校验响应结构(未声明字段即漂移、必填/nullable、
  类型/枚举/格式/边界、错误码值);覆盖门禁断言契约声明的每个
  (操作, 状态码) 单元格都被真实触发(唯一豁免:提醒 PATCH 409 并发守卫)。
- OpenApiContract/ContractValidator:snakeyaml 解析快照 + 自写严格断言,
  零新增依赖;快照守卫锁 info.version=1.2.0 与 18 路径/24 操作/45 schema,
  正典(doc 仓 docs/api/openapi.yaml)升版而快照未同步时 CI 立即变红。
- 修复漂移:CreatePetRequest.sex 按冻结契约改为必填(原实现缺省补
  unknown,契约 required 含 sex);既有测试载荷补 sex 字段。

全套 ./mvnw clean test 182 项全绿(171 → 182,+11 契约测试)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 10:36:12 +08:00
lixi 00f7dbdb69 feat: 档案聚合摘要接口与四项聚合口径定型(T2-08)
CI / backend-test (push) Successful in 3m36s
- GET /api/v1/pets/{petId}/summary,READ 档(viewer 可读),四项聚合全部实时计算、零持久化(4.3 红线)
- 最新体重:measured_at DESC, id DESC 首行,与体重列表口径一致;无记录为 null
- 疫苗进度:completed 剂次 / 非 cancelled 总剂次;无非 cancelled 记录为 null
- 下次接种:scheduled planned_on 与 completed next_due_on(同系列更高剂次未登记时)取全候选最早日期,含逾期;source 区分 planned/nextDue
- 当月花费:health_events.amount_cents 按 tz 参数(IANA 时区,缺省 UTC)自然月半开区间求和,NULL 金额不计入;恒非 null,无支出为 0
- 集成测试 12 例:空数据语义、跨月边界(含时区口径)、cancelled 不计入、被接续加强针剔除、多宠隔离、防枚举 404、零写入断言

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 09:45:50 +08:00
lixi 3b27f9fcbe feat: 照护提醒接口与 pending 状态流转(T2-07)
CI / backend-test (push) Successful in 5m8s
- GET/POST /api/v1/pets/{petId}/care-reminders + PATCH /api/v1/care-reminders/{reminderId}
- 四类提醒白名单;创建恒为 pending;列表按 due_at ASC,
  ?status=pending 待办视图走 ix_care_reminders_due 部分索引
- 状态机 pending→completed/dismissed,终态不可迁移、同状态重放幂等;
  completed 必带 completedAt、其余状态禁带(镜像 ck_care_reminder_completed),
  违反 → 新错误码 42202 REMINDER_RULE_VIOLATION
- 表无 version 列:流转用当前状态条件更新守卫竞态(落空 → 40902);
  顶层记录路径 40402 记录级防枚举;Idempotency-Key 派生主键幂等
- 仅 app 内数据接口,不做推送(D2-5,通知系统属 M6)
- Testcontainers 集成测试 10 例(六类路径 + 状态-completedAt 一致性)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:59:34 +08:00
lixi d8303bf446 feat: 健康事件时间线接口与 occurred_at 游标分页(T2-06)
- GET/POST /api/v1/pets/{petId}/health-events + PATCH /api/v1/health-events/{eventId}
- 六类事件白名单、amount_cents 整数分非负(禁用 Jackson float→int 截断)、
  created_by_user_id 记操作者
- occurred_at DESC, id DESC 游标分页对齐 ix_health_events_pet_time;
  Idempotency-Key 派生主键幂等;version 乐观锁 40902;
  顶层记录路径 40402 记录级防枚举
- health_event_media / provider / booking 不实现不外露(ADR-010)
- Testcontainers 集成测试 11 例(六类路径 + 分页专项)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:59:17 +08:00
lixi 4c2653c643 feat: 疫苗目录与疫苗记录接口、状态机与剂次唯一(T2-05)
CI / backend-test (push) Successful in 5m18s
- GET /api/v1/vaccine-catalog(只读字典,species 过滤,仅 enabled)
- GET/POST /api/v1/pets/{petId}/vaccinations、PATCH /api/v1/vaccinations/{id}
  (顶层短路径,记录级防枚举:不可见记录一律 404/40402)
- 状态机 scheduled→completed/cancelled,completed/cancelled 终态;
  日期规则镜像 ck_vaccination_dates/ck_vaccination_next_due,违反返回
  422/42201(新码 VACCINATION_RULE_VIOLATION)
- 剂次唯一冲突 409/40904(新码 VACCINATION_DOSE_EXISTS,草案提议的
  40903 已被 MICROCHIP_EXISTS 占用故改号);cancelled 释放唯一占位
- version 乐观锁 409/40902;Idempotency-Key 幂等与 T2-04 同机制
- 疫苗须存在/启用且物种与宠物匹配(40000)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:44:23 +08:00
lixi 825dde3928 feat: 体重记录接口与 cursor 分页、Idempotency-Key 幂等(T2-04)
- GET/POST /api/v1/pets/{petId}/weights,复用 PetAccessService 闸口
  (READ 读 / WRITE 写,含 caregiver 写成功正向用例)
- cursor 分页按 (measured_at DESC, id DESC) 与 ix_pet_weight_pet_measured
  对齐,limit+1 探测 hasMore,同刻记录跨页不丢不重
- Idempotency-Key 可选头:键派生确定性主键 + ON CONFLICT (id) DO NOTHING,
  重试返回原记录,零新增迁移
- weight_kg 校验镜像 ck_pet_weight(>0 且 ≤500,两位小数)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:43:59 +08:00
lixi 8fbf44472d feat: 宠物 CRUD 与 pet_owners 三角色权限框架(T2-03)
CI / backend-test (push) Successful in 7m22s
- GET/POST /api/v1/pets、GET/PATCH /api/v1/pets/{petId}、GET /api/v1/breeds
  (species 过滤);创建者自动写入 pet_owners primary owner(同事务)
- PetAccessService 统一权限闸口(READ/WRITE/MANAGE 三档 × owner/caregiver/
  viewer 三角色,ADR-015):无关系一律 404/40401 防枚举,可见越权 403/40300;
  每请求实时查库无缓存,撤销关系即时生效;T2-04~07 复用同一入口
- PATCH version 乐观锁(40902);部分更新语义;status 白名单排除 deleted,
  软删除不可经 PATCH 绕过 ck_pets_deleted
- ck_pets_breed 互斥 + 字典品种存在/物种匹配应用层先行校验(40000);
  芯片号唯一冲突新错误码 40903 MICROCHIP_EXISTS
- pet 模块接入 RS256 bearer 鉴权(与 user 同一公钥约定),compose 挂载公钥
- 测试基建:test classpath 引入 patbond-user + Flyway,Testcontainers 上跑
  完整 V1..V4 迁移链;三角色以测试数据直写 pet_owners 构造(T2-10)
- 集成测试覆盖六类路径 23 例;全套 118 测试全绿(基线 95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:27:44 +08:00
lixi 58576f8ebc refactor: 移除 EventDictionary 的 health_record_action(ADR-013)
CI / backend-test (push) Successful in 7m19s
- health_record_action 从白名单直接移除(客户端零引用,废弃零成本)
- M2 事件按字典 v2(具体事件名,iteration-2/06)随第二波接口纵切落地;
  不复活通用 actionType 设计(多套指标共享分母污染)
- v1 auth 漏斗事件 + page_viewed(遗留 §2)为当前完整白名单
- 新增 EventDictionaryTest:锁定移除后白名单边界,回归防护

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:35:31 +08:00
lixi 0eae1c9bec feat: 新建 patbond-pet 模块骨架(ADR-009,T2-02 前置)
- 新 Maven 模块挂入父 pom,依赖/插件管理对齐既有 user/auth 模式
  (common 依赖、starter-web/validation/jdbc、exec classifier repackage)
- 骨架内容:PetApplication、/health 探活端点(含 DB 连通检查)、
  GlobalExceptionHandler(同一 {code,message,data} 信封契约)
- 与 user 共库只读写 pet_health schema;不携带 Flyway——单一迁移链
  (V1..V4)仍由 patbond-user 启动时统一执行,flyway_schema_history 不拆
- 配置走 .sample 模式(默认端口 8083,敏感信息经环境变量注入不入库)
- compose 编排纳入 pet 服务(依赖 postgres 健康 + user 先起保证迁移就绪);
  Dockerfile 与既有服务同模式;Readme 模块清单同步
- 测试:Testcontainers postgres:18 上下文启动冒烟 + /health 探活断言

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:34:52 +08:00
lixi 49299fb4eb feat: Flyway V3 pet_health 结构基线 + V4 字典种子 + pet 域错误码(T2-01)
- V3:pet_health schema 8 张表(breeds/pets/pet_owners/pet_weight_records/
  vaccine_catalog/pet_vaccinations/health_events/care_reminders),列、CHECK、
  部分唯一索引、updated_at 触发器与目标模型一致
- 强制裁剪:剥离 bootstrap SQL 1156~1166 行 4 条指向 marketplace 的跨 schema
  外键(provider_id/booking_id 保留为裸可空 uuid,M5 迁移 marketplace 时补回)
- ADR-010:health_event_media 不建(media 剪出 M2,纯增量表后续补零成本)
- V4:breeds(28 条)与 vaccine_catalog(10 条)字典种子,生产参考数据走正式
  迁移链不放 db/dev;正典目录内容由产品侧供稿(D2-6)
- ErrorCode 预置 pets 域四码:40300 PET_ACCESS_DENIED、40401 PET_NOT_FOUND、
  40402 RECORD_NOT_FOUND、40902 VERSION_CONFLICT(第二波接口直接消费)
- 新增 PetHealthMigrationIntegrationTest:干净 postgres:18 上全量迁移验证
  表数、结构抽查、4 条 FK 确不存在、触发器与种子数据

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 14:34:19 +08:00
199 changed files with 35349 additions and 6 deletions
+4
View File
@@ -36,6 +36,10 @@ jobs:
git remote add origin "$AUTH_URL/${{ github.repository }}.git" git remote add origin "$AUTH_URL/${{ github.repository }}.git"
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head" git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
git checkout -q refs/ci-head git checkout -q refs/ci-head
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
# 扫全部已跟踪文件(覆盖本次 push 变更的超集),纯 shell 零外部依赖。
- name: Secret scan
run: sh scripts/check-secrets.sh --all
# 不用 actions/setup-java:它从 github.com 下载 JDK 二进制, # 不用 actions/setup-java:它从 github.com 下载 JDK 二进制,
# 服务器无法访问 GitHub(实测 ETIMEDOUT)。改用 apt 装 OpenJDK 17 # 服务器无法访问 GitHub(实测 ETIMEDOUT)。改用 apt 装 OpenJDK 17
# 并优先切换到腾讯云镜像源(runner 在腾讯云,内网加速)。 # 并优先切换到腾讯云镜像源(runner 在腾讯云,内网加速)。
+4 -1
View File
@@ -7,13 +7,14 @@ Patbond API is a Spring Boot multi-module backend.
- `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies. - `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies.
- `patbond-user`: user service for user creation, profile lookup, and password verification. - `patbond-user`: user service for user creation, profile lookup, and password verification.
- `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002). - `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002).
- `patbond-pet`: pet profile and health record service (M2, ADR-009). Shares the database with `patbond-user` and only reads/writes the `pet_health` schema; the Flyway migration chain stays owned by `patbond-user`. First-wave skeleton: liveness endpoint only.
## Technology Stack ## Technology Stack
- Java 17 (build baseline; use JDK 17 for release builds) - Java 17 (build baseline; use JDK 17 for release builds)
- Spring Boot 3.5.16 - Spring Boot 3.5.16
- Spring Cloud 2025.0.3 (OpenFeign only) - Spring Cloud 2025.0.3 (OpenFeign only)
- PostgreSQL 18 + Flyway (patbond-user owns the `identity`/`media` schemas) - PostgreSQL 18 + Flyway (patbond-user owns the migration chain: `identity`/`media`/`platform`/`pet_health` schemas)
- Maven (use the committed Maven Wrapper `./mvnw`) - Maven (use the committed Maven Wrapper `./mvnw`)
## Build and Test ## Build and Test
@@ -49,6 +50,8 @@ cp patbond-user/src/main/resources/application.yml.sample \
patbond-user/src/main/resources/application.yml patbond-user/src/main/resources/application.yml
cp patbond-auth/src/main/resources/application.yml.sample \ cp patbond-auth/src/main/resources/application.yml.sample \
patbond-auth/src/main/resources/application.yml patbond-auth/src/main/resources/application.yml
cp patbond-pet/src/main/resources/application.yml.sample \
patbond-pet/src/main/resources/application.yml
``` ```
Install the shared module once, then run each service in its own terminal: Install the shared module once, then run each service in its own terminal:
+11
View File
@@ -20,6 +20,17 @@ if [ ! -f .env ]; then
echo "已生成 .env(随机 DB 口令与内部令牌)" echo "已生成 .env(随机 DB 口令与内部令牌)"
fi fi
# M3ADR-016/021):MinIO 根凭证。按键幂等追加,兼容已有 .env;
# 凭证只存在于被 gitignore 的 .env 中,绝不入库。
if ! grep -q '^PATBOND_MINIO_ROOT_USER=' .env; then
echo "PATBOND_MINIO_ROOT_USER=patbond-minio-$(openssl rand -hex 4)" >> .env
echo "已追加 PATBOND_MINIO_ROOT_USER 到 .env"
fi
if ! grep -q '^PATBOND_MINIO_ROOT_PASSWORD=' .env; then
echo "PATBOND_MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)" >> .env
echo "已追加 PATBOND_MINIO_ROOT_PASSWORD 到 .env"
fi
# 容器内以 uid 10001 运行,密钥需可读 # 容器内以 uid 10001 运行,密钥需可读
chmod 644 deploy/keys/jwt-public.pem deploy/keys/jwt-private.pem chmod 644 deploy/keys/jwt-public.pem deploy/keys/jwt-private.pem
echo "OKdeploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)" echo "OKdeploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)"
+86
View File
@@ -25,6 +25,27 @@ services:
retries: 30 retries: 30
# 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"] # 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"]
# M3ADR-016):自托管 MinIO 对象存储。镜像 tag 与集成测试的 MinIO
# Testcontainer 钉同一版本(三环境零分叉);对象数据落 volume(ADR-007
# 应用容器保持无状态)。桶初始化由 user 服务启动时执行(ensureBucket
# 本地/compose/CI 同一条路径),无需 mc 初始化容器。9000 端口必须对客户端
# 可达:预签名直传/读取 URL 都直接指向 MinIO,不经应用服务器。
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data
environment:
MINIO_ROOT_USER: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
MINIO_ROOT_PASSWORD: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
volumes:
- minio-data:/data
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:9000/minio/health/live"]
interval: 2s
timeout: 3s
retries: 30
ports:
- "${PATBOND_MINIO_PORT:-9000}:9000"
user: user:
build: ./patbond-user build: ./patbond-user
environment: environment:
@@ -34,6 +55,14 @@ services:
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
# ADR-016/017media 上传流程在 user 服务。服务自身走内网端点;
# 预签名 URL 按 PATBOND_MINIO_PUBLIC_ENDPOINT 签发(默认本机回环,
# 真机联调/生产改为客户端可达地址)。
PATBOND_MINIO_ENDPOINT: http://minio:9000
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}
PATBOND_MINIO_BUCKET: ${PATBOND_MINIO_BUCKET:-patbond-media}
volumes: volumes:
- ./patbond-user/src/main/resources/application.yml.sample:/config/application.yml:ro - ./patbond-user/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro - ./deploy/keys:/run/patbond/keys:ro
@@ -44,6 +73,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
auth: auth:
build: ./patbond-auth build: ./patbond-auth
@@ -60,5 +91,60 @@ services:
depends_on: depends_on:
- user - user
# M2(ADR-009):宠物健康档案服务。第二波起提供 /api/v1/pets、/api/v1/breeds
# 业务端点(RS256 校验,与 user 同一公钥)。与 user 共库;Flyway 迁移链由
# user 服务统一执行,故依赖 user 先起,保证 pet_health schema 已就绪。
pet:
build: ./patbond-pet
environment:
SPRING_CONFIG_LOCATION: file:/config/application.yml
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
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
volumes:
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro
ports:
- "${PATBOND_PET_PORT:-8083}:8083"
depends_on:
postgres:
condition: service_healthy
user:
condition: service_started
# M3ADR-017):社区服务(Feed/帖子/评论/互动)。骨架起 /api/v1/** 即接
# RS256 校验(与 user/pet 同一公钥);只读写 community schema。与 user 共库;
# Flyway 迁移链(V1..V5,含 community 基线)由 user 服务统一执行,故依赖
# user 先起,保证 community schema 已就绪。
community:
build: ./patbond-community
environment:
SPRING_CONFIG_LOCATION: file:/config/application.yml
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
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}
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-community/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro
ports:
- "${PATBOND_COMMUNITY_PORT:-8084}:8084"
depends_on:
postgres:
condition: service_healthy
user:
condition: service_started
volumes: volumes:
pgdata: pgdata:
minio-data:
@@ -0,0 +1,343 @@
package com.patbond.patbond.auth.contract;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.auth.support.SpringTestSupport;
import com.patbond.patbond.auth.support.TestJwtKeys;
import com.patbond.patbond.user.UserApplication;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterAll;
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.builder.SpringApplicationBuilder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import java.nio.charset.StandardCharsets;
import java.security.PrivateKey;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Date;
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;
/**
* T3-19D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照
* {@code src/test/resources/contract/openapi-v1.3.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 短路。
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
class AuthContractConformanceTest {
private static final String INTERNAL_TOKEN = "contract-internal-token";
private static final OpenApiContract CONTRACT = OpenApiContract.load();
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** auth 域 6 个操作(= 契约中 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",
"POST /api/v1/events");
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
private static ConfigurableApplicationContext userApp;
private static String userBaseUrl;
@Autowired
private TestRestTemplate restTemplate;
@DynamicPropertySource
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
POSTGRES.start();
userApp = new SpringApplicationBuilder(UserApplication.class).run(
"--server.port=0",
"--spring.application.name=patbond-user",
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
"--spring.datasource.username=" + POSTGRES.getUsername(),
"--spring.datasource.password=" + POSTGRES.getPassword(),
"--patbond.internal-token=" + INTERNAL_TOKEN,
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
"--patbond.login-lock.max-failures=3");
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
registry.add("patbond.user-service.url", () -> userBaseUrl);
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
}
@AfterAll
static void shutdown() {
if (userApp != null) {
userApp.close();
}
POSTGRES.stop();
}
// ---- 校验骨架 ------------------------------------------------------
/**
* 真实发请求,断言 HTTP 状态并将响应体对照冻结契约严格校验;通过后把
* (操作, 状态码) 记入覆盖表。url 为相对路径时打 auth 服务,绝对 URL
* userBaseUrl 前缀)打 user 服务。
*/
private String verified(HttpMethod method, String url, String pathTemplate,
String body, String bearerToken, int expectedStatus) {
HttpHeaders headers = new HttpHeaders();
if (body != null) {
headers.setContentType(MediaType.APPLICATION_JSON);
}
if (bearerToken != null) {
headers.setBearerAuth(bearerToken);
}
ResponseEntity<byte[]> response = restTemplate.exchange(
url, method, new HttpEntity<>(body, headers), byte[].class);
String responseBody = response.getBody() == null
? "" : new String(response.getBody(), StandardCharsets.UTF_8);
assertThat(response.getStatusCode().value())
.as("%s %s 的 HTTP 状态(响应体: %s", method, pathTemplate, responseBody)
.isEqualTo(expectedStatus);
List<String> drift = VALIDATOR.validateResponse(
method.name(), pathTemplate, expectedStatus, responseBody);
assertThat(drift)
.as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, expectedStatus)
.isEmpty();
COVERED.add(method.name() + " " + pathTemplate + " " + expectedStatus);
return responseBody;
}
/** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
private String verifiedError(HttpMethod method, String url, String pathTemplate,
String body, String bearerToken, int status, int bizCode) {
String responseBody = verified(method, url, pathTemplate, body, bearerToken, status);
assertThat((Integer) JsonPath.read(responseBody, "$.code"))
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
.isEqualTo(bizCode);
return responseBody;
}
private String register(String username, String phone) {
return verified(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
.formatted(username, phone),
null, 200);
}
/** 用正确私钥签任意 subject 的 access token404 场景等)。 */
private static String signToken(PrivateKey key, String subject, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(subject)
.issuer("patbond-auth")
.claim("sid", UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(ttl)))
.signWith(key, Jwts.SIG.RS256)
.compact();
}
// ---- 成功路径 ------------------------------------------------------
@Test
@Order(1)
void registerRefreshLogoutSuccessShapes() {
String registered = register("contract_auth_alice", "+8613800000601");
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
// refresh 轮换出新令牌对
String rotated = verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
String rotatedAccess = JsonPath.read(rotated, "$.data.accessToken");
String rotatedRefresh = JsonPath.read(rotated, "$.data.refreshToken");
// logout 撤销当前会话
verified(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
"{\"refreshToken\":\"%s\"}".formatted(rotatedRefresh), rotatedAccess, 200);
}
@Test
@Order(2)
void loginAndMeSuccessShapes() {
register("contract_auth_bob", "+8613800000602");
String loggedIn = verified(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_bob\",\"password\":\"secret123\"}", null, 200);
String accessToken = JsonPath.read(loggedIn, "$.data.accessToken");
String me = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, accessToken, 200);
assertThat((String) JsonPath.read(me, "$.data.username")).isEqualTo("contract_auth_bob");
}
@Test
@Order(3)
void trackEventsSuccessShape() {
// 匿名合法批次:202 + 与请求等长的逐条结果
String body = verified(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
"""
{"events": [{
"eventId": "%s",
"eventName": "auth_register_started",
"eventVersion": 1,
"anonymousId": "%s",
"sessionId": "%s",
"clientTs": "2026-09-08T10:00:00Z",
"appVersion": "1.0.0+1",
"platform": "android",
"osVersion": "android-14",
"props": {"entryPoint": "onboarding"}
}]}
""".formatted(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()),
null, 202);
assertThat((String) JsonPath.read(body, "$.data.results[0].status")).isEqualTo("accepted");
}
// ---- 错误信封 ------------------------------------------------------
@Test
@Order(4)
void validationErrorsAnswer40000() {
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{}", null, 400, 40000);
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{}", null, 400, 40000);
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{}", null, 400, 40000);
// logout 400token 合法但 body 缺 refreshToken
String registered = register("contract_auth_carol", "+8613800000603");
String accessToken = JsonPath.read(registered, "$.data.accessToken");
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
"{}", accessToken, 400, 40000);
// events 400:空批次整批拒绝
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
"{\"events\":[]}", null, 400, 40000);
}
@Test
@Order(5)
void conflictAndCredentialErrorsMatchContract() {
register("contract_auth_dave", "+8613800000604");
// register 409:用户名占用 40900 / 手机号占用 40901(同一单元格的两种业务码)
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{\"username\":\"contract_auth_dave\",\"phone\":\"+8613800000605\",\"password\":\"secret123\"}",
null, 409, 40900);
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
"{\"username\":\"contract_auth_dave2\",\"phone\":\"+8613800000604\",\"password\":\"secret123\"}",
null, 409, 40901);
// login 401:密码错误 40100
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_dave\",\"password\":\"wrong-pass\"}",
null, 401, 40100);
// refresh 401:已轮换 token 重放 40102
String registered = register("contract_auth_erin", "+8613800000606");
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 401, 40102);
// logout 401:缺 access token
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
"{\"refreshToken\":\"whatever\"}", null, 401, 40101);
// me 401:无 token404:合法签名但用户不存在(40400)
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, null, 401, 40101);
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID().toString(), Duration.ofMinutes(15));
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, ghostToken, 404, 40400);
// events 401:携带了 Authorization 但 token 无效
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
"{\"events\":[]}", "not-a-token", 401, 40101);
}
@Test
@Order(6)
void loginLockoutAnswers42300() {
register("contract_auth_locked", "+8613800000607");
for (int i = 0; i < 3; i++) {
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_locked\",\"password\":\"wrong-pass\"}",
null, 401, 40100);
}
// 窗口内失败达到阈值(测试将阈值降为 3):即使密码正确也锁定
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
"{\"username\":\"contract_auth_locked\",\"password\":\"secret123\"}",
null, 423, 42300);
}
// ---- 快照与覆盖门禁 -------------------------------------------------
/**
* 冻结快照守卫:与 pet 侧同一纪律——正典契约升版时必须同步复制新快照
* 并更新期望值,忘记同步在 CI 立即变红。
*/
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.3.0");
assertThat(CONTRACT.paths()).hasSize(31);
assertThat(CONTRACT.operations()).hasSize(43);
assertThat(CONTRACT.schemas()).hasSize(72);
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
}
/**
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。
*/
@Test
@Order(99)
void everyDeclaredResponseCellIsExercised() {
List<String> missing = new ArrayList<>();
for (String op : AUTH_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.auth.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.auth.contract.OpenApiContract.cast;
import static com.patbond.patbond.auth.contract.OpenApiContract.list;
import static com.patbond.patbond.auth.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.auth.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.3.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.3.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.3.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);
}
}
File diff suppressed because it is too large Load Diff
@@ -13,9 +13,26 @@ public enum ErrorCode {
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"), INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
TOKEN_INVALID(40101, 401, "token 无效或过期"), TOKEN_INVALID(40101, 401, "token 无效或过期"),
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"), REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
PET_ACCESS_DENIED(40300, 403, "无权操作该宠物"),
POST_ACCESS_DENIED(40301, 403, "无权限执行该操作"),
USER_NOT_FOUND(40400, 404, "用户不存在"), USER_NOT_FOUND(40400, 404, "用户不存在"),
PET_NOT_FOUND(40401, 404, "宠物不存在"),
RECORD_NOT_FOUND(40402, 404, "记录不存在"),
POST_NOT_FOUND(40403, 404, "帖子不存在"),
USERNAME_EXISTS(40900, 409, "用户名已存在"), USERNAME_EXISTS(40900, 409, "用户名已存在"),
PHONE_EXISTS(40901, 409, "手机号已被使用"), PHONE_EXISTS(40901, 409, "手机号已被使用"),
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
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, "登录失败次数过多,账号已临时锁定"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"), INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用"); DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
+9
View File
@@ -0,0 +1,9 @@
# Runtime image only — build the jar first: ./mvnw -pl patbond-community -am package
# Stateless by design (ADR-007): no local state, config via env / mounted files.
FROM eclipse-temurin:17-jre
RUN useradd --system --uid 10001 patbond
USER patbond
WORKDIR /app
COPY target/patbond-community-1.0.0-SNAPSHOT-exec.jar app.jar
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+155
View File
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>patbond-community</artifactId>
<packaging>jar</packaging>
<name>patbond-community</name>
<description>Community feed, posts and interactions service for Patbond (ADR-017)</description>
<!-- M3 first-wave skeleton: RS256 bearer auth on /api/v1/** (same JWT
verification stack as patbond-user/pet), community schema access via
JDBC. Flyway remains absent — the migration chain is owned by
patbond-user. -->
<dependencies>
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<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>
<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). 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/pet. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Tests need the community schema. The migration chain (V1..V5) is
owned by patbond-user (single flyway_schema_history); pulling its
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
auto-config apply the same chain to the disposable container.
Production wiring is unchanged: this module still ships without
Flyway and the chain runs in patbond-user's startup path (same
mechanism as patbond-pet). -->
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-user</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- No spring-boot-starter-parent in this build, so the
executable-jar repackaging must be bound explicitly. -->
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<!-- Keep the plain jar as the main artifact so other
modules can depend on this one; the runnable fat
jar gets the -exec classifier and is what the
Dockerfile ships (same pattern as user/auth/pet). -->
<classifier>exec</classifier>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,24 @@
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). 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) {
SpringApplication.run(CommunityApplication.class, args);
}
}
@@ -0,0 +1,44 @@
package com.patbond.patbond.community.access;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* Cross-schema visibility probe for posts.pet_id references. Semantics
* follow patbond-pet's PetAccessService anti-enumeration rule: a pet the
* caller has no pet_owners row for is indistinguishable from a nonexistent
* one — both answer 404/40401. Any role (owner/caregiver/viewer) may
* reference a visible pet from a post; referencing needs no write power
* over the pet itself.
*/
@Service
public class PetVisibilityGateway {
private final JdbcClient jdbcClient;
public PetVisibilityGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** @throws BusinessException 40401 when the pet is invisible to the caller */
public void requireVisible(UUID userId, UUID petId) {
boolean visible = jdbcClient.sql("""
SELECT 1
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
WHERE p.id = :petId AND p.status <> 'deleted'
""")
.param("userId", userId)
.param("petId", petId)
.query(Integer.class)
.optional()
.isPresent();
if (!visible) {
throw new BusinessException(ErrorCode.PET_NOT_FOUND);
}
}
}
@@ -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);
}
}
@@ -0,0 +1,53 @@
package com.patbond.patbond.community.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* 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}), 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;
}
public static class Jwt {
/**
* RS256 public key for verifying access tokens signed by
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
* filesystem path. The private key never reaches this service.
*/
private String publicKey;
public String getPublicKey() {
return publicKey;
}
public void setPublicKey(String publicKey) {
this.publicKey = publicKey;
}
}
}
@@ -0,0 +1,21 @@
package com.patbond.patbond.community.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Integer fields must reject decimal input (development-plan 4.3) — Jackson's
* default is to silently truncate 45.5 to 45 in an integer field, which
* would corrupt values instead of rejecting them. Same setting as the other
* services so all envelopes behave identically.
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.config;
import com.patbond.patbond.community.media.CommunityMediaProperties;
import com.patbond.patbond.community.media.MediaUrlSigner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Read-side media wiring: a presigned-GET signer over the same MinIO
* configuration patbond-user uses (ADR-016). Bean destruction closes the
* underlying presigner.
*/
@Configuration
@EnableConfigurationProperties(CommunityMediaProperties.class)
public class MediaConfig {
@Bean(destroyMethod = "close")
public MediaUrlSigner mediaUrlSigner(CommunityMediaProperties properties) {
return new MediaUrlSigner(properties);
}
}
@@ -0,0 +1,36 @@
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;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires bearer authentication for /api/v1/** without pulling in
* spring-security — the same single-filter pattern patbond-user and
* patbond-pet use. The /health probe stays outside /api/v1 and therefore
* unauthenticated.
*/
@Configuration
@EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class})
public class SecurityConfig {
@Bean
public JwtVerifier jwtVerifier(CommunitySecurityProperties properties) {
return new JwtVerifier(properties.getJwt().getPublicKey());
}
@Bean
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
new BearerAuthFilter(jwtVerifier, objectMapper));
registration.addUrlPatterns("/api/v1/*");
registration.setOrder(20);
return registration;
}
}
@@ -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,36 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Liveness/readiness probe for the skeleton phase: confirms the service is
* up and that its datasource can reach the shared PostgreSQL. Deliberately
* outside /api/v1 so it stays unauthenticated (same reasoning as compose's
* pg_isready: infrastructure probes carry no business data).
*/
@RestController
public class HealthController {
private final JdbcClient jdbcClient;
public HealthController(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@GetMapping("/health")
public ApiResponse<Map<String, String>> health() {
String db;
try {
jdbcClient.sql("SELECT 1").query(Integer.class).single();
db = "up";
} catch (Exception e) {
db = "down";
}
return ApiResponse.success(Map.of("status", "ok", "db", db));
}
}
@@ -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));
}
}
@@ -0,0 +1,89 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostResponse;
import com.patbond.patbond.community.dto.UpdatePostRequest;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.PostService;
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.PatchMapping;
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;
/**
* Post lifecycle endpoints (T3-04). Idempotency-Key is MANDATORY on create
* (ADR-019 deliberately different from the pets domain's optional key;
* a missing header answers 400/40000). Publishing is a PATCH state
* transition, not a separate endpoint. All permission and error semantics
* live in PostService.
*/
@RestController
@Validated
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
@PostMapping("/api/v1/posts")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<PostResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreatePostRequest request) {
return ApiResponse.success(postService.create(userId, idempotencyKey, request));
}
@GetMapping("/api/v1/posts/{postId}")
public ApiResponse<PostResponse> get(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(postService.get(userId, postId));
}
@PatchMapping("/api/v1/posts/{postId}")
public ApiResponse<PostResponse> update(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId,
@Valid @RequestBody UpdatePostRequest request) {
return ApiResponse.success(postService.update(userId, postId, request));
}
@DeleteMapping("/api/v1/posts/{postId}")
public ApiResponse<Void> delete(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
postService.delete(userId, postId);
return ApiResponse.success(null);
}
@GetMapping("/api/v1/me/posts")
public ApiResponse<CursorPage<PostResponse>> listMine(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(required = false) String status,
@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(postService.listMine(userId, status, limit, cursor));
}
}
@@ -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 nicknameusername 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,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,86 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;
/**
* POST /api/v1/posts. Field widths mirror the ck_posts_* constraints;
* category and status enums are the write-side whitelists (ai_creation is an
* M4 read-side reservation and hidden/archived are operational states with
* no open endpoint, D3-7).
*/
public class CreatePostRequest {
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
private String title;
@NotBlank(message = "content 不能为空")
@Size(max = 10000, message = "content 最长 10000 字符")
private String content;
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
private String category;
@Pattern(regexp = "draft|published", message = "status 仅支持 draft/published")
private String status;
/** Optional pet reference; must be a pet visible to the caller (40401). */
private UUID petId;
@Size(max = 9, message = "media 最多 9 张图")
@Valid
private List<PostMediaAttachRequest> media;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public UUID getPetId() {
return petId;
}
public void setPetId(UUID petId) {
this.petId = petId;
}
public List<PostMediaAttachRequest> getMedia() {
return media;
}
public void setMedia(List<PostMediaAttachRequest> media) {
this.media = media;
}
}
@@ -0,0 +1,14 @@
package com.patbond.patbond.community.dto;
import java.util.List;
/**
* Cursor-pagination envelope body the pagination canon of the whole API
* (openapi v1.2.0 通用约定): {@code nextCursor} is null exactly when
* {@code hasMore} is false.
*/
public record CursorPage<T>(
List<T> items,
String nextCursor,
boolean hasMore) {
}
@@ -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) {
}
@@ -0,0 +1,66 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* One attached image in a create/update request. The asset must be owned by
* the caller and {@code status='ready'} (T3-03 联调协议), enforced in
* PostService against a read-only view of media.assets.
*/
public class PostMediaAttachRequest {
@NotNull(message = "assetId 不能为空")
private UUID assetId;
/**
* 0-based position. Either every item carries a position (together
* forming exactly 0..n-1) or none does (array order applies) a mix is
* a 40000.
*/
@Min(value = 0, message = "position 最小为 0")
@Max(value = 8, message = "position 最大为 8")
private Integer position;
/** At most one true per post (uq_post_media_cover); none → position 0. */
private Boolean isCover;
@Size(max = 300, message = "caption 最长 300 字符")
private String caption;
public UUID getAssetId() {
return assetId;
}
public void setAssetId(UUID assetId) {
this.assetId = assetId;
}
public Integer getPosition() {
return position;
}
public void setPosition(Integer position) {
this.position = position;
}
public Boolean getIsCover() {
return isCover;
}
public void setIsCover(Boolean isCover) {
this.isCover = isCover;
}
public String getCaption() {
return caption;
}
public void setCaption(String caption) {
this.caption = caption;
}
}
@@ -0,0 +1,18 @@
package com.patbond.patbond.community.dto;
import java.util.UUID;
/**
* One attached image in a post response. {@code url} is a presigned GET URL
* signed per response (T3-03: the bucket stays private, clients never
* persist it); null when object storage is unconfigured in this service.
*/
public record PostMediaItemResponse(
UUID assetId,
int position,
boolean isCover,
String url,
Integer widthPx,
Integer heightPx,
String caption) {
}
@@ -0,0 +1,34 @@
package com.patbond.patbond.community.dto;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
/**
* 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,
AuthorSummaryResponse author,
UUID petId,
String category,
String title,
String content,
String status,
String visibility,
List<PostMediaItemResponse> media,
long likeCount,
long commentCount,
long bookmarkCount,
boolean likedByMe,
boolean bookmarkedByMe,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime publishedAt,
int version) {
}
@@ -0,0 +1,100 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;
/**
* PATCH /api/v1/posts/{postId}. Partial update: an absent (or null) field is
* left unchanged no clearing back to null (M2 惯例). {@code version} is
* mandatory, it is the optimistic lock. {@code status} accepts only
* "published": draftpublished is the single open state transition (发布即
* 状态迁移, no separate /publish endpoint); publishing an already-published
* post is a no-op. {@code media}, when present, replaces the whole set
* (整组替换).
*/
public class UpdatePostRequest {
@NotNull(message = "version 不能为空")
@PositiveOrZero(message = "version 必须为非负整数")
private Integer version;
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
private String title;
@Size(min = 1, max = 10000, message = "content 长度须在 1~10000 字符")
private String content;
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
private String category;
private UUID petId;
@Pattern(regexp = "published", message = "status 仅支持 published(唯一开放的状态迁移)")
private String status;
@Size(max = 9, message = "media 最多 9 张图")
@Valid
private List<PostMediaAttachRequest> media;
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public UUID getPetId() {
return petId;
}
public void setPetId(UUID petId) {
this.petId = petId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public List<PostMediaAttachRequest> getMedia() {
return media;
}
public void setMedia(List<PostMediaAttachRequest> media) {
this.media = media;
}
}
@@ -0,0 +1,79 @@
package com.patbond.patbond.community.media;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Read-side subset of the media object-storage configuration (the write
* side upload flow, whitelists lives in patbond-user's MediaProperties).
* This service only signs GET URLs, a purely local SigV4 computation, so no
* bucket/HEAD client is needed. Values reuse the same PATBOND_MINIO_* /
* PATBOND_MEDIA_* environment variables as patbond-user, keeping one set of
* knobs per deployment (ADR-016/021).
*/
@ConfigurationProperties(prefix = "patbond.media")
public class CommunityMediaProperties {
/**
* Endpoint presigned GET URLs are issued against the address CLIENTS
* can reach. Empty means media is unconfigured for this service:
* responses carry {@code url: 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;
}
}
@@ -0,0 +1,59 @@
package com.patbond.patbond.community.media;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Read-only cross-schema access to media.assets the community side of the
* T3-03 联调协议 (business references accept only assets owned by the caller
* with status='ready'). Same-database read was chosen over an internal HTTP
* call to patbond-user (ADR-017 precedent: author data is likewise a
* cross-schema read while the schemas share one database; splitting the
* database later moves both to internal APIs 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 Map<UUID, MediaAssetRef> findByIds(Collection<UUID> ids) {
if (ids.isEmpty()) {
return Map.of();
}
return jdbcClient.sql("""
SELECT id, owner_user_id, status, bucket, object_key, width_px, height_px
FROM media.assets
WHERE id IN (:ids)
""")
.param("ids", List.copyOf(ids))
.query(MediaAssetGateway::mapRef)
.list()
.stream()
.collect(Collectors.toMap(MediaAssetRef::id, Function.identity()));
}
private static MediaAssetRef mapRef(ResultSet rs, int rowNum) throws SQLException {
return new MediaAssetRef(
rs.getObject("id", UUID.class),
rs.getObject("owner_user_id", UUID.class),
rs.getString("status"),
rs.getString("bucket"),
rs.getString("object_key"),
rs.getObject("width_px", Integer.class),
rs.getObject("height_px", Integer.class));
}
}
@@ -0,0 +1,17 @@
package com.patbond.patbond.community.media;
import java.util.UUID;
/**
* Read-only view of one media.assets row exactly the columns the post
* domain needs for attach validation and response URL signing.
*/
public record MediaAssetRef(
UUID id,
UUID ownerUserId,
String status,
String bucket,
String objectKey,
Integer widthPx,
Integer heightPx) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.community.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 media objects referenced by posts (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). When unconfigured, {@link #signGet}
* returns null and post responses degrade to {@code url: null}.
*/
public class MediaUrlSigner implements AutoCloseable {
private final CommunityMediaProperties properties;
private final S3Presigner presigner;
public MediaUrlSigner(CommunityMediaProperties 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,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();
}
}
@@ -0,0 +1,398 @@
package com.patbond.patbond.community.repository;
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;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* community.posts / community.post_media access. Visibility and authorship
* decisions live in PostService every query here is still explicitly
* scoped (viewer-dependent flags are parameters, never session state).
*/
@Repository
public class PostRepository {
/**
* The full post projection: row columns plus the two viewer-relative
* flags, each a primary-key probe into its relation table (post_likes /
* post_bookmarks composite PKs), so no N+1 and no separate round trip.
*/
private static final String SELECT_POST = """
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,
EXISTS (SELECT 1 FROM community.post_bookmarks pb
WHERE pb.post_id = p.id AND pb.user_id = :viewerId) AS bookmarked_by_me
FROM community.posts p
""";
private final JdbcClient jdbcClient;
public PostRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one post; {@code ON CONFLICT ON CONSTRAINT
* uq_posts_author_idempotency DO NOTHING} makes a keyed replay a no-op
* the caller then loads the first-write row by (author, key) and settles
* the retry-vs-mismatch question on request_hash (ADR-019).
*
* @return rows inserted 0 means this author already used the key
*/
public int insertPost(UUID id, UUID authorUserId, UUID petId, String category, String title,
String content, String status, OffsetDateTime publishedAt,
String idempotencyKey, byte[] requestHash) {
return jdbcClient.sql("""
INSERT INTO community.posts
(id, author_user_id, pet_id, category, title, content, status,
published_at, idempotency_key, request_hash)
VALUES (:id, :authorUserId, :petId, :category, :title, :content, :status,
:publishedAt, :idempotencyKey, :requestHash)
ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING
""")
.param("id", id)
.param("authorUserId", authorUserId)
.param("petId", petId)
.param("category", category)
.param("title", title)
.param("content", content)
.param("status", status)
.param("publishedAt", publishedAt)
.param("idempotencyKey", idempotencyKey)
.param("requestHash", requestHash)
.update();
}
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
public Optional<PostRow> findByAuthorAndIdempotencyKey(UUID authorUserId, String idempotencyKey,
UUID viewerId) {
return jdbcClient.sql(SELECT_POST
+ " WHERE p.author_user_id = :authorUserId AND p.idempotency_key = :idempotencyKey")
.param("authorUserId", authorUserId)
.param("idempotencyKey", idempotencyKey)
.param("viewerId", viewerId)
.query(PostRepository::mapPost)
.optional();
}
/** Any live (not soft-deleted) row by id; visibility is the service's call. */
public Optional<PostRow> findLiveById(UUID id, UUID viewerId) {
return jdbcClient.sql(SELECT_POST + " WHERE p.id = :id AND p.deleted_at IS NULL")
.param("id", id)
.param("viewerId", viewerId)
.query(PostRepository::mapPost)
.optional();
}
/**
* Locks the live row for a write (PATCH/DELETE): concurrent writers
* serialize here, so the later one sees the earlier one's version bump
* and fails its version condition deterministically.
*/
public Optional<LockedPost> lockLiveById(UUID id) {
return jdbcClient.sql("""
SELECT id, author_user_id, pet_id, category, title, content, status,
published_at, version
FROM community.posts
WHERE id = :id AND deleted_at IS NULL
FOR UPDATE
""")
.param("id", id)
.query((rs, rowNum) -> new LockedPost(
rs.getObject("id", UUID.class),
rs.getObject("author_user_id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getString("category"),
rs.getString("title"),
rs.getString("content"),
rs.getString("status"),
rs.getObject("published_at", OffsetDateTime.class),
rs.getInt("version")))
.optional();
}
/**
* The optimistic-locked merge write: one conditional UPDATE, version
* bumped only when the expected version still stands.
*
* @return rows updated 0 means the version went stale
*/
public int updatePost(UUID id, int expectedVersion, UUID petId, String category, String title,
String content, String status, OffsetDateTime publishedAt) {
return jdbcClient.sql("""
UPDATE community.posts
SET pet_id = :petId, category = :category, title = :title,
content = :content, status = :status, published_at = :publishedAt,
version = version + 1
WHERE id = :id AND deleted_at IS NULL AND version = :expectedVersion
""")
.param("id", id)
.param("expectedVersion", expectedVersion)
.param("petId", petId)
.param("category", category)
.param("title", title)
.param("content", content)
.param("status", status)
.param("publishedAt", publishedAt)
.update();
}
/**
* Soft delete (deleted_at is THE deletion marker everywhere). A deleted
* published post must also leave status='published' to satisfy
* ck_posts_publish_state, so it is parked as 'archived'; drafts keep
* their status. Once deleted the post answers 404 on every read path,
* so the parked status is internal bookkeeping only (D3-7 定型).
*/
public int softDelete(UUID id) {
return jdbcClient.sql("""
UPDATE community.posts
SET deleted_at = now(),
status = CASE WHEN status = 'published' THEN 'archived' ELSE status END,
version = version + 1
WHERE id = :id AND deleted_at IS NULL
""")
.param("id", id)
.update();
}
/**
* 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;
* hidden/archived are excluded (the contract exposes draft/published
* only). The caller asks for limit+1 rows to learn whether more exist.
*/
public List<PostRow> pageByAuthor(UUID authorUserId, String statusFilter, PostCursor after,
int limitPlusOne) {
String sql = SELECT_POST + """
WHERE p.author_user_id = :authorUserId AND p.deleted_at IS NULL
AND p.status IN ('draft', 'published')
""";
if (statusFilter != null) {
sql += " AND p.status = :statusFilter";
}
if (after != null) {
sql += " AND (p.created_at, p.id) < (:cursorCreatedAt, :cursorId)";
}
sql += " ORDER BY p.created_at DESC, p.id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("authorUserId", authorUserId)
.param("viewerId", authorUserId)
.param("limit", limitPlusOne);
if (statusFilter != null) {
spec = spec.param("statusFilter", statusFilter);
}
if (after != null) {
spec = spec.param("cursorCreatedAt", after.createdAt())
.param("cursorId", after.id());
}
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)
VALUES (:postId, :position, :assetId, :isCover, :caption)
""")
.param("postId", postId)
.param("position", position)
.param("assetId", assetId)
.param("isCover", isCover)
.param("caption", caption)
.update();
}
/** Whole-set replacement (PATCH media 整组替换): clear, then re-insert. */
public void deleteMedia(UUID postId) {
jdbcClient.sql("DELETE FROM community.post_media WHERE post_id = :postId")
.param("postId", postId)
.update();
}
/**
* Media of many posts in one query (position order within each post),
* joined with media.assets for the response-side url/dimension fields.
*/
public List<PostMediaRow> findMediaByPostIds(Collection<UUID> postIds) {
if (postIds.isEmpty()) {
return List.of();
}
return jdbcClient.sql("""
SELECT pm.post_id, pm.position, pm.asset_id, pm.is_cover, pm.caption,
a.bucket, a.object_key, a.width_px, a.height_px
FROM community.post_media pm
JOIN media.assets a ON a.id = pm.asset_id
WHERE pm.post_id IN (:postIds)
ORDER BY pm.post_id, pm.position
""")
.param("postIds", List.copyOf(postIds))
.query((rs, rowNum) -> new PostMediaRow(
rs.getObject("post_id", UUID.class),
rs.getInt("position"),
rs.getObject("asset_id", UUID.class),
rs.getBoolean("is_cover"),
rs.getString("caption"),
rs.getString("bucket"),
rs.getString("object_key"),
rs.getObject("width_px", Integer.class),
rs.getObject("height_px", Integer.class)))
.list();
}
private static PostRow mapPost(ResultSet rs, int rowNum) throws SQLException {
return new PostRow(
rs.getObject("id", UUID.class),
rs.getObject("author_user_id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getString("category"),
rs.getString("title"),
rs.getString("content"),
rs.getString("status"),
rs.getString("visibility"),
rs.getLong("like_count"),
rs.getLong("comment_count"),
rs.getLong("bookmark_count"),
rs.getBoolean("liked_by_me"),
rs.getBoolean("bookmarked_by_me"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
rs.getObject("published_at", OffsetDateTime.class),
rs.getObject("deleted_at", OffsetDateTime.class),
rs.getInt("version"),
rs.getBytes("request_hash"));
}
/** Full projection of one post as seen by a given viewer. */
public record PostRow(
UUID id,
UUID authorUserId,
UUID petId,
String category,
String title,
String content,
String status,
String visibility,
long likeCount,
long commentCount,
long bookmarkCount,
boolean likedByMe,
boolean bookmarkedByMe,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime publishedAt,
OffsetDateTime deletedAt,
int version,
byte[] requestHash) {
}
/** Row image under FOR UPDATE, the merge base of a PATCH. */
public record LockedPost(
UUID id,
UUID authorUserId,
UUID petId,
String category,
String title,
String content,
String status,
OffsetDateTime publishedAt,
int version) {
}
/** One post_media row joined with its asset's storage location. */
public record PostMediaRow(
UUID postId,
int position,
UUID assetId,
boolean isCover,
String caption,
String bucket,
String objectKey,
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,77 @@
package com.patbond.patbond.community.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* Bearer authentication for /api/v1/** community endpoints. Verifies the
* RS256 signature locally with the auth service's public key and exposes the
* authenticated user id as a request attribute. Missing, forged or expired
* tokens all answer 401/40101 without detail. Third copy of the user/pet
* filter sinking the shared pure-Java parts into patbond-common stays a
* separate decision (iteration-3/02 P9, not ratified in ADR-016~021).
*/
public class BearerAuthFilter extends OncePerRequestFilter {
/** Request attribute holding the authenticated user's UUID. */
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
private final JwtVerifier jwtVerifier;
private final ObjectMapper objectMapper;
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
this.jwtVerifier = jwtVerifier;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/v1/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
}
try {
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
} catch (BusinessException e) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
} catch (IllegalStateException | IllegalArgumentException e) {
log.error("Access token verification unavailable: {}", e.getMessage());
reject(response, ErrorCode.INTERNAL_ERROR);
return;
}
filterChain.doFilter(request, response);
}
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getHttpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
}
}
@@ -0,0 +1,41 @@
package com.patbond.patbond.community.security;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
/**
* Verifies RS256 access tokens issued by patbond-auth against the configured
* public key. The key is optional at startup so contexts that never serve
* protected routes (most tests) can boot without one; any verification
* attempt without a key fails loudly as a server misconfiguration instead of
* being reported to the client as an authentication problem.
*/
public class JwtVerifier {
private final JwtParser parser;
public JwtVerifier(String publicKeyLocation) {
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
? null
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
}
/**
* @return the verified claims
* @throws BusinessException 40101 when the token is forged, malformed or expired
*/
public Claims verify(String token) {
if (parser == null) {
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
}
try {
return parser.parseSignedClaims(token).getPayload();
} catch (JwtException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
}
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.community.security;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* Loads an RSA public key for JWT signature verification. Accepts either
* inline PEM or a filesystem path; the inline form (environment variable
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
* because mounted secrets beat files-in-the-image.
*/
public final class RsaPublicKeyLoader {
private RsaPublicKeyLoader() {
}
public static PublicKey load(String pemOrPath) {
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(stripped);
try {
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalArgumentException("Invalid RSA public key", e);
}
}
private static String readFile(String path) {
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new IllegalArgumentException("Cannot read public key from " + path, e);
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,421 @@
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.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
import com.patbond.patbond.community.dto.PostMediaItemResponse;
import com.patbond.patbond.community.dto.PostResponse;
import com.patbond.patbond.community.dto.UpdatePostRequest;
import com.patbond.patbond.community.media.MediaAssetGateway;
import com.patbond.patbond.community.media.MediaAssetRef;
import com.patbond.patbond.community.media.MediaUrlSigner;
import com.patbond.patbond.community.repository.PostRepository;
import com.patbond.patbond.community.repository.PostRepository.LockedPost;
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
import com.patbond.patbond.community.repository.PostRepository.PostRow;
import com.patbond.patbond.community.support.PostCursor;
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.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Post lifecycle use-cases (T3-04): draft/edit/publish/soft-delete/detail/
* my-posts. The permission and error semantics implemented here are the
* T3-10 freeze input:
*
* <ul>
* <li><b>Visibility</b> published is visible to every authenticated
* user; draft only to its author; hidden/archived (operational
* states, D3-7) and soft-deleted answer 404/40403 to EVERYONE, the
* author included. Every invisible case is byte-identical
* (anti-enumeration).</li>
* <li><b>403 vs 404</b> 403/40301 goes only to callers the post is
* VISIBLE to (non-author PATCH/DELETE of a published post); anything
* invisible is 404/40403, never 403.</li>
* <li><b>Idempotent create (ADR-019)</b> Idempotency-Key mandatory;
* same key + same normalized payload returns the first write (201
* again), same key + different payload answers 409/40905, keys are
* scoped per author (two users may reuse a key).</li>
* <li><b>Publish</b> a PATCH carrying {@code status: published}; the
* only open transition is draftpublished (publishedAt written once);
* re-publishing a published post is a no-op. publisheddraft does not
* exist (the request enum rejects it as 40000).</li>
* </ul>
*/
@Service
public class PostService {
private static final int MAX_MEDIA = 9;
private final PostRepository postRepository;
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,
AuthorProfileGateway authorProfileGateway) {
this.postRepository = postRepository;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
this.petVisibilityGateway = petVisibilityGateway;
this.authorProfileGateway = authorProfileGateway;
}
@Transactional
public PostResponse create(UUID userId, String idempotencyKey, CreatePostRequest request) {
String key = normalizeIdempotencyKey(idempotencyKey);
String title = requireTitleOrNull(request.getTitle());
String content = requireContent(request.getContent());
String category = request.getCategory() == null ? "general" : request.getCategory();
String status = request.getStatus() == null ? "draft" : request.getStatus();
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
if (request.getPetId() != null) {
petVisibilityGateway.requireVisible(userId, request.getPetId());
}
validateAssets(userId, media);
byte[] requestHash = RequestHashes.sha256(
canonicalize(category, status, title, content, request.getPetId(), media));
UUID id = UuidV7.generate();
OffsetDateTime publishedAt = "published".equals(status) ? OffsetDateTime.now() : null;
int inserted = postRepository.insertPost(id, userId, request.getPetId(), category, title,
content, status, publishedAt, key, requestHash);
if (inserted == 0) {
// The author used this key before (or a concurrent retry won the
// race): settle retry-vs-mismatch on the stored request_hash.
PostRow first = postRepository.findByAuthorAndIdempotencyKey(userId, key, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
if (!Arrays.equals(first.requestHash(), requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
}
if (first.deletedAt() != null) {
// The first write was deleted meanwhile the resource the
// retry asks about is gone, same anti-enumeration 404.
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
return assembleOne(first);
}
for (NormalizedMedia item : media) {
postRepository.insertMedia(id, item.position(), item.assetId(), item.isCover(),
item.caption());
}
PostRow row = postRepository.findLiveById(id, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return assembleOne(row);
}
@Transactional(readOnly = true)
public PostResponse get(UUID userId, UUID postId) {
PostRow row = postRepository.findLiveById(postId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
if (!visibleTo(row.status(), row.authorUserId(), userId)) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
return assembleOne(row);
}
@Transactional
public PostResponse update(UUID userId, UUID postId, UpdatePostRequest request) {
LockedPost current = requireAuthorEditable(userId, postId);
if (request.getVersion() != current.version()) {
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
String title = request.getTitle() != null
? requireTitleOrNull(request.getTitle())
: current.title();
String content = request.getContent() != null
? requireContent(request.getContent())
: current.content();
String category = request.getCategory() != null ? request.getCategory() : current.category();
UUID petId = current.petId();
if (request.getPetId() != null) {
petVisibilityGateway.requireVisible(userId, request.getPetId());
petId = request.getPetId();
}
String status = current.status();
OffsetDateTime publishedAt = current.publishedAt();
if ("published".equals(request.getStatus()) && "draft".equals(current.status())) {
// The single open transition: draftpublished, publishedAt
// written exactly once (ck_posts_publish_state). Publishing an
// already-published post falls through as a no-op.
status = "published";
publishedAt = OffsetDateTime.now();
}
if (request.getMedia() != null) {
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
validateAssets(userId, media);
postRepository.deleteMedia(postId);
for (NormalizedMedia item : media) {
postRepository.insertMedia(postId, item.position(), item.assetId(), item.isCover(),
item.caption());
}
}
int updated = postRepository.updatePost(postId, request.getVersion(), petId, category,
title, content, status, publishedAt);
if (updated == 0) {
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
PostRow row = postRepository.findLiveById(postId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return assembleOne(row);
}
@Transactional
public void delete(UUID userId, UUID postId) {
requireAuthorEditable(userId, postId);
postRepository.softDelete(postId);
}
@Transactional(readOnly = true)
public CursorPage<PostResponse> listMine(UUID userId, String status, int limit, String cursor) {
if (status != null && !status.equals("draft") && !status.equals("published")) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "status 仅支持 draft/published");
}
PostCursor after = cursor == null ? null : PostCursor.decode(cursor);
List<PostRow> rows = postRepository.pageByAuthor(userId, status, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new PostCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(assemble(page), nextCursor, hasMore);
}
/**
* The shared write gate of PATCH/DELETE: locks the live row, then walks
* the 403/404 boundary invisible (absent, deleted, hidden/archived,
* someone else's draft) 40403; visible but not the author's
* (published, someone else's) 40301.
*/
private LockedPost requireAuthorEditable(UUID userId, UUID postId) {
LockedPost current = postRepository.lockLiveById(postId)
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
boolean visible = visibleTo(current.status(), current.authorUserId(), userId);
if (!visible) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
if (!current.authorUserId().equals(userId)) {
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
}
return current;
}
/**
* The visibility matrix (T3-10 freeze input): published everyone;
* draft author only; hidden/archived no one, the author included
* (no operational state leaks through the M3 contract, whose status
* enum stays [draft, published]).
*/
private static boolean visibleTo(String status, UUID authorUserId, UUID viewerId) {
return switch (status) {
case "published" -> true;
case "draft" -> authorUserId.equals(viewerId);
default -> false;
};
}
// ---------- create-side normalization ----------
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;
}
/**
* A provided title must survive trimming (ck_posts_title width) a
* whitespace-only title is a 40000, NOT a clear-to-null: PATCH does not
* support clearing optional fields back to null (M2 惯例), and create
* stays symmetric.
*/
private static String requireTitleOrNull(String title) {
if (title == null) {
return null;
}
String trimmed = title.trim();
if (trimmed.isEmpty() || trimmed.length() > 120) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "title 长度须在 1~120 字符");
}
return trimmed;
}
private static String requireContent(String content) {
String trimmed = content == null ? "" : content.trim();
if (trimmed.isEmpty() || trimmed.length() > 10000) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~10000 字符");
}
return trimmed;
}
/**
* Resolves positions and the cover flag: either every item names a
* position (together exactly 0..n-1) or none does (array order); at
* most one isCover=true (uq_post_media_cover), none position 0 gets
* the flag (草案预设 false 服务端取 position 0).
*/
private static List<NormalizedMedia> normalizeMedia(List<PostMediaAttachRequest> requested) {
if (requested == null || requested.isEmpty()) {
return List.of();
}
long withPosition = requested.stream().filter(m -> m.getPosition() != null).count();
if (withPosition != 0 && withPosition != requested.size()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"media position 须全部提供或全部省略");
}
long covers = requested.stream().filter(m -> Boolean.TRUE.equals(m.getIsCover())).count();
if (covers > 1) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "isCover 至多一个");
}
List<NormalizedMedia> items = new ArrayList<>(requested.size());
Set<Integer> seenPositions = new HashSet<>();
Set<UUID> seenAssets = new HashSet<>();
for (int i = 0; i < requested.size(); i++) {
PostMediaAttachRequest m = requested.get(i);
int position = m.getPosition() != null ? m.getPosition() : i;
if (!seenPositions.add(position) || position >= requested.size()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"media position 须为 0 起连续且不重复");
}
if (!seenAssets.add(m.getAssetId())) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 中 assetId 重复");
}
items.add(new NormalizedMedia(m.getAssetId(), position,
Boolean.TRUE.equals(m.getIsCover()), trimOrNull(m.getCaption())));
}
items.sort((a, b) -> Integer.compare(a.position(), b.position()));
if (covers == 0) {
items.set(0, items.get(0).asCover());
}
if (items.size() > MAX_MEDIA) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 最多 9 张图");
}
return items;
}
/**
* The T3-03 联调协议 on the referencing side: an asset that does not
* exist, is not the caller's or is deleted answers 404/40405 (one merged
* anti-enumeration case); the caller's own asset in uploading/failed
* answers 422/42203.
*/
private void validateAssets(UUID userId, List<NormalizedMedia> media) {
if (media.isEmpty()) {
return;
}
Map<UUID, MediaAssetRef> assets = mediaAssetGateway.findByIds(
media.stream().map(NormalizedMedia::assetId).collect(Collectors.toSet()));
for (NormalizedMedia item : media) {
MediaAssetRef ref = assets.get(item.assetId());
if (ref == null || !userId.equals(ref.ownerUserId()) || "deleted".equals(ref.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!"ready".equals(ref.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
}
}
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
private static String canonicalize(String category, String status, String title, String content,
UUID petId, List<NormalizedMedia> media) {
StringBuilder sb = new StringBuilder("post.v1\n")
.append(category).append('\n')
.append(status).append('\n')
.append(title == null ? "" : title).append('\n')
.append(content).append('\n')
.append(petId == null ? "" : petId).append('\n');
for (NormalizedMedia item : media) {
sb.append(item.assetId()).append(':').append(item.position()).append(':')
.append(item.isCover()).append(':')
.append(item.caption() == null ? "" : item.caption()).append('\n');
}
return sb.toString();
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
// ---------- response assembly ----------
private PostResponse assembleOne(PostRow row) {
return assemble(List.of(row)).get(0);
}
private List<PostResponse> assemble(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 -> new PostResponse(
row.id(),
authors.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.petId(),
row.category(),
row.title(),
row.content(),
row.status(),
row.visibility(),
mediaByPost.getOrDefault(row.id(), List.of()).stream()
.map(m -> new PostMediaItemResponse(
m.assetId(),
m.position(),
m.isCover(),
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
m.widthPx(),
m.heightPx(),
m.caption()))
.toList(),
row.likeCount(),
row.commentCount(),
row.bookmarkCount(),
row.likedByMe(),
row.bookmarkedByMe(),
row.createdAt(),
row.updatedAt(),
row.publishedAt(),
row.version())).toList();
}
private record NormalizedMedia(UUID assetId, int position, boolean isCover, String caption) {
NormalizedMedia asCover() {
return new NormalizedMedia(assetId, position, true, caption);
}
}
}
@@ -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 无效");
}
}
}
@@ -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 for the my-posts list (created_at DESC, id DESC the exact
* key of ix_posts_author_created), isomorphic to patbond-pet's EventCursor:
* 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 PostCursor(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 PostCursor 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 PostCursor(createdAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,30 @@
package com.patbond.patbond.community.support;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* SHA-256 of the canonical form of a create request (ADR-019: creation-type
* writes carry a mandatory Idempotency-Key and the request body's hash is
* stored next to it a keyed retry with the same payload returns the first
* write, a different payload answers 40905). Hashing the NORMALIZED command
* (trimmed fields, defaults applied, media positions resolved) rather than
* the raw bytes makes the comparison insensitive to JSON formatting while
* still catching every semantic difference. 32 bytes, matching
* ck_posts_idempotency's octet_length(request_hash) = 32.
*/
public final class RequestHashes {
private RequestHashes() {
}
public static byte[] sha256(String canonical) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,30 @@
package com.patbond.patbond.community.support;
import java.security.SecureRandom;
import java.util.UUID;
/**
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
* B-tree page churn low on uuid primary keys; the database DEFAULT
* gen_random_uuid() remains the fallback for rows not inserted through the
* application. Third copy after patbond-user/pet the services deploy
* independently and patbond-common stays contract-only.
*/
public final class UuidV7 {
private static final SecureRandom RANDOM = new SecureRandom();
private UuidV7() {
}
public static UUID generate() {
long timestampMs = System.currentTimeMillis();
long randA = RANDOM.nextLong() & 0x0FFFL;
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
long msb = (timestampMs << 16) | 0x7000L | randA;
long lsb = 0x8000000000000000L | randB;
return new UUID(msb, lsb);
}
}
@@ -0,0 +1,68 @@
package com.patbond.patbond.community.web;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* Single place that turns exceptions into the {code, message, data} envelope
* with a matching HTTP status (development-plan 6.1) same contract as the
* user/auth/pet handlers. Unexpected exceptions are logged in full but never
* leak internals to the client.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
return ResponseEntity.status(e.getHttpStatus())
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(FieldError::getDefaultMessage)
.orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage());
return failure(ErrorCode.VALIDATION_ERROR, message);
}
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
ConstraintViolationException.class, HandlerMethodValidationException.class,
MissingRequestHeaderException.class})
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception e) {
log.error("Unhandled exception", e);
return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage());
}
private static ResponseEntity<ApiResponse<Void>> failure(ErrorCode errorCode, String message) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ApiResponse.failure(errorCode.getCode(), message));
}
}
@@ -0,0 +1,41 @@
server:
port: ${PATBOND_COMMUNITY_PORT:8084}
spring:
application:
name: patbond-community
datasource:
# 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 community schema
# (作者公开资料按 D3-9 方案 B 走 user 的 /internal 批量接口,后续波次落地)。
# Flyway 迁移链(V1..V5,含 community 基线)由 patbond-user 启动时统一执行,
# 本服务不携带 Flyway —— 单一 flyway_schema_history 归属不拆。
url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond}
username: ${PATBOND_DB_USER:patbond}
password: ${PATBOND_DB_PASSWORD:patbond}
# /api/v1/** 业务端点自骨架起即接 RS256 校验,与 patbond-user/pet 同一约定。
patbond:
jwt:
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
# 值可以是 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。
# 环境变量与 patbond-user 共用同一组(一套部署一套旋钮)。
# public-endpoint 为空时服务照常启动,帖子响应中 media[].url 为 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}
@@ -0,0 +1,18 @@
package com.patbond.patbond.community;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
/**
* Context smoke test: the skeleton boots against a clean postgres:18 with
* the full V1..V5 migration chain applied from the test classpath.
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class CommunityApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,27 @@
package com.patbond.patbond.community;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
/**
* Shared Testcontainers setup: a disposable postgres:18 (the production
* target version) wired into the Spring context via @ServiceConnection.
* The production module carries no Flyway (the single migration chain is
* owned by patbond-user), but the TEST classpath adds patbond-user's jar
* plus Flyway, so Boot applies the full V1..V5 chain including the
* community schema these tests exercise to the fresh container exactly
* as the shared database gets it in production (same mechanism as
* patbond-pet).
*/
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
}
}
@@ -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,512 @@
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 17 个操作补进契约一致性保障机制与
* patbond-pet ContractConformanceTest 同构对冻结契约 v1.3.0快照
* {@code src/test/resources/contract/openapi-v1.3.0.yaml}正典在 doc
* {@code docs/api/openapi.yaml}逐操作真实起服务发请求
* {@link ContractValidator} 严格校验响应结构路径/方法/状态码已声明字段名
* 与类型必填与 nullable枚举与格式信封结构错误码值
*
* <p>覆盖目标是**全响应矩阵**最后的 {@link #everyDeclaredResponseCellIsExercised()}
* 断言契约为这 17 个操作声明的每一个 (操作, 状态码) 单元格 64 都被
* 至少一次真实响应校验过**无豁免**community 域的 409 均为幂等键/乐观锁
* 冲突422 均为业务规则拒绝单线程即可确定性触发
*
* <p>media 2 个操作属 patbond-user 模块由该模块的
* MediaContractConformanceTest 覆盖快照同一份
*/
@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 域 17 个操作(= 契约中 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");
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");
}
// ---- 成功路径17 操作全覆盖 ---------------------------------------
@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);
// 发布草稿draftpublished 唯一开放迁移
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();
}
// ---- 错误信封 ------------------------------------------------------
@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.3.0");
assertThat(CONTRACT.paths()).hasSize(31);
assertThat(CONTRACT.operations()).hasSize(43);
assertThat(CONTRACT.schemas()).hasSize(72);
assertThat(CONTRACT.operationsTagged(
Set.of("posts", "feed", "comments", "interactions", "follows")))
.containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS);
}
/**
* 全矩阵覆盖门禁community 17 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验64 个单元格无豁免
*/
@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.3.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.3.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.3.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,36 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.community.TestcontainersConfiguration;
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.test.web.servlet.MockMvc;
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;
/**
* The liveness probe answers 200 with the standard envelope and reports the
* datasource as reachable (the Testcontainers database is up by definition).
* It sits outside /api/v1, so no token is needed.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class HealthControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void healthReportsServiceAndDatabaseUp() throws Exception {
mockMvc.perform(get("/health"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("ok"))
.andExpect(jsonPath("$.data.db").value("up"));
}
}
@@ -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();
// alicebob, carolbob, bobalice
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 04), 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
* nicknameusername 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));
}
// Oldestnewest 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,95 @@
package com.patbond.patbond.community.post;
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;
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 org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* Shared plumbing of the T3-04 post-lifecycle tests: one cached Spring
* context over a disposable postgres:18 with the full V1..V5 chain, JWT
* material minted per run, and media signing configured with fake
* credentials presigning a GET URL is a local SigV4 computation, so URL
* assertions need no MinIO container.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import({TestcontainersConfiguration.class, StubAuthorProfileConfig.class})
public abstract class PostApiTestBase {
@Autowired
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
@Autowired
protected JdbcClient jdbcClient;
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
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");
}
protected UUID newUser() {
return CommunityTestData.insertUser(jdbcClient,
"u" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
}
protected String token(UUID userId) {
return TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId,
Duration.ofMinutes(15));
}
protected MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
UUID userId) {
return builder.header("Authorization", "Bearer " + token(userId))
.contentType(APPLICATION_JSON)
.characterEncoding("UTF-8");
}
protected MockHttpServletRequestBuilder createPostRequest(UUID userId, String idempotencyKey,
String body) {
return authed(post("/api/v1/posts"), userId)
.header("Idempotency-Key", idempotencyKey)
.content(body);
}
/** Creates a post and returns the response `data` node. */
protected JsonNode createPost(UUID userId, String body) throws Exception {
MvcResult result = mockMvc.perform(
createPostRequest(userId, UUID.randomUUID().toString(), body))
.andReturn();
if (result.getResponse().getStatus() != 201) {
throw new AssertionError("createPost failed: " + result.getResponse().getStatus()
+ " " + result.getResponse().getContentAsString());
}
return data(result);
}
protected JsonNode data(MvcResult result) throws Exception {
return objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
}
}
@@ -0,0 +1,101 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ADR-019 幂等专项: creation-type idempotency on the posts table itself
* mandatory Idempotency-Key falling on uq_posts_author_idempotency with a
* stored request_hash. Same key + same payload replays the first write,
* same key + different payload answers 40905, keys are scoped per author.
*/
class PostIdempotencyIntegrationTest extends PostApiTestBase {
@Test
void sameKeySamePayloadReturnsTheFirstWrite() throws Exception {
UUID author = newUser();
String key = UUID.randomUUID().toString();
String body = "{\"title\": \"幂等\", \"content\": \"同键同体\"}";
JsonNode first = data(mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isCreated())
.andReturn());
JsonNode retry = data(mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isCreated())
.andReturn());
assertThat(retry.get("id").asText()).isEqualTo(first.get("id").asText());
Long rows = jdbcClient.sql(
"SELECT count(*) FROM community.posts WHERE author_user_id = :author")
.param("author", author)
.query(Long.class)
.single();
assertThat(rows).isEqualTo(1);
}
@Test
void semanticallyIdenticalPayloadStillReplays() throws Exception {
// The hash covers the NORMALIZED command, so whitespace-only
// differences (or a spelled-out default) do not break a retry.
UUID author = newUser();
String key = UUID.randomUUID().toString();
JsonNode first = data(mockMvc.perform(createPostRequest(author, key,
"{\"content\": \"规范化\"}"))
.andExpect(status().isCreated())
.andReturn());
JsonNode retry = data(mockMvc.perform(createPostRequest(author, key,
"{\"content\": \" 规范化 \", \"category\": \"general\", \"status\": \"draft\"}"))
.andExpect(status().isCreated())
.andReturn());
assertThat(retry.get("id").asText()).isEqualTo(first.get("id").asText());
}
@Test
void sameKeyDifferentPayloadAnswers40905() throws Exception {
UUID author = newUser();
String key = UUID.randomUUID().toString();
mockMvc.perform(createPostRequest(author, key, "{\"content\": \"版本甲\"}"))
.andExpect(status().isCreated());
mockMvc.perform(createPostRequest(author, key, "{\"content\": \"版本乙\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40905));
}
@Test
void keysAreScopedPerAuthor() throws Exception {
UUID alice = newUser();
UUID bob = newUser();
String key = "shared-client-key";
JsonNode alicesPost = data(mockMvc.perform(createPostRequest(alice, key,
"{\"content\": \"撞键\"}"))
.andExpect(status().isCreated())
.andReturn());
JsonNode bobsPost = data(mockMvc.perform(createPostRequest(bob, key,
"{\"content\": \"撞键\"}"))
.andExpect(status().isCreated())
.andReturn());
assertThat(bobsPost.get("id").asText()).isNotEqualTo(alicesPost.get("id").asText());
}
@Test
void retryAfterTheFirstWriteWasDeletedAnswers404() throws Exception {
// Edge frozen for the contract: the retry asks about a resource that
// is gone same anti-enumeration 404 as any other deleted post.
UUID author = newUser();
String key = UUID.randomUUID().toString();
String body = "{\"content\": \"建了又删\"}";
String id = data(mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isCreated())
.andReturn()).get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
.andExpect(status().isOk());
mockMvc.perform(createPostRequest(author, key, body))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
}
}
@@ -0,0 +1,376 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.Test;
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.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-04 lifecycle walk on the real database: draft edit publish
* detail delete, the 403/404 permission boundary, the draft-visibility
* matrix, the optimistic lock (including a true concurrent race) and the
* my-posts cursor list. These assertions ARE the T3-10 freeze input for the
* post domain's permission and error semantics.
*/
class PostLifecycleIntegrationTest extends PostApiTestBase {
@Test
void createDraftReturnsFullShape() throws Exception {
UUID author = newUser();
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"""
{"title": "第一帖", "content": "大家好"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.author.userId").value(author.toString()))
.andExpect(jsonPath("$.data.title").value("第一帖"))
.andExpect(jsonPath("$.data.content").value("大家好"))
.andExpect(jsonPath("$.data.category").value("general"))
.andExpect(jsonPath("$.data.status").value("draft"))
.andExpect(jsonPath("$.data.visibility").value("public"))
.andExpect(jsonPath("$.data.media").isEmpty())
.andExpect(jsonPath("$.data.likeCount").value(0))
.andExpect(jsonPath("$.data.commentCount").value(0))
.andExpect(jsonPath("$.data.bookmarkCount").value(0))
.andExpect(jsonPath("$.data.likedByMe").value(false))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false))
.andExpect(jsonPath("$.data.publishedAt").isEmpty())
.andExpect(jsonPath("$.data.version").value(0));
}
@Test
void createPublishedDirectlyWritesPublishedAt() throws Exception {
UUID author = newUser();
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"""
{"content": "直接发布", "status": "published", "category": "help"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.status").value("published"))
.andExpect(jsonPath("$.data.category").value("help"))
.andExpect(jsonPath("$.data.publishedAt").isNotEmpty());
}
@Test
void createWithVisiblePetAttachesIt() throws Exception {
UUID author = newUser();
UUID petId = CommunityTestData.insertPetOwnedBy(jdbcClient, author);
JsonNode created = createPost(author,
"{\"content\": \"我家猫\", \"petId\": \"" + petId + "\"}");
assertThat(created.get("petId").asText()).isEqualTo(petId.toString());
}
@Test
void createWithInvisibleOrMissingPetAnswers40401() throws Exception {
UUID author = newUser();
UUID stranger = newUser();
UUID strangersPet = CommunityTestData.insertPetOwnedBy(jdbcClient, stranger);
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"petId\": \"" + strangersPet + "\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"petId\": \"" + UUID.randomUUID() + "\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
@Test
void createValidationFailuresAnswer40000() throws Exception {
UUID author = newUser();
// content is mandatory
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"title\": \"无正文\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// ai_creation is an M4 read-side reservation, not writable in M3
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"category\": \"ai_creation\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// hidden/archived are not creatable states
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"status\": \"hidden\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// a whitespace-only title is rejected, not silently cleared to null
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
"{\"content\": \"x\", \"title\": \" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void idempotencyKeyHeaderIsMandatoryAndBounded() throws Exception {
UUID author = newUser();
mockMvc.perform(authed(
org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.post("/api/v1/posts"), author)
.content("{\"content\": \"没带幂等键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(createPostRequest(author, " ", "{\"content\": \"空白键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(createPostRequest(author, "k".repeat(129), "{\"content\": \"超长键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void draftVisibilityMatrix() throws Exception {
UUID author = newUser();
UUID other = newUser();
JsonNode draft = createPost(author, "{\"content\": \"草稿\"}");
String draftId = draft.get("id").asText();
JsonNode published = createPost(author,
"{\"content\": \"已发布\", \"status\": \"published\"}");
String publishedId = published.get("id").asText();
// author sees the draft; anyone else gets the anti-enumeration 404
mockMvc.perform(authed(get("/api/v1/posts/" + draftId), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("draft"));
mockMvc.perform(authed(get("/api/v1/posts/" + draftId), other))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// published is open to any authenticated user
mockMvc.perform(authed(get("/api/v1/posts/" + publishedId), other))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("published"));
// hidden (operational state, D3-7) answers 404 to EVERYONE the
// author included: the M3 contract's status enum stays two-valued
jdbcClient.sql("UPDATE community.posts SET status = 'hidden', published_at = NULL WHERE id = :id")
.param("id", UUID.fromString(publishedId))
.update();
mockMvc.perform(authed(get("/api/v1/posts/" + publishedId), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// nonexistent id identical 404; malformed id 40000
mockMvc.perform(authed(get("/api/v1/posts/" + UUID.randomUUID()), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
mockMvc.perform(authed(get("/api/v1/posts/not-a-uuid"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void patchEditsFieldsAndBumpsVersion() throws Exception {
UUID author = newUser();
String id = createPost(author, "{\"title\": \"旧标题\", \"content\": \"旧正文\"}")
.get("id").asText();
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"title\": \"新标题\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.title").value("新标题"))
.andExpect(jsonPath("$.data.content").value("旧正文"))
.andExpect(jsonPath("$.data.version").value(1));
// the spent version is stale now optimistic lock answers 40902
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"title\": \"迟到的编辑\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40902));
// version is mandatory
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"title\": \"没带 version\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void publishIsThePatchStateTransition() throws Exception {
UUID author = newUser();
String id = createPost(author, "{\"content\": \"待发布\"}").get("id").asText();
String publishedAt = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"status\": \"published\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("published"))
.andExpect(jsonPath("$.data.publishedAt").isNotEmpty())
.andReturn()).get("publishedAt").asText();
// re-publishing an already-published post is a no-op (publishedAt
// written exactly once), not an error
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 1, \"status\": \"published\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.publishedAt").value(publishedAt))
.andExpect(jsonPath("$.data.version").value(2));
// publisheddraft does not exist: the request enum rejects it
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 2, \"status\": \"draft\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void nonAuthorWritesWalkThe403404Boundary() throws Exception {
UUID author = newUser();
UUID other = newUser();
String draftId = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
String publishedId = createPost(author,
"{\"content\": \"已发布\", \"status\": \"published\"}").get("id").asText();
// visible but not yours 403/40301
mockMvc.perform(authed(patch("/api/v1/posts/" + publishedId), other)
.content("{\"version\": 0, \"title\": \"篡改\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
mockMvc.perform(authed(delete("/api/v1/posts/" + publishedId), other))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
// invisible (someone else's draft) 404/40403, never 403
mockMvc.perform(authed(patch("/api/v1/posts/" + draftId), other)
.content("{\"version\": 0, \"title\": \"篡改\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
mockMvc.perform(authed(delete("/api/v1/posts/" + draftId), other))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
}
@Test
void deleteIsSoftAndMergesWithNotFoundAfterwards() throws Exception {
UUID author = newUser();
String id = createPost(author,
"{\"content\": \"要删的\", \"status\": \"published\"}").get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// gone from every read path
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// and a repeated delete merges with not-found (anti-enumeration)
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403));
// the row survives as a soft-deleted tombstone; the published row is
// parked as archived so ck_posts_publish_state holds
var row = jdbcClient.sql(
"SELECT status, deleted_at FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(id))
.query((rs, n) -> List.of(rs.getString("status"),
String.valueOf(rs.getObject("deleted_at") != null)))
.single();
assertThat(row).containsExactly("archived", "true");
}
@Test
void concurrentPatchesLetExactlyOneWin() throws Exception {
UUID author = newUser();
String id = createPost(author, "{\"content\": \"并发对象\"}").get("id").asText();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
List<Future<Integer>> results = List.of("", "").stream()
.map(tag -> pool.submit(() -> {
start.await();
return mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"title\": \"" + tag + "\"}"))
.andReturn().getResponse().getStatus();
}))
.toList();
start.countDown();
List<Integer> statuses = List.of(results.get(0).get(30, TimeUnit.SECONDS),
results.get(1).get(30, TimeUnit.SECONDS));
assertThat(statuses).containsExactlyInAnyOrder(200, 409);
} finally {
pool.shutdownNow();
}
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
.andExpect(jsonPath("$.data.version").value(1));
}
@Test
void myPostsListPagesWithCursorAndFilters() throws Exception {
UUID author = newUser();
UUID other = newUser();
String draft1 = createPost(author, "{\"content\": \"\"}").get("id").asText();
String published = createPost(author,
"{\"content\": \"\", \"status\": \"published\"}").get("id").asText();
String draft2 = createPost(author, "{\"content\": \"\"}").get("id").asText();
String deleted = createPost(author, "{\"content\": \"已删\"}").get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
createPost(other, "{\"content\": \"别人的\"}");
// full list: own drafts + published, deleted excluded, newest first
JsonNode page = data(mockMvc.perform(authed(get("/api/v1/me/posts"), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.hasMore").value(false))
.andExpect(jsonPath("$.data.nextCursor").isEmpty())
.andReturn());
assertThat(page.get("items")).hasSize(3);
assertThat(page.get("items").findValues("id").stream().map(JsonNode::asText))
.containsExactly(draft2, published, draft1);
// keyset pagination: limit 2 cursor remaining 1, no loss/overlap
JsonNode first = data(mockMvc.perform(authed(get("/api/v1/me/posts?limit=2"), author))
.andExpect(jsonPath("$.data.hasMore").value(true))
.andReturn());
JsonNode second = data(mockMvc.perform(authed(
get("/api/v1/me/posts?limit=2&cursor=" + first.get("nextCursor").asText()),
author))
.andExpect(jsonPath("$.data.hasMore").value(false))
.andReturn());
assertThat(second.get("items")).hasSize(1);
assertThat(second.get("items").get(0).get("id").asText()).isEqualTo(draft1);
// status filter, bad filter, bad cursor, bad limit
mockMvc.perform(authed(get("/api/v1/me/posts?status=draft"), author))
.andExpect(jsonPath("$.data.items.length()").value(2));
mockMvc.perform(authed(get("/api/v1/me/posts?status=hidden"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/me/posts?cursor=%21%21"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/me/posts?limit=0"), author))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void viewerRelativeFlagsReflectRelationRows() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String id = createPost(author,
"{\"content\": \"有人点赞\", \"status\": \"published\"}").get("id").asText();
jdbcClient.sql("""
INSERT INTO community.post_likes (post_id, user_id) VALUES (:postId, :userId)
""")
.param("postId", UUID.fromString(id))
.param("userId", fan)
.update();
jdbcClient.sql("UPDATE community.posts SET like_count = 1 WHERE id = :id")
.param("id", UUID.fromString(id))
.update();
mockMvc.perform(authed(get("/api/v1/posts/" + id), fan))
.andExpect(jsonPath("$.data.likedByMe").value(true))
.andExpect(jsonPath("$.data.likeCount").value(1))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false));
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
.andExpect(jsonPath("$.data.likedByMe").value(false))
.andExpect(jsonPath("$.data.likeCount").value(1));
}
}
@@ -0,0 +1,171 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
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;
/**
* post_media attach semantics (T3-03 联调协议 on the referencing side):
* only the caller's own ready assets; position/is_cover normalization
* consistent with uq_post_media_cover; PATCH media replaces the whole set.
*/
class PostMediaAttachIntegrationTest extends PostApiTestBase {
@Test
void attachOrdersItemsAndDefaultsTheCover() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
JsonNode post = createPost(author, """
{"content": "两张图", "media": [
{"assetId": "%s", "caption": " 封面图 "},
{"assetId": "%s"}
]}
""".formatted(assetA, assetB));
JsonNode media = post.get("media");
assertThat(media).hasSize(2);
// array order positions 0/1; no isCover given position 0 is cover
assertThat(media.get(0).get("assetId").asText()).isEqualTo(assetA.toString());
assertThat(media.get(0).get("position").asInt()).isZero();
assertThat(media.get(0).get("isCover").asBoolean()).isTrue();
assertThat(media.get(0).get("caption").asText()).isEqualTo("封面图");
assertThat(media.get(1).get("isCover").asBoolean()).isFalse();
// presigned GET URL, signed against the configured public endpoint
assertThat(media.get(0).get("url").asText())
.startsWith("http://127.0.0.1:9000/patbond-media/post_image/")
.contains("X-Amz-Signature=");
assertThat(media.get(0).get("widthPx").asInt()).isEqualTo(640);
assertThat(media.get(0).get("heightPx").asInt()).isEqualTo(480);
}
@Test
void explicitPositionsAndCoverAreRespected() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
JsonNode post = createPost(author, """
{"content": "指定顺序", "media": [
{"assetId": "%s", "position": 1},
{"assetId": "%s", "position": 0, "isCover": true}
]}
""".formatted(assetA, assetB));
JsonNode media = post.get("media");
assertThat(media.get(0).get("assetId").asText()).isEqualTo(assetB.toString());
assertThat(media.get(0).get("isCover").asBoolean()).isTrue();
assertThat(media.get(1).get("assetId").asText()).isEqualTo(assetA.toString());
}
@Test
void foreignMissingOrDeletedAssetsMergeInto40405() throws Exception {
UUID author = newUser();
UUID stranger = newUser();
UUID strangersAsset = CommunityTestData.insertReadyAsset(jdbcClient, stranger);
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(strangersAsset)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40405));
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(UUID.randomUUID())))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40405));
}
@Test
void ownButNotReadyAssetAnswers42203() throws Exception {
UUID author = newUser();
UUID uploading = CommunityTestData.insertAsset(jdbcClient, author, "uploading");
UUID failed = CommunityTestData.insertAsset(jdbcClient, author, "failed");
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(uploading)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
mediaBody(failed)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
}
@Test
void mediaShapeViolationsAnswer40000() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
// duplicate assetId
expectBadRequest(author, """
{"content": "x", "media": [{"assetId": "%s"}, {"assetId": "%s"}]}
""".formatted(assetA, assetA));
// two covers (uq_post_media_cover)
expectBadRequest(author, """
{"content": "x", "media": [
{"assetId": "%s", "isCover": true}, {"assetId": "%s", "isCover": true}]}
""".formatted(assetA, assetB));
// positions must be 0-based contiguous
expectBadRequest(author, """
{"content": "x", "media": [
{"assetId": "%s", "position": 0}, {"assetId": "%s", "position": 2}]}
""".formatted(assetA, assetB));
// all-or-none positions
expectBadRequest(author, """
{"content": "x", "media": [
{"assetId": "%s", "position": 0}, {"assetId": "%s"}]}
""".formatted(assetA, assetB));
}
@Test
void patchMediaReplacesTheWholeSet() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetC = CommunityTestData.insertReadyAsset(jdbcClient, author);
String id = createPost(author, """
{"content": "初始两图", "media": [{"assetId": "%s"}, {"assetId": "%s"}]}
""".formatted(assetA, assetB)).get("id").asText();
// media present in the PATCH whole-set replacement (整组替换)
JsonNode replaced = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 0, \"media\": [{\"assetId\": \"" + assetC + "\"}]}"))
.andExpect(status().isOk())
.andReturn());
assertThat(replaced.get("media")).hasSize(1);
assertThat(replaced.get("media").get(0).get("assetId").asText())
.isEqualTo(assetC.toString());
assertThat(replaced.get("media").get(0).get("isCover").asBoolean()).isTrue();
// media absent untouched
JsonNode untouched = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 1, \"title\": \"只改标题\"}"))
.andExpect(status().isOk())
.andReturn());
assertThat(untouched.get("media")).hasSize(1);
// empty array clears down to a text-only post
JsonNode cleared = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
.content("{\"version\": 2, \"media\": []}"))
.andExpect(status().isOk())
.andReturn());
assertThat(cleared.get("media")).isEmpty();
Long rows = jdbcClient.sql(
"SELECT count(*) FROM community.post_media WHERE post_id = :id")
.param("id", UUID.fromString(id))
.query(Long.class)
.single();
assertThat(rows).isZero();
}
private void expectBadRequest(UUID author, String body) throws Exception {
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(), body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
private static String mediaBody(UUID assetId) {
return "{\"content\": \"带图\", \"media\": [{\"assetId\": \"" + assetId + "\"}]}";
}
}
@@ -0,0 +1,87 @@
package com.patbond.patbond.community.security;
import com.patbond.patbond.community.TestcontainersConfiguration;
import com.patbond.patbond.community.support.TestJwtKeys;
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.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.util.UUID;
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;
/**
* The BearerAuthFilter guards every /api/v1/** route from the skeleton on
* (T3-02 acceptance): missing, forged, wrong-key and expired tokens all
* answer 401 with the 40101 envelope, while a valid token passes the filter
* (and reaches the 404 of a not-yet-implemented route instead of a 401).
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class BearerAuthIntegrationTest {
@Autowired
private MockMvc mockMvc;
@DynamicPropertySource
static void jwtPublicKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
}
@Test
void missingTokenAnswers401() throws Exception {
mockMvc.perform(get("/api/v1/posts"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void malformedTokenAnswers401() throws Exception {
mockMvc.perform(get("/api/v1/posts")
.header("Authorization", "Bearer not-a-jwt"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void wrongKeyTokenAnswers401() throws Exception {
String forged = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(),
UUID.randomUUID(), Duration.ofMinutes(15));
mockMvc.perform(get("/api/v1/posts")
.header("Authorization", "Bearer " + forged))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void expiredTokenAnswers401() throws Exception {
String expired = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID(), Duration.ofMinutes(-5));
mockMvc.perform(get("/api/v1/posts")
.header("Authorization", "Bearer " + expired))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void validTokenPassesTheFilter() throws Exception {
// An unmapped route: an authenticated request reaches the 404
// envelope proving the filter let it in. (/api/v1/posts is a real
// route since T3-04, so the probe moved to a path that stays free.)
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID(), Duration.ofMinutes(15));
mockMvc.perform(get("/api/v1/not-a-route")
.header("Authorization", "Bearer " + token))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
}
}
@@ -0,0 +1,103 @@
package com.patbond.patbond.community.support;
import org.springframework.jdbc.core.simple.JdbcClient;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Direct-SQL fixtures for the cross-schema rows community tests depend on
* (identity.users, media.assets, pet_health.pets/pet_owners). The community
* service never writes those schemas in production tests seed them the
* way the owning services would.
*/
public final class CommunityTestData {
private CommunityTestData() {
}
public static UUID insertUser(JdbcClient jdbc, String username) {
UUID id = UuidV7.generate();
jdbc.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
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");
}
public static UUID insertAsset(JdbcClient jdbc, UUID ownerUserId, String status) {
UUID id = UuidV7.generate();
jdbc.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, width_px, height_px, status, ready_at)
VALUES (:id, :owner, 'image', 'post_image', 'object', 'patbond-media',
:objectKey, 'image/jpeg', 123, 640, 480, :status, :readyAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("objectKey", "post_image/2026/09/" + id + ".jpg")
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
.update();
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("""
INSERT INTO pet_health.pets (id, name, species, custom_breed_name)
VALUES (:id, '毛毛', 'cat', '中华田园猫')
""")
.param("id", id)
.update();
jdbc.sql("""
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
VALUES (:petId, :userId, 'owner', true)
""")
.param("petId", id)
.param("userId", ownerUserId)
.update();
return id;
}
}
@@ -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 nicknameusername 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);
}
}
@@ -0,0 +1,59 @@
package com.patbond.patbond.community.support;
import io.jsonwebtoken.Jwts;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
/**
* Runtime-generated RSA material for JWT tests. Nothing here is committed
* key material (git-workflow: no credentials in the repository) every test
* run mints a fresh pair and injects the public key via
* {@code @DynamicPropertySource}.
*/
public final class TestJwtKeys {
public static final KeyPair KEY_PAIR = generate();
/** A second pair, for signing tokens the service must reject. */
public static final KeyPair WRONG_KEY_PAIR = generate();
private TestJwtKeys() {
}
public static String publicPem() {
return "-----BEGIN PUBLIC KEY-----\n"
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
+ "\n-----END PUBLIC KEY-----";
}
/** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(userId.toString())
.issuer("patbond-auth")
.claim("sid", UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(ttl)))
.signWith(key, Jwts.SIG.RS256)
.compact();
}
private static KeyPair generate() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,15 @@
# Test-only configuration: keeps @SpringBootTest deterministic on a clean
# checkout, where the git-ignored main application.yml does not exist. The
# datasource comes from Testcontainers (@ServiceConnection).
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
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
# Runtime image only — build the jar first: ./mvnw -pl patbond-pet -am package
# Stateless by design (ADR-007): no local state, config via env / mounted files.
FROM eclipse-temurin:17-jre
RUN useradd --system --uid 10001 patbond
USER patbond
WORKDIR /app
COPY target/patbond-pet-1.0.0-SNAPSHOT-exec.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+133
View File
@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>patbond-pet</artifactId>
<packaging>jar</packaging>
<name>patbond-pet</name>
<description>Pet profile and health record service for Patbond (ADR-009)</description>
<!-- M2 second-wave: business endpoints with RS256 bearer auth (same JWT
verification stack as patbond-user), pet_health schema CRUD. Flyway
remains absent — the migration chain is owned by patbond-user. -->
<dependencies>
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned in step with patbond-user/auth. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Tests need the pet_health schema. The migration chain (V1..V4) is
owned by patbond-user (single flyway_schema_history); pulling its
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
auto-config apply the same chain to the disposable container.
Production wiring is unchanged: this module still ships without
Flyway and the chain runs in patbond-user's startup path. -->
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-user</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- No spring-boot-starter-parent in this build, so the
executable-jar repackaging must be bound explicitly. -->
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<!-- Keep the plain jar as the main artifact so other
modules can depend on this one; the runnable fat
jar gets the -exec classifier and is what the
Dockerfile ships (same pattern as user/auth). -->
<classifier>exec</classifier>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,18 @@
package com.patbond.patbond.pet;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Pet profile and health record service (M2, ADR-009: the pet_health domain
* lives in its own Maven module, not inside patbond-user). First-wave
* skeleton: configuration wiring, datasource and a liveness endpoint only
* business endpoints follow the frozen OpenAPI contract in the second wave.
*/
@SpringBootApplication
public class PetApplication {
public static void main(String[] args) {
SpringApplication.run(PetApplication.class, args);
}
}
@@ -0,0 +1,29 @@
package com.patbond.patbond.pet.access;
/**
* What a request wants to do with a pet. Every /pets/** endpoint maps to
* exactly one level, checked by {@link PetAccessService}:
*
* <ul>
* <li>{@code READ} owner, caregiver and viewer.</li>
* <li>{@code WRITE} owner and caregiver; the level for health-record
* style sub-resources (weights, vaccinations, health events,
* reminders T2-04~07 reuse this).</li>
* <li>{@code MANAGE} owner only; the pet profile itself (PATCH /pets/
* {petId}, status transitions) and, later, member management.</li>
* </ul>
*/
public enum AccessLevel {
READ,
WRITE,
MANAGE;
boolean allowedFor(PetRole role) {
return switch (this) {
case READ -> true;
case WRITE -> role == PetRole.OWNER || role == PetRole.CAREGIVER;
case MANAGE -> role == PetRole.OWNER;
};
}
}
@@ -0,0 +1,69 @@
package com.patbond.patbond.pet.access;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* The single permission gate for every /pets/{petId}/** request T2-04~07
* call {@link #require} first and then trust the returned {@link PetAccess}.
*
* <p>Semantics (frozen for the M2 contract, iteration-2/02 P7):
* <ul>
* <li>Pet does not exist, is soft-deleted, or the caller has NO row in
* pet_owners {@code 404/40401 PET_NOT_FOUND}. A caller without a
* relationship cannot distinguish "someone else's pet" from "no such
* pet" — anti-enumeration: the response must not leak that a random
* UUID hits a real record.</li>
* <li>Relationship exists but the role does not cover the requested
* {@link AccessLevel} (e.g. viewer tries to write)
* {@code 403/40300 PET_ACCESS_DENIED}.</li>
* </ul>
*
* <p>The check is one indexed query (pets pet_owners, both on their
* primary keys) per request no caching, so revoking a caregiver row takes
* effect immediately (iteration-2/02 §6: this is why pet permissions don't
* need an access-token blacklist).
*/
@Service
public class PetAccessService {
private final JdbcClient jdbcClient;
public PetAccessService(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Asserts the caller may act on the pet at the given level.
*
* @return the caller's resolved relationship, for handlers that need the
* role (e.g. pet detail echoes {@code myRole})
* @throws BusinessException 40401 (pet invisible to this caller) or
* 40300 (visible but insufficient role)
*/
public PetAccess require(UUID userId, UUID petId, AccessLevel level) {
PetAccess access = jdbcClient.sql("""
SELECT po.role
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
WHERE p.id = :petId AND p.status <> 'deleted'
""")
.param("userId", userId)
.param("petId", petId)
.query((rs, rowNum) -> new PetAccess(petId, PetRole.fromDb(rs.getString("role"))))
.optional()
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
if (!level.allowedFor(access.role())) {
throw new BusinessException(ErrorCode.PET_ACCESS_DENIED);
}
return access;
}
/** The caller's verified relationship to a pet. */
public record PetAccess(UUID petId, PetRole role) {
}
}
@@ -0,0 +1,27 @@
package com.patbond.patbond.pet.access;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
/**
* The caller's relationship to a pet, straight from pet_owners.role
* (ADR-015 three-tier model: owner / caregiver / viewer).
*/
public enum PetRole {
OWNER,
CAREGIVER,
VIEWER;
public static PetRole fromDb(String value) {
try {
return valueOf(value.toUpperCase());
} catch (IllegalArgumentException | NullPointerException e) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "未知的照护角色: " + value);
}
}
public String toWire() {
return name().toLowerCase();
}
}
@@ -0,0 +1,21 @@
package com.patbond.patbond.pet.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Money fields travel as integer cents (development-plan 4.3), so a decimal
* like 45.5 in an integer field must be a 40000 Jackson's default is to
* silently truncate it to 45, which would corrupt amounts instead of
* rejecting them.
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}
@@ -0,0 +1,37 @@
package com.patbond.patbond.pet.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Security knobs of the pet service: only the RS256 public key for verifying
* access tokens issued by patbond-auth (same contract as patbond-user's
* {@code patbond.jwt.public-key}). No /internal routes exist here yet, so no
* service token property.
*/
@ConfigurationProperties(prefix = "patbond")
public class PetSecurityProperties {
private final Jwt jwt = new Jwt();
public Jwt getJwt() {
return jwt;
}
public static class Jwt {
/**
* RS256 public key for verifying access tokens signed by
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
* filesystem path. The private key never reaches this service.
*/
private String publicKey;
public String getPublicKey() {
return publicKey;
}
public void setPublicKey(String publicKey) {
this.publicKey = publicKey;
}
}
}
@@ -0,0 +1,34 @@
package com.patbond.patbond.pet.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.security.JwtVerifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires bearer authentication for /api/v1/** without pulling in
* spring-security the same single-filter pattern patbond-user uses. The
* /health probe stays outside /api/v1 and therefore unauthenticated.
*/
@Configuration
@EnableConfigurationProperties(PetSecurityProperties.class)
public class SecurityConfig {
@Bean
public JwtVerifier jwtVerifier(PetSecurityProperties properties) {
return new JwtVerifier(properties.getJwt().getPublicKey());
}
@Bean
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
new BearerAuthFilter(jwtVerifier, objectMapper));
registration.addUrlPatterns("/api/v1/*");
registration.setOrder(20);
return registration;
}
}
@@ -0,0 +1,38 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.BreedResponse;
import com.patbond.patbond.pet.repository.BreedRepository;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
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.List;
/**
* Read-only breed dictionary. Authenticated (behind BearerAuthFilter like
* every /api/v1 route) but not permission-checked dictionary rows are not
* user data. Returned in full (30 seed rows, D2-6); no pagination.
*/
@RestController
@RequestMapping("/api/v1/breeds")
@Validated
public class BreedController {
private final BreedRepository breedRepository;
public BreedController(BreedRepository breedRepository) {
this.breedRepository = breedRepository;
}
@GetMapping
public ApiResponse<List<BreedResponse>> list(
@RequestParam(required = false)
@Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other")
String species) {
return ApiResponse.success(breedRepository.listEnabled(species));
}
}

Some files were not shown because too many files have changed in this diff Show More