From 99a3c1f8abcf2e0b195b31721ac1ba32a5de2d50 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Wed, 9 Sep 2026 10:26:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=85=B1=20Feed=20=E6=B8=B8?= =?UTF-8?q?=E6=A0=87=E5=88=86=E9=A1=B5=20+=20=E4=BD=9C=E8=80=85=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E8=B5=84=E6=96=99=E9=93=BE=E8=B7=AF=EF=BC=88T3-05=20/?= =?UTF-8?q?=20D3-9=20=E6=96=B9=E6=A1=88=20B=EF=BC=8CADR-002/017=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docker-compose.yml | 4 + patbond-community/pom.xml | 12 + .../community/CommunityApplication.java | 14 +- .../community/author/AuthorProfileClient.java | 24 ++ .../community/author/AuthorProfileDto.java | 14 ++ .../author/AuthorProfileGateway.java | 145 ++++++++++++ .../author/AuthorProfileProperties.java | 26 +++ .../config/CommunityFeignConfig.java | 35 +++ .../config/CommunitySecurityProperties.java | 22 +- .../community/config/SecurityConfig.java | 3 +- .../community/controller/FeedController.java | 43 ++++ .../community/dto/AuthorSummaryResponse.java | 21 ++ .../community/dto/FeedCardResponse.java | 29 +++ .../patbond/community/dto/PostResponse.java | 14 +- .../community/repository/PostRepository.java | 29 +++ .../community/service/FeedService.java | 118 ++++++++++ .../community/service/PostService.java | 12 +- .../patbond/community/support/FeedCursor.java | 46 ++++ .../src/main/resources/application.yml.sample | 10 + .../author/AuthorProfileClientWireTest.java | 146 ++++++++++++ .../post/AuthorProfileIntegrationTest.java | 150 +++++++++++++ .../post/FeedCardIntegrationTest.java | 133 +++++++++++ .../post/FeedPaginationIntegrationTest.java | 207 ++++++++++++++++++ .../community/post/PostApiTestBase.java | 3 +- .../post/PostLifecycleIntegrationTest.java | 2 +- .../community/support/CommunityTestData.java | 35 +++ .../support/StubAuthorProfileClient.java | 61 ++++++ .../support/StubAuthorProfileConfig.java | 22 ++ .../src/test/resources/application.yml | 9 + 29 files changed, 1369 insertions(+), 20 deletions(-) create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileClient.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileDto.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileGateway.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileProperties.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/config/CommunityFeignConfig.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/controller/FeedController.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/dto/AuthorSummaryResponse.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/dto/FeedCardResponse.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/support/FeedCursor.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/author/AuthorProfileClientWireTest.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/post/AuthorProfileIntegrationTest.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/post/FeedCardIntegrationTest.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/post/FeedPaginationIntegrationTest.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileClient.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileConfig.java diff --git a/docker-compose.yml b/docker-compose.yml index 83b14ad..bd462f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,6 +125,10 @@ services: PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem + # 作者公开资料(D3-9 方案 B):走 user 服务 /internal 批量接口, + # 服务间共享密钥与 auth/user 同一值。 + PATBOND_USER_SERVICE_URL: http://user:8082 + PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env} # 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一 # 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。 PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000} diff --git a/patbond-community/pom.xml b/patbond-community/pom.xml index a45af07..69572e6 100644 --- a/patbond-community/pom.xml +++ b/patbond-community/pom.xml @@ -39,6 +39,18 @@ org.springframework.boot spring-boot-starter-jdbc + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + io.github.openfeign + feign-hc5 + org.postgresql postgresql diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java b/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java index 1168054..5f0c1a3 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java @@ -1,17 +1,21 @@ package com.patbond.patbond.community; +import com.patbond.patbond.community.config.CommunityFeignConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; /** * Community feed, posts and interactions service (M3, ADR-017: the community - * domain lives in its own Maven module on :8084). First-wave skeleton: - * configuration wiring, datasource, RS256 bearer auth on /api/v1/** and a - * liveness endpoint — business endpoints follow the contract work in the - * next waves. The module only reads and writes the community schema - * (author profile lookups follow the D3-9 plan later). + * domain lives in its own Maven module on :8084). Configuration wiring, + * datasource, RS256 bearer auth on /api/v1/**, the post lifecycle (T3-04) + * and the public feed (T3-05). The module only reads and writes the + * community schema (plus the ADR-017 read-only media.assets exception); + * author public profiles come from patbond-user's /internal batch API over + * Feign (D3-9 方案 B). */ @SpringBootApplication +@EnableFeignClients(defaultConfiguration = CommunityFeignConfig.class) public class CommunityApplication { public static void main(String[] args) { diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileClient.java b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileClient.java new file mode 100644 index 0000000..b79c5f8 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileClient.java @@ -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> profiles(@RequestParam("ids") String ids); +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileDto.java b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileDto.java new file mode 100644 index 0000000..068f599 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileDto.java @@ -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) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileGateway.java b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileGateway.java new file mode 100644 index 0000000..026ade6 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileGateway.java @@ -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. + * + *
    + *
  • Batch, never loop — 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).
  • + *
  • Avatar — 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.
  • + *
  • Degradation — 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.
  • + *
+ */ +@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 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 summarize(Collection userIds) { + if (userIds.isEmpty()) { + return Map.of(); + } + long now = System.nanoTime(); + Map resolved = new HashMap<>(); + List 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 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 resolved, List misses, long now) { + List profiles = new ArrayList<>(); + try { + for (int i = 0; i < misses.size(); i += MAX_BATCH) { + List chunk = misses.subList(i, Math.min(i + MAX_BATCH, misses.size())); + ApiResponse> 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 assetIds = profiles.stream() + .map(AuthorProfileDto::avatarAssetId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map 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) { + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileProperties.java b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileProperties.java new file mode 100644 index 0000000..cb5b465 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/author/AuthorProfileProperties.java @@ -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; + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunityFeignConfig.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunityFeignConfig.java new file mode 100644 index 0000000..3def841 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunityFeignConfig.java @@ -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). + * + *

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).

+ */ +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); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java index 5e5dea4..777e858 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java @@ -3,16 +3,32 @@ package com.patbond.patbond.community.config; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Security knobs of the community service: only the RS256 public key for + * Security knobs of the community service: the RS256 public key for * verifying access tokens issued by patbond-auth (same contract as - * patbond-user/pet's {@code patbond.jwt.public-key}). No /internal routes - * exist here yet, so no service token property. + * patbond-user/pet's {@code patbond.jwt.public-key}), and the shared + * service secret presented on outbound /internal/** calls to patbond-user + * (D3-9 方案 B author-profile lookups — this service still exposes no + * /internal routes of its own). */ @ConfigurationProperties(prefix = "patbond") public class CommunitySecurityProperties { + /** + * Shared secret sent as X-Internal-Token on calls to patbond-user's + * /internal/** API; must equal the value patbond-user expects. + */ + private String internalToken; + private final Jwt jwt = new Jwt(); + public String getInternalToken() { + return internalToken; + } + + public void setInternalToken(String value) { + this.internalToken = value; + } + public Jwt getJwt() { return jwt; } diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java index 50e327a..50d423c 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.patbond.patbond.community.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.patbond.patbond.community.author.AuthorProfileProperties; import com.patbond.patbond.community.security.BearerAuthFilter; import com.patbond.patbond.community.security.JwtVerifier; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -15,7 +16,7 @@ import org.springframework.context.annotation.Configuration; * unauthenticated. */ @Configuration -@EnableConfigurationProperties(CommunitySecurityProperties.class) +@EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class}) public class SecurityConfig { @Bean diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/FeedController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/FeedController.java new file mode 100644 index 0000000..3f7f31d --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/FeedController.java @@ -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> 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)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/AuthorSummaryResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/AuthorSummaryResponse.java new file mode 100644 index 0000000..1043dcb --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/AuthorSummaryResponse.java @@ -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); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/FeedCardResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/FeedCardResponse.java new file mode 100644 index 0000000..577bc30 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/FeedCardResponse.java @@ -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) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java index b704969..b6abd9d 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/PostResponse.java @@ -5,16 +5,16 @@ import java.util.List; import java.util.UUID; /** - * Full post shape (detail / my-posts list / write responses). Deviation from - * the contract draft, recorded for the T3-10 freeze: the draft's - * {@code author: AuthorSummary} is placeheld by {@code authorId} until T3-05 - * lands the public-profile aggregation (工单口径:author 字段可先占位 - * authorId). Trimmed fields (region/generationJob/topics …) do not appear at - * all (ADR-018 + ADR-010 precedent). + * Full post shape (detail / my-posts list / write responses). The T3-04 + * {@code authorId} placeholder is gone: {@code author} is the D3-9 + * AuthorSummary, degraded to its id-only shape when the profile lookup is + * unavailable (contract deviation #1 closed by T3-05). Trimmed fields + * (region/generationJob/topics …) do not appear at all (ADR-018 + ADR-010 + * precedent). */ public record PostResponse( UUID id, - UUID authorId, + AuthorSummaryResponse author, UUID petId, String category, String title, diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java index 48047d9..31c77ba 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.patbond.patbond.community.repository; +import com.patbond.patbond.community.support.FeedCursor; import com.patbond.patbond.community.support.PostCursor; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Repository; @@ -200,6 +201,34 @@ public class PostRepository { return spec.query(PostRepository::mapPost).list(); } + /** + * One public-feed page in (published_at DESC, id DESC) — the exact key + * and predicate of the ix_posts_feed partial index. The explicit + * {@code deleted_at IS NULL} is belt-and-braces: softDelete parks + * published rows as 'archived', so status='published' already implies + * live (ck_posts_publish_state), and the planner still matches the + * partial index. The caller asks for limit+1 rows to learn whether more + * exist. + */ + public List 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(); + } + public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) { jdbcClient.sql(""" INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption) diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java new file mode 100644 index 0000000..134aac4 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/FeedService.java @@ -0,0 +1,118 @@ +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.PostMediaRow; +import com.patbond.patbond.community.repository.PostRepository.PostRow; +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 list(UUID viewerId, int limit, String cursor) { + FeedCursor after = cursor == null ? null : FeedCursor.decode(cursor); + List rows = postRepository.pageFeed(viewerId, after, limit + 1); + boolean hasMore = rows.size() > limit; + List 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); + } + + private List assembleCards(List rows) { + Map> mediaByPost = postRepository + .findMediaByPostIds(rows.stream().map(PostRow::id).toList()) + .stream() + .collect(Collectors.groupingBy(PostMediaRow::postId)); + Map authors = authorProfileGateway.summarize( + rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet())); + return rows.stream().map(row -> { + List 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 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)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java index d490b97..8450670 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java @@ -3,6 +3,8 @@ package com.patbond.patbond.community.service; import com.patbond.patbond.common.error.BusinessException; import com.patbond.patbond.common.error.ErrorCode; import com.patbond.patbond.community.access.PetVisibilityGateway; +import com.patbond.patbond.community.author.AuthorProfileGateway; +import com.patbond.patbond.community.dto.AuthorSummaryResponse; import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.PostMediaAttachRequest; @@ -65,13 +67,16 @@ public class PostService { private final MediaAssetGateway mediaAssetGateway; private final MediaUrlSigner mediaUrlSigner; private final PetVisibilityGateway petVisibilityGateway; + private final AuthorProfileGateway authorProfileGateway; public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway, - MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway) { + MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway, + AuthorProfileGateway authorProfileGateway) { this.postRepository = postRepository; this.mediaAssetGateway = mediaAssetGateway; this.mediaUrlSigner = mediaUrlSigner; this.petVisibilityGateway = petVisibilityGateway; + this.authorProfileGateway = authorProfileGateway; } @Transactional @@ -374,9 +379,12 @@ public class PostService { .findMediaByPostIds(rows.stream().map(PostRow::id).toList()) .stream() .collect(Collectors.groupingBy(PostMediaRow::postId)); + Map authors = authorProfileGateway.summarize( + rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet())); return rows.stream().map(row -> new PostResponse( row.id(), - row.authorUserId(), + authors.getOrDefault(row.authorUserId(), + AuthorSummaryResponse.idOnly(row.authorUserId())), row.petId(), row.category(), row.title(), diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/support/FeedCursor.java b/patbond-community/src/main/java/com/patbond/patbond/community/support/FeedCursor.java new file mode 100644 index 0000000..51d789e --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/support/FeedCursor.java @@ -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 无效"); + } + } +} diff --git a/patbond-community/src/main/resources/application.yml.sample b/patbond-community/src/main/resources/application.yml.sample index 426ed29..065e74c 100644 --- a/patbond-community/src/main/resources/application.yml.sample +++ b/patbond-community/src/main/resources/application.yml.sample @@ -20,6 +20,16 @@ patbond: # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 # 私钥只给 patbond-auth,绝不入库。 public-key: ${PATBOND_JWT_PUBLIC_KEY:} + # 作者公开资料来源(D3-9 方案 B):patbond-user 的 /internal 批量接口, + # ADR-002 静态直连。不可达时 Feed/详情照常返回,作者摘要降级为仅 userId。 + user-service: + url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082} + # /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须 + # 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。 + internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token} + author-profile: + # 作者公开资料的进程内缓存 TTL:昵称/头像变更最迟一分钟可见。 + cache-ttl: ${PATBOND_AUTHOR_PROFILE_CACHE_TTL:60s} media: # 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4 # 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。 diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/author/AuthorProfileClientWireTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/author/AuthorProfileClientWireTest.java new file mode 100644 index 0000000..bf8b160 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/author/AuthorProfileClientWireTest.java @@ -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 RESPONSE_BODY = new AtomicReference<>(""); + private static final AtomicInteger RESPONSE_STATUS = new AtomicInteger(200); + private static final AtomicReference SEEN_TOKEN = new AtomicReference<>(); + private static final AtomicReference 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 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(); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/AuthorProfileIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/AuthorProfileIntegrationTest.java new file mode 100644 index 0000000..8db4412 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/AuthorProfileIntegrationTest.java @@ -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("恢复后的昵称"); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/FeedCardIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/FeedCardIntegrationTest.java new file mode 100644 index 0000000..4c4531d --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/FeedCardIntegrationTest.java @@ -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(); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/FeedPaginationIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/FeedPaginationIntegrationTest.java new file mode 100644 index 0000000..5b67258 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/FeedPaginationIntegrationTest.java @@ -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 idsOf(JsonNode page) { + List 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 published = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + published.add(publish(author, "翻页帖 " + i).toString()); + } + List expected = new ArrayList<>(published); + java.util.Collections.reverse(expected); + + List 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 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 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 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 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)); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java index 7d5635a..3e9f55a 100644 --- a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostApiTestBase.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.patbond.patbond.community.TestcontainersConfiguration; import com.patbond.patbond.community.support.CommunityTestData; +import com.patbond.patbond.community.support.StubAuthorProfileConfig; import com.patbond.patbond.community.support.TestJwtKeys; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -32,7 +33,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder */ @SpringBootTest @AutoConfigureMockMvc -@Import(TestcontainersConfiguration.class) +@Import({TestcontainersConfiguration.class, StubAuthorProfileConfig.class}) public abstract class PostApiTestBase { @Autowired diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java index 2ddfe0c..2ba07e0 100644 --- a/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/PostLifecycleIntegrationTest.java @@ -38,7 +38,7 @@ class PostLifecycleIntegrationTest extends PostApiTestBase { .andExpect(status().isCreated()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").isNotEmpty()) - .andExpect(jsonPath("$.data.authorId").value(author.toString())) + .andExpect(jsonPath("$.data.author.userId").value(author.toString())) .andExpect(jsonPath("$.data.title").value("第一帖")) .andExpect(jsonPath("$.data.content").value("大家好")) .andExpect(jsonPath("$.data.category").value("general")) diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java b/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java index fd8f15c..bb8139f 100644 --- a/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java +++ b/patbond-community/src/test/java/com/patbond/patbond/community/support/CommunityTestData.java @@ -25,6 +25,23 @@ public final class CommunityTestData { return id; } + public static void setNickname(JdbcClient jdbc, UUID userId, String nickname) { + jdbc.sql("UPDATE identity.users SET nickname = :nickname WHERE id = :id") + .param("nickname", nickname) + .param("id", userId) + .update(); + } + + /** Gives the user an avatar asset in the given status; returns the asset id. */ + public static UUID attachAvatar(JdbcClient jdbc, UUID userId, String status) { + UUID assetId = insertAsset(jdbc, userId, status); + jdbc.sql("UPDATE identity.users SET avatar_asset_id = :assetId WHERE id = :id") + .param("assetId", assetId) + .param("id", userId) + .update(); + return assetId; + } + /** One ready image asset owned by the given user, as T3-03 would leave it. */ public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) { return insertAsset(jdbc, ownerUserId, "ready"); @@ -48,6 +65,24 @@ public final class CommunityTestData { return id; } + /** + * A published post inserted straight into community.posts — used when a + * test must NOT go through the create API (whose response assembly + * would already resolve and cache the author's profile). + */ + public static UUID insertPublishedPost(JdbcClient jdbc, UUID authorUserId, String content) { + UUID id = UuidV7.generate(); + jdbc.sql(""" + INSERT INTO community.posts (id, author_user_id, content, status, published_at) + VALUES (:id, :author, :content, 'published', now()) + """) + .param("id", id) + .param("author", authorUserId) + .param("content", content) + .update(); + return id; + } + public static UUID insertPetOwnedBy(JdbcClient jdbc, UUID ownerUserId) { UUID id = UuidV7.generate(); jdbc.sql(""" diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileClient.java b/patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileClient.java new file mode 100644 index 0000000..1a66929 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileClient.java @@ -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> profiles(String ids) { + invocations.incrementAndGet(); + if (unavailable) { + throw new IllegalStateException("stub: user service unavailable"); + } + List parsed = Arrays.stream(ids.split(",")).map(UUID::fromString).toList(); + List 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(); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileConfig.java b/patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileConfig.java new file mode 100644 index 0000000..7889556 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/support/StubAuthorProfileConfig.java @@ -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); + } +} diff --git a/patbond-community/src/test/resources/application.yml b/patbond-community/src/test/resources/application.yml index 0482e99..b881e33 100644 --- a/patbond-community/src/test/resources/application.yml +++ b/patbond-community/src/test/resources/application.yml @@ -4,3 +4,12 @@ spring: application: name: patbond-community + +patbond: + # Feign client wiring must resolve at context start. Author-profile tests + # either replace the client bean with a DB-backed stub or (the wire test) + # override this URL with an in-test HTTP server; nothing ever calls this + # unroutable address. + user-service: + url: http://127.0.0.1:1 + internal-token: test-internal-token