Compare commits

6 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
73 changed files with 16249 additions and 62 deletions
+4
View File
@@ -125,6 +125,10 @@ services:
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 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
# 作者公开资料(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 服务同一凭证/同一 # 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一
# 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。 # 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000} PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
@@ -39,8 +39,8 @@ import static org.assertj.core.api.Assertions.assertThat;
/** /**
* T3-19D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与 * T3-19D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.2.0(快照 * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓 * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator} * {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
* 严格校验响应结构,最后以全响应矩阵门禁兜底。 * 严格校验响应结构,最后以全响应矩阵门禁兜底。
* *
@@ -314,10 +314,10 @@ class AuthContractConformanceTest {
@Test @Test
@Order(98) @Order(98)
void frozenSnapshotIsTheExpectedContractVersion() { void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.2.0"); assertThat(CONTRACT.version()).isEqualTo("1.3.0");
assertThat(CONTRACT.paths()).hasSize(18); assertThat(CONTRACT.paths()).hasSize(31);
assertThat(CONTRACT.operations()).hasSize(24); assertThat(CONTRACT.operations()).hasSize(43);
assertThat(CONTRACT.schemas()).hasSize(45); assertThat(CONTRACT.schemas()).hasSize(72);
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics"))) assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS); .containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
} }
@@ -10,6 +10,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -81,7 +82,7 @@ final class ContractValidator {
} }
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) { private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = contract.resolve(rawSchema); Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) { if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失"); errors.add(loc + ": 字段缺失");
return; return;
@@ -130,6 +131,32 @@ final class ContractValidator {
} }
} }
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys — e.g.
* the outer {@code nullable} — win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) { private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) { if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType()); errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
@@ -13,27 +13,30 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.2.0.yaml}. * {@code /contract/openapi-v1.3.0.yaml}.
* *
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical * <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 * 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 * 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 * the module-local copy of the pet module's contract framework (same
* per-module duplication discipline as BearerAuthFilter). Whenever the * per-module duplication discipline as BearerAuthFilter). Whenever the
* canonical contract changes, copy it here AND in patbond-pet under the new * canonical contract changes, copy it into every framework-carrying module
* version's file name and update both conformance tests (expected version + * (patbond-pet / patbond-auth / patbond-community / patbond-user) under the
* snapshot counts). The guard test on {@code info.version} makes a forgotten * 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 * sync fail loudly in CI instead of silently testing against a stale
* contract. * contract.
* *
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported: * <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum}, * local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf. * {@code required}, {@code properties}, {@code items}, and the v1.3.0
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
* {@link ContractValidator}) — no oneOf/anyOf.
*/ */
final class OpenApiContract { final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml"; static final String RESOURCE = "/contract/openapi-v1.3.0.yaml";
private static final Set<String> HTTP_METHODS = private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -26,9 +26,12 @@ public enum ErrorCode {
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"), VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"), IDEMPOTENCY_PAYLOAD_MISMATCH(40905, 409, "幂等键已用于不同请求"),
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"), MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
COMMENT_NOT_FOUND(40404, 404, "评论不存在"),
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"), VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"), REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"), MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"),
FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"),
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"), MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"), INTERNAL_ERROR(50000, 500, "服务器内部错误"),
+12
View File
@@ -39,6 +39,18 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId> <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency> </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> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
@@ -1,17 +1,21 @@
package com.patbond.patbond.community; package com.patbond.patbond.community;
import com.patbond.patbond.community.config.CommunityFeignConfig;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/** /**
* Community feed, posts and interactions service (M3, ADR-017: the community * Community feed, posts and interactions service (M3, ADR-017: the community
* domain lives in its own Maven module on :8084). First-wave skeleton: * domain lives in its own Maven module on :8084). Configuration wiring,
* configuration wiring, datasource, RS256 bearer auth on /api/v1/** and a * datasource, RS256 bearer auth on /api/v1/**, the post lifecycle (T3-04)
* liveness endpoint — business endpoints follow the contract work in the * and the public feed (T3-05). The module only reads and writes the
* next waves. The module only reads and writes the community schema * community schema (plus the ADR-017 read-only media.assets exception);
* (author profile lookups follow the D3-9 plan later). * author public profiles come from patbond-user's /internal batch API over
* Feign (D3-9 方案 B).
*/ */
@SpringBootApplication @SpringBootApplication
@EnableFeignClients(defaultConfiguration = CommunityFeignConfig.class)
public class CommunityApplication { public class CommunityApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -0,0 +1,34 @@
package com.patbond.patbond.community.access;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Component;
import java.util.UUID;
/**
* Existence probe into identity.users for write gates that reference a
* user (follow target, comment @-reply target). Same-database read-only
* access under the ADR-017 exception — the user_follows/comments foreign
* keys already bind these schemas together, and a WRITE gate cannot ride
* the Feign profile path, whose degradation deliberately cannot tell
* "absent" from "unreachable". A soft-deleted (注销) user counts as absent.
*/
@Component
public class UserExistenceGateway {
private final JdbcClient jdbcClient;
public UserExistenceGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public boolean existsActive(UUID userId) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM identity.users
WHERE id = :id AND deleted_at IS NULL)
""")
.param("id", userId)
.query(Boolean.class)
.single();
}
}
@@ -0,0 +1,24 @@
package com.patbond.patbond.community.author;
import com.patbond.patbond.common.response.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
/**
* Batch public-profile API of patbond-user, the identity schema owner
* (D3-9 方案 B; static direct URL per ADR-002). The X-Internal-Token header
* is attached by the interceptor in CommunityFeignConfig. Unknown or
* deleted ids are silently absent from the reply. {@code primary = false}
* only matters to tests (lets a stub take precedence); in production this
* is the sole candidate.
*/
@FeignClient(name = "patbond-user-profiles", url = "${patbond.user-service.url}", primary = false)
public interface AuthorProfileClient {
/** @param ids comma-separated user ids, at most 50 per call */
@GetMapping("/internal/users/profiles")
ApiResponse<List<AuthorProfileDto>> profiles(@RequestParam("ids") String ids);
}
@@ -0,0 +1,14 @@
package com.patbond.patbond.community.author;
import java.util.UUID;
/**
* Wire shape of one profile in patbond-user's /internal/users/profiles
* reply (D3-9 方案 B): display name (nickname→username fallback already
* applied by the owning service) plus the avatar asset pointer. The avatar
* arrives as an id, not a URL — this service resolves it against
* media.assets (ADR-017 read-only exception) and signs a fresh presigned
* GET per response, so nothing cached here ever holds an expiring URL.
*/
public record AuthorProfileDto(UUID userId, String nickname, UUID avatarAssetId) {
}
@@ -0,0 +1,145 @@
package com.patbond.patbond.community.author;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.media.MediaAssetGateway;
import com.patbond.patbond.community.media.MediaAssetRef;
import com.patbond.patbond.community.media.MediaUrlSigner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Author public-profile lookup (D3-9 方案 B): a batch Feign call to
* patbond-user's /internal/users/profiles behind a short-TTL in-process
* cache, plus local avatar resolution.
*
* <ul>
* <li><b>Batch, never loop</b> — one call per ≤50 distinct cache-missed
* authors (a feed page has ≤20 cards, so normally exactly one call,
* and none on a warm cache).</li>
* <li><b>Avatar</b> — travels as an asset id; resolved to bucket/key via
* the ADR-017 read-only media.assets exception (ready assets only)
* and signed fresh per response, so the cache stores no expiring
* URL.</li>
* <li><b>Degradation</b> — ANY lookup failure (user service down, slow,
* or answering an error) logs one warning and leaves the ids
* unresolved; callers render the id-only summary. Failures are never
* cached, so the next request retries; the feed never 5xxes over a
* profile lookup.</li>
* </ul>
*/
@Component
public class AuthorProfileGateway {
private static final int MAX_BATCH = 50;
/** Expired entries are pruned opportunistically past this size. */
private static final int PRUNE_THRESHOLD = 10_000;
private static final Logger log = LoggerFactory.getLogger(AuthorProfileGateway.class);
private final AuthorProfileClient client;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
private final AuthorProfileProperties properties;
private final ConcurrentHashMap<UUID, CacheEntry> cache = new ConcurrentHashMap<>();
public AuthorProfileGateway(AuthorProfileClient client, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner, AuthorProfileProperties properties) {
this.client = client;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
this.properties = properties;
}
/**
* Summaries for the given authors, avatar URLs signed fresh. Ids that
* could not be resolved (lookup degraded, or the user no longer exists)
* are absent — callers fall back to
* {@link AuthorSummaryResponse#idOnly}.
*/
public Map<UUID, AuthorSummaryResponse> summarize(Collection<UUID> userIds) {
if (userIds.isEmpty()) {
return Map.of();
}
long now = System.nanoTime();
Map<UUID, AuthorRef> resolved = new HashMap<>();
List<UUID> misses = new ArrayList<>();
for (UUID id : new LinkedHashSet<>(userIds)) {
CacheEntry entry = cache.get(id);
if (entry != null && entry.expiresAtNanos() - now > 0) {
resolved.put(id, entry.ref());
} else {
misses.add(id);
}
}
if (!misses.isEmpty()) {
fetchInto(resolved, misses, now);
}
Map<UUID, AuthorSummaryResponse> summaries = new HashMap<>();
resolved.forEach((id, ref) -> summaries.put(id, new AuthorSummaryResponse(
id, ref.nickname(), mediaUrlSigner.signGet(ref.avatarBucket(), ref.avatarObjectKey()))));
return summaries;
}
private void fetchInto(Map<UUID, AuthorRef> resolved, List<UUID> misses, long now) {
List<AuthorProfileDto> profiles = new ArrayList<>();
try {
for (int i = 0; i < misses.size(); i += MAX_BATCH) {
List<UUID> chunk = misses.subList(i, Math.min(i + MAX_BATCH, misses.size()));
ApiResponse<List<AuthorProfileDto>> reply = client.profiles(
chunk.stream().map(UUID::toString).collect(Collectors.joining(",")));
if (reply != null && reply.getData() != null) {
profiles.addAll(reply.getData());
}
}
} catch (RuntimeException e) {
// Chunks fetched before the failure still count below.
log.warn("作者公开资料获取失败,本次响应对未解析作者降级为 authorId 保底: {}",
e.toString());
}
if (profiles.isEmpty()) {
return;
}
Set<UUID> assetIds = profiles.stream()
.map(AuthorProfileDto::avatarAssetId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<UUID, MediaAssetRef> assets = assetIds.isEmpty()
? Map.of()
: mediaAssetGateway.findByIds(assetIds);
long expiresAt = now + properties.getCacheTtl().toNanos();
for (AuthorProfileDto profile : profiles) {
MediaAssetRef asset = profile.avatarAssetId() == null
? null
: assets.get(profile.avatarAssetId());
boolean ready = asset != null && "ready".equals(asset.status());
AuthorRef ref = new AuthorRef(profile.nickname(),
ready ? asset.bucket() : null,
ready ? asset.objectKey() : null);
cache.put(profile.userId(), new CacheEntry(ref, expiresAt));
resolved.put(profile.userId(), ref);
}
if (cache.size() > PRUNE_THRESHOLD) {
cache.values().removeIf(entry -> entry.expiresAtNanos() - now <= 0);
}
}
private record AuthorRef(String nickname, String avatarBucket, String avatarObjectKey) {
}
private record CacheEntry(AuthorRef ref, long expiresAtNanos) {
}
}
@@ -0,0 +1,26 @@
package com.patbond.patbond.community.author;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Knobs of the author-profile lookup (D3-9 方案 B): a short in-process TTL
* cache in front of patbond-user's /internal batch API. 60 s is the frozen
* default — long enough to absorb feed scrolling and refresh bursts,
* short enough that a nickname/avatar change propagates within a minute.
*/
@ConfigurationProperties(prefix = "patbond.author-profile")
public class AuthorProfileProperties {
/** How long one resolved profile stays in the in-process cache. */
private Duration cacheTtl = Duration.ofSeconds(60);
public Duration getCacheTtl() {
return cacheTtl;
}
public void setCacheTtl(Duration value) {
this.cacheTtl = value;
}
}
@@ -0,0 +1,35 @@
package com.patbond.patbond.community.config;
import feign.Request;
import feign.RequestInterceptor;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.TimeUnit;
/**
* Feign child-context beans, registered via
* {@code @EnableFeignClients(defaultConfiguration = …)} — deliberately not
* a @Configuration, same reasoning as patbond-auth's FeignInternalConfig
* (a component-scanned bean would land in the parent context and be
* shadowed by the child's defaults).
*
* <p>No ErrorDecoder on purpose: the only Feign consumer here is the author
* profile lookup, whose gateway degrades on ANY failure instead of
* propagating it — a downstream business error is as much "no profile" as a
* connection refusal. Timeouts are tight because this call sits on the feed
* read path: a hung patbond-user must cost one bounded stall, not an
* unbounded one (connection refused already fails fast on its own).</p>
*/
public class CommunityFeignConfig {
/** Presents the shared service secret on every call to patbond-user. */
@Bean
public RequestInterceptor internalTokenInterceptor(CommunitySecurityProperties properties) {
return template -> template.header("X-Internal-Token", properties.getInternalToken());
}
@Bean
public Request.Options feignOptions() {
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, true);
}
}
@@ -3,16 +3,32 @@ package com.patbond.patbond.community.config;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
/** /**
* Security knobs of the community service: only the RS256 public key for * Security knobs of the community service: the RS256 public key for
* verifying access tokens issued by patbond-auth (same contract as * verifying access tokens issued by patbond-auth (same contract as
* patbond-user/pet's {@code patbond.jwt.public-key}). No /internal routes * patbond-user/pet's {@code patbond.jwt.public-key}), and the shared
* exist here yet, so no service token property. * 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") @ConfigurationProperties(prefix = "patbond")
public class CommunitySecurityProperties { 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(); private final Jwt jwt = new Jwt();
public String getInternalToken() {
return internalToken;
}
public void setInternalToken(String value) {
this.internalToken = value;
}
public Jwt getJwt() { public Jwt getJwt() {
return jwt; return jwt;
} }
@@ -1,6 +1,7 @@
package com.patbond.patbond.community.config; package com.patbond.patbond.community.config;
import com.fasterxml.jackson.databind.ObjectMapper; 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.BearerAuthFilter;
import com.patbond.patbond.community.security.JwtVerifier; import com.patbond.patbond.community.security.JwtVerifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -15,7 +16,7 @@ import org.springframework.context.annotation.Configuration;
* unauthenticated. * unauthenticated.
*/ */
@Configuration @Configuration
@EnableConfigurationProperties(CommunitySecurityProperties.class) @EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class})
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean
@@ -0,0 +1,71 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CommentResponse;
import com.patbond.patbond.community.dto.CreateCommentRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.CommentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Flat comment endpoints (T3-07). Delete rides the top-level short path
* (commentId is globally unique — the pets-domain precedent); create
* carries a MANDATORY Idempotency-Key (ADR-019). All permission and error
* semantics live in CommentService.
*/
@RestController
@Validated
public class CommentController {
private final CommentService commentService;
public CommentController(CommentService commentService) {
this.commentService = commentService;
}
@GetMapping("/api/v1/posts/{postId}/comments")
public ApiResponse<CursorPage<CommentResponse>> list(
@PathVariable UUID postId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(commentService.list(postId, limit, cursor));
}
@PostMapping("/api/v1/posts/{postId}/comments")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<CommentResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateCommentRequest request) {
return ApiResponse.success(commentService.create(userId, postId, idempotencyKey, request));
}
@DeleteMapping("/api/v1/comments/{commentId}")
public ApiResponse<Void> delete(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID commentId) {
commentService.delete(userId, commentId);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.FeedService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Public feed endpoint (T3-05). Authenticated like every /api/v1 route —
* the viewer identity feeds likedByMe/bookmarkedByMe; the feed content
* itself is the same for everyone (published + public only).
*/
@RestController
@Validated
public class FeedController {
private final FeedService feedService;
public FeedController(FeedService feedService) {
this.feedService = feedService;
}
@GetMapping("/api/v1/feed")
public ApiResponse<CursorPage<FeedCardResponse>> feed(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(feedService.list(userId, limit, cursor));
}
}
@@ -0,0 +1,51 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.FollowStateResponse;
import com.patbond.patbond.community.dto.FollowStatsResponse;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.FollowService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* The ADR-018 minimal follow surface (T3-07): idempotent follow/unfollow
* plus the numbers endpoint. Follower/following LISTS are deliberately not
* in M3.
*/
@RestController
public class FollowController {
private final FollowService followService;
public FollowController(FollowService followService) {
this.followService = followService;
}
@PutMapping("/api/v1/users/{userId}/follow")
public ApiResponse<FollowStateResponse> follow(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.follow(callerId, userId));
}
@DeleteMapping("/api/v1/users/{userId}/follow")
public ApiResponse<FollowStateResponse> unfollow(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.unfollow(callerId, userId));
}
@GetMapping("/api/v1/users/{userId}/follow-stats")
public ApiResponse<FollowStatsResponse> stats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID callerId,
@PathVariable UUID userId) {
return ApiResponse.success(followService.stats(callerId, userId));
}
}
@@ -0,0 +1,79 @@
package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.BookmarkStateResponse;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.dto.LikeStateResponse;
import com.patbond.patbond.community.security.BearerAuthFilter;
import com.patbond.patbond.community.service.FeedService;
import com.patbond.patbond.community.service.InteractionService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Binary post interactions (T3-06): PUT/DELETE idempotent like and
* bookmark, each answering the authoritative terminal state, plus the
* my-bookmarks list whose items reuse the feed card shape.
*/
@RestController
@Validated
public class InteractionController {
private final InteractionService interactionService;
private final FeedService feedService;
public InteractionController(InteractionService interactionService, FeedService feedService) {
this.interactionService = interactionService;
this.feedService = feedService;
}
@PutMapping("/api/v1/posts/{postId}/like")
public ApiResponse<LikeStateResponse> like(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.like(userId, postId));
}
@DeleteMapping("/api/v1/posts/{postId}/like")
public ApiResponse<LikeStateResponse> unlike(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.unlike(userId, postId));
}
@PutMapping("/api/v1/posts/{postId}/bookmark")
public ApiResponse<BookmarkStateResponse> bookmark(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.bookmark(userId, postId));
}
@DeleteMapping("/api/v1/posts/{postId}/bookmark")
public ApiResponse<BookmarkStateResponse> unbookmark(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID postId) {
return ApiResponse.success(interactionService.unbookmark(userId, postId));
}
@GetMapping("/api/v1/me/bookmarks")
public ApiResponse<CursorPage<FeedCardResponse>> myBookmarks(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit,
@RequestParam(required = false) String cursor) {
return ApiResponse.success(feedService.listBookmarked(userId, limit, cursor));
}
}
@@ -0,0 +1,21 @@
package com.patbond.patbond.community.dto;
import java.util.UUID;
/**
* Author public summary embedded in post/feed/comment responses (D3-9).
* {@code nickname} carries the nickname→username fallback applied by
* patbond-user, so clients never assemble a display name themselves;
* {@code avatarUrl} is a fresh presigned GET (null when the author has no
* ready avatar, or when object storage is unconfigured — clients show a
* placeholder). The degraded shape — profile service unreachable, or the
* author since deleted — keeps only {@code userId} and nulls the rest
* (authorId 保底:the feed never 5xxes over a profile lookup).
*/
public record AuthorSummaryResponse(UUID userId, String nickname, String avatarUrl) {
/** The degraded / tombstone shape: id only, client renders placeholders. */
public static AuthorSummaryResponse idOnly(UUID userId) {
return new AuthorSummaryResponse(userId, null, null);
}
}
@@ -0,0 +1,5 @@
package com.patbond.patbond.community.dto;
/** Authoritative post-write bookmark state — isomorphic to {@link LikeStateResponse}. */
public record BookmarkStateResponse(boolean bookmarked, long bookmarkCount) {
}
@@ -0,0 +1,19 @@
package com.patbond.patbond.community.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One flat comment (T3-07 定型): the author and the optional @-reply target
* both travel as the D3-9 AuthorSummary shape, resolved through the same
* batch profile gateway as posts, so a degraded profile service renders
* id-only summaries here too and never fails the request.
*/
public record CommentResponse(
UUID id,
UUID postId,
AuthorSummaryResponse author,
AuthorSummaryResponse replyToUser,
String content,
OffsetDateTime createdAt) {
}
@@ -0,0 +1,38 @@
package com.patbond.patbond.community.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* POST /api/v1/posts/{postId}/comments. Content width mirrors
* ck_comments_content (1~2000 after trim); {@code replyToUserId} is the
* optional flat @-reply target (single level, no parentCommentId — ADR-018
* rules out nested threads).
*/
public class CreateCommentRequest {
@NotBlank(message = "content 不能为空")
@Size(max = 2000, message = "content 最长 2000 字符")
private String content;
/** Optional @-reply target; must be an existing active user (40406). */
private UUID replyToUserId;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public UUID getReplyToUserId() {
return replyToUserId;
}
public void setReplyToUserId(UUID replyToUserId) {
this.replyToUserId = replyToUserId;
}
}
@@ -0,0 +1,29 @@
package com.patbond.patbond.community.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One public-feed card (T3-05 定型, the FeedCard freeze input): the Post
* shape trimmed for list rendering — content cut to a 200-code-point
* preview, the media set reduced to the cover item plus a count, counts
* read from the posts table's denormalized columns. {@code coverImage} is
* null exactly for text-only posts (T3-04 guarantees a unique is_cover row
* whenever media exist); {@code publishedAt} is never null here (the feed
* predicate admits published posts only).
*/
public record FeedCardResponse(
UUID id,
AuthorSummaryResponse author,
String category,
String title,
String contentPreview,
PostMediaItemResponse coverImage,
int mediaCount,
long likeCount,
long commentCount,
long bookmarkCount,
boolean likedByMe,
boolean bookmarkedByMe,
OffsetDateTime publishedAt) {
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.community.dto;
/**
* Authoritative post-write follow state; {@code followerCount} is the
* TARGET user's follower count (real-time COUNT — user_follows has no
* denormalized counter column, and the double index keeps both directions
* cheap).
*/
public record FollowStateResponse(boolean following, long followerCount) {
}
@@ -0,0 +1,9 @@
package com.patbond.patbond.community.dto;
/**
* GET /api/v1/users/{userId}/follow-stats — the ADR-018 minimal "numbers"
* endpoint. {@code followedByMe} is the caller's view; asking about oneself
* yields false (a self-follow row cannot exist, ck_user_follows_self).
*/
public record FollowStatsResponse(long followerCount, long followingCount, boolean followedByMe) {
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.community.dto;
/**
* Authoritative post-write like state (草案定型): a PUT answers
* {@code liked=true} and a DELETE {@code liked=false} regardless of whether
* the call changed anything; {@code likeCount} is the count as of this
* write's transaction, the value optimistic clients reconcile against.
*/
public record LikeStateResponse(boolean liked, long likeCount) {
}
@@ -5,16 +5,16 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
/** /**
* Full post shape (detail / my-posts list / write responses). Deviation from * Full post shape (detail / my-posts list / write responses). The T3-04
* the contract draft, recorded for the T3-10 freeze: the draft's * {@code authorId} placeholder is gone: {@code author} is the D3-9
* {@code author: AuthorSummary} is placeheld by {@code authorId} until T3-05 * AuthorSummary, degraded to its id-only shape when the profile lookup is
* lands the public-profile aggregation (工单口径:author 字段可先占位 * unavailable (contract deviation #1 closed by T3-05). Trimmed fields
* authorId). Trimmed fields (region/generationJob/topics …) do not appear at * (region/generationJob/topics …) do not appear at all (ADR-018 + ADR-010
* all (ADR-018 + ADR-010 precedent). * precedent).
*/ */
public record PostResponse( public record PostResponse(
UUID id, UUID id,
UUID authorId, AuthorSummaryResponse author,
UUID petId, UUID petId,
String category, String category,
String title, String title,
@@ -0,0 +1,145 @@
package com.patbond.patbond.community.repository;
import com.patbond.patbond.community.support.CommentCursor;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* community.comments access. Post visibility and authorship decisions live
* in CommentService; every query here filters on the comment's own state
* only (status='visible' is the single liveness predicate — 'hidden' has no
* producing endpoint in M3 and 'deleted' pairs with deleted_at,
* ck_comments_deleted).
*/
@Repository
public class CommentRepository {
private static final String SELECT_COMMENT = """
SELECT c.id, c.post_id, c.author_user_id, c.reply_to_user_id, c.content,
c.status, c.request_hash, c.created_at, c.deleted_at
FROM community.comments c
""";
private final JdbcClient jdbcClient;
public CommentRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one comment; the conflict target is the (author_user_id,
* client_request_id) unique constraint, so a keyed replay is a no-op and
* the caller settles retry-vs-mismatch on the stored request_hash
* (ADR-019, same shape as posts).
*
* @return rows inserted — 0 means this author already used the key
*/
public int insertComment(UUID id, UUID postId, UUID authorUserId, UUID replyToUserId,
String content, String clientRequestId, byte[] requestHash) {
return jdbcClient.sql("""
INSERT INTO community.comments
(id, post_id, author_user_id, reply_to_user_id, content,
client_request_id, request_hash)
VALUES (:id, :postId, :authorUserId, :replyToUserId, :content,
:clientRequestId, :requestHash)
ON CONFLICT (author_user_id, client_request_id) DO NOTHING
""")
.param("id", id)
.param("postId", postId)
.param("authorUserId", authorUserId)
.param("replyToUserId", replyToUserId)
.param("content", content)
.param("clientRequestId", clientRequestId)
.param("requestHash", requestHash)
.update();
}
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
public Optional<CommentRow> findByAuthorAndClientRequestId(UUID authorUserId,
String clientRequestId) {
return jdbcClient.sql(SELECT_COMMENT
+ " WHERE c.author_user_id = :authorUserId"
+ " AND c.client_request_id = :clientRequestId")
.param("authorUserId", authorUserId)
.param("clientRequestId", clientRequestId)
.query(CommentRepository::mapComment)
.optional();
}
/**
* Locks the visible row for the delete transition: concurrent deletes
* of the same comment serialize here, so the status flip — and with it
* the comment_count decrement — happens exactly once.
*/
public Optional<CommentRow> lockVisibleById(UUID id) {
return jdbcClient.sql(SELECT_COMMENT + " WHERE c.id = :id AND c.status = 'visible' FOR UPDATE")
.param("id", id)
.query(CommentRepository::mapComment)
.optional();
}
/** The soft-delete transition; deleted_at pairs with status (ck_comments_deleted). */
public int softDelete(UUID id) {
return jdbcClient.sql("""
UPDATE community.comments
SET status = 'deleted', deleted_at = now()
WHERE id = :id AND status = 'visible'
""")
.param("id", id)
.update();
}
/**
* One page of a post's visible comments in (created_at DESC, id DESC) —
* the exact key of ix_comments_post_created. The caller asks for
* limit+1 rows to learn whether more exist.
*/
public List<CommentRow> pageByPost(UUID postId, CommentCursor after, int limitPlusOne) {
String sql = SELECT_COMMENT + " WHERE c.post_id = :postId AND c.status = 'visible'";
if (after != null) {
sql += " AND (c.created_at, c.id) < (:cursorCreatedAt, :cursorId)";
}
sql += " ORDER BY c.created_at DESC, c.id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("postId", postId)
.param("limit", limitPlusOne);
if (after != null) {
spec = spec.param("cursorCreatedAt", after.createdAt())
.param("cursorId", after.id());
}
return spec.query(CommentRepository::mapComment).list();
}
private static CommentRow mapComment(ResultSet rs, int rowNum) throws SQLException {
return new CommentRow(
rs.getObject("id", UUID.class),
rs.getObject("post_id", UUID.class),
rs.getObject("author_user_id", UUID.class),
rs.getObject("reply_to_user_id", UUID.class),
rs.getString("content"),
rs.getString("status"),
rs.getBytes("request_hash"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("deleted_at", OffsetDateTime.class));
}
/** One comments row; requestHash carries the ADR-019 replay comparison. */
public record CommentRow(
UUID id,
UUID postId,
UUID authorUserId,
UUID replyToUserId,
String content,
String status,
byte[] requestHash,
OffsetDateTime createdAt,
OffsetDateTime deletedAt) {
}
}
@@ -0,0 +1,202 @@
package com.patbond.patbond.community.repository;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.UUID;
/**
* community.post_likes / post_bookmarks / user_follows access, plus the
* denormalized counter writes on community.posts. The invariant every
* caller must hold (工单验收硬项): a counter column moves IN THE SAME
* TRANSACTION as its relation row, and only by the number of rows the
* relation write actually changed — {@code ON CONFLICT DO NOTHING} inserts
* and conditional deletes report that number, so concurrent duplicates
* converge on the composite primary key and never double-count.
*/
@Repository
public class InteractionRepository {
private final JdbcClient jdbcClient;
public InteractionRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* The interaction gate: likes, bookmarks and comments attach to the
* PUBLIC face of a post only — published and live. Drafts (the
* author's own included), hidden/archived and soft-deleted posts all
* fail this probe and answer the byte-identical 404/40403.
*/
public boolean isInteractable(UUID postId) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM community.posts
WHERE id = :id AND status = 'published'
AND deleted_at IS NULL)
""")
.param("id", postId)
.query(Boolean.class)
.single();
}
/** @return rows inserted — 0 when the like already existed */
public int insertLike(UUID postId, UUID userId) {
return jdbcClient.sql("""
INSERT INTO community.post_likes (post_id, user_id)
VALUES (:postId, :userId)
ON CONFLICT (post_id, user_id) DO NOTHING
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/** @return rows deleted — 0 when there was nothing to cancel */
public int deleteLike(UUID postId, UUID userId) {
return jdbcClient.sql("""
DELETE FROM community.post_likes
WHERE post_id = :postId AND user_id = :userId
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/** @return rows inserted — 0 when the bookmark already existed */
public int insertBookmark(UUID postId, UUID userId) {
return jdbcClient.sql("""
INSERT INTO community.post_bookmarks (post_id, user_id)
VALUES (:postId, :userId)
ON CONFLICT (post_id, user_id) DO NOTHING
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/** @return rows deleted — 0 when there was nothing to cancel */
public int deleteBookmark(UUID postId, UUID userId) {
return jdbcClient.sql("""
DELETE FROM community.post_bookmarks
WHERE post_id = :postId AND user_id = :userId
""")
.param("postId", postId)
.param("userId", userId)
.update();
}
/**
* Moves like_count by delta and returns the resulting value — the
* authoritative count the write response carries. Callers pass the row
* count their relation write reported; a zero delta must instead read
* via {@link #likeCount} so a no-op replay takes no row lock and does
* not touch updated_at.
*/
public long bumpLikeCount(UUID postId, int delta) {
return jdbcClient.sql("""
UPDATE community.posts SET like_count = like_count + :delta
WHERE id = :id
RETURNING like_count
""")
.param("id", postId)
.param("delta", delta)
.query(Long.class)
.single();
}
public long bumpBookmarkCount(UUID postId, int delta) {
return jdbcClient.sql("""
UPDATE community.posts SET bookmark_count = bookmark_count + :delta
WHERE id = :id
RETURNING bookmark_count
""")
.param("id", postId)
.param("delta", delta)
.query(Long.class)
.single();
}
public long bumpCommentCount(UUID postId, int delta) {
return jdbcClient.sql("""
UPDATE community.posts SET comment_count = comment_count + :delta
WHERE id = :id
RETURNING comment_count
""")
.param("id", postId)
.param("delta", delta)
.query(Long.class)
.single();
}
public long likeCount(UUID postId) {
return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id")
.param("id", postId)
.query(Long.class)
.single();
}
public long bookmarkCount(UUID postId) {
return jdbcClient.sql("SELECT bookmark_count FROM community.posts WHERE id = :id")
.param("id", postId)
.query(Long.class)
.single();
}
/** @return rows inserted — 0 when the follow already existed */
public int insertFollow(UUID followerUserId, UUID followeeUserId) {
return jdbcClient.sql("""
INSERT INTO community.user_follows (follower_user_id, followee_user_id)
VALUES (:follower, :followee)
ON CONFLICT (follower_user_id, followee_user_id) DO NOTHING
""")
.param("follower", followerUserId)
.param("followee", followeeUserId)
.update();
}
/** @return rows deleted — 0 when there was nothing to cancel */
public int deleteFollow(UUID followerUserId, UUID followeeUserId) {
return jdbcClient.sql("""
DELETE FROM community.user_follows
WHERE follower_user_id = :follower AND followee_user_id = :followee
""")
.param("follower", followerUserId)
.param("followee", followeeUserId)
.update();
}
/** Real-time follower count of a user — ix_user_follows_followee. */
public long countFollowers(UUID userId) {
return jdbcClient.sql("""
SELECT count(*) FROM community.user_follows
WHERE followee_user_id = :userId
""")
.param("userId", userId)
.query(Long.class)
.single();
}
/** Real-time following count of a user — the primary key prefix. */
public long countFollowing(UUID userId) {
return jdbcClient.sql("""
SELECT count(*) FROM community.user_follows
WHERE follower_user_id = :userId
""")
.param("userId", userId)
.query(Long.class)
.single();
}
public boolean followExists(UUID followerUserId, UUID followeeUserId) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM community.user_follows
WHERE follower_user_id = :follower
AND followee_user_id = :followee)
""")
.param("follower", followerUserId)
.param("followee", followeeUserId)
.query(Boolean.class)
.single();
}
}
@@ -1,5 +1,7 @@
package com.patbond.patbond.community.repository; 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 com.patbond.patbond.community.support.PostCursor;
import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -200,6 +202,74 @@ public class PostRepository {
return spec.query(PostRepository::mapPost).list(); 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) { public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
jdbcClient.sql(""" jdbcClient.sql("""
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption) INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
@@ -321,4 +391,8 @@ public class PostRepository {
Integer widthPx, Integer widthPx,
Integer heightPx) { Integer heightPx) {
} }
/** A bookmarked post plus the relation row's timestamp (the page key). */
public record BookmarkedPostRow(PostRow post, OffsetDateTime bookmarkedAt) {
}
} }
@@ -0,0 +1,177 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.UserExistenceGateway;
import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.dto.CommentResponse;
import com.patbond.patbond.community.dto.CreateCommentRequest;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.repository.CommentRepository;
import com.patbond.patbond.community.repository.CommentRepository.CommentRow;
import com.patbond.patbond.community.repository.InteractionRepository;
import com.patbond.patbond.community.support.CommentCursor;
import com.patbond.patbond.community.support.RequestHashes;
import com.patbond.patbond.community.support.UuidV7;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Flat comments (T3-07 定型). The semantics fixed here are T3-10 freeze
* input:
*
* <ul>
* <li><b>Interaction surface</b> — comments attach to the PUBLIC face of
* a post only: published and live. A draft (its author included),
* hidden/archived or soft-deleted post answers the byte-identical
* 404/40403 on every comment path — 互动域不区分「作者的草稿」.</li>
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory,
* stored as client_request_id next to the normalized request hash;
* same key + same payload returns the first comment (201 again),
* different payload 40905, keys scoped per author. A replay hitting
* a since-deleted first comment answers 404/40404 (T3-04 §2.4
* 同一先例).</li>
* <li><b>Delete</b> — the comment's author onlyD3-7 拍板:帖主删他人
* 评论首版不做); a non-author on a visible comment gets 403/40301,
* everything invisible (absent, deleted, its post invisible) merges
* into 404/40404. comment_count moves -1 in the same transaction,
* exactly once — the FOR UPDATE lock serializes double deletes.</li>
* </ul>
*/
@Service
public class CommentService {
private final CommentRepository commentRepository;
private final InteractionRepository interactionRepository;
private final UserExistenceGateway userExistenceGateway;
private final AuthorProfileGateway authorProfileGateway;
public CommentService(CommentRepository commentRepository,
InteractionRepository interactionRepository,
UserExistenceGateway userExistenceGateway,
AuthorProfileGateway authorProfileGateway) {
this.commentRepository = commentRepository;
this.interactionRepository = interactionRepository;
this.userExistenceGateway = userExistenceGateway;
this.authorProfileGateway = authorProfileGateway;
}
@Transactional(readOnly = true)
public CursorPage<CommentResponse> list(UUID postId, int limit, String cursor) {
requireInteractable(postId);
CommentCursor after = cursor == null ? null : CommentCursor.decode(cursor);
List<CommentRow> rows = commentRepository.pageByPost(postId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<CommentRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new CommentCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(assemble(page), nextCursor, hasMore);
}
@Transactional
public CommentResponse create(UUID userId, UUID postId, String idempotencyKey,
CreateCommentRequest request) {
String key = normalizeIdempotencyKey(idempotencyKey);
String content = requireContent(request.getContent());
requireInteractable(postId);
if (request.getReplyToUserId() != null
&& !userExistenceGateway.existsActive(request.getReplyToUserId())) {
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
}
byte[] requestHash = RequestHashes.sha256(
canonicalize(postId, content, request.getReplyToUserId()));
UUID id = UuidV7.generate();
int inserted = commentRepository.insertComment(id, postId, userId,
request.getReplyToUserId(), content, key, requestHash);
if (inserted == 0) {
CommentRow first = commentRepository.findByAuthorAndClientRequestId(userId, key)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
if (!Arrays.equals(first.requestHash(), requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
}
if (first.deletedAt() != null) {
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
}
return assemble(List.of(first)).get(0);
}
interactionRepository.bumpCommentCount(postId, 1);
CommentRow row = commentRepository.lockVisibleById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return assemble(List.of(row)).get(0);
}
@Transactional
public void delete(UUID userId, UUID commentId) {
CommentRow comment = commentRepository.lockVisibleById(commentId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND));
if (!interactionRepository.isInteractable(comment.postId())) {
throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND);
}
if (!comment.authorUserId().equals(userId)) {
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
}
commentRepository.softDelete(commentId);
interactionRepository.bumpCommentCount(comment.postId(), -1);
}
private void requireInteractable(UUID postId) {
if (!interactionRepository.isInteractable(postId)) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
}
private static String normalizeIdempotencyKey(String idempotencyKey) {
String key = idempotencyKey == null ? "" : idempotencyKey.trim();
if (key.isEmpty() || key.length() > 128) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"Idempotency-Key 必带且长度须在 1~128 字符");
}
return key;
}
private static String requireContent(String content) {
String trimmed = content == null ? "" : content.trim();
if (trimmed.isEmpty() || trimmed.length() > 2000) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~2000 字符");
}
return trimmed;
}
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
private static String canonicalize(UUID postId, String content, UUID replyToUserId) {
return "comment.v1\n" + postId + '\n'
+ (replyToUserId == null ? "" : replyToUserId) + '\n'
+ content + '\n';
}
private List<CommentResponse> assemble(List<CommentRow> rows) {
Set<UUID> userIds = new HashSet<>();
for (CommentRow row : rows) {
userIds.add(row.authorUserId());
if (row.replyToUserId() != null) {
userIds.add(row.replyToUserId());
}
}
Map<UUID, AuthorSummaryResponse> profiles = authorProfileGateway.summarize(userIds);
return rows.stream().map(row -> new CommentResponse(
row.id(),
row.postId(),
profiles.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.replyToUserId() == null ? null
: profiles.getOrDefault(row.replyToUserId(),
AuthorSummaryResponse.idOnly(row.replyToUserId())),
row.content(),
row.createdAt())).toList();
}
}
@@ -0,0 +1,142 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.FeedCardResponse;
import com.patbond.patbond.community.dto.PostMediaItemResponse;
import com.patbond.patbond.community.media.MediaUrlSigner;
import com.patbond.patbond.community.repository.PostRepository;
import com.patbond.patbond.community.repository.PostRepository.BookmarkedPostRow;
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
import com.patbond.patbond.community.repository.PostRepository.PostRow;
import com.patbond.patbond.community.support.BookmarkCursor;
import com.patbond.patbond.community.support.FeedCursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* The public feed (T3-05): keyset pagination over the ix_posts_feed key
* (published_at DESC, id DESC), cards assembled from the posts row (counts
* come from the denormalized like/comment/bookmark_count columns — the
* writers of T3-06/T3-07 maintain them in the same transaction as the
* relation rows), the cover media item, the viewer's liked/bookmarked flags
* and the D3-9 author summary. Everything is batch: one page query, one
* media query, at most one profile call — no per-card work.
*/
@Service
public class FeedService {
/** Frozen preview rule: the first 200 Unicode code points, verbatim. */
static final int PREVIEW_CODE_POINTS = 200;
private final PostRepository postRepository;
private final MediaUrlSigner mediaUrlSigner;
private final AuthorProfileGateway authorProfileGateway;
public FeedService(PostRepository postRepository, MediaUrlSigner mediaUrlSigner,
AuthorProfileGateway authorProfileGateway) {
this.postRepository = postRepository;
this.mediaUrlSigner = mediaUrlSigner;
this.authorProfileGateway = authorProfileGateway;
}
@Transactional(readOnly = true)
public CursorPage<FeedCardResponse> list(UUID viewerId, int limit, String cursor) {
FeedCursor after = cursor == null ? null : FeedCursor.decode(cursor);
List<PostRow> rows = postRepository.pageFeed(viewerId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new FeedCursor(page.get(limit - 1).publishedAt(), page.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(assembleCards(page), nextCursor, hasMore);
}
/**
* My-bookmarks page (T3-07): the item IS the feed card(草案定型:项
* 形态复用 Feed 卡片), the order and cursor key on the bookmark
* relation row, and posts that turned invisible since bookmarking are
* silently dropped inside the page query — the same public-face
* predicate the feed uses, so a card here never breaks the
* publishedAt-non-null invariant.
*/
@Transactional(readOnly = true)
public CursorPage<FeedCardResponse> listBookmarked(UUID userId, int limit, String cursor) {
BookmarkCursor after = cursor == null ? null : BookmarkCursor.decode(cursor);
List<BookmarkedPostRow> rows = postRepository.pageBookmarked(userId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<BookmarkedPostRow> page = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new BookmarkCursor(page.get(limit - 1).bookmarkedAt(),
page.get(limit - 1).post().id()).encode()
: null;
return new CursorPage<>(assembleCards(page.stream().map(BookmarkedPostRow::post).toList()),
nextCursor, hasMore);
}
private List<FeedCardResponse> assembleCards(List<PostRow> rows) {
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
.stream()
.collect(Collectors.groupingBy(PostMediaRow::postId));
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
return rows.stream().map(row -> {
List<PostMediaRow> media = mediaByPost.getOrDefault(row.id(), List.of());
return new FeedCardResponse(
row.id(),
authors.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.category(),
row.title(),
preview(row.content()),
coverOf(media),
media.size(),
row.likeCount(),
row.commentCount(),
row.bookmarkCount(),
row.likedByMe(),
row.bookmarkedByMe(),
row.publishedAt());
}).toList();
}
/**
* The is_cover row (unique per post, and present whenever media exist —
* T3-04 §2.6 sets it on position 0 when the author picked none).
*/
private PostMediaItemResponse coverOf(List<PostMediaRow> media) {
return media.stream()
.filter(PostMediaRow::isCover)
.findFirst()
.map(m -> new PostMediaItemResponse(
m.assetId(),
m.position(),
m.isCover(),
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
m.widthPx(),
m.heightPx(),
m.caption()))
.orElse(null);
}
/**
* Preview = the first {@value #PREVIEW_CODE_POINTS} code points of the
* stored content, cut on a code-point boundary (no surrogate is ever
* split), no ellipsis appended — whether the card is a truncation is
* the client's call via {@code contentPreview.length} vs its own
* rendering, and the full text always comes from the detail endpoint.
*/
static String preview(String content) {
if (content.codePointCount(0, content.length()) <= PREVIEW_CODE_POINTS) {
return content;
}
return content.substring(0, content.offsetByCodePoints(0, PREVIEW_CODE_POINTS));
}
}
@@ -0,0 +1,67 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.UserExistenceGateway;
import com.patbond.patbond.community.dto.FollowStateResponse;
import com.patbond.patbond.community.dto.FollowStatsResponse;
import com.patbond.patbond.community.repository.InteractionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* The ADR-018 minimal follow surface: follow/unfollow (PUT/DELETE
* idempotent on the composite primary key, ADR-019) plus the follow-stats
* numbers. Counts are real-time COUNTs — user_follows carries no
* denormalized counters, and both directions ride an index. The target
* must be an existing active user (404/40406, absent and 注销 merged);
* following oneself is 422/42204 on PUT (ck_user_follows_self is the
* database backstop), while DELETE stays a plain idempotent no-op — a
* self-follow row cannot exist, so the authoritative false is the truth.
*/
@Service
public class FollowService {
private final InteractionRepository interactionRepository;
private final UserExistenceGateway userExistenceGateway;
public FollowService(InteractionRepository interactionRepository,
UserExistenceGateway userExistenceGateway) {
this.interactionRepository = interactionRepository;
this.userExistenceGateway = userExistenceGateway;
}
@Transactional
public FollowStateResponse follow(UUID userId, UUID targetUserId) {
if (userId.equals(targetUserId)) {
throw new BusinessException(ErrorCode.FOLLOW_RULE_VIOLATION);
}
requireActive(targetUserId);
interactionRepository.insertFollow(userId, targetUserId);
return new FollowStateResponse(true, interactionRepository.countFollowers(targetUserId));
}
@Transactional
public FollowStateResponse unfollow(UUID userId, UUID targetUserId) {
requireActive(targetUserId);
interactionRepository.deleteFollow(userId, targetUserId);
return new FollowStateResponse(false, interactionRepository.countFollowers(targetUserId));
}
@Transactional(readOnly = true)
public FollowStatsResponse stats(UUID viewerId, UUID targetUserId) {
requireActive(targetUserId);
return new FollowStatsResponse(
interactionRepository.countFollowers(targetUserId),
interactionRepository.countFollowing(targetUserId),
interactionRepository.followExists(viewerId, targetUserId));
}
private void requireActive(UUID targetUserId) {
if (!userExistenceGateway.existsActive(targetUserId)) {
throw new BusinessException(ErrorCode.TARGET_USER_NOT_FOUND);
}
}
}
@@ -0,0 +1,78 @@
package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.dto.BookmarkStateResponse;
import com.patbond.patbond.community.dto.LikeStateResponse;
import com.patbond.patbond.community.repository.InteractionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* Binary post interactions (T3-06, ADR-019): PUT/DELETE are idempotent by
* construction — the relation row's composite primary key is the
* idempotency key, the counter column moves in the same transaction and
* only by the number of rows the relation write actually changed, so
* concurrent duplicates converge (N concurrent PUTs land exactly one row
* and exactly +1) and every response carries the authoritative terminal
* state. The interaction gate is the post's public face: anything not
* published-and-live answers the byte-identical 404/40403 on PUT and
* DELETE alike.
*/
@Service
public class InteractionService {
private final InteractionRepository interactionRepository;
public InteractionService(InteractionRepository interactionRepository) {
this.interactionRepository = interactionRepository;
}
@Transactional
public LikeStateResponse like(UUID userId, UUID postId) {
requireInteractable(postId);
int inserted = interactionRepository.insertLike(postId, userId);
long count = inserted > 0
? interactionRepository.bumpLikeCount(postId, inserted)
: interactionRepository.likeCount(postId);
return new LikeStateResponse(true, count);
}
@Transactional
public LikeStateResponse unlike(UUID userId, UUID postId) {
requireInteractable(postId);
int deleted = interactionRepository.deleteLike(postId, userId);
long count = deleted > 0
? interactionRepository.bumpLikeCount(postId, -deleted)
: interactionRepository.likeCount(postId);
return new LikeStateResponse(false, count);
}
@Transactional
public BookmarkStateResponse bookmark(UUID userId, UUID postId) {
requireInteractable(postId);
int inserted = interactionRepository.insertBookmark(postId, userId);
long count = inserted > 0
? interactionRepository.bumpBookmarkCount(postId, inserted)
: interactionRepository.bookmarkCount(postId);
return new BookmarkStateResponse(true, count);
}
@Transactional
public BookmarkStateResponse unbookmark(UUID userId, UUID postId) {
requireInteractable(postId);
int deleted = interactionRepository.deleteBookmark(postId, userId);
long count = deleted > 0
? interactionRepository.bumpBookmarkCount(postId, -deleted)
: interactionRepository.bookmarkCount(postId);
return new BookmarkStateResponse(false, count);
}
private void requireInteractable(UUID postId) {
if (!interactionRepository.isInteractable(postId)) {
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
}
}
}
@@ -3,6 +3,8 @@ package com.patbond.patbond.community.service;
import com.patbond.patbond.common.error.BusinessException; import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode; import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.PetVisibilityGateway; 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.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest; import com.patbond.patbond.community.dto.PostMediaAttachRequest;
@@ -65,13 +67,16 @@ public class PostService {
private final MediaAssetGateway mediaAssetGateway; private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner; private final MediaUrlSigner mediaUrlSigner;
private final PetVisibilityGateway petVisibilityGateway; private final PetVisibilityGateway petVisibilityGateway;
private final AuthorProfileGateway authorProfileGateway;
public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway, public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway) { MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway,
AuthorProfileGateway authorProfileGateway) {
this.postRepository = postRepository; this.postRepository = postRepository;
this.mediaAssetGateway = mediaAssetGateway; this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner; this.mediaUrlSigner = mediaUrlSigner;
this.petVisibilityGateway = petVisibilityGateway; this.petVisibilityGateway = petVisibilityGateway;
this.authorProfileGateway = authorProfileGateway;
} }
@Transactional @Transactional
@@ -374,9 +379,12 @@ public class PostService {
.findMediaByPostIds(rows.stream().map(PostRow::id).toList()) .findMediaByPostIds(rows.stream().map(PostRow::id).toList())
.stream() .stream()
.collect(Collectors.groupingBy(PostMediaRow::postId)); .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( return rows.stream().map(row -> new PostResponse(
row.id(), row.id(),
row.authorUserId(), authors.getOrDefault(row.authorUserId(),
AuthorSummaryResponse.idOnly(row.authorUserId())),
row.petId(), row.petId(),
row.category(), row.category(),
row.title(), row.title(),
@@ -0,0 +1,46 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor of the my-bookmarks list (bookmarks.created_at DESC,
* post_id DESC — the exact key of ix_post_bookmarks_user_created). The key
* lives on the RELATION row, not the post: a bookmarked post that later
* turns invisible is filtered inside the same keyset query, so pages stay
* complete and the cursor never points at a value the client saw filtered.
* Encoding is the shared base64url("epochMicros:id") shape.
*/
public record BookmarkCursor(OffsetDateTime bookmarkedAt, UUID postId) {
public String encode() {
long micros = Math.multiplyExact(bookmarkedAt.toInstant().getEpochSecond(), 1_000_000L)
+ bookmarkedAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + postId).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static BookmarkCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID postId = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime bookmarkedAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new BookmarkCursor(bookmarkedAt, postId);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,45 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor of a post's comment list (created_at DESC, id DESC — the
* exact key of ix_comments_post_created), same encoding as
* {@link PostCursor}: base64url("epochMicros:id"), next page selects
* {@code (created_at, id) < (cursor)} so ties on created_at are broken by
* id and rows are neither lost nor repeated across page boundaries.
*/
public record CommentCursor(OffsetDateTime createdAt, UUID id) {
public String encode() {
long micros = Math.multiplyExact(createdAt.toInstant().getEpochSecond(), 1_000_000L)
+ createdAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static CommentCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID id = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime createdAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new CommentCursor(createdAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,46 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Opaque cursor of the public feed (published_at DESC, id DESC — the exact
* key of ix_posts_feed), same encoding as {@link PostCursor}:
* base64url("epochMicros:id"), next page selects
* {@code (published_at, id) < (cursor)} so ties on published_at are broken
* by id and rows are neither lost nor repeated across page boundaries.
* timestamptz carries microseconds, so the micros encoding is lossless.
*/
public record FeedCursor(OffsetDateTime publishedAt, UUID id) {
public String encode() {
long micros = Math.multiplyExact(publishedAt.toInstant().getEpochSecond(), 1_000_000L)
+ publishedAt.getNano() / 1_000L;
return Base64.getUrlEncoder().withoutPadding()
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
}
/** @throws BusinessException 40000 when the cursor is not one we issued */
public static FeedCursor decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
int sep = raw.indexOf(':');
long micros = Long.parseLong(raw.substring(0, sep));
UUID id = UUID.fromString(raw.substring(sep + 1));
OffsetDateTime publishedAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new FeedCursor(publishedAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -20,6 +20,16 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。 # 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:} 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: media:
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4 # 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
# 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。 # 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。
@@ -0,0 +1,146 @@
package com.patbond.patbond.community.author;
import com.patbond.patbond.community.TestcontainersConfiguration;
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.TestJwtKeys;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The real Feign wiring against an in-test HTTP server standing in for
* patbond-user: static URL resolution, the X-Internal-Token interceptor,
* query-string batching, envelope decoding, avatar resolution through
* media.assets plus URL signing — and degradation when the downstream
* answers an error. (The /internal endpoint itself is tested in the
* patbond-user module; the DB-backed stub covers the service-level tests.)
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class AuthorProfileClientWireTest {
private static final HttpServer SERVER;
private static final AtomicReference<String> RESPONSE_BODY = new AtomicReference<>("");
private static final AtomicInteger RESPONSE_STATUS = new AtomicInteger(200);
private static final AtomicReference<String> SEEN_TOKEN = new AtomicReference<>();
private static final AtomicReference<String> SEEN_QUERY = new AtomicReference<>();
static {
try {
SERVER = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
} catch (IOException e) {
throw new IllegalStateException(e);
}
SERVER.createContext("/internal/users/profiles", exchange -> {
SEEN_TOKEN.set(exchange.getRequestHeaders().getFirst("X-Internal-Token"));
SEEN_QUERY.set(exchange.getRequestURI().getRawQuery());
byte[] body = RESPONSE_BODY.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(RESPONSE_STATUS.get(), body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
SERVER.start();
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.user-service.url",
() -> "http://127.0.0.1:" + SERVER.getAddress().getPort());
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
@AfterAll
static void stopServer() {
SERVER.stop(0);
}
@Autowired
private AuthorProfileGateway gateway;
@Autowired
private JdbcClient jdbcClient;
@BeforeEach
void resetServer() {
RESPONSE_STATUS.set(200);
RESPONSE_BODY.set("{\"code\":0,\"message\":\"success\",\"data\":[]}");
SEEN_TOKEN.set(null);
SEEN_QUERY.set(null);
}
private UUID newUser() {
return CommunityTestData.insertUser(jdbcClient,
"w" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
}
@Test
void presentsTheServiceSecretAndBatchesIdsIntoOneQuery() {
UUID userA = UUID.randomUUID();
UUID userB = UUID.randomUUID();
RESPONSE_BODY.set("""
{"code":0,"message":"success","data":[
{"userId":"%s","nickname":"小白","avatarAssetId":null}
]}""".formatted(userA));
Map<UUID, AuthorSummaryResponse> summaries =
gateway.summarize(java.util.List.of(userA, userB));
assertThat(SEEN_TOKEN.get()).isEqualTo("test-internal-token");
String ids = URLDecoder.decode(SEEN_QUERY.get(), StandardCharsets.UTF_8)
.replaceFirst("^ids=", "");
assertThat(ids.split(",")).containsExactlyInAnyOrder(
userA.toString(), userB.toString());
assertThat(summaries).containsOnlyKeys(userA);
assertThat(summaries.get(userA).nickname()).isEqualTo("小白");
assertThat(summaries.get(userA).avatarUrl()).isNull();
}
@Test
void resolvesTheAvatarAssetLocallyAndSignsTheUrl() {
UUID owner = newUser();
UUID assetId = CommunityTestData.insertReadyAsset(jdbcClient, owner);
RESPONSE_BODY.set("""
{"code":0,"message":"success","data":[
{"userId":"%s","nickname":"有头像","avatarAssetId":"%s"}
]}""".formatted(owner, assetId));
AuthorSummaryResponse summary = gateway.summarize(java.util.List.of(owner)).get(owner);
assertThat(summary.nickname()).isEqualTo("有头像");
assertThat(summary.avatarUrl())
.contains(assetId.toString())
.contains("X-Amz-Signature");
}
@Test
void aDownstreamErrorDegradesToNoSummaries() {
RESPONSE_STATUS.set(500);
RESPONSE_BODY.set("{\"code\":50000,\"message\":\"boom\",\"data\":null}");
assertThat(gateway.summarize(java.util.List.of(UUID.randomUUID()))).isEmpty();
}
}
@@ -0,0 +1,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);
// 发布草稿(draft→published 唯一开放迁移)
String published = verified(authed(patch("/api/v1/posts/{postId}", draftId), author)
.content("{\"version\":0,\"status\":\"published\"}"),
"PATCH", "/api/v1/posts/{postId}", 200);
assertThat((String) JsonPath.read(published, "$.data.status")).isEqualTo("published");
assertThat((Object) JsonPath.read(published, "$.data.publishedAt")).isNotNull();
// 我的帖子列表:keyset 翻页两态 + status 过滤
String page1 = verified(get("/api/v1/me/posts").param("limit", "1")
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/posts", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
assertThat(cursor).as("hasMore=true 时 nextCursor 非空").isNotNull();
verified(get("/api/v1/me/posts").param("limit", "1").param("cursor", cursor)
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/posts", 200);
verified(get("/api/v1/me/posts").param("status", "draft")
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/posts", 200);
// 软删(VoidEnvelope
String victim = newPublishedPost(author, "契约待删帖");
verified(authed(delete("/api/v1/posts/{postId}", victim), author),
"DELETE", "/api/v1/posts/{postId}", 200);
}
@Test
@Order(2)
void feedSuccessShapes() throws Exception {
UUID author = newUser();
UUID reader = newUser();
UUID asset = CommunityTestData.insertReadyAsset(jdbcClient, author);
// 有封面与纯文字两种卡片形态(coverImage 的 allOf 非空/null 两分支)
newPost(author, """
{"title":"契约图帖","content":"Feed 封面卡片","status":"published",
"media":[{"assetId":"%s","isCover":true}]}
""".formatted(asset));
newPublishedPost(author, "Feed 纯文字卡片");
String page1 = verified(get("/api/v1/feed").param("limit", "1")
.header("Authorization", "Bearer " + token(reader)),
"GET", "/api/v1/feed", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
verified(get("/api/v1/feed").param("cursor", cursor)
.header("Authorization", "Bearer " + token(reader)),
"GET", "/api/v1/feed", 200);
}
@Test
@Order(3)
void commentSuccessShapes() throws Exception {
UUID author = newUser();
UUID commenter = newUser();
String postId = newPublishedPost(author, "契约评论帖");
// 普通评论与 @ 回复(replyToUser 的 allOf null/非空两分支)
newComment(commenter, postId, "普通评论");
verified(authed(post("/api/v1/posts/{postId}/comments", postId), author)
.header(IDEMPOTENCY_KEY, UUID.randomUUID().toString())
.content("""
{"content":"@ 回复","replyToUserId":"%s"}
""".formatted(commenter)),
"POST", "/api/v1/posts/{postId}/comments", 201);
String page1 = verified(get("/api/v1/posts/{postId}/comments", postId)
.param("limit", "1")
.header("Authorization", "Bearer " + token(commenter)),
"GET", "/api/v1/posts/{postId}/comments", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
verified(get("/api/v1/posts/{postId}/comments", postId)
.param("cursor", cursor)
.header("Authorization", "Bearer " + token(commenter)),
"GET", "/api/v1/posts/{postId}/comments", 200);
// 作者软删自己的评论(VoidEnvelope
String commentId = newComment(commenter, postId, "待删评论");
verified(authed(delete("/api/v1/comments/{commentId}", commentId), commenter),
"DELETE", "/api/v1/comments/{commentId}", 200);
}
@Test
@Order(4)
void interactionSuccessShapes() throws Exception {
UUID author = newUser();
UUID actor = newUser();
String postA = newPublishedPost(author, "契约互动帖 A");
String postB = newPublishedPost(author, "契约互动帖 B");
// PUT/DELETE 权威终态(重复 PUT 同格,幂等语义顺带实证)
verified(authed(put("/api/v1/posts/{postId}/like", postA), actor),
"PUT", "/api/v1/posts/{postId}/like", 200);
String likedAgain = verified(authed(put("/api/v1/posts/{postId}/like", postA), actor),
"PUT", "/api/v1/posts/{postId}/like", 200);
assertThat((Boolean) JsonPath.read(likedAgain, "$.data.liked")).isTrue();
assertThat((Integer) JsonPath.read(likedAgain, "$.data.likeCount")).isEqualTo(1);
verified(authed(delete("/api/v1/posts/{postId}/like", postA), actor),
"DELETE", "/api/v1/posts/{postId}/like", 200);
verified(authed(put("/api/v1/posts/{postId}/bookmark", postA), actor),
"PUT", "/api/v1/posts/{postId}/bookmark", 200);
verified(authed(put("/api/v1/posts/{postId}/bookmark", postB), actor),
"PUT", "/api/v1/posts/{postId}/bookmark", 200);
String page1 = verified(get("/api/v1/me/bookmarks").param("limit", "1")
.header("Authorization", "Bearer " + token(actor)),
"GET", "/api/v1/me/bookmarks", 200);
assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
String cursor = JsonPath.read(page1, "$.data.nextCursor");
verified(get("/api/v1/me/bookmarks").param("cursor", cursor)
.header("Authorization", "Bearer " + token(actor)),
"GET", "/api/v1/me/bookmarks", 200);
verified(authed(delete("/api/v1/posts/{postId}/bookmark", postB), actor),
"DELETE", "/api/v1/posts/{postId}/bookmark", 200);
}
@Test
@Order(5)
void followSuccessShapes() throws Exception {
UUID follower = newUser();
UUID followee = newUser();
String followed = verified(authed(put("/api/v1/users/{userId}/follow", followee), follower),
"PUT", "/api/v1/users/{userId}/follow", 200);
assertThat((Boolean) JsonPath.read(followed, "$.data.following")).isTrue();
String stats = verified(get("/api/v1/users/{userId}/follow-stats", followee)
.header("Authorization", "Bearer " + token(follower)),
"GET", "/api/v1/users/{userId}/follow-stats", 200);
assertThat((Boolean) JsonPath.read(stats, "$.data.followedByMe")).isTrue();
// 查自己:followedByMe 恒 false 分支
verified(get("/api/v1/users/{userId}/follow-stats", follower)
.header("Authorization", "Bearer " + token(follower)),
"GET", "/api/v1/users/{userId}/follow-stats", 200);
verified(authed(delete("/api/v1/users/{userId}/follow", followee), follower),
"DELETE", "/api/v1/users/{userId}/follow", 200);
// 取消不存在的关注:幂等 no-op 仍 200 权威 false
String unfollowedAgain = verified(
authed(delete("/api/v1/users/{userId}/follow", followee), follower),
"DELETE", "/api/v1/users/{userId}/follow", 200);
assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse();
}
// ---- 错误信封 ------------------------------------------------------
@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,363 @@
package com.patbond.patbond.community.interaction;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.post.PostApiTestBase;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-07 flat comments on the real database: the six canonical paths, the
* ADR-019 keyed-idempotency matrix, the interaction-surface 40403 merge,
* DESC keyset pagination and the same-transaction comment_count invariant.
* These assertions are T3-10 freeze input for the comment domain.
*/
class CommentIntegrationTest extends PostApiTestBase {
@Test
void createCommentReturnsFullShapeAndBumpsCount() throws Exception {
UUID author = newUser();
UUID commenter = newUser();
CommunityTestData.setNickname(jdbcClient, commenter, "毛豆妈");
String postId = publishPost(author);
mockMvc.perform(commentRequest(commenter, postId, UUID.randomUUID().toString(),
"{\"content\": \" 说得好! \"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.postId").value(postId))
.andExpect(jsonPath("$.data.author.userId").value(commenter.toString()))
.andExpect(jsonPath("$.data.author.nickname").value("毛豆妈"))
.andExpect(jsonPath("$.data.replyToUser").isEmpty())
.andExpect(jsonPath("$.data.content").value("说得好!"))
.andExpect(jsonPath("$.data.createdAt").isNotEmpty());
mockMvc.perform(authed(get("/api/v1/posts/" + postId), author))
.andExpect(jsonPath("$.data.commentCount").value(1));
}
@Test
void createWithReplyToUserCarriesReplySummary() throws Exception {
UUID author = newUser();
UUID replyTarget = newUser();
CommunityTestData.setNickname(jdbcClient, replyTarget, "被@的人");
String postId = publishPost(author);
mockMvc.perform(commentRequest(author, postId, UUID.randomUUID().toString(),
"{\"content\": \"回复你\", \"replyToUserId\": \"" + replyTarget + "\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.replyToUser.userId").value(replyTarget.toString()))
.andExpect(jsonPath("$.data.replyToUser.nickname").value("被@的人"));
}
@Test
void contentAndKeyValidationAnswer40000() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
"{\"content\": \" \"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
"{\"content\": \"" + "".repeat(2001) + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// Idempotency-Key: missing header, blank, oversized
mockMvc.perform(authed(post("/api/v1/posts/" + postId + "/comments"), user)
.content("{\"content\": \"没带键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(commentRequest(user, postId, " ", "{\"content\": \"空白键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(commentRequest(user, postId, "k".repeat(129), "{\"content\": \"超长键\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void replyToAbsentOrDeletedUserAnswers40406() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
UUID ghost = UUID.randomUUID();
UUID cancelled = newUser();
jdbcClient.sql("UPDATE identity.users SET status = 'deleted', deleted_at = now()"
+ " WHERE id = :id")
.param("id", cancelled)
.update();
for (UUID target : List.of(ghost, cancelled)) {
mockMvc.perform(commentRequest(user, postId, UUID.randomUUID().toString(),
"{\"content\": \"@不存在\", \"replyToUserId\": \"" + target + "\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406))
.andExpect(jsonPath("$.message").value("用户不存在"));
}
}
@Test
void commentPathsOnInvisiblePostsAnswerIdentical40403() throws Exception {
UUID author = newUser();
UUID stranger = newUser();
String ownDraft = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
String hidden = publishPost(author);
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(hidden))
.update();
String deleted = publishPost(author);
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
Set<String> bodies = new LinkedHashSet<>();
// own draft (the interaction surface is the PUBLIC face — the
// author's own draft is not commentable), hidden, deleted, absent
for (String target : List.of(ownDraft, hidden, deleted, UUID.randomUUID().toString())) {
MvcResult postResult = mockMvc.perform(
commentRequest(author, target, UUID.randomUUID().toString(),
"{\"content\": \"评一下\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
bodies.add(postResult.getResponse().getContentAsString());
MvcResult listResult = mockMvc.perform(
authed(get("/api/v1/posts/" + target + "/comments"), stranger))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
bodies.add(listResult.getResponse().getContentAsString());
}
// anti-enumeration: every invisible case is byte-identical
assertThat(bodies).hasSize(1);
}
@Test
void keyedReplayReturnsFirstCommentWithoutDoubleCounting() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String key = UUID.randomUUID().toString();
String first = data(mockMvc.perform(commentRequest(user, postId, key,
"{\"content\": \"就一条\"}"))
.andExpect(status().isCreated())
.andReturn()).get("id").asText();
String replay = data(mockMvc.perform(commentRequest(user, postId, key,
"{\"content\": \"就一条\"}"))
.andExpect(status().isCreated())
.andReturn()).get("id").asText();
assertThat(replay).isEqualTo(first);
assertThat(countRows("community.comments", "post_id", postId)).isEqualTo(1);
mockMvc.perform(authed(get("/api/v1/posts/" + postId), user))
.andExpect(jsonPath("$.data.commentCount").value(1));
}
@Test
void sameKeyDifferentPayloadAnswers40905() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String key = UUID.randomUUID().toString();
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"\"}"))
.andExpect(status().isCreated());
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40905));
}
@Test
void idempotencyKeysAreScopedPerAuthor() throws Exception {
UUID one = newUser();
UUID two = newUser();
String postId = publishPost(one);
String shared = UUID.randomUUID().toString();
mockMvc.perform(commentRequest(one, postId, shared, "{\"content\": \"同键\"}"))
.andExpect(status().isCreated());
mockMvc.perform(commentRequest(two, postId, shared, "{\"content\": \"同键\"}"))
.andExpect(status().isCreated());
assertThat(countRows("community.comments", "post_id", postId)).isEqualTo(2);
}
@Test
void replayAfterFirstCommentDeletedAnswers40404() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String key = UUID.randomUUID().toString();
String commentId = data(mockMvc.perform(commentRequest(user, postId, key,
"{\"content\": \"将被删\"}"))
.andReturn()).get("id").asText();
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), user))
.andExpect(status().isOk());
mockMvc.perform(commentRequest(user, postId, key, "{\"content\": \"将被删\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
}
@Test
void listPagesNewestFirstWithoutLossOrOverlap() throws Exception {
UUID author = newUser();
UUID reader = newUser();
String postId = publishPost(author);
List<String> created = new ArrayList<>();
for (int i = 0; i < 7; i++) {
created.add(data(mockMvc.perform(commentRequest(author, postId,
UUID.randomUUID().toString(), "{\"content\": \"评论" + i + "\"}"))
.andReturn()).get("id").asText());
}
String deletedId = created.get(3);
mockMvc.perform(authed(delete("/api/v1/comments/" + deletedId), author))
.andExpect(status().isOk());
List<String> seen = new ArrayList<>();
String cursor = null;
for (int page = 0; page < 3; page++) {
String url = "/api/v1/posts/" + postId + "/comments?limit=3"
+ (cursor == null ? "" : "&cursor=" + cursor);
JsonNode body = data(mockMvc.perform(authed(get(url), reader))
.andExpect(status().isOk())
.andReturn());
body.get("items").forEach(item -> seen.add(item.get("id").asText()));
if (!body.get("hasMore").asBoolean()) {
assertThat(body.get("nextCursor").isNull()).isTrue();
break;
}
cursor = body.get("nextCursor").asText();
}
List<String> expected = new ArrayList<>(created);
java.util.Collections.reverse(expected);
expected.remove(deletedId);
assertThat(seen).containsExactlyElementsOf(expected);
}
@Test
void listRejectsBadPagingInput() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments?limit=0"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments?cursor=不是游标"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/posts/不是UUID/comments"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void deleteWalksThePermissionBoundary() throws Exception {
UUID author = newUser();
UUID commenter = newUser();
UUID stranger = newUser();
String postId = publishPost(author);
String commentId = data(mockMvc.perform(commentRequest(commenter, postId,
UUID.randomUUID().toString(), "{\"content\": \"别人的评论\"}"))
.andReturn()).get("id").asText();
// a visible comment deleted by a non-author (the post's owner
// included — D3-7: 帖主删他人评论首版不做) is 403/40301
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), stranger))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), author))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40301));
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), commenter))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
String state = jdbcClient.sql(
"SELECT status || ':' || (deleted_at IS NOT NULL) FROM community.comments"
+ " WHERE id = :id")
.param("id", UUID.fromString(commentId))
.query(String.class)
.single();
assertThat(state).isEqualTo("deleted:true");
// repeat delete and absent id merge into 404/40404
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), commenter))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
mockMvc.perform(authed(delete("/api/v1/comments/" + UUID.randomUUID()), commenter))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
}
@Test
void deleteOnCommentOfDeletedPostAnswers40404() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
String commentId = data(mockMvc.perform(commentRequest(user, postId,
UUID.randomUUID().toString(), "{\"content\": \"帖没了\"}"))
.andReturn()).get("id").asText();
mockMvc.perform(authed(delete("/api/v1/posts/" + postId), user))
.andExpect(status().isOk());
mockMvc.perform(authed(delete("/api/v1/comments/" + commentId), user))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40404));
}
@Test
void commentCountReconcilesWithVisibleRows() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
List<String> ids = new ArrayList<>();
for (int i = 0; i < 3; i++) {
ids.add(data(mockMvc.perform(commentRequest(user, postId,
UUID.randomUUID().toString(), "{\"content\": \"" + i + "\"}"))
.andReturn()).get("id").asText());
}
mockMvc.perform(authed(delete("/api/v1/comments/" + ids.get(0)), user))
.andExpect(status().isOk());
long column = jdbcClient.sql("SELECT comment_count FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
long visible = jdbcClient.sql("""
SELECT count(*) FROM community.comments
WHERE post_id = :id AND status = 'visible'
""")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
assertThat(column).isEqualTo(2).isEqualTo(visible);
mockMvc.perform(authed(get("/api/v1/posts/" + postId + "/comments"), user))
.andExpect(jsonPath("$.data.items.length()").value(2));
}
private String publishPost(UUID author) throws Exception {
return createPost(author, "{\"content\": \"被评论的帖子\", \"status\": \"published\"}")
.get("id").asText();
}
private MockHttpServletRequestBuilder commentRequest(UUID userId, String postId, String key,
String body) {
return authed(post("/api/v1/posts/" + postId + "/comments"), userId)
.header("Idempotency-Key", key)
.content(body);
}
private long countRows(String table, String column, String value) {
return jdbcClient.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :value")
.param("value", UUID.fromString(value))
.query(Long.class)
.single();
}
}
@@ -0,0 +1,156 @@
package com.patbond.patbond.community.interaction;
import com.patbond.patbond.community.post.PostApiTestBase;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-07 minimal follow surface on the real database: idempotent
* follow/unfollow with authoritative state, the 42204 self-follow gate,
* the 40406 target gate (absent and 注销 merged), the numbers endpoint
* and true concurrent convergence on the composite primary key.
*/
class FollowIntegrationTest extends PostApiTestBase {
@Test
void followLifecycleIsIdempotentWithAuthoritativeState() throws Exception {
UUID follower = newUser();
UUID target = newUser();
mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), follower))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.following").value(true))
.andExpect(jsonPath("$.data.followerCount").value(1));
mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), follower))
.andExpect(jsonPath("$.data.following").value(true))
.andExpect(jsonPath("$.data.followerCount").value(1));
mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), follower))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.following").value(false))
.andExpect(jsonPath("$.data.followerCount").value(0));
mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), follower))
.andExpect(jsonPath("$.data.following").value(false))
.andExpect(jsonPath("$.data.followerCount").value(0));
assertThat(followRows(target)).isEqualTo(0);
}
@Test
void selfFollowIsRejectedWith42204() throws Exception {
UUID user = newUser();
mockMvc.perform(authed(put("/api/v1/users/" + user + "/follow"), user))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42204))
.andExpect(jsonPath("$.message").value("不能关注自己"));
// DELETE stays a plain idempotent no-op — the row cannot exist
mockMvc.perform(authed(delete("/api/v1/users/" + user + "/follow"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.following").value(false));
}
@Test
void absentOrCancelledTargetAnswers40406OnEveryPath() throws Exception {
UUID caller = newUser();
UUID ghost = UUID.randomUUID();
UUID cancelled = newUser();
jdbcClient.sql("UPDATE identity.users SET status = 'deleted', deleted_at = now()"
+ " WHERE id = :id")
.param("id", cancelled)
.update();
for (UUID target : List.of(ghost, cancelled)) {
mockMvc.perform(authed(put("/api/v1/users/" + target + "/follow"), caller))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406));
mockMvc.perform(authed(delete("/api/v1/users/" + target + "/follow"), caller))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406));
mockMvc.perform(authed(get("/api/v1/users/" + target + "/follow-stats"), caller))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40406));
}
mockMvc.perform(authed(put("/api/v1/users/不是UUID/follow"), caller))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void followStatsCountBothDirectionsWithViewerFlag() throws Exception {
UUID alice = newUser();
UUID bob = newUser();
UUID carol = newUser();
// alice→bob, carol→bob, bob→alice
mockMvc.perform(authed(put("/api/v1/users/" + bob + "/follow"), alice))
.andExpect(status().isOk());
mockMvc.perform(authed(put("/api/v1/users/" + bob + "/follow"), carol))
.andExpect(status().isOk());
mockMvc.perform(authed(put("/api/v1/users/" + alice + "/follow"), bob))
.andExpect(status().isOk());
mockMvc.perform(authed(get("/api/v1/users/" + bob + "/follow-stats"), alice))
.andExpect(jsonPath("$.data.followerCount").value(2))
.andExpect(jsonPath("$.data.followingCount").value(1))
.andExpect(jsonPath("$.data.followedByMe").value(true));
mockMvc.perform(authed(get("/api/v1/users/" + alice + "/follow-stats"), carol))
.andExpect(jsonPath("$.data.followerCount").value(1))
.andExpect(jsonPath("$.data.followingCount").value(1))
.andExpect(jsonPath("$.data.followedByMe").value(false));
// asking about oneself: followedByMe is definitionally false
mockMvc.perform(authed(get("/api/v1/users/" + bob + "/follow-stats"), bob))
.andExpect(jsonPath("$.data.followerCount").value(2))
.andExpect(jsonPath("$.data.followedByMe").value(false));
}
@Test
void concurrentDuplicateFollowsLandExactlyOneRow() throws Exception {
UUID follower = newUser();
UUID target = newUser();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(3);
try {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < 3; i++) {
results.add(pool.submit(() -> {
start.await();
return mockMvc.perform(
authed(put("/api/v1/users/" + target + "/follow"), follower))
.andReturn().getResponse().getStatus();
}));
}
start.countDown();
for (Future<Integer> result : results) {
assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(200);
}
} finally {
pool.shutdownNow();
}
assertThat(followRows(target)).isEqualTo(1);
mockMvc.perform(authed(get("/api/v1/users/" + target + "/follow-stats"), follower))
.andExpect(jsonPath("$.data.followerCount").value(1));
}
private long followRows(UUID followee) {
return jdbcClient.sql("""
SELECT count(*) FROM community.user_follows
WHERE followee_user_id = :id
""")
.param("id", followee)
.query(Long.class)
.single();
}
}
@@ -0,0 +1,295 @@
package com.patbond.patbond.community.interaction;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.post.PostApiTestBase;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3-06 idempotent like/bookmark on the real database: the authoritative
* terminal-state responses, TRUE concurrent convergence on the composite
* primary key (the M3 acceptance criterion: N concurrent PUTs count
* exactly 1), the 40403 interaction gate, the my-bookmarks keyset list
* with silent removal, and column-vs-relation reconciliation.
*/
class LikeBookmarkIntegrationTest extends PostApiTestBase {
@Test
void likeLifecycleIsIdempotentWithAuthoritativeState() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(true))
.andExpect(jsonPath("$.data.likeCount").value(1));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(true))
.andExpect(jsonPath("$.data.likeCount").value(1));
assertThat(likeRows(postId)).isEqualTo(1);
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(false))
.andExpect(jsonPath("$.data.likeCount").value(0));
// cancelling a like that does not exist neither errors nor
// decrements (工单验收)
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.liked").value(false))
.andExpect(jsonPath("$.data.likeCount").value(0));
assertThat(likeRows(postId)).isEqualTo(0);
}
@Test
void bookmarkLifecycleIsIdempotentWithAuthoritativeState() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.bookmarked").value(true))
.andExpect(jsonPath("$.data.bookmarkCount").value(1));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(jsonPath("$.data.bookmarkCount").value(1));
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(jsonPath("$.data.bookmarked").value(false))
.andExpect(jsonPath("$.data.bookmarkCount").value(0));
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(jsonPath("$.data.bookmarkCount").value(0));
}
@Test
void distinctUsersAccumulateAndSurfaceInDetail() throws Exception {
UUID author = newUser();
UUID other = newUser();
String postId = publishPost(author);
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), author))
.andExpect(jsonPath("$.data.likeCount").value(1));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), other))
.andExpect(jsonPath("$.data.likeCount").value(2));
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), other))
.andExpect(jsonPath("$.data.bookmarkCount").value(1));
mockMvc.perform(authed(get("/api/v1/posts/" + postId), other))
.andExpect(jsonPath("$.data.likeCount").value(2))
.andExpect(jsonPath("$.data.bookmarkCount").value(1))
.andExpect(jsonPath("$.data.likedByMe").value(true))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(true));
mockMvc.perform(authed(get("/api/v1/posts/" + postId), author))
.andExpect(jsonPath("$.data.likedByMe").value(true))
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false));
}
@Test
void interactionsOnInvisiblePostsAnswerIdentical40403() throws Exception {
UUID author = newUser();
String ownDraft = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
String hidden = publishPost(author);
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(hidden))
.update();
String deleted = publishPost(author);
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
Set<String> bodies = new LinkedHashSet<>();
for (String target : List.of(ownDraft, hidden, deleted, UUID.randomUUID().toString())) {
for (String action : List.of("like", "bookmark")) {
MvcResult puts = mockMvc.perform(
authed(put("/api/v1/posts/" + target + "/" + action), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
MvcResult deletes = mockMvc.perform(
authed(delete("/api/v1/posts/" + target + "/" + action), author))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40403))
.andReturn();
bodies.add(puts.getResponse().getContentAsString());
bodies.add(deletes.getResponse().getContentAsString());
}
}
assertThat(bodies).hasSize(1);
}
@Test
void concurrentDuplicatePutsCountExactlyOne() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
List<Future<Integer>> results = new ArrayList<>();
for (int i = 0; i < 4; i++) {
results.add(pool.submit(() -> {
start.await();
return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
}));
}
start.countDown();
for (Future<Integer> result : results) {
assertThat(result.get(30, TimeUnit.SECONDS)).isEqualTo(200);
}
} finally {
pool.shutdownNow();
}
assertThat(likeRows(postId)).isEqualTo(1);
assertThat(likeColumn(postId)).isEqualTo(1);
}
@Test
void concurrentPutAndDeleteConvergeOnConsistentTerminalState() throws Exception {
UUID user = newUser();
String postId = publishPost(user);
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Future<Integer> putting = pool.submit(() -> {
start.await();
return mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
});
Future<Integer> deleting = pool.submit(() -> {
start.await();
return mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), user))
.andReturn().getResponse().getStatus();
});
start.countDown();
assertThat(putting.get(30, TimeUnit.SECONDS)).isEqualTo(200);
assertThat(deleting.get(30, TimeUnit.SECONDS)).isEqualTo(200);
} finally {
pool.shutdownNow();
}
// whichever order the race resolved in, the column agrees with the
// relation table — never a phantom count
assertThat(likeColumn(postId)).isEqualTo(likeRows(postId));
}
@Test
void countColumnsReconcileWithRelationRowsAfterMixedOps() throws Exception {
UUID author = newUser();
UUID second = newUser();
UUID third = newUser();
String postId = publishPost(author);
for (UUID user : List.of(author, second, third)) {
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/like"), user))
.andExpect(status().isOk());
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), user))
.andExpect(status().isOk());
}
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/like"), second))
.andExpect(status().isOk());
mockMvc.perform(authed(delete("/api/v1/posts/" + postId + "/bookmark"), third))
.andExpect(status().isOk());
assertThat(likeColumn(postId)).isEqualTo(2).isEqualTo(likeRows(postId));
long bookmarkColumn = jdbcClient.sql(
"SELECT bookmark_count FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
long bookmarkRows = jdbcClient.sql(
"SELECT count(*) FROM community.post_bookmarks WHERE post_id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
assertThat(bookmarkColumn).isEqualTo(2).isEqualTo(bookmarkRows);
}
@Test
void myBookmarksPagesByBookmarkTimeAndDropsInvisible() throws Exception {
UUID author = newUser();
UUID reader = newUser();
List<String> posts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
posts.add(publishPost(author));
}
for (String postId : posts) {
mockMvc.perform(authed(put("/api/v1/posts/" + postId + "/bookmark"), reader))
.andExpect(status().isOk());
}
// one bookmarked post soft-deleted, one hidden → silently dropped
mockMvc.perform(authed(delete("/api/v1/posts/" + posts.get(1)), author))
.andExpect(status().isOk());
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(posts.get(3)))
.update();
List<String> seen = new ArrayList<>();
String cursor = null;
for (int page = 0; page < 3; page++) {
String url = "/api/v1/me/bookmarks?limit=2"
+ (cursor == null ? "" : "&cursor=" + cursor);
JsonNode body = data(mockMvc.perform(authed(get(url), reader))
.andExpect(status().isOk())
.andReturn());
for (JsonNode item : body.get("items")) {
seen.add(item.get("id").asText());
// the item IS the feed card: cover-less text post, counts,
// viewer flags, non-null publishedAt
assertThat(item.get("bookmarkedByMe").asBoolean()).isTrue();
assertThat(item.get("publishedAt").isNull()).isFalse();
assertThat(item.has("contentPreview")).isTrue();
assertThat(item.has("content")).isFalse();
}
if (!body.get("hasMore").asBoolean()) {
break;
}
cursor = body.get("nextCursor").asText();
}
// bookmark order DESC (posts were bookmarked 0→4), invisible dropped
assertThat(seen).containsExactly(posts.get(4), posts.get(2), posts.get(0));
}
@Test
void myBookmarksRejectsBadPagingInput() throws Exception {
UUID user = newUser();
mockMvc.perform(authed(get("/api/v1/me/bookmarks?limit=101"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(authed(get("/api/v1/me/bookmarks?cursor=损坏"), user))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
private String publishPost(UUID author) throws Exception {
return createPost(author, "{\"content\": \"被互动的帖子\", \"status\": \"published\"}")
.get("id").asText();
}
private long likeRows(String postId) {
return jdbcClient.sql("SELECT count(*) FROM community.post_likes WHERE post_id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
}
private long likeColumn(String postId) {
return jdbcClient.sql("SELECT like_count FROM community.posts WHERE id = :id")
.param("id", UUID.fromString(postId))
.query(Long.class)
.single();
}
}
@@ -0,0 +1,150 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.StubAuthorProfileClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MvcResult;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* AuthorSummary 定型 (D3-9 方案 B / T3-05): the detail response's author
* backfill (closes T3-04 contract deviation #1), the server-side
* nickname→username fallback, avatar URL signing, the short-TTL cache and
* the degrade-don't-5xx semantics when the user service is unreachable.
* The Feign transport itself is covered by AuthorProfileClientWireTest.
*/
class AuthorProfileIntegrationTest extends PostApiTestBase {
@Autowired
private StubAuthorProfileClient stubClient;
@AfterEach
void restoreUserService() {
stubClient.setUnavailable(false);
}
private JsonNode detail(UUID viewer, String postId) throws Exception {
MvcResult result = mockMvc.perform(authed(get("/api/v1/posts/" + postId), viewer))
.andExpect(status().isOk())
.andReturn();
return data(result);
}
@Test
void detailBackfillsTheAuthorSummaryWithTheNickname() throws Exception {
UUID author = newUser();
CommunityTestData.setNickname(jdbcClient, author, "毛毛的铲屎官");
JsonNode post = createPost(author, "{\"content\": \"作者摘要\", \"status\": \"published\"}");
JsonNode summary = detail(newUser(), post.get("id").asText()).get("author");
assertThat(summary.get("userId").asText()).isEqualTo(author.toString());
assertThat(summary.get("nickname").asText()).isEqualTo("毛毛的铲屎官");
assertThat(summary.get("avatarUrl").isNull()).isTrue();
}
@Test
void nicknameFallsBackToUsernameServerSide() throws Exception {
UUID author = newUser();
String username = jdbcClient.sql("SELECT username::text FROM identity.users WHERE id = :id")
.param("id", author)
.query(String.class)
.single();
JsonNode post = createPost(author, "{\"content\": \"回退昵称\", \"status\": \"published\"}");
JsonNode summary = detail(newUser(), post.get("id").asText()).get("author");
assertThat(summary.get("nickname").asText()).isEqualTo(username);
}
@Test
void readyAvatarBecomesASignedUrlAndUnreadyStaysNull() throws Exception {
UUID withReady = newUser();
UUID readyAsset = CommunityTestData.attachAvatar(jdbcClient, withReady, "ready");
UUID withUploading = newUser();
CommunityTestData.attachAvatar(jdbcClient, withUploading, "uploading");
JsonNode readyPost = createPost(withReady, "{\"content\": \"有头像\", \"status\": \"published\"}");
JsonNode uploadingPost = createPost(withUploading, "{\"content\": \"头像未就绪\", \"status\": \"published\"}");
UUID viewer = newUser();
JsonNode readySummary = detail(viewer, readyPost.get("id").asText()).get("author");
assertThat(readySummary.get("avatarUrl").asText())
.contains(readyAsset.toString())
.contains("X-Amz-Signature");
JsonNode uploadingSummary = detail(viewer, uploadingPost.get("id").asText()).get("author");
assertThat(uploadingSummary.get("avatarUrl").isNull()).isTrue();
}
@Test
void secondLookupWithinTheTtlIsServedFromTheCache() throws Exception {
UUID author = newUser();
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "缓存命中");
UUID viewer = newUser();
int before = stubClient.invocationCount();
detail(viewer, postId.toString());
int afterFirst = stubClient.invocationCount();
detail(viewer, postId.toString());
int afterSecond = stubClient.invocationCount();
assertThat(afterFirst - before).isEqualTo(1);
assertThat(afterSecond - afterFirst).isZero();
}
@Test
void unreachableUserServiceDegradesToIdOnlyInsteadOf5xx() throws Exception {
UUID author = newUser();
CommunityTestData.setNickname(jdbcClient, author, "看不见的昵称");
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "降级帖");
stubClient.setUnavailable(true);
JsonNode summary = detail(newUser(), postId.toString()).get("author");
assertThat(summary.get("userId").asText()).isEqualTo(author.toString());
assertThat(summary.get("nickname").isNull()).isTrue();
assertThat(summary.get("avatarUrl").isNull()).isTrue();
}
@Test
void degradedFeedStillServesEveryCard() throws Exception {
UUID author = newUser();
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "降级 Feed");
stubClient.setUnavailable(true);
MvcResult result = mockMvc.perform(
authed(get("/api/v1/feed").queryParam("limit", "100"), newUser()))
.andExpect(status().isOk())
.andReturn();
JsonNode items = data(result).get("items");
JsonNode card = null;
for (JsonNode item : items) {
if (item.get("id").asText().equals(postId.toString())) {
card = item;
}
}
assertThat(card).isNotNull();
assertThat(card.get("author").get("userId").asText()).isEqualTo(author.toString());
assertThat(card.get("author").get("nickname").isNull()).isTrue();
}
@Test
void aFailedLookupIsNotCachedSoTheNextRequestRecovers() throws Exception {
UUID author = newUser();
CommunityTestData.setNickname(jdbcClient, author, "恢复后的昵称");
UUID postId = CommunityTestData.insertPublishedPost(jdbcClient, author, "降级不缓存");
UUID viewer = newUser();
stubClient.setUnavailable(true);
JsonNode degraded = detail(viewer, postId.toString()).get("author");
assertThat(degraded.get("nickname").isNull()).isTrue();
stubClient.setUnavailable(false);
JsonNode recovered = detail(viewer, postId.toString()).get("author");
assertThat(recovered.get("nickname").asText()).isEqualTo("恢复后的昵称");
}
}
@@ -0,0 +1,133 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.patbond.patbond.community.support.CommunityTestData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Feed card field 定型 (T3-05, the FeedCard freeze input): the 200-code-point
* preview rule, cover selection from the unique is_cover row, mediaCount,
* counts read from the posts table's denormalized columns, and the
* viewer-relative flags.
*/
class FeedCardIntegrationTest extends PostApiTestBase {
private UUID viewer;
@BeforeEach
void wipeFeed() {
jdbcClient.sql("DELETE FROM community.posts").update();
viewer = newUser();
}
private JsonNode firstCard() throws Exception {
MvcResult result = mockMvc.perform(authed(get("/api/v1/feed"), viewer))
.andExpect(status().isOk())
.andReturn();
JsonNode items = data(result).get("items");
assertThat(items).hasSize(1);
return items.get(0);
}
@Test
void cardCarriesTheFrozenFieldSetForATextOnlyPost() throws Exception {
UUID author = newUser();
JsonNode post = createPost(author, """
{"title": "卡片字段", "content": "纯文字帖", "category": "help",
"status": "published"}
""");
JsonNode card = firstCard();
assertThat(card.get("id").asText()).isEqualTo(post.get("id").asText());
assertThat(card.get("author").get("userId").asText()).isEqualTo(author.toString());
assertThat(card.get("category").asText()).isEqualTo("help");
assertThat(card.get("title").asText()).isEqualTo("卡片字段");
assertThat(card.get("contentPreview").asText()).isEqualTo("纯文字帖");
assertThat(card.get("coverImage").isNull()).isTrue();
assertThat(card.get("mediaCount").asInt()).isZero();
assertThat(card.get("likeCount").asLong()).isZero();
assertThat(card.get("commentCount").asLong()).isZero();
assertThat(card.get("bookmarkCount").asLong()).isZero();
assertThat(card.get("likedByMe").asBoolean()).isFalse();
assertThat(card.get("bookmarkedByMe").asBoolean()).isFalse();
assertThat(card.get("publishedAt").asText()).contains("T");
// Trimmed relative to Post: no full content, no version, no visibility.
assertThat(card.has("content")).isFalse();
assertThat(card.has("version")).isFalse();
}
@Test
void previewCutsAtTwoHundredCodePointsWithoutSplittingSurrogates() throws Exception {
UUID author = newUser();
String content = "".repeat(199) + "🐱" + "这些字符必须被截掉";
createPost(author,
"{\"content\": \"%s\", \"status\": \"published\"}".formatted(content));
String preview = firstCard().get("contentPreview").asText();
assertThat(preview.codePointCount(0, preview.length())).isEqualTo(200);
assertThat(preview).isEqualTo("".repeat(199) + "🐱");
}
@Test
void shortContentIsPassedThroughVerbatim() throws Exception {
UUID author = newUser();
createPost(author, "{\"content\": \"刚好不截断\", \"status\": \"published\"}");
assertThat(firstCard().get("contentPreview").asText()).isEqualTo("刚好不截断");
}
@Test
void coverIsTheIsCoverRowAndMediaCountTheWholeSet() throws Exception {
UUID author = newUser();
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
createPost(author, """
{"content": "两图帖", "status": "published",
"media": [{"assetId": "%s"}, {"assetId": "%s", "isCover": true}]}
""".formatted(assetA, assetB));
JsonNode card = firstCard();
assertThat(card.get("mediaCount").asInt()).isEqualTo(2);
JsonNode cover = card.get("coverImage");
assertThat(cover.get("assetId").asText()).isEqualTo(assetB.toString());
assertThat(cover.get("isCover").asBoolean()).isTrue();
assertThat(cover.get("url").asText())
.contains(assetB.toString())
.contains("X-Amz-Signature");
}
@Test
void countsComeFromTheDenormalizedColumnsAndFlagsFromTheRelationTables() throws Exception {
UUID author = newUser();
JsonNode post = createPost(author, "{\"content\": \"计数帖\", \"status\": \"published\"}");
UUID postId = UUID.fromString(post.get("id").asText());
jdbcClient.sql("""
UPDATE community.posts
SET like_count = 5, comment_count = 3, bookmark_count = 2
WHERE id = :id
""")
.param("id", postId)
.update();
jdbcClient.sql("""
INSERT INTO community.post_likes (post_id, user_id)
VALUES (:postId, :userId)
""")
.param("postId", postId)
.param("userId", viewer)
.update();
JsonNode card = firstCard();
assertThat(card.get("likeCount").asLong()).isEqualTo(5);
assertThat(card.get("commentCount").asLong()).isEqualTo(3);
assertThat(card.get("bookmarkCount").asLong()).isEqualTo(2);
assertThat(card.get("likedByMe").asBoolean()).isTrue();
assertThat(card.get("bookmarkedByMe").asBoolean()).isFalse();
}
}
@@ -0,0 +1,207 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Feed pagination 专项 (T3-05 工单要求): empty feed, single page, page
* walking with no loss and no duplication (including published_at ties and
* inserts/deletes between page fetches), cursor validity, and the
* visibility predicate. The feed is global state, so every test starts
* from a wiped community.posts (the FK cascades clear media/likes/
* bookmarks); other test classes create their own rows per test and run
* sequentially, so the wipe races nothing.
*/
class FeedPaginationIntegrationTest extends PostApiTestBase {
private UUID viewer;
@BeforeEach
void wipeFeed() {
jdbcClient.sql("DELETE FROM community.posts").update();
viewer = newUser();
}
private MockHttpServletRequestBuilder feed(UUID userId, Integer limit, String cursor) {
MockHttpServletRequestBuilder builder = authed(get("/api/v1/feed"), userId);
if (limit != null) {
builder = builder.queryParam("limit", String.valueOf(limit));
}
if (cursor != null) {
builder = builder.queryParam("cursor", cursor);
}
return builder;
}
private JsonNode feedPage(UUID userId, Integer limit, String cursor) throws Exception {
MvcResult result = mockMvc.perform(feed(userId, limit, cursor))
.andExpect(status().isOk())
.andReturn();
return data(result);
}
private UUID publish(UUID author, String content) throws Exception {
JsonNode post = createPost(author,
"{\"content\": \"%s\", \"status\": \"published\"}".formatted(content));
return UUID.fromString(post.get("id").asText());
}
private List<String> idsOf(JsonNode page) {
List<String> ids = new ArrayList<>();
page.get("items").forEach(item -> ids.add(item.get("id").asText()));
return ids;
}
@Test
void emptyFeedIsAnEmptyPage() throws Exception {
JsonNode page = feedPage(viewer, null, null);
assertThat(page.get("items")).isEmpty();
assertThat(page.get("hasMore").asBoolean()).isFalse();
assertThat(page.get("nextCursor").isNull()).isTrue();
}
@Test
void singlePageListsNewestFirstWithoutACursor() throws Exception {
UUID author = newUser();
UUID first = publish(author, "一号帖");
UUID second = publish(author, "二号帖");
JsonNode page = feedPage(viewer, null, null);
assertThat(idsOf(page)).containsExactly(second.toString(), first.toString());
assertThat(page.get("hasMore").asBoolean()).isFalse();
assertThat(page.get("nextCursor").isNull()).isTrue();
}
@Test
void onlyLivePublishedPublicPostsAppear() throws Exception {
UUID author = newUser();
UUID visible = publish(author, "可见的帖子");
createPost(author, "{\"content\": \"草稿不进 Feed\"}");
UUID deleted = publish(author, "删除后不进 Feed");
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
.andExpect(status().isOk());
UUID hidden = publish(author, "hidden 不进 Feed");
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", hidden)
.update();
UUID nonPublic = publish(author, "followers 可见性不进 Feed");
jdbcClient.sql("UPDATE community.posts SET visibility = 'followers' WHERE id = :id")
.param("id", nonPublic)
.update();
JsonNode page = feedPage(viewer, null, null);
assertThat(idsOf(page)).containsExactly(visible.toString());
}
@Test
void pageWalkLosesNothingAndRepeatsNothing() throws Exception {
UUID author = newUser();
List<String> published = new ArrayList<>();
for (int i = 0; i < 7; i++) {
published.add(publish(author, "翻页帖 " + i).toString());
}
List<String> expected = new ArrayList<>(published);
java.util.Collections.reverse(expected);
List<String> crawled = new ArrayList<>();
String cursor = null;
int pages = 0;
while (true) {
JsonNode page = feedPage(viewer, 3, cursor);
crawled.addAll(idsOf(page));
pages++;
if (!page.get("hasMore").asBoolean()) {
assertThat(page.get("nextCursor").isNull()).isTrue();
break;
}
cursor = page.get("nextCursor").asText();
}
assertThat(pages).isEqualTo(3);
assertThat(crawled).containsExactlyElementsOf(expected);
}
@Test
void publishedAtTiesAreBrokenByIdWithoutLossOrDuplication() throws Exception {
UUID author = newUser();
List<UUID> ids = new ArrayList<>();
for (int i = 0; i < 3; i++) {
ids.add(publish(author, "同刻帖 " + i));
}
OffsetDateTime sameInstant = OffsetDateTime.now();
for (UUID id : ids) {
jdbcClient.sql("UPDATE community.posts SET published_at = :ts WHERE id = :id")
.param("ts", sameInstant)
.param("id", id)
.update();
}
List<String> expected = ids.stream()
.map(UUID::toString)
.sorted(java.util.Comparator.reverseOrder())
.toList();
JsonNode page1 = feedPage(viewer, 2, null);
JsonNode page2 = feedPage(viewer, 2, page1.get("nextCursor").asText());
List<String> crawled = new ArrayList<>(idsOf(page1));
crawled.addAll(idsOf(page2));
assertThat(crawled).containsExactlyElementsOf(expected);
assertThat(page2.get("hasMore").asBoolean()).isFalse();
}
@Test
void insertsAndDeletesBetweenPagesNeitherShiftNorRepeat() throws Exception {
UUID author = newUser();
List<UUID> ids = new ArrayList<>();
for (int i = 0; i < 5; i++) {
ids.add(publish(author, "间隙帖 " + i));
}
// Oldest→newest is ids[0..4]; page 1 (limit 2) shows ids[4], ids[3].
JsonNode page1 = feedPage(viewer, 2, null);
assertThat(idsOf(page1)).containsExactly(ids.get(4).toString(), ids.get(3).toString());
// Between the fetches: a new post lands (newer than the cursor — must
// NOT shift page 2) and one page-2 candidate is deleted (must vanish
// without repeating anything).
publish(author, "翻页间隙新发布");
mockMvc.perform(authed(delete("/api/v1/posts/" + ids.get(2)), author))
.andExpect(status().isOk());
JsonNode page2 = feedPage(viewer, 2, page1.get("nextCursor").asText());
assertThat(idsOf(page2)).containsExactly(ids.get(1).toString(), ids.get(0).toString());
assertThat(page2.get("hasMore").asBoolean()).isFalse();
}
@Test
void invalidCursorsAreA400() throws Exception {
mockMvc.perform(feed(viewer, null, "not-base64url!!"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(feed(viewer, null,
java.util.Base64.getUrlEncoder().encodeToString("garbage".getBytes())))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void limitOutOfBoundsIsA400() throws Exception {
mockMvc.perform(feed(viewer, 0, null))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(feed(viewer, 101, null))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.community.TestcontainersConfiguration; import com.patbond.patbond.community.TestcontainersConfiguration;
import com.patbond.patbond.community.support.CommunityTestData; import com.patbond.patbond.community.support.CommunityTestData;
import com.patbond.patbond.community.support.StubAuthorProfileConfig;
import com.patbond.patbond.community.support.TestJwtKeys; import com.patbond.patbond.community.support.TestJwtKeys;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -32,7 +33,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
*/ */
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class) @Import({TestcontainersConfiguration.class, StubAuthorProfileConfig.class})
public abstract class PostApiTestBase { public abstract class PostApiTestBase {
@Autowired @Autowired
@@ -38,7 +38,7 @@ class PostLifecycleIntegrationTest extends PostApiTestBase {
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").isNotEmpty()) .andExpect(jsonPath("$.data.id").isNotEmpty())
.andExpect(jsonPath("$.data.authorId").value(author.toString())) .andExpect(jsonPath("$.data.author.userId").value(author.toString()))
.andExpect(jsonPath("$.data.title").value("第一帖")) .andExpect(jsonPath("$.data.title").value("第一帖"))
.andExpect(jsonPath("$.data.content").value("大家好")) .andExpect(jsonPath("$.data.content").value("大家好"))
.andExpect(jsonPath("$.data.category").value("general")) .andExpect(jsonPath("$.data.category").value("general"))
@@ -25,6 +25,23 @@ public final class CommunityTestData {
return id; 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. */ /** One ready image asset owned by the given user, as T3-03 would leave it. */
public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) { public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) {
return insertAsset(jdbc, ownerUserId, "ready"); return insertAsset(jdbc, ownerUserId, "ready");
@@ -48,6 +65,24 @@ public final class CommunityTestData {
return id; 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) { public static UUID insertPetOwnedBy(JdbcClient jdbc, UUID ownerUserId) {
UUID id = UuidV7.generate(); UUID id = UuidV7.generate();
jdbc.sql(""" jdbc.sql("""
@@ -0,0 +1,61 @@
package com.patbond.patbond.community.support;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.author.AuthorProfileClient;
import com.patbond.patbond.community.author.AuthorProfileDto;
import org.springframework.jdbc.core.simple.JdbcClient;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
/**
* In-process stand-in for patbond-user's /internal/users/profiles, wired in
* place of the Feign proxy (工单许可:Feign 层用替身,/internal 端点自身在
* patbond-user 模块测全;两服务同 JVM 的 AuthE2e 先例成本过高)。 It answers
* from identity.users with the same query the real endpoint runs — including
* the nickname→username fallback — so profile tests seed users exactly like
* every other cross-schema fixture. {@link #unavailable} simulates the user
* service being down (the gateway must degrade, not 5xx);
* {@link #invocations} makes the cache observable.
*/
public class StubAuthorProfileClient implements AuthorProfileClient {
private final JdbcClient jdbcClient;
private final AtomicInteger invocations = new AtomicInteger();
private volatile boolean unavailable;
public StubAuthorProfileClient(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public ApiResponse<List<AuthorProfileDto>> profiles(String ids) {
invocations.incrementAndGet();
if (unavailable) {
throw new IllegalStateException("stub: user service unavailable");
}
List<UUID> parsed = Arrays.stream(ids.split(",")).map(UUID::fromString).toList();
List<AuthorProfileDto> profiles = jdbcClient.sql("""
SELECT id, COALESCE(nickname, username::text) AS nickname, avatar_asset_id
FROM identity.users
WHERE id IN (:ids) AND deleted_at IS NULL
""")
.param("ids", parsed)
.query((rs, rowNum) -> new AuthorProfileDto(
rs.getObject("id", UUID.class),
rs.getString("nickname"),
rs.getObject("avatar_asset_id", UUID.class)))
.list();
return ApiResponse.success(profiles);
}
public void setUnavailable(boolean value) {
this.unavailable = value;
}
public int invocationCount() {
return invocations.get();
}
}
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.support;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* Replaces the AuthorProfileClient Feign proxy with the DB-backed stub for
* the shared post/feed test context. The Feign machinery itself (URL, token
* interceptor, envelope decoding) is exercised separately by
* AuthorProfileClientWireTest against a real HTTP server.
*/
@TestConfiguration(proxyBeanMethods = false)
public class StubAuthorProfileConfig {
@Bean
@Primary
public StubAuthorProfileClient stubAuthorProfileClient(JdbcClient jdbcClient) {
return new StubAuthorProfileClient(jdbcClient);
}
}
@@ -4,3 +4,12 @@
spring: spring:
application: application:
name: patbond-community 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
@@ -27,8 +27,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/** /**
* T2-09 契约一致性保障:对冻结契约 v1.2.0(快照 * T2-09 契约一致性保障:对冻结契约 v1.3.0(快照
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓 * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求, * {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、 * 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 * 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
@@ -703,10 +703,10 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
@Test @Test
@Order(98) @Order(98)
void frozenSnapshotIsTheExpectedContractVersion() { void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.2.0"); assertThat(CONTRACT.version()).isEqualTo("1.3.0");
assertThat(CONTRACT.paths()).hasSize(18); assertThat(CONTRACT.paths()).hasSize(31);
assertThat(CONTRACT.operations()).hasSize(24); assertThat(CONTRACT.operations()).hasSize(43);
assertThat(CONTRACT.schemas()).hasSize(45); assertThat(CONTRACT.schemas()).hasSize(72);
assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records"))) assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
.containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS); .containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
} }
@@ -10,6 +10,7 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -81,7 +82,7 @@ final class ContractValidator {
} }
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) { private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = contract.resolve(rawSchema); Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) { if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失"); errors.add(loc + ": 字段缺失");
return; return;
@@ -130,6 +131,32 @@ final class ContractValidator {
} }
} }
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys — e.g.
* the outer {@code nullable} — win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) { private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) { if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType()); errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
@@ -13,8 +13,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.2.0.yaml}. * {@code /contract/openapi-v1.3.0.yaml}.
* *
* <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc * <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc
* repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical * repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical
@@ -26,11 +26,13 @@ import java.util.Set;
* *
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported: * <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
* local {@code #/} refs, plain types, {@code nullable}, {@code enum}, * local {@code #/} refs, plain types, {@code nullable}, {@code enum},
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf. * {@code required}, {@code properties}, {@code items}, and the v1.3.0
* single-branch {@code nullable + allOf: [$ref]} pattern (merged in
* {@link ContractValidator}) — no oneOf/anyOf.
*/ */
final class OpenApiContract { final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.2.0.yaml"; static final String RESOURCE = "/contract/openapi-v1.3.0.yaml";
private static final Set<String> HTTP_METHODS = private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
File diff suppressed because it is too large Load Diff
@@ -5,20 +5,32 @@ import java.util.Set;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* Event dictionary v2 (report 06 §1.4/§1.5): v1 auth funnel (report 13 §4) * Event dictionary v3 (iteration-3 report 06 §1.4/§1.5, ADR-020): v1 auth
* plus the M2 increment pet domain (3 events) and health_record domain * funnel + v2 pet/health_record domains + page_viewed 正稿, plus the M3
* (7 events) and page_viewed formalized (report 06 §5.2, was report 19 * community increment post domain (8: publish funnel, draft, delete, and
* ad-hoc addition; same props keys pageName/referrer). * the per-file media upload funnel), feed domain (2: feed_viewed as an
* aggregated browse-segment exposure event per §1.2 no per-card
* post_impression and feed_load_failed), interactions (8: like/unlike,
* favorite/unfavorite, comment success/failure pair, follow/unfollow)
* and experiment_exposed (platform domain, A/B prerequisite #5, dictionary
* ahead of its M4 first use).
* ADR-013: health_record_action removed (client zero-reference), replaced by * ADR-013: health_record_action removed (client zero-reference), replaced by
* the per-action health_record_* events below. * the per-action health_record_* events below.
* Unknown event names reject the whole event; props outside the per-event * Unknown event names reject the whole event; props outside the per-event
* whitelist are stripped (kept event, counted warning); props whose KEY * whitelist are stripped (kept event, counted warning); props whose KEY
* matches the privacy red-line pattern (report 13 §5.2.4) reject the event. * matches the privacy red-line pattern (report 13 §5.2.4) reject the event.
* Community privacy red-lines (report 06 §1.3): no free text, no content or
* counterpart IDs (postId/commentId/topicId/target userId), no topic names,
* no file names/paths/URLs only behavior counts and buckets
* (textLengthBucket, sizeBucket) ever reach props.
* Value-level enum conformance (recordType: weight/vaccine/health_event/ * Value-level enum conformance (recordType: weight/vaccine/health_event/
* reminder; failureReason incl. permission_denied/conflict/not_found; * reminder; failureReason incl. media_too_large/unsupported_format;
* pageName: login/register/home/profile/pet_list/pet_detail/pet_form/ * pageName v3 family: login/register/home/profile/pet_list/pet_detail/
* record_form/record_detail) is enforced client-side (compile-time enums) * pet_form/record_form/record_detail + create/pet_archive/services/
* and patrolled offline (report 06 §6.4); ingest validates keys only. * post_detail (收编) + post_form/topic_list/topic_detail/user_profile/
* follower_list/following_list/favorite_list/draft_list) is enforced
* client-side (compile-time enums) and patrolled offline (report 06 §6.4);
* ingest validates keys only pageName growth needs no code change here.
*/ */
public final class EventDictionary { public final class EventDictionary {
@@ -59,7 +71,36 @@ public final class EventDictionary {
Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")), Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")),
Map.entry("health_record_edit_failed", Map.entry("health_record_edit_failed",
Set.of("recordType", "failureReason", "errorCode", "httpStatus")), Set.of("recordType", "failureReason", "errorCode", "httpStatus")),
Map.entry("health_record_deleted", Set.of("recordType")) Map.entry("health_record_deleted", Set.of("recordType")),
// v3 增量 post iteration-3 报告 06 §1.4
Map.entry("post_create_started", Set.of("entryPoint")),
Map.entry("post_draft_saved", Set.of("trigger", "mediaCount")),
Map.entry("post_publish_succeeded",
Set.of("durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft")),
Map.entry("post_publish_failed",
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("post_deleted", Set.of()),
Map.entry("post_media_upload_started", Set.of("mediaType", "sizeBucket")),
Map.entry("post_media_upload_succeeded", Set.of("mediaType", "sizeBucket", "durationMs")),
Map.entry("post_media_upload_failed",
Set.of("mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq")),
// v3 增量 feed 聚合曝光设计§1.2 裁定
Map.entry("feed_viewed",
Set.of("feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount")),
Map.entry("feed_load_failed",
Set.of("feedTab", "loadType", "failureReason", "errorCode", "httpStatus")),
// v3 增量互动
Map.entry("post_liked", Set.of("source")),
Map.entry("post_unliked", Set.of("source")),
Map.entry("post_favorited", Set.of("source")),
Map.entry("post_unfavorited", Set.of("source")),
Map.entry("comment_create_succeeded", Set.of("durationMs", "isReply", "textLengthBucket")),
Map.entry("comment_create_failed",
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("user_followed", Set.of("source")),
Map.entry("user_unfollowed", Set.of("source")),
// A/B 前置 #5曝光事件字典先行M4 启用§1.4
Map.entry("experiment_exposed", Set.of("experimentKey", "variant"))
); );
public static boolean isKnownEvent(String eventName) { public static boolean isKnownEvent(String eventName) {
@@ -0,0 +1,77 @@
package com.patbond.patbond.user.controller;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.user.dto.PublicProfileResponse;
import com.patbond.patbond.user.repository.UserRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Internal batch public-profile API (D3-9 方案 B, T3-05), consumed by
* patbond-community for feed/comment author summaries. Guarded by
* InternalAuthFilter like every /internal/** route.
*
* <p>Semantics: {@code ids} is a mandatory comma-separated list of user ids,
* at most {@value #MAX_BATCH} per call (one feed page's worth of authors
* with headroom) more is a 400, matching the batch-not-loop contract the
* consumer's cache is built around. Ids that do not resolve (unknown, or the
* user is soft-deleted) are silently absent from the reply; the caller
* renders its id-only fallback for them, so absence leaks nothing about
* which of the two cases it was.</p>
*/
@RestController
@RequestMapping("/internal/users")
public class InternalProfileController {
static final int MAX_BATCH = 50;
private final UserRepository userRepository;
public InternalProfileController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/profiles")
public ApiResponse<List<PublicProfileResponse>> profiles(
@RequestParam(required = false) String ids) {
List<PublicProfileResponse> profiles = userRepository.findPublicProfiles(parse(ids))
.stream()
.map(row -> new PublicProfileResponse(row.id(), row.nickname(), row.avatarAssetId()))
.toList();
return ApiResponse.success(profiles);
}
/**
* Parsed by hand (not bound as List&lt;UUID&gt;) so every malformed shape
* answers a deterministic 400/40000 instead of falling through to the
* generic handler.
*/
private static Set<UUID> parse(String ids) {
if (ids == null || ids.isBlank()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "ids 必带且不得为空");
}
String[] parts = ids.split(",", -1);
if (parts.length > MAX_BATCH) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"ids 一次最多 " + MAX_BATCH + "");
}
Set<UUID> parsed = new LinkedHashSet<>(parts.length);
for (String part : parts) {
try {
parsed.add(UUID.fromString(part.trim()));
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "ids 含非法 UUID");
}
}
return parsed;
}
}
@@ -0,0 +1,16 @@
package com.patbond.patbond.user.dto;
import java.util.UUID;
/**
* One public profile in the /internal/users/profiles batch reply (D3-9 方案 B):
* exactly the fields another service may see display name and the avatar
* asset pointer, nothing else (no phone, no username-vs-nickname distinction,
* no timestamps). The nicknameusername fallback is applied HERE, on the
* owning side, so consumers never see or need the raw username. The avatar
* travels as an asset id, not a URL: URL signing is the consumer's read-side
* concern (community signs presigned GETs locally, T3-03 定型), and a signed
* URL would go stale inside the consumer's cache.
*/
public record PublicProfileResponse(UUID userId, String nickname, UUID avatarAssetId) {
}
@@ -4,6 +4,8 @@ import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -36,6 +38,10 @@ public class UserRepository {
OffsetDateTime lockedUntil) { OffsetDateTime lockedUntil) {
} }
/** Public-profile projection; nickname already carries the username fallback. */
public record PublicProfileRow(UUID id, String nickname, UUID avatarAssetId) {
}
/** Inserts the user row; created_at/updated_at come from the DB defaults. */ /** Inserts the user row; created_at/updated_at come from the DB defaults. */
public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) { public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) {
return jdbcClient.sql(""" return jdbcClient.sql("""
@@ -86,6 +92,31 @@ public class UserRepository {
.optional(); .optional();
} }
/**
* Batch public-profile lookup for /internal/users/profiles (D3-9 方案 B).
* The nicknameusername fallback happens in SQL (ck_users_nickname
* guarantees a stored nickname is trimmed and non-empty, so plain
* COALESCE suffices); soft-deleted users are simply absent their
* tombstone shape is the caller's id-only fallback, indistinguishable
* from a user it failed to resolve.
*/
public List<PublicProfileRow> findPublicProfiles(Collection<UUID> ids) {
if (ids.isEmpty()) {
return List.of();
}
return jdbcClient.sql("""
SELECT id, COALESCE(nickname, username::text) AS nickname, avatar_asset_id
FROM identity.users
WHERE id IN (:ids) AND deleted_at IS NULL
""")
.param("ids", List.copyOf(ids))
.query((rs, rowNum) -> new PublicProfileRow(
rs.getObject("id", UUID.class),
rs.getString("nickname"),
rs.getObject("avatar_asset_id", UUID.class)))
.list();
}
public Optional<AuthRow> findAuthByUsername(String username) { public Optional<AuthRow> findAuthByUsername(String username) {
return jdbcClient.sql(""" return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
@@ -360,4 +360,115 @@ class AnalyticsIntegrationTest {
.andExpect(jsonPath("$.data.rejected").value(1)) .andExpect(jsonPath("$.data.rejected").value(1))
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name")); .andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
} }
@Test
void acceptsV3FeedViewedAggregateEvent() throws Exception {
String eventId = UUID.randomUUID().toString();
String body = """
{
"events": [{
"eventId": "%s",
"eventName": "feed_viewed",
"eventVersion": 1,
"anonymousId": "019212aa-0000-7000-8000-000000000001",
"sessionId": "019212aa-1111-7000-8000-000000000001",
"clientTs": "%s",
"appVersion": "1.2.0",
"platform": "android",
"osVersion": "android-14",
"props": {
"feedTab": "home",
"durationMs": 45000,
"impressionCount": 18,
"loadMoreCount": 2,
"refreshCount": 1
}
}]
}
""".formatted(eventId, OffsetDateTime.now());
mockMvc.perform(post("/api/v1/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.accepted").value(1))
.andExpect(jsonPath("$.data.results[0].status").value("accepted"));
String storedName = jdbcClient.sql(
"SELECT event_name FROM platform.product_events WHERE event_id = :id")
.param("id", UUID.fromString(eventId))
.query(String.class)
.single();
assertThat(storedName).isEqualTo("feed_viewed");
}
@Test
void stripsContentIdPropsFromV3InteractionEvent() throws Exception {
String eventId = UUID.randomUUID().toString();
// 红线 2report 06 §1.3行为客体的内容标识不进 props白名单外的 postId 必须被剥离
String body = """
{
"events": [{
"eventId": "%s",
"eventName": "post_liked",
"eventVersion": 1,
"anonymousId": "019212aa-0000-7000-8000-000000000001",
"sessionId": "019212aa-1111-7000-8000-000000000001",
"clientTs": "%s",
"appVersion": "1.2.0",
"platform": "ios",
"osVersion": "ios-17",
"props": {"source": "feed", "postId": "should_be_stripped"}
}]
}
""".formatted(eventId, OffsetDateTime.now());
mockMvc.perform(post("/api/v1/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.accepted").value(1));
Map<String, Object> storedProps = jdbcClient.sql(
"SELECT props::text FROM platform.product_events WHERE event_id = :id")
.param("id", UUID.fromString(eventId))
.query((rs, rowNum) -> {
try {
return new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(rs.getString(1), Map.class);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.single();
assertThat(storedProps).containsEntry("source", "feed");
assertThat(storedProps).doesNotContainKey("postId");
}
@Test
void rejectedPerCardImpressionStaysOutOfDictionary() throws Exception {
// report 06 §1.2 裁定逐卡曝光 post_impression 被否决接收端按未知事件拒绝
String body = """
{
"events": [{
"eventId": "019212aa-5555-7000-8000-000000000001",
"eventName": "post_impression",
"eventVersion": 1,
"anonymousId": "019212aa-0000-7000-8000-000000000001",
"sessionId": "019212aa-1111-7000-8000-000000000001",
"clientTs": "%s",
"appVersion": "1.2.0",
"platform": "android",
"osVersion": "android-14"
}]
}
""".formatted(OffsetDateTime.now());
mockMvc.perform(post("/api/v1/events")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isAccepted())
.andExpect(jsonPath("$.data.rejected").value(1))
.andExpect(jsonPath("$.data.results[0].reason").value("unknown_event_name"));
}
} }
@@ -5,8 +5,10 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
* Locks the dictionary v2 whitelist boundaries (report 06 §1.4/§1.5): * Locks the dictionary v3 whitelist boundaries (iteration-3 report 06
* v1 auth funnel + page_viewed 正稿 + pet 3 事件 + health_record 7 事件. * §1.4/§1.5, ADR-020): v1 auth funnel + page_viewed 正稿 + v2 pet/
* health_record domains + M3 community increment (post 8, feed 2,
* interactions 8) + experiment_exposed (dictionary ahead of M4 use).
* ADR-013's health_record_action stays removed the per-action events * ADR-013's health_record_action stays removed the per-action events
* below replace it. * below replace it.
*/ */
@@ -77,4 +79,77 @@ class EventDictionaryTest {
assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse(); assertThat(EventDictionary.isKnownEvent("health_record_edit_started")).isFalse();
assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse(); assertThat(EventDictionary.isKnownEvent("health_record_delete_failed")).isFalse();
} }
@Test
void v3PostPublishFunnelMatchesDictionary() {
assertThat(EventDictionary.allowedProps("post_create_started"))
.containsExactlyInAnyOrder("entryPoint");
assertThat(EventDictionary.allowedProps("post_draft_saved"))
.containsExactlyInAnyOrder("trigger", "mediaCount");
assertThat(EventDictionary.allowedProps("post_publish_succeeded"))
.containsExactlyInAnyOrder(
"durationMs", "mediaCount", "topicCount", "textLengthBucket", "fromDraft");
assertThat(EventDictionary.allowedProps("post_publish_failed"))
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
// post_deleted 单事件风格无专有属性report 06 §1.4
assertThat(EventDictionary.isKnownEvent("post_deleted")).isTrue();
assertThat(EventDictionary.allowedProps("post_deleted")).isEmpty();
}
@Test
void v3MediaUploadFunnelMatchesDictionary() {
assertThat(EventDictionary.allowedProps("post_media_upload_started"))
.containsExactlyInAnyOrder("mediaType", "sizeBucket");
assertThat(EventDictionary.allowedProps("post_media_upload_succeeded"))
.containsExactlyInAnyOrder("mediaType", "sizeBucket", "durationMs");
assertThat(EventDictionary.allowedProps("post_media_upload_failed"))
.containsExactlyInAnyOrder(
"mediaType", "sizeBucket", "failureReason", "errorCode", "httpStatus", "attemptSeq");
}
@Test
void v3FeedDomainMatchesDictionary() {
// feed_viewed 是浏览段聚合曝光事件report 06 §1.2只有计数与时长绝无 postId 类内容标识
assertThat(EventDictionary.allowedProps("feed_viewed"))
.containsExactlyInAnyOrder(
"feedTab", "durationMs", "impressionCount", "loadMoreCount", "refreshCount");
assertThat(EventDictionary.allowedProps("feed_load_failed"))
.containsExactlyInAnyOrder("feedTab", "loadType", "failureReason", "errorCode", "httpStatus");
}
@Test
void v3InteractionEventsMatchDictionary() {
// like/unlikefavorite/unfavoritefollow/unfollow 分立事件名v2 废弃 action 属性同一逻辑
assertThat(EventDictionary.allowedProps("post_liked")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("post_unliked")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("post_favorited")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("post_unfavorited")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("user_followed")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("user_unfollowed")).containsExactlyInAnyOrder("source");
assertThat(EventDictionary.allowedProps("comment_create_succeeded"))
.containsExactlyInAnyOrder("durationMs", "isReply", "textLengthBucket");
assertThat(EventDictionary.allowedProps("comment_create_failed"))
.containsExactlyInAnyOrder("failureReason", "errorCode", "httpStatus", "attemptSeq");
}
@Test
void v3ExperimentExposedRegisteredAheadOfM4Use() {
// A/B 前置 #5report 06 §1.4M4 首实验才启用字典与白名单本迭代一次进
assertThat(EventDictionary.allowedProps("experiment_exposed"))
.containsExactlyInAnyOrder("experimentKey", "variant");
}
@Test
void v3DeliberatelyAbsentEventsStayUnknown() {
// report 06 §1.2逐卡曝光被否决不设 post_impression§1.4 取舍
// 帖子浏览由 page_viewed(post_detail) 覆盖评论不设 started单点互动不埋失败
// §1.6 缺口 3话题关注 UI 定稿前挂起
assertThat(EventDictionary.isKnownEvent("post_impression")).isFalse();
assertThat(EventDictionary.isKnownEvent("post_viewed")).isFalse();
assertThat(EventDictionary.isKnownEvent("comment_create_started")).isFalse();
assertThat(EventDictionary.isKnownEvent("post_like_failed")).isFalse();
assertThat(EventDictionary.isKnownEvent("user_follow_failed")).isFalse();
assertThat(EventDictionary.isKnownEvent("topic_followed")).isFalse();
assertThat(EventDictionary.isKnownEvent("topic_unfollowed")).isFalse();
}
} }
@@ -0,0 +1,256 @@
package com.patbond.patbond.user.contract;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static com.patbond.patbond.user.contract.OpenApiContract.cast;
import static com.patbond.patbond.user.contract.OpenApiContract.list;
import static com.patbond.patbond.user.contract.OpenApiContract.map;
/**
* Validates an actual HTTP response against the frozen contract, strictly:
*
* <ul>
* <li>the operation and the status must be declared;</li>
* <li>required fields must be present; a null value needs {@code nullable};</li>
* <li>fields the schema does not declare are rejected (this is what catches
* a renamed or newly leaked field plain OpenAPI semantics would allow
* extra properties, but the frozen contract is "exactly these fields");</li>
* <li>types, enum membership, uuid / date-time / date formats and
* min/max(Length) bounds are checked.</li>
* </ul>
*
* Behavioural semantics (state machines, anti-enumeration, permission logic)
* stay with the existing integration tests this class only pins structure.
*/
final class ContractValidator {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final OpenApiContract contract;
ContractValidator(OpenApiContract contract) {
this.contract = contract;
}
/**
* @return drift findings, empty when the response conforms; each entry is
* a human-readable "where: what" line
*/
List<String> validateResponse(String method, String pathTemplate, int status, String body) {
List<String> errors = new ArrayList<>();
String opKey = method + " " + pathTemplate;
Map<String, Object> op = contract.operation(opKey);
if (op == null) {
errors.add("契约未声明该操作: " + opKey);
return errors;
}
Object respNode = map(op, "responses").get(String.valueOf(status));
if (respNode == null) {
errors.add("契约未为 " + opKey + " 声明状态码 " + status);
return errors;
}
Map<String, Object> content = map(contract.resolve(cast(respNode)), "content");
if (content == null) {
return errors; // response declared without a body
}
Map<String, Object> schema = map(map(content, "application/json"), "schema");
if (schema == null) {
errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
return errors;
}
JsonNode node;
try {
node = MAPPER.readTree(body);
} catch (JsonProcessingException e) {
errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
return errors;
}
validate(schema, node, "$", errors);
return errors;
}
private void validate(Map<String, Object> rawSchema, JsonNode node, String loc, List<String> errors) {
Map<String, Object> schema = effectiveSchema(rawSchema);
if (node == null || node.isMissingNode()) {
errors.add(loc + ": 字段缺失");
return;
}
if (node.isNull()) {
if (!Boolean.TRUE.equals(schema.get("nullable"))) {
errors.add(loc + ": 为 null,但契约未声明 nullable");
}
return;
}
List<Object> allowed = list(schema, "enum");
if (allowed != null && !enumMatches(allowed, node)) {
errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + "");
}
String type = (String) schema.get("type");
if (type == null) {
type = schema.containsKey("properties") ? "object" : null;
}
if (type == null) {
return;
}
switch (type) {
case "object" -> validateObject(schema, node, loc, errors);
case "array" -> validateArray(schema, node, loc, errors);
case "string" -> validateString(schema, node, loc, errors);
case "integer" -> {
if (!node.isIntegralNumber()) {
errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "number" -> {
if (!node.isNumber()) {
errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
} else {
checkRange(schema, node.decimalValue(), loc, errors);
}
}
case "boolean" -> {
if (!node.isBoolean()) {
errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
}
}
default -> errors.add(loc + ": 契约测试不支持的 type " + type);
}
}
/**
* Resolves $refs and flattens the v1.3.0 {@code nullable + allOf: [$ref]}
* pattern into one plain schema (branch keys first, sibling keys e.g.
* the outer {@code nullable} win). The frozen contract only ever uses
* single-branch allOf, so a shallow merge is exact; overlapping
* {@code properties} across branches would need a deep merge and are not
* supported.
*/
private Map<String, Object> effectiveSchema(Map<String, Object> rawSchema) {
Map<String, Object> schema = contract.resolve(rawSchema);
List<Object> allOf = list(schema, "allOf");
if (allOf == null) {
return schema;
}
Map<String, Object> merged = new LinkedHashMap<>();
for (Object branch : allOf) {
merged.putAll(effectiveSchema(cast(branch)));
}
schema.forEach((key, value) -> {
if (!"allOf".equals(key)) {
merged.put(key, value);
}
});
return merged;
}
private void validateObject(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isObject()) {
errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
return;
}
Map<String, Object> props = map(schema, "properties");
List<Object> required = list(schema, "required");
if (required != null) {
for (Object r : required) {
if (!node.has((String) r)) {
errors.add(loc + "." + r + ": 契约必填字段缺失");
}
}
}
Object additional = schema.get("additionalProperties");
boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
Map<String, Object> propSchema = props == null ? null : cast(props.get(field.getKey()));
if (propSchema != null) {
validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
} else if (!open) {
errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
}
}
}
private void validateArray(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isArray()) {
errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
return;
}
Map<String, Object> items = map(schema, "items");
if (items == null) {
return;
}
int i = 0;
for (JsonNode element : node) {
validate(items, element, loc + "[" + i++ + "]", errors);
}
}
private void validateString(Map<String, Object> schema, JsonNode node, String loc, List<String> errors) {
if (!node.isTextual()) {
errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
return;
}
String value = node.asText();
String format = (String) schema.get("format");
if (format != null) {
try {
switch (format) {
case "uuid" -> {
if (value.length() != 36) {
throw new IllegalArgumentException("非规范 UUID 长度");
}
java.util.UUID.fromString(value);
}
case "date-time" -> OffsetDateTime.parse(value);
case "date" -> LocalDate.parse(value);
default -> { /* password 等纯标注格式不校验 */ }
}
} catch (IllegalArgumentException | DateTimeParseException e) {
errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
}
}
if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
}
if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
}
}
private static void checkRange(Map<String, Object> schema, BigDecimal value, String loc, List<String> errors) {
if (schema.get("minimum") instanceof Number min
&& value.compareTo(new BigDecimal(min.toString())) < 0) {
errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
}
if (schema.get("maximum") instanceof Number max
&& value.compareTo(new BigDecimal(max.toString())) > 0) {
errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
}
}
private static boolean enumMatches(List<Object> allowed, JsonNode node) {
if (node.isTextual()) {
return allowed.contains(node.asText());
}
if (node.isIntegralNumber()) {
long v = node.longValue();
return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
}
return false;
}
}
@@ -0,0 +1,274 @@
package com.patbond.patbond.user.contract;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.support.TestJwtKeys;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.utility.DockerImageName;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* T3-20M3 第二波收尾media 2 个操作两步上传 user 模块补进契约
* 一致性保障机制与 patbond-pet ContractConformanceTest 同构对冻结契约
* v1.3.0快照 {@code src/test/resources/contract/openapi-v1.3.0.yaml}正典在
* doc {@code docs/api/openapi.yaml}逐操作真实起服务发请求真实 MinIO
* Testcontainer直传走真实 HTTP PUT {@link ContractValidator} 严格校验
* 响应结构最后以全响应矩阵门禁兜底8 个单元格无豁免
*
* <p>auth 6 操作在 patbond-authpets 18 操作在 patbond-petcommunity
* 17 操作在 patbond-community 的同构测试内快照同一份
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MediaContractConformanceTest {
private static final OpenApiContract CONTRACT = OpenApiContract.load();
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** media 域 2 个操作(= 契约中 tags ∈ {media})。 */
private static final List<String> MEDIA_OPERATIONS = List.of(
"POST /api/v1/media/uploads",
"POST /api/v1/media/uploads/{assetId}/complete");
/** 与 docker-compose.yml 的 minio 服务钉同一 tagADR-016 三环境零分叉)。 */
private static final MinIOContainer MINIO = new MinIOContainer(
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
// 值仅为测试占位dummy非真实凭证
.withUserName("minio-dummy-access")
.withPassword("minio-dummy-secret");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final byte[] FAKE_JPEG = fakeJpeg();
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void wireMedia(DynamicPropertyRegistry registry) {
MINIO.start();
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.media.endpoint", MINIO::getS3URL);
registry.add("patbond.media.access-key", MINIO::getUserName);
registry.add("patbond.media.secret-key", MINIO::getPassword);
}
// ---- 校验骨架 ------------------------------------------------------
private String verified(MockHttpServletRequestBuilder rq, String method,
String pathTemplate, int expectedStatus) throws Exception {
MvcResult result = mockMvc.perform(rq).andReturn();
int actual = result.getResponse().getStatus();
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(actual)
.as("%s %s 的 HTTP 状态(响应体: %s", method, pathTemplate, body)
.isEqualTo(expectedStatus);
List<String> drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body);
assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty();
COVERED.add(method + " " + pathTemplate + " " + actual);
return body;
}
private String verifiedError(MockHttpServletRequestBuilder rq, String method,
String pathTemplate, int status, int bizCode) throws Exception {
String body = verified(rq, method, pathTemplate, status);
assertThat((Integer) JsonPath.read(body, "$.code"))
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
.isEqualTo(bizCode);
return body;
}
private static byte[] fakeJpeg() {
byte[] bytes = new byte[2048];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i * 31);
}
bytes[0] = (byte) 0xFF;
bytes[1] = (byte) 0xD8; // JPEG SOI凑个像样的文件头
return bytes;
}
private UUID newUser(String username) {
UUID id = UUID.randomUUID();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 创建上传(经 verified,201 凭据形态即被契约校验),返回响应体。 */
private String createUpload(UUID user, long byteSize) throws Exception {
return verified(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"post_image",
"mimeType":"image/jpeg","byteSize":%d}
""".formatted(byteSize)),
"POST", "/api/v1/media/uploads", 201);
}
/** 按凭据把字节真实 PUT 到 MinIO。 */
private void directPut(String createdBody, byte[] bytes) throws Exception {
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
.PUT(HttpRequest.BodyPublishers.ofByteArray(bytes));
headers.forEach(put::header);
int status = HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode();
assertThat(status).as("预签名直传应被 MinIO 接受").isEqualTo(200);
}
private MockHttpServletRequestBuilder completeRequest(UUID user, String assetId) {
return post("/api/v1/media/uploads/{assetId}/complete", assetId)
.header("Authorization", bearer(user));
}
// ---- 成功路径 ------------------------------------------------------
@Test
@Order(1)
void twoStepUploadSuccessShapes() throws Exception {
UUID user = newUser("contract_media_owner");
String created = createUpload(user, FAKE_JPEG.length);
String assetId = JsonPath.read(created, "$.data.assetId");
directPut(created, FAKE_JPEG);
String completed = verified(completeRequest(user, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 200);
assertThat((String) JsonPath.read(completed, "$.data.status")).isEqualTo("ready");
assertThat((String) JsonPath.read(completed, "$.data.url"))
.as("ready 资产必须带预签名 GET URL").isNotNull();
// 幂等重复确认同格 200同一 asset
String again = verified(completeRequest(user, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 200);
assertThat((String) JsonPath.read(again, "$.data.id")).isEqualTo(assetId);
}
// ---- 错误信封 ------------------------------------------------------
@Test
@Order(2)
void errorEnvelopesMatchContract() throws Exception {
UUID user = newUser("contract_media_err");
// 400/40000mime 白名单外complete 的畸形 assetId
verifiedError(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"post_image",
"mimeType":"image/gif","byteSize":1024}
"""),
"POST", "/api/v1/media/uploads", 400, 40000);
verifiedError(completeRequest(user, "not-a-uuid"),
"POST", "/api/v1/media/uploads/{assetId}/complete", 400, 40000);
// 401/40101两操作均缺 token
verifiedError(post("/api/v1/media/uploads")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"post_image",
"mimeType":"image/jpeg","byteSize":1024}
"""),
"POST", "/api/v1/media/uploads", 401, 40101);
verifiedError(post("/api/v1/media/uploads/{assetId}/complete", UUID.randomUUID()),
"POST", "/api/v1/media/uploads/{assetId}/complete", 401, 40101);
// 404/40405他人 asset 与不存在 asset 防枚举合并
UUID intruder = newUser("contract_media_intruder");
String created = createUpload(user, FAKE_JPEG.length);
String assetId = JsonPath.read(created, "$.data.assetId");
verifiedError(completeRequest(intruder, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405);
verifiedError(completeRequest(user, UUID.randomUUID().toString()),
"POST", "/api/v1/media/uploads/{assetId}/complete", 404, 40405);
// 422/42205直传完成前确认asset 保持 uploading 可重试
verifiedError(completeRequest(user, assetId),
"POST", "/api/v1/media/uploads/{assetId}/complete", 422, 42205);
}
// ---- 快照与覆盖门禁 -------------------------------------------------
/**
* 冻结快照守卫 pet/auth/community 侧同一纪律正典契约升版时必须同步
* 复制新快照并更新期望值忘记同步在 CI 立即变红
*/
@Test
@Order(98)
void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.3.0");
assertThat(CONTRACT.paths()).hasSize(31);
assertThat(CONTRACT.operations()).hasSize(43);
assertThat(CONTRACT.schemas()).hasSize(72);
assertThat(CONTRACT.operationsTagged(Set.of("media")))
.containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS);
}
/**
* 全矩阵覆盖门禁media 2 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验8 个单元格无豁免
*/
@Test
@Order(99)
void everyDeclaredResponseCellIsExercised() {
List<String> missing = new ArrayList<>();
for (String op : MEDIA_OPERATIONS) {
for (int status : CONTRACT.responseStatuses(op)) {
String cell = op + " " + status;
if (!COVERED.contains(cell)) {
missing.add(cell);
}
}
}
assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
}
}
@@ -0,0 +1,151 @@
package com.patbond.patbond.user.contract;
import org.yaml.snakeyaml.Yaml;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The frozen v1.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,190 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.security.InternalAuthFilter;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* /internal/users/profiles (D3-9 方案 B, T3-05): service auth, batch limits,
* the nicknameusername fallback, avatar pointer passthrough, and the
* silent-absence semantics for unknown or soft-deleted users.
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class InternalProfileEndpointTest {
private static final String INTERNAL_TOKEN = "test-internal-token";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
private MockHttpServletRequestBuilder profiles(String ids) {
return get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.queryParam("ids", ids);
}
private String registerUser(String username) throws Exception {
String body = mockMvc.perform(post("/internal/users")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
return JsonPath.read(body, "$.data.id");
}
private String freshUsername() {
return "prof" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL);
}
/** Profiles of a successful reply, keyed by userId. */
private Map<String, Map<String, Object>> fetch(String ids) throws Exception {
String body = mockMvc.perform(profiles(ids))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
return rows.stream().collect(Collectors.toMap(
row -> (String) row.get("userId"), Function.identity()));
}
private UUID insertReadyAvatarAsset(UUID ownerId) {
UUID assetId = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at)
VALUES (:id, :owner, 'image', 'user_avatar', 'object', 'patbond-media',
:objectKey, 'image/jpeg', 123, 'ready', :readyAt)
""")
.param("id", assetId)
.param("owner", ownerId)
.param("objectKey", "user_avatar/2026/09/" + assetId + ".jpg")
.param("readyAt", OffsetDateTime.now())
.update();
jdbcClient.sql("UPDATE identity.users SET avatar_asset_id = :assetId WHERE id = :id")
.param("assetId", assetId)
.param("id", ownerId)
.update();
return assetId;
}
@Test
void rejectsCallsWithoutTheServiceSecret() throws Exception {
mockMvc.perform(get("/internal/users/profiles").queryParam("ids", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, "wrong-token")
.queryParam("ids", UUID.randomUUID().toString()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void returnsNicknameWhenSetAndFallsBackToUsernameWhenNot() throws Exception {
String withNickname = registerUser(freshUsername());
String withoutNickname = freshUsername();
String withoutNicknameId = registerUser(withoutNickname);
jdbcClient.sql("UPDATE identity.users SET nickname = '毛毛的铲屎官' WHERE id = :id")
.param("id", UUID.fromString(withNickname))
.update();
Map<String, Map<String, Object>> profiles = fetch(withNickname + "," + withoutNicknameId);
assertThat(profiles).hasSize(2);
assertThat(profiles.get(withNickname).get("nickname")).isEqualTo("毛毛的铲屎官");
assertThat(profiles.get(withoutNicknameId).get("nickname")).isEqualTo(withoutNickname);
}
@Test
void passesTheAvatarAssetPointerThroughAndNullWithoutAvatar() throws Exception {
String withAvatar = registerUser(freshUsername());
String withoutAvatar = registerUser(freshUsername());
UUID assetId = insertReadyAvatarAsset(UUID.fromString(withAvatar));
Map<String, Map<String, Object>> profiles = fetch(withAvatar + "," + withoutAvatar);
assertThat(profiles.get(withAvatar).get("avatarAssetId")).isEqualTo(assetId.toString());
assertThat(profiles.get(withoutAvatar).get("avatarAssetId")).isNull();
}
@Test
void unknownAndSoftDeletedIdsAreSilentlyAbsent() throws Exception {
String live = registerUser(freshUsername());
String deleted = registerUser(freshUsername());
jdbcClient.sql("UPDATE identity.users SET deleted_at = now(), status = 'deleted' WHERE id = :id")
.param("id", UUID.fromString(deleted))
.update();
Map<String, Map<String, Object>> profiles =
fetch(live + "," + deleted + "," + UUID.randomUUID());
assertThat(profiles).containsOnlyKeys(live);
}
@Test
void missingOrBlankIdsIsA400() throws Exception {
mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
mockMvc.perform(profiles(" "))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void malformedUuidIsA400() throws Exception {
mockMvc.perform(profiles(UUID.randomUUID() + ",not-a-uuid"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void moreThanFiftyIdsIsA400AndExactlyFiftyIsNot() throws Exception {
String fifty = IntStream.range(0, 50)
.mapToObj(i -> UUID.randomUUID().toString())
.collect(Collectors.joining(","));
mockMvc.perform(profiles(fifty))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(0));
mockMvc.perform(profiles(fifty + "," + UUID.randomUUID()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void duplicateIdsCollapseToOneRow() throws Exception {
String user = registerUser(freshUsername());
mockMvc.perform(profiles(user + "," + user))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.length()").value(1));
}
}
File diff suppressed because it is too large Load Diff