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