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