feat: 公共 Feed 游标分页 + 作者公开资料链路(T3-05 / D3-9 方案 B,ADR-002/017)
CI / backend-test (push) Successful in 8m0s
CI / backend-test (push) Successful in 8m0s
- GET /api/v1/feed:仅 published+public+未删,(published_at DESC, id DESC) keyset
游标恰合 ix_posts_feed,{items,nextCursor,hasMore},禁 OFFSET
- FeedCard 定型:author/category/title/contentPreview(200 码点截断)/coverImage
(唯一 is_cover 行)/mediaCount/三计数(posts 冗余列)/likedByMe/bookmarkedByMe/publishedAt
- AuthorSummary 定型并回填 Post 详情(T3-04 偏差① authorId 占位闭环):
userId+nickname+avatarUrl;Feign 批量调 user /internal/users/profiles,
60s 进程内 TTL 缓存,avatarAssetId 经 media.assets 只读解析后本地签名
- 降级语义:user 服务不可达/出错时 Feed/详情照常 200,作者摘要退为仅 userId,
失败不入缓存;Feign 1s/2s 超时兜底
- compose 为 community 注入 PATBOND_USER_SERVICE_URL/PATBOND_INTERNAL_TOKEN
- 新增分页专项 8 例、卡片定型 5 例、作者链路 7 例、Feign 线路 3 例
(community 32→55;全仓 251→282 全绿,check-secrets --all 通过)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -125,6 +125,10 @@ services:
|
|||||||
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||||
|
# 作者公开资料(D3-9 方案 B):走 user 服务 /internal 批量接口,
|
||||||
|
# 服务间共享密钥与 auth/user 同一值。
|
||||||
|
PATBOND_USER_SERVICE_URL: http://user:8082
|
||||||
|
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
# 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一
|
# 媒体读取侧:帖子响应中图片 URL 的预签名 GET 与 user 服务同一凭证/同一
|
||||||
# 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
|
# 客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
|
||||||
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
|
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
|
||||||
|
|||||||
@@ -39,6 +39,18 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- Author public profiles come from patbond-user's /internal batch
|
||||||
|
API (D3-9 方案 B), static direct URL per ADR-002. feign-hc5 for
|
||||||
|
the same reason as patbond-auth: the JDK default client loses
|
||||||
|
error bodies on some replies. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.openfeign</groupId>
|
||||||
|
<artifactId>feign-hc5</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
|
|||||||
+9
-5
@@ -1,17 +1,21 @@
|
|||||||
package com.patbond.patbond.community;
|
package com.patbond.patbond.community;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.config.CommunityFeignConfig;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Community feed, posts and interactions service (M3, ADR-017: the community
|
* Community feed, posts and interactions service (M3, ADR-017: the community
|
||||||
* domain lives in its own Maven module on :8084). First-wave skeleton:
|
* domain lives in its own Maven module on :8084). Configuration wiring,
|
||||||
* configuration wiring, datasource, RS256 bearer auth on /api/v1/** and a
|
* datasource, RS256 bearer auth on /api/v1/**, the post lifecycle (T3-04)
|
||||||
* liveness endpoint — business endpoints follow the contract work in the
|
* and the public feed (T3-05). The module only reads and writes the
|
||||||
* next waves. The module only reads and writes the community schema
|
* community schema (plus the ADR-017 read-only media.assets exception);
|
||||||
* (author profile lookups follow the D3-9 plan later).
|
* author public profiles come from patbond-user's /internal batch API over
|
||||||
|
* Feign (D3-9 方案 B).
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableFeignClients(defaultConfiguration = CommunityFeignConfig.class)
|
||||||
public class CommunityApplication {
|
public class CommunityApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch public-profile API of patbond-user, the identity schema owner
|
||||||
|
* (D3-9 方案 B; static direct URL per ADR-002). The X-Internal-Token header
|
||||||
|
* is attached by the interceptor in CommunityFeignConfig. Unknown or
|
||||||
|
* deleted ids are silently absent from the reply. {@code primary = false}
|
||||||
|
* only matters to tests (lets a stub take precedence); in production this
|
||||||
|
* is the sole candidate.
|
||||||
|
*/
|
||||||
|
@FeignClient(name = "patbond-user-profiles", url = "${patbond.user-service.url}", primary = false)
|
||||||
|
public interface AuthorProfileClient {
|
||||||
|
|
||||||
|
/** @param ids comma-separated user ids, at most 50 per call */
|
||||||
|
@GetMapping("/internal/users/profiles")
|
||||||
|
ApiResponse<List<AuthorProfileDto>> profiles(@RequestParam("ids") String ids);
|
||||||
|
}
|
||||||
+14
@@ -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) {
|
||||||
|
}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.media.MediaAssetGateway;
|
||||||
|
import com.patbond.patbond.community.media.MediaAssetRef;
|
||||||
|
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author public-profile lookup (D3-9 方案 B): a batch Feign call to
|
||||||
|
* patbond-user's /internal/users/profiles behind a short-TTL in-process
|
||||||
|
* cache, plus local avatar resolution.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>Batch, never loop</b> — one call per ≤50 distinct cache-missed
|
||||||
|
* authors (a feed page has ≤20 cards, so normally exactly one call,
|
||||||
|
* and none on a warm cache).</li>
|
||||||
|
* <li><b>Avatar</b> — travels as an asset id; resolved to bucket/key via
|
||||||
|
* the ADR-017 read-only media.assets exception (ready assets only)
|
||||||
|
* and signed fresh per response, so the cache stores no expiring
|
||||||
|
* URL.</li>
|
||||||
|
* <li><b>Degradation</b> — ANY lookup failure (user service down, slow,
|
||||||
|
* or answering an error) logs one warning and leaves the ids
|
||||||
|
* unresolved; callers render the id-only summary. Failures are never
|
||||||
|
* cached, so the next request retries; the feed never 5xxes over a
|
||||||
|
* profile lookup.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AuthorProfileGateway {
|
||||||
|
|
||||||
|
private static final int MAX_BATCH = 50;
|
||||||
|
/** Expired entries are pruned opportunistically past this size. */
|
||||||
|
private static final int PRUNE_THRESHOLD = 10_000;
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthorProfileGateway.class);
|
||||||
|
|
||||||
|
private final AuthorProfileClient client;
|
||||||
|
private final MediaAssetGateway mediaAssetGateway;
|
||||||
|
private final MediaUrlSigner mediaUrlSigner;
|
||||||
|
private final AuthorProfileProperties properties;
|
||||||
|
private final ConcurrentHashMap<UUID, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public AuthorProfileGateway(AuthorProfileClient client, MediaAssetGateway mediaAssetGateway,
|
||||||
|
MediaUrlSigner mediaUrlSigner, AuthorProfileProperties properties) {
|
||||||
|
this.client = client;
|
||||||
|
this.mediaAssetGateway = mediaAssetGateway;
|
||||||
|
this.mediaUrlSigner = mediaUrlSigner;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summaries for the given authors, avatar URLs signed fresh. Ids that
|
||||||
|
* could not be resolved (lookup degraded, or the user no longer exists)
|
||||||
|
* are absent — callers fall back to
|
||||||
|
* {@link AuthorSummaryResponse#idOnly}.
|
||||||
|
*/
|
||||||
|
public Map<UUID, AuthorSummaryResponse> summarize(Collection<UUID> userIds) {
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
long now = System.nanoTime();
|
||||||
|
Map<UUID, AuthorRef> resolved = new HashMap<>();
|
||||||
|
List<UUID> misses = new ArrayList<>();
|
||||||
|
for (UUID id : new LinkedHashSet<>(userIds)) {
|
||||||
|
CacheEntry entry = cache.get(id);
|
||||||
|
if (entry != null && entry.expiresAtNanos() - now > 0) {
|
||||||
|
resolved.put(id, entry.ref());
|
||||||
|
} else {
|
||||||
|
misses.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!misses.isEmpty()) {
|
||||||
|
fetchInto(resolved, misses, now);
|
||||||
|
}
|
||||||
|
Map<UUID, AuthorSummaryResponse> summaries = new HashMap<>();
|
||||||
|
resolved.forEach((id, ref) -> summaries.put(id, new AuthorSummaryResponse(
|
||||||
|
id, ref.nickname(), mediaUrlSigner.signGet(ref.avatarBucket(), ref.avatarObjectKey()))));
|
||||||
|
return summaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fetchInto(Map<UUID, AuthorRef> resolved, List<UUID> misses, long now) {
|
||||||
|
List<AuthorProfileDto> profiles = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < misses.size(); i += MAX_BATCH) {
|
||||||
|
List<UUID> chunk = misses.subList(i, Math.min(i + MAX_BATCH, misses.size()));
|
||||||
|
ApiResponse<List<AuthorProfileDto>> reply = client.profiles(
|
||||||
|
chunk.stream().map(UUID::toString).collect(Collectors.joining(",")));
|
||||||
|
if (reply != null && reply.getData() != null) {
|
||||||
|
profiles.addAll(reply.getData());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// Chunks fetched before the failure still count below.
|
||||||
|
log.warn("作者公开资料获取失败,本次响应对未解析作者降级为 authorId 保底: {}",
|
||||||
|
e.toString());
|
||||||
|
}
|
||||||
|
if (profiles.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<UUID> assetIds = profiles.stream()
|
||||||
|
.map(AuthorProfileDto::avatarAssetId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Map<UUID, MediaAssetRef> assets = assetIds.isEmpty()
|
||||||
|
? Map.of()
|
||||||
|
: mediaAssetGateway.findByIds(assetIds);
|
||||||
|
long expiresAt = now + properties.getCacheTtl().toNanos();
|
||||||
|
for (AuthorProfileDto profile : profiles) {
|
||||||
|
MediaAssetRef asset = profile.avatarAssetId() == null
|
||||||
|
? null
|
||||||
|
: assets.get(profile.avatarAssetId());
|
||||||
|
boolean ready = asset != null && "ready".equals(asset.status());
|
||||||
|
AuthorRef ref = new AuthorRef(profile.nickname(),
|
||||||
|
ready ? asset.bucket() : null,
|
||||||
|
ready ? asset.objectKey() : null);
|
||||||
|
cache.put(profile.userId(), new CacheEntry(ref, expiresAt));
|
||||||
|
resolved.put(profile.userId(), ref);
|
||||||
|
}
|
||||||
|
if (cache.size() > PRUNE_THRESHOLD) {
|
||||||
|
cache.values().removeIf(entry -> entry.expiresAtNanos() - now <= 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record AuthorRef(String nickname, String avatarBucket, String avatarObjectKey) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CacheEntry(AuthorRef ref, long expiresAtNanos) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
|
import feign.Request;
|
||||||
|
import feign.RequestInterceptor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feign child-context beans, registered via
|
||||||
|
* {@code @EnableFeignClients(defaultConfiguration = …)} — deliberately not
|
||||||
|
* a @Configuration, same reasoning as patbond-auth's FeignInternalConfig
|
||||||
|
* (a component-scanned bean would land in the parent context and be
|
||||||
|
* shadowed by the child's defaults).
|
||||||
|
*
|
||||||
|
* <p>No ErrorDecoder on purpose: the only Feign consumer here is the author
|
||||||
|
* profile lookup, whose gateway degrades on ANY failure instead of
|
||||||
|
* propagating it — a downstream business error is as much "no profile" as a
|
||||||
|
* connection refusal. Timeouts are tight because this call sits on the feed
|
||||||
|
* read path: a hung patbond-user must cost one bounded stall, not an
|
||||||
|
* unbounded one (connection refused already fails fast on its own).</p>
|
||||||
|
*/
|
||||||
|
public class CommunityFeignConfig {
|
||||||
|
|
||||||
|
/** Presents the shared service secret on every call to patbond-user. */
|
||||||
|
@Bean
|
||||||
|
public RequestInterceptor internalTokenInterceptor(CommunitySecurityProperties properties) {
|
||||||
|
return template -> template.header("X-Internal-Token", properties.getInternalToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Request.Options feignOptions() {
|
||||||
|
return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-3
@@ -3,16 +3,32 @@ package com.patbond.patbond.community.config;
|
|||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security knobs of the community service: only the RS256 public key for
|
* Security knobs of the community service: the RS256 public key for
|
||||||
* verifying access tokens issued by patbond-auth (same contract as
|
* verifying access tokens issued by patbond-auth (same contract as
|
||||||
* patbond-user/pet's {@code patbond.jwt.public-key}). No /internal routes
|
* patbond-user/pet's {@code patbond.jwt.public-key}), and the shared
|
||||||
* exist here yet, so no service token property.
|
* service secret presented on outbound /internal/** calls to patbond-user
|
||||||
|
* (D3-9 方案 B author-profile lookups — this service still exposes no
|
||||||
|
* /internal routes of its own).
|
||||||
*/
|
*/
|
||||||
@ConfigurationProperties(prefix = "patbond")
|
@ConfigurationProperties(prefix = "patbond")
|
||||||
public class CommunitySecurityProperties {
|
public class CommunitySecurityProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared secret sent as X-Internal-Token on calls to patbond-user's
|
||||||
|
* /internal/** API; must equal the value patbond-user expects.
|
||||||
|
*/
|
||||||
|
private String internalToken;
|
||||||
|
|
||||||
private final Jwt jwt = new Jwt();
|
private final Jwt jwt = new Jwt();
|
||||||
|
|
||||||
|
public String getInternalToken() {
|
||||||
|
return internalToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInternalToken(String value) {
|
||||||
|
this.internalToken = value;
|
||||||
|
}
|
||||||
|
|
||||||
public Jwt getJwt() {
|
public Jwt getJwt() {
|
||||||
return jwt;
|
return jwt;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.patbond.patbond.community.config;
|
package com.patbond.patbond.community.config;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileProperties;
|
||||||
import com.patbond.patbond.community.security.BearerAuthFilter;
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
import com.patbond.patbond.community.security.JwtVerifier;
|
import com.patbond.patbond.community.security.JwtVerifier;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
@@ -15,7 +16,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
* unauthenticated.
|
* unauthenticated.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(CommunitySecurityProperties.class)
|
@EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class})
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.patbond.patbond.community.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.community.dto.FeedCardResponse;
|
||||||
|
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.community.service.FeedService;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public feed endpoint (T3-05). Authenticated like every /api/v1 route —
|
||||||
|
* the viewer identity feeds likedByMe/bookmarkedByMe; the feed content
|
||||||
|
* itself is the same for everyone (published + public only).
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
public class FeedController {
|
||||||
|
|
||||||
|
private final FeedService feedService;
|
||||||
|
|
||||||
|
public FeedController(FeedService feedService) {
|
||||||
|
this.feedService = feedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/feed")
|
||||||
|
public ApiResponse<CursorPage<FeedCardResponse>> feed(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
@Min(value = 1, message = "limit 最小为 1")
|
||||||
|
@Max(value = 100, message = "limit 最大为 100")
|
||||||
|
int limit,
|
||||||
|
@RequestParam(required = false) String cursor) {
|
||||||
|
return ApiResponse.success(feedService.list(userId, limit, cursor));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -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) {
|
||||||
|
}
|
||||||
@@ -5,16 +5,16 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full post shape (detail / my-posts list / write responses). Deviation from
|
* Full post shape (detail / my-posts list / write responses). The T3-04
|
||||||
* the contract draft, recorded for the T3-10 freeze: the draft's
|
* {@code authorId} placeholder is gone: {@code author} is the D3-9
|
||||||
* {@code author: AuthorSummary} is placeheld by {@code authorId} until T3-05
|
* AuthorSummary, degraded to its id-only shape when the profile lookup is
|
||||||
* lands the public-profile aggregation (工单口径:author 字段可先占位
|
* unavailable (contract deviation #1 closed by T3-05). Trimmed fields
|
||||||
* authorId). Trimmed fields (region/generationJob/topics …) do not appear at
|
* (region/generationJob/topics …) do not appear at all (ADR-018 + ADR-010
|
||||||
* all (ADR-018 + ADR-010 precedent).
|
* precedent).
|
||||||
*/
|
*/
|
||||||
public record PostResponse(
|
public record PostResponse(
|
||||||
UUID id,
|
UUID id,
|
||||||
UUID authorId,
|
AuthorSummaryResponse author,
|
||||||
UUID petId,
|
UUID petId,
|
||||||
String category,
|
String category,
|
||||||
String title,
|
String title,
|
||||||
|
|||||||
+29
@@ -1,5 +1,6 @@
|
|||||||
package com.patbond.patbond.community.repository;
|
package com.patbond.patbond.community.repository;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.support.FeedCursor;
|
||||||
import com.patbond.patbond.community.support.PostCursor;
|
import com.patbond.patbond.community.support.PostCursor;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
@@ -200,6 +201,34 @@ public class PostRepository {
|
|||||||
return spec.query(PostRepository::mapPost).list();
|
return spec.query(PostRepository::mapPost).list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One public-feed page in (published_at DESC, id DESC) — the exact key
|
||||||
|
* and predicate of the ix_posts_feed partial index. The explicit
|
||||||
|
* {@code deleted_at IS NULL} is belt-and-braces: softDelete parks
|
||||||
|
* published rows as 'archived', so status='published' already implies
|
||||||
|
* live (ck_posts_publish_state), and the planner still matches the
|
||||||
|
* partial index. The caller asks for limit+1 rows to learn whether more
|
||||||
|
* exist.
|
||||||
|
*/
|
||||||
|
public List<PostRow> pageFeed(UUID viewerId, FeedCursor after, int limitPlusOne) {
|
||||||
|
String sql = SELECT_POST + """
|
||||||
|
WHERE p.status = 'published' AND p.visibility = 'public'
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
""";
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (p.published_at, p.id) < (:cursorPublishedAt, :cursorId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY p.published_at DESC, p.id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("viewerId", viewerId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorPublishedAt", after.publishedAt())
|
||||||
|
.param("cursorId", after.id());
|
||||||
|
}
|
||||||
|
return spec.query(PostRepository::mapPost).list();
|
||||||
|
}
|
||||||
|
|
||||||
public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
|
public void insertMedia(UUID postId, int position, UUID assetId, boolean isCover, String caption) {
|
||||||
jdbcClient.sql("""
|
jdbcClient.sql("""
|
||||||
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
|
INSERT INTO community.post_media (post_id, position, asset_id, is_cover, caption)
|
||||||
|
|||||||
+118
@@ -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<FeedCardResponse> list(UUID viewerId, int limit, String cursor) {
|
||||||
|
FeedCursor after = cursor == null ? null : FeedCursor.decode(cursor);
|
||||||
|
List<PostRow> rows = postRepository.pageFeed(viewerId, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new FeedCursor(page.get(limit - 1).publishedAt(), page.get(limit - 1).id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(assembleCards(page), nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FeedCardResponse> assembleCards(List<PostRow> rows) {
|
||||||
|
Map<UUID, List<PostMediaRow>> mediaByPost = postRepository
|
||||||
|
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.groupingBy(PostMediaRow::postId));
|
||||||
|
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
|
||||||
|
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
|
||||||
|
return rows.stream().map(row -> {
|
||||||
|
List<PostMediaRow> media = mediaByPost.getOrDefault(row.id(), List.of());
|
||||||
|
return new FeedCardResponse(
|
||||||
|
row.id(),
|
||||||
|
authors.getOrDefault(row.authorUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||||
|
row.category(),
|
||||||
|
row.title(),
|
||||||
|
preview(row.content()),
|
||||||
|
coverOf(media),
|
||||||
|
media.size(),
|
||||||
|
row.likeCount(),
|
||||||
|
row.commentCount(),
|
||||||
|
row.bookmarkCount(),
|
||||||
|
row.likedByMe(),
|
||||||
|
row.bookmarkedByMe(),
|
||||||
|
row.publishedAt());
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The is_cover row (unique per post, and present whenever media exist —
|
||||||
|
* T3-04 §2.6 sets it on position 0 when the author picked none).
|
||||||
|
*/
|
||||||
|
private PostMediaItemResponse coverOf(List<PostMediaRow> media) {
|
||||||
|
return media.stream()
|
||||||
|
.filter(PostMediaRow::isCover)
|
||||||
|
.findFirst()
|
||||||
|
.map(m -> new PostMediaItemResponse(
|
||||||
|
m.assetId(),
|
||||||
|
m.position(),
|
||||||
|
m.isCover(),
|
||||||
|
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
|
||||||
|
m.widthPx(),
|
||||||
|
m.heightPx(),
|
||||||
|
m.caption()))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview = the first {@value #PREVIEW_CODE_POINTS} code points of the
|
||||||
|
* stored content, cut on a code-point boundary (no surrogate is ever
|
||||||
|
* split), no ellipsis appended — whether the card is a truncation is
|
||||||
|
* the client's call via {@code contentPreview.length} vs its own
|
||||||
|
* rendering, and the full text always comes from the detail endpoint.
|
||||||
|
*/
|
||||||
|
static String preview(String content) {
|
||||||
|
if (content.codePointCount(0, content.length()) <= PREVIEW_CODE_POINTS) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return content.substring(0, content.offsetByCodePoints(0, PREVIEW_CODE_POINTS));
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -3,6 +3,8 @@ package com.patbond.patbond.community.service;
|
|||||||
import com.patbond.patbond.common.error.BusinessException;
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
import com.patbond.patbond.common.error.ErrorCode;
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
import com.patbond.patbond.community.access.PetVisibilityGateway;
|
import com.patbond.patbond.community.access.PetVisibilityGateway;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileGateway;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
import com.patbond.patbond.community.dto.CreatePostRequest;
|
import com.patbond.patbond.community.dto.CreatePostRequest;
|
||||||
import com.patbond.patbond.community.dto.CursorPage;
|
import com.patbond.patbond.community.dto.CursorPage;
|
||||||
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
|
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
|
||||||
@@ -65,13 +67,16 @@ public class PostService {
|
|||||||
private final MediaAssetGateway mediaAssetGateway;
|
private final MediaAssetGateway mediaAssetGateway;
|
||||||
private final MediaUrlSigner mediaUrlSigner;
|
private final MediaUrlSigner mediaUrlSigner;
|
||||||
private final PetVisibilityGateway petVisibilityGateway;
|
private final PetVisibilityGateway petVisibilityGateway;
|
||||||
|
private final AuthorProfileGateway authorProfileGateway;
|
||||||
|
|
||||||
public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway,
|
public PostService(PostRepository postRepository, MediaAssetGateway mediaAssetGateway,
|
||||||
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway) {
|
MediaUrlSigner mediaUrlSigner, PetVisibilityGateway petVisibilityGateway,
|
||||||
|
AuthorProfileGateway authorProfileGateway) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.mediaAssetGateway = mediaAssetGateway;
|
this.mediaAssetGateway = mediaAssetGateway;
|
||||||
this.mediaUrlSigner = mediaUrlSigner;
|
this.mediaUrlSigner = mediaUrlSigner;
|
||||||
this.petVisibilityGateway = petVisibilityGateway;
|
this.petVisibilityGateway = petVisibilityGateway;
|
||||||
|
this.authorProfileGateway = authorProfileGateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -374,9 +379,12 @@ public class PostService {
|
|||||||
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
|
.findMediaByPostIds(rows.stream().map(PostRow::id).toList())
|
||||||
.stream()
|
.stream()
|
||||||
.collect(Collectors.groupingBy(PostMediaRow::postId));
|
.collect(Collectors.groupingBy(PostMediaRow::postId));
|
||||||
|
Map<UUID, AuthorSummaryResponse> authors = authorProfileGateway.summarize(
|
||||||
|
rows.stream().map(PostRow::authorUserId).collect(Collectors.toSet()));
|
||||||
return rows.stream().map(row -> new PostResponse(
|
return rows.stream().map(row -> new PostResponse(
|
||||||
row.id(),
|
row.id(),
|
||||||
row.authorUserId(),
|
authors.getOrDefault(row.authorUserId(),
|
||||||
|
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||||
row.petId(),
|
row.petId(),
|
||||||
row.category(),
|
row.category(),
|
||||||
row.title(),
|
row.title(),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor of the public feed (published_at DESC, id DESC — the exact
|
||||||
|
* key of ix_posts_feed), same encoding as {@link PostCursor}:
|
||||||
|
* base64url("epochMicros:id"), next page selects
|
||||||
|
* {@code (published_at, id) < (cursor)} so ties on published_at are broken
|
||||||
|
* by id and rows are neither lost nor repeated across page boundaries.
|
||||||
|
* timestamptz carries microseconds, so the micros encoding is lossless.
|
||||||
|
*/
|
||||||
|
public record FeedCursor(OffsetDateTime publishedAt, UUID id) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(publishedAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ publishedAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static FeedCursor decode(String cursor) {
|
||||||
|
try {
|
||||||
|
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||||
|
int sep = raw.indexOf(':');
|
||||||
|
long micros = Long.parseLong(raw.substring(0, sep));
|
||||||
|
UUID id = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime publishedAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new FeedCursor(publishedAt, id);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,16 @@ patbond:
|
|||||||
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||||
# 私钥只给 patbond-auth,绝不入库。
|
# 私钥只给 patbond-auth,绝不入库。
|
||||||
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
||||||
|
# 作者公开资料来源(D3-9 方案 B):patbond-user 的 /internal 批量接口,
|
||||||
|
# ADR-002 静态直连。不可达时 Feed/详情照常返回,作者摘要降级为仅 userId。
|
||||||
|
user-service:
|
||||||
|
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
|
||||||
|
# /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须
|
||||||
|
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
|
||||||
|
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
|
||||||
|
author-profile:
|
||||||
|
# 作者公开资料的进程内缓存 TTL:昵称/头像变更最迟一分钟可见。
|
||||||
|
cache-ttl: ${PATBOND_AUTHOR_PROFILE_CACHE_TTL:60s}
|
||||||
media:
|
media:
|
||||||
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
|
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
|
||||||
# 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。
|
# 签名计算生成图片访问 URL,从不直连对象存储;写入流程在 patbond-user。
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
package com.patbond.patbond.community.author;
|
||||||
|
|
||||||
|
import com.patbond.patbond.community.TestcontainersConfiguration;
|
||||||
|
import com.patbond.patbond.community.dto.AuthorSummaryResponse;
|
||||||
|
import com.patbond.patbond.community.support.CommunityTestData;
|
||||||
|
import com.patbond.patbond.community.support.TestJwtKeys;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real Feign wiring against an in-test HTTP server standing in for
|
||||||
|
* patbond-user: static URL resolution, the X-Internal-Token interceptor,
|
||||||
|
* query-string batching, envelope decoding, avatar resolution through
|
||||||
|
* media.assets plus URL signing — and degradation when the downstream
|
||||||
|
* answers an error. (The /internal endpoint itself is tested in the
|
||||||
|
* patbond-user module; the DB-backed stub covers the service-level tests.)
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfiguration.class)
|
||||||
|
class AuthorProfileClientWireTest {
|
||||||
|
|
||||||
|
private static final HttpServer SERVER;
|
||||||
|
private static final AtomicReference<String> RESPONSE_BODY = new AtomicReference<>("");
|
||||||
|
private static final AtomicInteger RESPONSE_STATUS = new AtomicInteger(200);
|
||||||
|
private static final AtomicReference<String> SEEN_TOKEN = new AtomicReference<>();
|
||||||
|
private static final AtomicReference<String> SEEN_QUERY = new AtomicReference<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
try {
|
||||||
|
SERVER = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
SERVER.createContext("/internal/users/profiles", exchange -> {
|
||||||
|
SEEN_TOKEN.set(exchange.getRequestHeaders().getFirst("X-Internal-Token"));
|
||||||
|
SEEN_QUERY.set(exchange.getRequestURI().getRawQuery());
|
||||||
|
byte[] body = RESPONSE_BODY.get().getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(RESPONSE_STATUS.get(), body.length);
|
||||||
|
try (OutputStream out = exchange.getResponseBody()) {
|
||||||
|
out.write(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
SERVER.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void properties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||||
|
registry.add("patbond.user-service.url",
|
||||||
|
() -> "http://127.0.0.1:" + SERVER.getAddress().getPort());
|
||||||
|
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
|
||||||
|
registry.add("patbond.media.access-key", () -> "test-access-key");
|
||||||
|
registry.add("patbond.media.secret-key", () -> "test-secret-key");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void stopServer() {
|
||||||
|
SERVER.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AuthorProfileGateway gateway;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void resetServer() {
|
||||||
|
RESPONSE_STATUS.set(200);
|
||||||
|
RESPONSE_BODY.set("{\"code\":0,\"message\":\"success\",\"data\":[]}");
|
||||||
|
SEEN_TOKEN.set(null);
|
||||||
|
SEEN_QUERY.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID newUser() {
|
||||||
|
return CommunityTestData.insertUser(jdbcClient,
|
||||||
|
"w" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void presentsTheServiceSecretAndBatchesIdsIntoOneQuery() {
|
||||||
|
UUID userA = UUID.randomUUID();
|
||||||
|
UUID userB = UUID.randomUUID();
|
||||||
|
RESPONSE_BODY.set("""
|
||||||
|
{"code":0,"message":"success","data":[
|
||||||
|
{"userId":"%s","nickname":"小白","avatarAssetId":null}
|
||||||
|
]}""".formatted(userA));
|
||||||
|
|
||||||
|
Map<UUID, AuthorSummaryResponse> summaries =
|
||||||
|
gateway.summarize(java.util.List.of(userA, userB));
|
||||||
|
|
||||||
|
assertThat(SEEN_TOKEN.get()).isEqualTo("test-internal-token");
|
||||||
|
String ids = URLDecoder.decode(SEEN_QUERY.get(), StandardCharsets.UTF_8)
|
||||||
|
.replaceFirst("^ids=", "");
|
||||||
|
assertThat(ids.split(",")).containsExactlyInAnyOrder(
|
||||||
|
userA.toString(), userB.toString());
|
||||||
|
assertThat(summaries).containsOnlyKeys(userA);
|
||||||
|
assertThat(summaries.get(userA).nickname()).isEqualTo("小白");
|
||||||
|
assertThat(summaries.get(userA).avatarUrl()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesTheAvatarAssetLocallyAndSignsTheUrl() {
|
||||||
|
UUID owner = newUser();
|
||||||
|
UUID assetId = CommunityTestData.insertReadyAsset(jdbcClient, owner);
|
||||||
|
RESPONSE_BODY.set("""
|
||||||
|
{"code":0,"message":"success","data":[
|
||||||
|
{"userId":"%s","nickname":"有头像","avatarAssetId":"%s"}
|
||||||
|
]}""".formatted(owner, assetId));
|
||||||
|
|
||||||
|
AuthorSummaryResponse summary = gateway.summarize(java.util.List.of(owner)).get(owner);
|
||||||
|
assertThat(summary.nickname()).isEqualTo("有头像");
|
||||||
|
assertThat(summary.avatarUrl())
|
||||||
|
.contains(assetId.toString())
|
||||||
|
.contains("X-Amz-Signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aDownstreamErrorDegradesToNoSummaries() {
|
||||||
|
RESPONSE_STATUS.set(500);
|
||||||
|
RESPONSE_BODY.set("{\"code\":50000,\"message\":\"boom\",\"data\":null}");
|
||||||
|
assertThat(gateway.summarize(java.util.List.of(UUID.randomUUID()))).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
@@ -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("恢复后的昵称");
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
package com.patbond.patbond.community.post;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed pagination 专项 (T3-05 工单要求): empty feed, single page, page
|
||||||
|
* walking with no loss and no duplication (including published_at ties and
|
||||||
|
* inserts/deletes between page fetches), cursor validity, and the
|
||||||
|
* visibility predicate. The feed is global state, so every test starts
|
||||||
|
* from a wiped community.posts (the FK cascades clear media/likes/
|
||||||
|
* bookmarks); other test classes create their own rows per test and run
|
||||||
|
* sequentially, so the wipe races nothing.
|
||||||
|
*/
|
||||||
|
class FeedPaginationIntegrationTest extends PostApiTestBase {
|
||||||
|
|
||||||
|
private UUID viewer;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void wipeFeed() {
|
||||||
|
jdbcClient.sql("DELETE FROM community.posts").update();
|
||||||
|
viewer = newUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MockHttpServletRequestBuilder feed(UUID userId, Integer limit, String cursor) {
|
||||||
|
MockHttpServletRequestBuilder builder = authed(get("/api/v1/feed"), userId);
|
||||||
|
if (limit != null) {
|
||||||
|
builder = builder.queryParam("limit", String.valueOf(limit));
|
||||||
|
}
|
||||||
|
if (cursor != null) {
|
||||||
|
builder = builder.queryParam("cursor", cursor);
|
||||||
|
}
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode feedPage(UUID userId, Integer limit, String cursor) throws Exception {
|
||||||
|
MvcResult result = mockMvc.perform(feed(userId, limit, cursor))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
return data(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID publish(UUID author, String content) throws Exception {
|
||||||
|
JsonNode post = createPost(author,
|
||||||
|
"{\"content\": \"%s\", \"status\": \"published\"}".formatted(content));
|
||||||
|
return UUID.fromString(post.get("id").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> idsOf(JsonNode page) {
|
||||||
|
List<String> ids = new ArrayList<>();
|
||||||
|
page.get("items").forEach(item -> ids.add(item.get("id").asText()));
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyFeedIsAnEmptyPage() throws Exception {
|
||||||
|
JsonNode page = feedPage(viewer, null, null);
|
||||||
|
assertThat(page.get("items")).isEmpty();
|
||||||
|
assertThat(page.get("hasMore").asBoolean()).isFalse();
|
||||||
|
assertThat(page.get("nextCursor").isNull()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singlePageListsNewestFirstWithoutACursor() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID first = publish(author, "一号帖");
|
||||||
|
UUID second = publish(author, "二号帖");
|
||||||
|
|
||||||
|
JsonNode page = feedPage(viewer, null, null);
|
||||||
|
assertThat(idsOf(page)).containsExactly(second.toString(), first.toString());
|
||||||
|
assertThat(page.get("hasMore").asBoolean()).isFalse();
|
||||||
|
assertThat(page.get("nextCursor").isNull()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyLivePublishedPublicPostsAppear() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
UUID visible = publish(author, "可见的帖子");
|
||||||
|
createPost(author, "{\"content\": \"草稿不进 Feed\"}");
|
||||||
|
UUID deleted = publish(author, "删除后不进 Feed");
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
UUID hidden = publish(author, "hidden 不进 Feed");
|
||||||
|
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
|
||||||
|
.param("id", hidden)
|
||||||
|
.update();
|
||||||
|
UUID nonPublic = publish(author, "followers 可见性不进 Feed");
|
||||||
|
jdbcClient.sql("UPDATE community.posts SET visibility = 'followers' WHERE id = :id")
|
||||||
|
.param("id", nonPublic)
|
||||||
|
.update();
|
||||||
|
|
||||||
|
JsonNode page = feedPage(viewer, null, null);
|
||||||
|
assertThat(idsOf(page)).containsExactly(visible.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pageWalkLosesNothingAndRepeatsNothing() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
List<String> published = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 7; i++) {
|
||||||
|
published.add(publish(author, "翻页帖 " + i).toString());
|
||||||
|
}
|
||||||
|
List<String> expected = new ArrayList<>(published);
|
||||||
|
java.util.Collections.reverse(expected);
|
||||||
|
|
||||||
|
List<String> crawled = new ArrayList<>();
|
||||||
|
String cursor = null;
|
||||||
|
int pages = 0;
|
||||||
|
while (true) {
|
||||||
|
JsonNode page = feedPage(viewer, 3, cursor);
|
||||||
|
crawled.addAll(idsOf(page));
|
||||||
|
pages++;
|
||||||
|
if (!page.get("hasMore").asBoolean()) {
|
||||||
|
assertThat(page.get("nextCursor").isNull()).isTrue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = page.get("nextCursor").asText();
|
||||||
|
}
|
||||||
|
assertThat(pages).isEqualTo(3);
|
||||||
|
assertThat(crawled).containsExactlyElementsOf(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publishedAtTiesAreBrokenByIdWithoutLossOrDuplication() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
List<UUID> ids = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
ids.add(publish(author, "同刻帖 " + i));
|
||||||
|
}
|
||||||
|
OffsetDateTime sameInstant = OffsetDateTime.now();
|
||||||
|
for (UUID id : ids) {
|
||||||
|
jdbcClient.sql("UPDATE community.posts SET published_at = :ts WHERE id = :id")
|
||||||
|
.param("ts", sameInstant)
|
||||||
|
.param("id", id)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
List<String> expected = ids.stream()
|
||||||
|
.map(UUID::toString)
|
||||||
|
.sorted(java.util.Comparator.reverseOrder())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
JsonNode page1 = feedPage(viewer, 2, null);
|
||||||
|
JsonNode page2 = feedPage(viewer, 2, page1.get("nextCursor").asText());
|
||||||
|
List<String> crawled = new ArrayList<>(idsOf(page1));
|
||||||
|
crawled.addAll(idsOf(page2));
|
||||||
|
assertThat(crawled).containsExactlyElementsOf(expected);
|
||||||
|
assertThat(page2.get("hasMore").asBoolean()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertsAndDeletesBetweenPagesNeitherShiftNorRepeat() throws Exception {
|
||||||
|
UUID author = newUser();
|
||||||
|
List<UUID> ids = new ArrayList<>();
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
ids.add(publish(author, "间隙帖 " + i));
|
||||||
|
}
|
||||||
|
// Oldest→newest is ids[0..4]; page 1 (limit 2) shows ids[4], ids[3].
|
||||||
|
JsonNode page1 = feedPage(viewer, 2, null);
|
||||||
|
assertThat(idsOf(page1)).containsExactly(ids.get(4).toString(), ids.get(3).toString());
|
||||||
|
|
||||||
|
// Between the fetches: a new post lands (newer than the cursor — must
|
||||||
|
// NOT shift page 2) and one page-2 candidate is deleted (must vanish
|
||||||
|
// without repeating anything).
|
||||||
|
publish(author, "翻页间隙新发布");
|
||||||
|
mockMvc.perform(authed(delete("/api/v1/posts/" + ids.get(2)), author))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
JsonNode page2 = feedPage(viewer, 2, page1.get("nextCursor").asText());
|
||||||
|
assertThat(idsOf(page2)).containsExactly(ids.get(1).toString(), ids.get(0).toString());
|
||||||
|
assertThat(page2.get("hasMore").asBoolean()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidCursorsAreA400() throws Exception {
|
||||||
|
mockMvc.perform(feed(viewer, null, "not-base64url!!"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(feed(viewer, null,
|
||||||
|
java.util.Base64.getUrlEncoder().encodeToString("garbage".getBytes())))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void limitOutOfBoundsIsA400() throws Exception {
|
||||||
|
mockMvc.perform(feed(viewer, 0, null))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
mockMvc.perform(feed(viewer, 101, null))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.patbond.patbond.community.TestcontainersConfiguration;
|
import com.patbond.patbond.community.TestcontainersConfiguration;
|
||||||
import com.patbond.patbond.community.support.CommunityTestData;
|
import com.patbond.patbond.community.support.CommunityTestData;
|
||||||
|
import com.patbond.patbond.community.support.StubAuthorProfileConfig;
|
||||||
import com.patbond.patbond.community.support.TestJwtKeys;
|
import com.patbond.patbond.community.support.TestJwtKeys;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
@@ -32,7 +33,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@Import(TestcontainersConfiguration.class)
|
@Import({TestcontainersConfiguration.class, StubAuthorProfileConfig.class})
|
||||||
public abstract class PostApiTestBase {
|
public abstract class PostApiTestBase {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ class PostLifecycleIntegrationTest extends PostApiTestBase {
|
|||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.code").value(0))
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
.andExpect(jsonPath("$.data.id").isNotEmpty())
|
.andExpect(jsonPath("$.data.id").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.data.authorId").value(author.toString()))
|
.andExpect(jsonPath("$.data.author.userId").value(author.toString()))
|
||||||
.andExpect(jsonPath("$.data.title").value("第一帖"))
|
.andExpect(jsonPath("$.data.title").value("第一帖"))
|
||||||
.andExpect(jsonPath("$.data.content").value("大家好"))
|
.andExpect(jsonPath("$.data.content").value("大家好"))
|
||||||
.andExpect(jsonPath("$.data.category").value("general"))
|
.andExpect(jsonPath("$.data.category").value("general"))
|
||||||
|
|||||||
+35
@@ -25,6 +25,23 @@ public final class CommunityTestData {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void setNickname(JdbcClient jdbc, UUID userId, String nickname) {
|
||||||
|
jdbc.sql("UPDATE identity.users SET nickname = :nickname WHERE id = :id")
|
||||||
|
.param("nickname", nickname)
|
||||||
|
.param("id", userId)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives the user an avatar asset in the given status; returns the asset id. */
|
||||||
|
public static UUID attachAvatar(JdbcClient jdbc, UUID userId, String status) {
|
||||||
|
UUID assetId = insertAsset(jdbc, userId, status);
|
||||||
|
jdbc.sql("UPDATE identity.users SET avatar_asset_id = :assetId WHERE id = :id")
|
||||||
|
.param("assetId", assetId)
|
||||||
|
.param("id", userId)
|
||||||
|
.update();
|
||||||
|
return assetId;
|
||||||
|
}
|
||||||
|
|
||||||
/** One ready image asset owned by the given user, as T3-03 would leave it. */
|
/** One ready image asset owned by the given user, as T3-03 would leave it. */
|
||||||
public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) {
|
public static UUID insertReadyAsset(JdbcClient jdbc, UUID ownerUserId) {
|
||||||
return insertAsset(jdbc, ownerUserId, "ready");
|
return insertAsset(jdbc, ownerUserId, "ready");
|
||||||
@@ -48,6 +65,24 @@ public final class CommunityTestData {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A published post inserted straight into community.posts — used when a
|
||||||
|
* test must NOT go through the create API (whose response assembly
|
||||||
|
* would already resolve and cache the author's profile).
|
||||||
|
*/
|
||||||
|
public static UUID insertPublishedPost(JdbcClient jdbc, UUID authorUserId, String content) {
|
||||||
|
UUID id = UuidV7.generate();
|
||||||
|
jdbc.sql("""
|
||||||
|
INSERT INTO community.posts (id, author_user_id, content, status, published_at)
|
||||||
|
VALUES (:id, :author, :content, 'published', now())
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.param("author", authorUserId)
|
||||||
|
.param("content", content)
|
||||||
|
.update();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
public static UUID insertPetOwnedBy(JdbcClient jdbc, UUID ownerUserId) {
|
public static UUID insertPetOwnedBy(JdbcClient jdbc, UUID ownerUserId) {
|
||||||
UUID id = UuidV7.generate();
|
UUID id = UuidV7.generate();
|
||||||
jdbc.sql("""
|
jdbc.sql("""
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileClient;
|
||||||
|
import com.patbond.patbond.community.author.AuthorProfileDto;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process stand-in for patbond-user's /internal/users/profiles, wired in
|
||||||
|
* place of the Feign proxy (工单许可:Feign 层用替身,/internal 端点自身在
|
||||||
|
* patbond-user 模块测全;两服务同 JVM 的 AuthE2e 先例成本过高)。 It answers
|
||||||
|
* from identity.users with the same query the real endpoint runs — including
|
||||||
|
* the nickname→username fallback — so profile tests seed users exactly like
|
||||||
|
* every other cross-schema fixture. {@link #unavailable} simulates the user
|
||||||
|
* service being down (the gateway must degrade, not 5xx);
|
||||||
|
* {@link #invocations} makes the cache observable.
|
||||||
|
*/
|
||||||
|
public class StubAuthorProfileClient implements AuthorProfileClient {
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
private final AtomicInteger invocations = new AtomicInteger();
|
||||||
|
private volatile boolean unavailable;
|
||||||
|
|
||||||
|
public StubAuthorProfileClient(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ApiResponse<List<AuthorProfileDto>> profiles(String ids) {
|
||||||
|
invocations.incrementAndGet();
|
||||||
|
if (unavailable) {
|
||||||
|
throw new IllegalStateException("stub: user service unavailable");
|
||||||
|
}
|
||||||
|
List<UUID> parsed = Arrays.stream(ids.split(",")).map(UUID::fromString).toList();
|
||||||
|
List<AuthorProfileDto> profiles = jdbcClient.sql("""
|
||||||
|
SELECT id, COALESCE(nickname, username::text) AS nickname, avatar_asset_id
|
||||||
|
FROM identity.users
|
||||||
|
WHERE id IN (:ids) AND deleted_at IS NULL
|
||||||
|
""")
|
||||||
|
.param("ids", parsed)
|
||||||
|
.query((rs, rowNum) -> new AuthorProfileDto(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getString("nickname"),
|
||||||
|
rs.getObject("avatar_asset_id", UUID.class)))
|
||||||
|
.list();
|
||||||
|
return ApiResponse.success(profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnavailable(boolean value) {
|
||||||
|
this.unavailable = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int invocationCount() {
|
||||||
|
return invocations.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.patbond.patbond.community.support;
|
||||||
|
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the AuthorProfileClient Feign proxy with the DB-backed stub for
|
||||||
|
* the shared post/feed test context. The Feign machinery itself (URL, token
|
||||||
|
* interceptor, envelope decoding) is exercised separately by
|
||||||
|
* AuthorProfileClientWireTest against a real HTTP server.
|
||||||
|
*/
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
public class StubAuthorProfileConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
public StubAuthorProfileClient stubAuthorProfileClient(JdbcClient jdbcClient) {
|
||||||
|
return new StubAuthorProfileClient(jdbcClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,12 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: patbond-community
|
name: patbond-community
|
||||||
|
|
||||||
|
patbond:
|
||||||
|
# Feign client wiring must resolve at context start. Author-profile tests
|
||||||
|
# either replace the client bean with a DB-backed stub or (the wire test)
|
||||||
|
# override this URL with an in-test HTTP server; nothing ever calls this
|
||||||
|
# unroutable address.
|
||||||
|
user-service:
|
||||||
|
url: http://127.0.0.1:1
|
||||||
|
internal-token: test-internal-token
|
||||||
|
|||||||
Reference in New Issue
Block a user