Compare commits
10 Commits
101ac0fbbc
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd8005577 | |||
| d98a400f47 | |||
| 15c2e66519 | |||
| a5634c5f85 | |||
| 8089c06a73 | |||
| 0569585434 | |||
| 7f1dd33097 | |||
| 19e8cba59f | |||
| 99a3c1f8ab | |||
| 40bac85543 |
@@ -102,6 +102,11 @@ 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
|
||||
# 媒体读取侧(M3.5 T3.5-05):宠物头像的预签名 GET 与 user 服务同一凭证/
|
||||
# 同一客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
|
||||
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
|
||||
PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
volumes:
|
||||
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||
- ./deploy/keys:/run/patbond/keys:ro
|
||||
@@ -125,6 +130,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}
|
||||
|
||||
+95
-11
@@ -20,6 +20,7 @@ import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
@@ -38,16 +39,24 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
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 仓
|
||||
* T3-19(D3-8):auth 域 M1 操作补进契约一致性保障,机制与
|
||||
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照
|
||||
* {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
|
||||
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
|
||||
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
|
||||
*
|
||||
* <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP
|
||||
* 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内
|
||||
* 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例),
|
||||
* 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。
|
||||
* 因此这些操作是跨服务的真实纵切,不是 MockMvc 短路。
|
||||
*
|
||||
* <p><b>T3.5-07</b>:v1.4.0 把 {@code PATCH /api/v1/me} 与 {@code GET} 的
|
||||
* nickname/avatarUrl 纳入冻结面,故本类的操作面自 6 增至 7。注意
|
||||
* {@code /api/v1/me} 的守卫与矩阵都在 **auth 模块**(实现在 patbond-user,
|
||||
* 但契约测试在这里跨服务发请求),扩契约时容易漏。本类不配置对象存储,
|
||||
* 故 {@code avatarUrl} 恒为 null——正是「存储未配置时资料读取整体降级」的
|
||||
* nullable 实证;真实签名 URL 的全链路在 patbond-user 的
|
||||
* MeAvatarSigningIntegrationTest(真实 MinIO)覆盖。
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
@SpringBootTest(
|
||||
@@ -63,13 +72,14 @@ class AuthContractConformanceTest {
|
||||
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
|
||||
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** auth 域 6 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
|
||||
/** auth 域 7 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
|
||||
private static final List<String> AUTH_OPERATIONS = List.of(
|
||||
"POST /api/v1/auth/register",
|
||||
"POST /api/v1/auth/login",
|
||||
"POST /api/v1/auth/refresh",
|
||||
"POST /api/v1/auth/logout",
|
||||
"GET /api/v1/me",
|
||||
"PATCH /api/v1/me",
|
||||
"POST /api/v1/events");
|
||||
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
|
||||
@@ -305,6 +315,80 @@ class AuthContractConformanceTest {
|
||||
null, 423, 42300);
|
||||
}
|
||||
|
||||
// ---- PATCH /api/v1/me(v1.4.0 新增操作,T3.5-07)--------------------
|
||||
|
||||
/**
|
||||
* `PATCH /api/v1/me` 的全响应矩阵(200/400/401/404/422)与三态语义的形态
|
||||
* 实证,外加 GET 在 nickname 非空分支上再走一遍严格校验。
|
||||
*
|
||||
* <p>422/42203 需要一枚「本人所有、用途 user_avatar、状态 uploading」的
|
||||
* asset:本上下文未配置对象存储(创建上传会 500),故按 MeProfileIntegrationTest
|
||||
* 的先例直接写 media.assets 行——测试数据造法,不触碰实现。
|
||||
*/
|
||||
@Test
|
||||
@Order(7)
|
||||
void meProfileWriteShapes() {
|
||||
String registered = register("contract_auth_frank", "+8613800000608");
|
||||
String accessToken = JsonPath.read(registered, "$.data.accessToken");
|
||||
UUID userId = UUID.fromString(JsonPath.read(registered, "$.data.userId"));
|
||||
|
||||
// 200:三态「给值」——设昵称;avatarUrl 为 null(存储未配置时的降级实证)
|
||||
String patched = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{\"nickname\":\"契约昵称\"}", accessToken, 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("契约昵称");
|
||||
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
|
||||
|
||||
// GET 回读:nickname 非空分支同样过严格校验(PATCH 与 GET 同一 Me 形态)
|
||||
String reread = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
null, accessToken, 200);
|
||||
assertThat((String) JsonPath.read(reread, "$.data.nickname")).isEqualTo("契约昵称");
|
||||
|
||||
// 200:三态「显式 null = 清空」
|
||||
String cleared = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{\"nickname\":null}", accessToken, 200);
|
||||
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
|
||||
|
||||
// 400/40000:空 patch(不静默 200)
|
||||
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{}", accessToken, 400, 40000);
|
||||
|
||||
// 401/40101:无 token
|
||||
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{\"nickname\":\"无票\"}", null, 401, 40101);
|
||||
|
||||
// 404:同一单元格的两种业务码——用户已注销 40400 / asset 不可引用 40405
|
||||
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.randomUUID().toString(), Duration.ofMinutes(15));
|
||||
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{\"nickname\":\"幽灵\"}", ghostToken, 404, 40400);
|
||||
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()),
|
||||
accessToken, 404, 40405);
|
||||
|
||||
// 422/42203:本人的 user_avatar asset 仍在 uploading
|
||||
UUID uploading = insertUserAvatarAsset(userId, "uploading");
|
||||
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(uploading), accessToken, 422, 42203);
|
||||
}
|
||||
|
||||
/** 一枚本人所有的 user_avatar asset 行(模拟 T3-03 两步上传的中间产物)。 */
|
||||
private UUID insertUserAvatarAsset(UUID ownerUserId, String status) {
|
||||
UUID id = UUID.randomUUID();
|
||||
userApp.getBean(JdbcClient.class).sql("""
|
||||
INSERT INTO media.assets
|
||||
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
|
||||
mime_type, byte_size, status)
|
||||
VALUES (:id, :owner, 'image', 'user_avatar', 'object', 'patbond-media',
|
||||
:objectKey, 'image/jpeg', 2048, :status)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("objectKey", "user_avatar/2026/09/" + id)
|
||||
.param("status", status)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -314,17 +398,17 @@ 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.4.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(32);
|
||||
assertThat(CONTRACT.operations()).hasSize(45);
|
||||
assertThat(CONTRACT.schemas()).hasSize(75);
|
||||
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
|
||||
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被
|
||||
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。
|
||||
* 全矩阵覆盖门禁:auth 域 7 个操作声明的每个 (操作, 状态码) 都必须被
|
||||
* 前面的测试真实触发并通过契约校验(24 个单元格,无豁免)。
|
||||
*/
|
||||
@Test
|
||||
@Order(99)
|
||||
|
||||
+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.4.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.4.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.4.0.yaml";
|
||||
|
||||
private static final Set<String> HTTP_METHODS =
|
||||
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
||||
|
||||
+1781
-19
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));
|
||||
}
|
||||
}
|
||||
+14
@@ -1,6 +1,7 @@
|
||||
package com.patbond.patbond.community.controller;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.community.dto.CommunityStatsResponse;
|
||||
import com.patbond.patbond.community.dto.CreatePostRequest;
|
||||
import com.patbond.patbond.community.dto.CursorPage;
|
||||
import com.patbond.patbond.community.dto.PostResponse;
|
||||
@@ -86,4 +87,17 @@ public class PostController {
|
||||
@RequestParam(required = false) String cursor) {
|
||||
return ApiResponse.success(postService.listMine(userId, status, limit, cursor));
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's own community numbers (T3.5-06, ADR-022 决策 A: a dedicated
|
||||
* endpoint rather than an addition to /users/{userId}/follow-stats, whose
|
||||
* subject is "some user's follow counts" — mixing "my likes received" in
|
||||
* would give one payload two subjects). Lives here because posts are the
|
||||
* sole source of both numbers, next to the other /me posts read.
|
||||
*/
|
||||
@GetMapping("/api/v1/me/community-stats")
|
||||
public ApiResponse<CommunityStatsResponse> communityStats(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
return ApiResponse.success(postService.communityStats(userId));
|
||||
}
|
||||
}
|
||||
|
||||
+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) {
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
/**
|
||||
* GET /api/v1/me/community-stats (T3.5-06, ADR-022 决策 A) — the caller's own
|
||||
* community numbers, read-side aggregates over community.posts with no new
|
||||
* denormalized column.
|
||||
*
|
||||
* <p>Scope, frozen with this ticket: both numbers count ONLY the caller's own
|
||||
* posts that are {@code status='published'} and not soft-deleted. Drafts are
|
||||
* excluded (they are not works yet, and their likes cannot exist anyway),
|
||||
* soft-deleted posts are excluded (deleting a post removes its numbers), and
|
||||
* the operational states hidden/archived are excluded for the same reason
|
||||
* they are invisible everywhere else in the M3 contract. Empty data yields
|
||||
* {@code 0}, never null.</p>
|
||||
*
|
||||
* <p>{@code receivedLikeCount} is {@code SUM(posts.like_count)} — the counter
|
||||
* the write side maintains in the same transaction as the like row (T3-07),
|
||||
* so this is exact, not an estimate. A user's own likes on their own posts are
|
||||
* counted, exactly as the per-post number shows them.</p>
|
||||
*/
|
||||
public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) {
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+100
@@ -1,5 +1,8 @@
|
||||
package com.patbond.patbond.community.repository;
|
||||
|
||||
import com.patbond.patbond.community.dto.CommunityStatsResponse;
|
||||
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;
|
||||
@@ -167,6 +170,31 @@ public class PostRepository {
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* The author's own community aggregates in one indexed pass over
|
||||
* ix_posts_author_created's leading column (T3.5-06, ADR-022: read-side
|
||||
* aggregation, no denormalized column). {@code COALESCE} turns the empty
|
||||
* SUM into 0 so the endpoint never answers null; the
|
||||
* {@code deleted_at IS NULL} predicate is belt-and-braces — softDelete
|
||||
* parks published rows as 'archived', so status='published' already
|
||||
* implies live (ck_posts_publish_state).
|
||||
*/
|
||||
public CommunityStatsResponse aggregateByAuthor(UUID authorUserId) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT COALESCE(SUM(like_count), 0) AS received_like_count,
|
||||
COUNT(*) AS published_post_count
|
||||
FROM community.posts
|
||||
WHERE author_user_id = :authorUserId
|
||||
AND status = 'published'
|
||||
AND deleted_at IS NULL
|
||||
""")
|
||||
.param("authorUserId", authorUserId)
|
||||
.query((rs, rowNum) -> new CommunityStatsResponse(
|
||||
rs.getLong("received_like_count"),
|
||||
rs.getLong("published_post_count")))
|
||||
.single();
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the author's own posts in (created_at DESC, id DESC) — the
|
||||
* exact key of ix_posts_author_created. Soft-deleted rows never appear;
|
||||
@@ -200,6 +228,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 +417,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -3,6 +3,9 @@ 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.CommunityStatsResponse;
|
||||
import com.patbond.patbond.community.dto.CreatePostRequest;
|
||||
import com.patbond.patbond.community.dto.CursorPage;
|
||||
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
|
||||
@@ -65,13 +68,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
|
||||
@@ -200,6 +206,19 @@ public class PostService {
|
||||
return new CursorPage<>(assemble(page), nextCursor, hasMore);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's own community numbers (T3.5-06): total likes received on
|
||||
* published, live posts plus the count of those posts. Aggregated on read
|
||||
* (ADR-022) — the write side keeps no per-user counter, so there is
|
||||
* nothing that can drift out of sync. Empty data is a legitimate answer of
|
||||
* zeros, never a 404: every authenticated user has stats, even a brand-new
|
||||
* one with nothing published.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public CommunityStatsResponse communityStats(UUID userId) {
|
||||
return postRepository.aggregateByAuthor(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared write gate of PATCH/DELETE: locks the live row, then walks
|
||||
* the 403/404 boundary — invisible (absent, deleted, hidden/archived,
|
||||
@@ -374,9 +393,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();
|
||||
}
|
||||
}
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
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 域 18 个操作补进契约一致性保障,机制与
|
||||
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照
|
||||
* {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
|
||||
* {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用
|
||||
* {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名
|
||||
* 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
|
||||
*
|
||||
* <p>覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()}
|
||||
* 断言契约为这 18 个操作声明的每一个 (操作, 状态码) 单元格(共 66 格)都被
|
||||
* 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁
|
||||
* 冲突、422 均为业务规则拒绝,单线程即可确定性触发。
|
||||
*
|
||||
* <p>media 域 2 个操作属 patbond-user 模块,由该模块的
|
||||
* MediaContractConformanceTest 覆盖;auth/user 域 7 操作在 patbond-auth
|
||||
* (含 v1.4.0 新增的 PATCH /api/v1/me)。快照四模块同一份。
|
||||
*/
|
||||
@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 域 18 个操作(= 契约中 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",
|
||||
"GET /api/v1/me/community-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");
|
||||
}
|
||||
|
||||
// ---- 成功路径:18 操作全覆盖 ---------------------------------------
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/v1/me/community-stats`(v1.4.0 新增操作,T3.5-07):空数据零值
|
||||
* 与有数据两个分支都过严格校验。**永不 404**,故本操作只有 200/401 两格
|
||||
* (401 由 {@link #unauthenticatedRequestsAnswer40101OnAllOperations()} 的
|
||||
* 全操作循环覆盖)。
|
||||
*/
|
||||
@Test
|
||||
@Order(51)
|
||||
void myCommunityStatsSuccessShapes() throws Exception {
|
||||
UUID fresh = newUser();
|
||||
|
||||
// 空数据:两数为 0 而非 null,且不是 404
|
||||
String zeros = verified(get("/api/v1/me/community-stats")
|
||||
.header("Authorization", "Bearer " + token(fresh)),
|
||||
"GET", "/api/v1/me/community-stats", 200);
|
||||
assertThat(((Number) JsonPath.read(zeros, "$.data.receivedLikeCount")).longValue())
|
||||
.isZero();
|
||||
assertThat(((Number) JsonPath.read(zeros, "$.data.publishedPostCount")).longValue())
|
||||
.isZero();
|
||||
|
||||
// 有数据:两篇已发布帖,其中一篇被他人赞一次 → 1 赞 / 2 作品
|
||||
UUID author = newUser();
|
||||
UUID fan = newUser();
|
||||
String postA = newPublishedPost(author, "契约统计帖甲");
|
||||
newPublishedPost(author, "契约统计帖乙");
|
||||
verified(authed(put("/api/v1/posts/{postId}/like", postA), fan),
|
||||
"PUT", "/api/v1/posts/{postId}/like", 200);
|
||||
|
||||
String counted = verified(get("/api/v1/me/community-stats")
|
||||
.header("Authorization", "Bearer " + token(author)),
|
||||
"GET", "/api/v1/me/community-stats", 200);
|
||||
assertThat(((Number) JsonPath.read(counted, "$.data.receivedLikeCount")).longValue())
|
||||
.isEqualTo(1L);
|
||||
assertThat(((Number) JsonPath.read(counted, "$.data.publishedPostCount")).longValue())
|
||||
.isEqualTo(2L);
|
||||
}
|
||||
|
||||
// ---- 错误信封 ------------------------------------------------------
|
||||
|
||||
@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.4.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(32);
|
||||
assertThat(CONTRACT.operations()).hasSize(45);
|
||||
assertThat(CONTRACT.schemas()).hasSize(75);
|
||||
assertThat(CONTRACT.operationsTagged(
|
||||
Set.of("posts", "feed", "comments", "interactions", "follows")))
|
||||
.containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全矩阵覆盖门禁:community 域 18 个操作声明的每个 (操作, 状态码) 都必须被
|
||||
* 前面的测试真实触发并通过契约校验(66 个单元格,无豁免)。
|
||||
*/
|
||||
@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.4.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.4.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.4.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));
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package com.patbond.patbond.community.post;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
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.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* T3.5-06 获赞聚合:GET /api/v1/me/community-stats。口径(ADR-022,读侧实时
|
||||
* 聚合,不引冗余列):
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code receivedLikeCount} = 本人「已发布且未软删」帖的 like_count 之和;</li>
|
||||
* <li>{@code publishedPostCount} = 同一集合的帖子数;</li>
|
||||
* <li>草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、他人的帖不计;</li>
|
||||
* <li>空数据答 0 而非 null,任何已认证用户都有 stats,从不 404。</li>
|
||||
* </ul>
|
||||
*/
|
||||
class MeCommunityStatsIntegrationTest extends PostApiTestBase {
|
||||
|
||||
private static final String DRAFT = "{\"content\":\"草稿内容\"}";
|
||||
private static final String PUBLISHED = "{\"content\":\"已发布内容\",\"status\":\"published\"}";
|
||||
|
||||
private JsonNode stats(UUID userId) throws Exception {
|
||||
return data(mockMvc.perform(authed(get("/api/v1/me/community-stats"), userId))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn());
|
||||
}
|
||||
|
||||
private void like(UUID actor, String postId) throws Exception {
|
||||
mockMvc.perform(authed(put("/api/v1/posts/{postId}/like", postId), actor))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ---- 成功路径 + 空数据 ----------------------------------------------
|
||||
|
||||
@Test
|
||||
void freshUserGetsZerosNotNullsAndNever404() throws Exception {
|
||||
JsonNode stats = stats(newUser());
|
||||
assertThat(stats.get("receivedLikeCount").isNull()).isFalse();
|
||||
assertThat(stats.get("publishedPostCount").isNull()).isFalse();
|
||||
assertThat(stats.get("receivedLikeCount").asLong()).isZero();
|
||||
assertThat(stats.get("publishedPostCount").asLong()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sumsLikesAcrossThePublishedPostsOfTheCaller() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID fanA = newUser();
|
||||
UUID fanB = newUser();
|
||||
String first = createPost(author, PUBLISHED).get("id").asText();
|
||||
String second = createPost(author, PUBLISHED).get("id").asText();
|
||||
|
||||
like(fanA, first);
|
||||
like(fanB, first);
|
||||
like(fanA, second);
|
||||
|
||||
JsonNode stats = stats(author);
|
||||
assertThat(stats.get("receivedLikeCount").asLong()).isEqualTo(3);
|
||||
assertThat(stats.get("publishedPostCount").asLong()).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsSelfLikesExactlyAsThePerPostNumberDoes() throws Exception {
|
||||
UUID author = newUser();
|
||||
String postId = createPost(author, PUBLISHED).get("id").asText();
|
||||
like(author, postId);
|
||||
|
||||
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlikingBringsTheNumberBackDown() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID fan = newUser();
|
||||
String postId = createPost(author, PUBLISHED).get("id").asText();
|
||||
like(fan, postId);
|
||||
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
|
||||
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/{postId}/like", postId), fan))
|
||||
.andExpect(status().isOk());
|
||||
assertThat(stats(author).get("receivedLikeCount").asLong()).isZero();
|
||||
}
|
||||
|
||||
// ---- 口径边界:草稿 / 软删 / 他人 -----------------------------------
|
||||
|
||||
@Test
|
||||
void draftsAreExcludedFromBothNumbers() throws Exception {
|
||||
UUID author = newUser();
|
||||
createPost(author, DRAFT);
|
||||
createPost(author, DRAFT);
|
||||
|
||||
JsonNode before = stats(author);
|
||||
assertThat(before.get("publishedPostCount").asLong()).isZero();
|
||||
assertThat(before.get("receivedLikeCount").asLong()).isZero();
|
||||
|
||||
// 发布其中一篇后才计入
|
||||
String draftId = createPost(author, DRAFT).get("id").asText();
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/{postId}", draftId), author)
|
||||
.content("{\"version\":0,\"status\":\"published\"}"))
|
||||
.andExpect(status().isOk());
|
||||
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void softDeletedPostsDropOutOfBothNumbers() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID fan = newUser();
|
||||
String keep = createPost(author, PUBLISHED).get("id").asText();
|
||||
String doomed = createPost(author, PUBLISHED).get("id").asText();
|
||||
like(fan, keep);
|
||||
like(fan, doomed);
|
||||
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(2);
|
||||
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/{postId}", doomed), author))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
JsonNode after = stats(author);
|
||||
assertThat(after.get("receivedLikeCount").asLong()).isEqualTo(1);
|
||||
assertThat(after.get("publishedPostCount").asLong()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherPeoplesPostsNeverLeakIntoMyStats() throws Exception {
|
||||
UUID me = newUser();
|
||||
UUID other = newUser();
|
||||
UUID fan = newUser();
|
||||
String theirs = createPost(other, PUBLISHED).get("id").asText();
|
||||
like(fan, theirs);
|
||||
like(me, theirs);
|
||||
|
||||
JsonNode mine = stats(me);
|
||||
assertThat(mine.get("receivedLikeCount").asLong()).isZero();
|
||||
assertThat(mine.get("publishedPostCount").asLong()).isZero();
|
||||
// 对方的数字是对方的
|
||||
assertThat(stats(other).get("receivedLikeCount").asLong()).isEqualTo(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* hidden/archived 是运营态(D3-7),在 M3 契约里对所有人不可见,因此也不计
|
||||
* 入作品数——「看不到的帖不该出现在我的作品计数里」。
|
||||
*/
|
||||
@Test
|
||||
void operationalStatesAreExcluded() throws Exception {
|
||||
UUID author = newUser();
|
||||
String postId = createPost(author, PUBLISHED).get("id").asText();
|
||||
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
|
||||
|
||||
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
|
||||
.param("id", UUID.fromString(postId))
|
||||
.update();
|
||||
assertThat(stats(author).get("publishedPostCount").asLong()).isZero();
|
||||
}
|
||||
|
||||
// ---- 无权限 / 不存在 -------------------------------------------------
|
||||
|
||||
@Test
|
||||
void requiresAValidAccessToken() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/me/community-stats"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
mockMvc.perform(get("/api/v1/me/community-stats")
|
||||
.header("Authorization", "Bearer not.a.jwt"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
/**
|
||||
* 主体永远是 token 里的自己,路径上没有可枚举的 userId —— 「查不到别人的
|
||||
* 获赞」不靠权限判断,而靠端点形态本身就没有别人的入口。
|
||||
*/
|
||||
@Test
|
||||
void hasNoPathParameterToProbeSomeoneElse() throws Exception {
|
||||
UUID me = newUser();
|
||||
mockMvc.perform(authed(get("/api/v1/me/community-stats/{userId}", newUser()), me))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
// ---- 并发与重放 -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* 读侧聚合天然幂等:并发重复读必须给出同一答案,且不产生任何副作用
|
||||
* (连续两次读的数字完全相同)。
|
||||
*/
|
||||
@Test
|
||||
void concurrentAndRepeatedReadsAreIdenticalAndSideEffectFree() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID fan = newUser();
|
||||
String postId = createPost(author, PUBLISHED).get("id").asText();
|
||||
like(fan, postId);
|
||||
|
||||
CyclicBarrier startTogether = new CyclicBarrier(2);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Long> read = () -> {
|
||||
startTogether.await();
|
||||
return stats(author).get("receivedLikeCount").asLong();
|
||||
};
|
||||
Future<Long> first = pool.submit(read);
|
||||
Future<Long> second = pool.submit(read);
|
||||
assertThat(first.get()).isEqualTo(1);
|
||||
assertThat(second.get()).isEqualTo(1);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
|
||||
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ---- 形态回归 -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void payloadCarriesExactlyTheTwoNumbers() throws Exception {
|
||||
UUID author = newUser();
|
||||
mockMvc.perform(authed(get("/api/v1/me/community-stats"), author))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.receivedLikeCount").value(0))
|
||||
.andExpect(jsonPath("$.data.publishedPostCount").value(0))
|
||||
.andExpect(jsonPath("$.data.followerCount").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.userId").doesNotExist());
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+1781
-19
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,15 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- Read-side media URL signing only (presigned GET is a local SigV4
|
||||
computation): this service never talks to the object store, the
|
||||
media write flow stays in patbond-user (ADR-016/017). Same
|
||||
precedent as patbond-community's read side. Version managed by
|
||||
the root pom's awssdk bom. -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</dependency>
|
||||
<!-- Access token verification (RS256, public key only): jjwt is not in
|
||||
the Boot BOM, version pinned in step with patbond-user/auth. -->
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.patbond.patbond.pet.config;
|
||||
|
||||
import com.patbond.patbond.pet.media.MediaUrlSigner;
|
||||
import com.patbond.patbond.pet.media.PetMediaProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Read-side media wiring (T3.5-05): a presigned-GET signer over the same
|
||||
* MinIO configuration patbond-user uses (ADR-016). Bean destruction closes
|
||||
* the underlying presigner.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(PetMediaProperties.class)
|
||||
public class MediaConfig {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
public MediaUrlSigner mediaUrlSigner(PetMediaProperties properties) {
|
||||
return new MediaUrlSigner(properties);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import java.util.UUID;
|
||||
* dictionary when {@code breedId} is set; exactly one of {@code breedId} /
|
||||
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
|
||||
* calling user's own pet_owners role — the client uses it to gate write UI.
|
||||
*
|
||||
* <p>{@code avatarUrl} (T3.5-05) is a freshly signed presigned GET against a
|
||||
* private bucket: it EXPIRES and must never be persisted client-side (the
|
||||
* client's image cache key strips the signature parameters). It is null both
|
||||
* when the pet has no avatar and when the referenced asset is not (or no
|
||||
* longer) ready, so "has an avatar" is exactly {@code avatarUrl != null}. The
|
||||
* asset id is not echoed — the client only ever writes it.</p>
|
||||
*/
|
||||
public record PetResponse(
|
||||
UUID id,
|
||||
@@ -24,6 +31,7 @@ public record PetResponse(
|
||||
String microchipNo,
|
||||
LocalDate sterilizedOn,
|
||||
String status,
|
||||
String avatarUrl,
|
||||
String myRole,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
|
||||
@@ -16,6 +16,21 @@ import java.util.UUID;
|
||||
* replaces the pair as a whole (they are mutually exclusive per
|
||||
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
|
||||
* the whole endpoint exists to enforce.
|
||||
*
|
||||
* <p><b>{@code avatarAssetId} is the one three-state field</b> (T3.5-05):
|
||||
* absent = unchanged, explicit {@code null} = remove the avatar, value = set
|
||||
* it. Removing an avatar is a first-class user action with no other way to
|
||||
* express it, whereas the M2 fields either cannot be empty at all (name,
|
||||
* species, sex) or are edited, not erased — so the asymmetry is deliberate
|
||||
* and confined to this field. Presence is tracked in the setter: Jackson
|
||||
* calls it exactly when the JSON key is present, including for an explicit
|
||||
* null.</p>
|
||||
*
|
||||
* <p>Permission note: this endpoint is MANAGE (owner only), but an
|
||||
* avatar-ONLY patch is WRITE (owner + caregiver) per ADR-022 — the avatar is
|
||||
* day-to-day care information, same tier as weights and vaccinations. The
|
||||
* required level is therefore computed from which fields the body touches;
|
||||
* see PetService.</p>
|
||||
*/
|
||||
public class UpdatePetRequest {
|
||||
|
||||
@@ -56,6 +71,33 @@ public class UpdatePetRequest {
|
||||
message = "status 仅支持 active/lost/deceased/archived")
|
||||
private String status;
|
||||
|
||||
private UUID avatarAssetId;
|
||||
private boolean avatarAssetIdPresent;
|
||||
|
||||
public UUID getAvatarAssetId() {
|
||||
return avatarAssetId;
|
||||
}
|
||||
|
||||
public void setAvatarAssetId(UUID avatarAssetId) {
|
||||
this.avatarAssetId = avatarAssetId;
|
||||
this.avatarAssetIdPresent = true;
|
||||
}
|
||||
|
||||
public boolean isAvatarAssetIdPresent() {
|
||||
return avatarAssetIdPresent;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the body touches any pet-profile field, i.e. anything beyond
|
||||
* the avatar. {@code version} does not count — it is the lock, not an
|
||||
* edit. Drives the MANAGE-vs-WRITE decision in PetService.
|
||||
*/
|
||||
public boolean touchesProfileFields() {
|
||||
return name != null || breedId != null || customBreedName != null || sex != null
|
||||
|| birthDate != null || birthDateEstimated != null || personality != null
|
||||
|| microchipNo != null || sterilizedOn != null || status != null;
|
||||
}
|
||||
|
||||
public Integer getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.patbond.patbond.pet.media;
|
||||
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-only cross-schema access to media.assets — the pet side of the T3-03
|
||||
* 联调协议 (business references accept only assets owned by the caller with
|
||||
* status='ready' and the matching purpose). Same-database read was chosen
|
||||
* over an internal HTTP call to patbond-user, exactly as patbond-community
|
||||
* did (ADR-017 precedent: while the schemas share one database this is a
|
||||
* cross-schema read; splitting the database later moves every such gateway to
|
||||
* an internal API together). This class never writes media.assets — the media
|
||||
* state machine belongs to patbond-user.
|
||||
*/
|
||||
@Repository
|
||||
public class MediaAssetGateway {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public MediaAssetGateway(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
public Optional<MediaAssetRef> findById(UUID assetId) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT id, owner_user_id, purpose, status
|
||||
FROM media.assets
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("id", assetId)
|
||||
.query((rs, rowNum) -> new MediaAssetRef(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("owner_user_id", UUID.class),
|
||||
rs.getString("purpose"),
|
||||
rs.getString("status")))
|
||||
.optional();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.patbond.patbond.pet.media;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-only view of one media.assets row — exactly the columns the pet
|
||||
* avatar flow needs for attach validation (owner, purpose, status).
|
||||
*/
|
||||
public record MediaAssetRef(UUID id, UUID ownerUserId, String purpose, String status) {
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.patbond.patbond.pet.media;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* Signs presigned GET URLs for pet avatars (T3-03 定型:private bucket +
|
||||
* presigned GET, TTL configurable, signed fresh on every response — clients
|
||||
* never persist the URL). Presigning is a local SigV4 computation against the
|
||||
* public endpoint; this service never talks to the object store itself.
|
||||
* Path-style addressing is forced because MinIO has no wildcard DNS for
|
||||
* virtual-host-style buckets (same as patbond-user's S3ObjectStorage and
|
||||
* patbond-community's signer). When unconfigured, {@link #signGet} returns
|
||||
* null and pet responses degrade to {@code avatarUrl: null}.
|
||||
*/
|
||||
public class MediaUrlSigner implements AutoCloseable {
|
||||
|
||||
private final PetMediaProperties properties;
|
||||
private final S3Presigner presigner;
|
||||
|
||||
public MediaUrlSigner(PetMediaProperties properties) {
|
||||
this.properties = properties;
|
||||
if (properties.getPublicEndpoint().isBlank()) {
|
||||
this.presigner = null;
|
||||
return;
|
||||
}
|
||||
this.presigner = S3Presigner.builder()
|
||||
.endpointOverride(URI.create(properties.getPublicEndpoint()))
|
||||
.region(Region.of(properties.getRegion()))
|
||||
.credentialsProvider(StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
|
||||
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** @return a presigned GET URL, or null when storage is unconfigured */
|
||||
public String signGet(String bucket, String objectKey) {
|
||||
if (presigner == null || bucket == null || objectKey == null) {
|
||||
return null;
|
||||
}
|
||||
return presigner.presignGetObject(GetObjectPresignRequest.builder()
|
||||
.signatureDuration(properties.getDownloadTtl())
|
||||
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
|
||||
.build())
|
||||
.url()
|
||||
.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (presigner != null) {
|
||||
presigner.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.patbond.patbond.pet.media;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Read-side subset of the media object-storage configuration (T3.5-05). The
|
||||
* write side — upload flow, mime/purpose whitelists, bucket init — lives in
|
||||
* patbond-user's MediaProperties; this service only signs presigned GET URLs
|
||||
* for pet avatars, a purely local SigV4 computation, so no S3 client is
|
||||
* needed. Values reuse the same PATBOND_MINIO_* / PATBOND_MEDIA_*
|
||||
* environment variables as patbond-user and patbond-community, keeping one
|
||||
* set of knobs per deployment (ADR-016/021).
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "patbond.media")
|
||||
public class PetMediaProperties {
|
||||
|
||||
/**
|
||||
* Endpoint presigned GET URLs are issued against — the address CLIENTS
|
||||
* can reach. Empty means media is unconfigured for this service: pet
|
||||
* responses carry {@code avatarUrl: null} (same degradation precedent as
|
||||
* the missing JWT public key).
|
||||
*/
|
||||
private String publicEndpoint = "";
|
||||
|
||||
/** S3 access key; injected via environment, never committed (ADR-021). */
|
||||
private String accessKey = "";
|
||||
|
||||
/** S3 secret key; injected via environment, never committed (ADR-021). */
|
||||
private String secretKey = "";
|
||||
|
||||
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
|
||||
private String region = "us-east-1";
|
||||
|
||||
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
|
||||
private Duration downloadTtl = Duration.ofHours(1);
|
||||
|
||||
public String getPublicEndpoint() {
|
||||
return publicEndpoint;
|
||||
}
|
||||
|
||||
public void setPublicEndpoint(String publicEndpoint) {
|
||||
this.publicEndpoint = publicEndpoint;
|
||||
}
|
||||
|
||||
public String getAccessKey() {
|
||||
return accessKey;
|
||||
}
|
||||
|
||||
// setter 形参名取 value:check-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
|
||||
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
|
||||
public void setAccessKey(String value) {
|
||||
this.accessKey = value;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String value) {
|
||||
this.secretKey = value;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public Duration getDownloadTtl() {
|
||||
return downloadTtl;
|
||||
}
|
||||
|
||||
public void setDownloadTtl(Duration downloadTtl) {
|
||||
this.downloadTtl = downloadTtl;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.patbond.patbond.pet.repository;
|
||||
|
||||
import com.patbond.patbond.pet.dto.PetResponse;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -16,6 +15,12 @@ import java.util.UUID;
|
||||
* pet_health.pets + pet_owners access. All reads join pet_owners on the
|
||||
* calling user so a row only comes back when a relationship exists — the
|
||||
* repository layer itself never exposes another user's pet.
|
||||
*
|
||||
* <p>Reads return {@link PetRow}, not the API DTO: the avatar travels as
|
||||
* storage coordinates (bucket + object key of a READY media asset) and the
|
||||
* presigned URL is produced one layer up, in PetService — same split as
|
||||
* patbond-community's PostRow → PostResponse assembly, and the reason a
|
||||
* signed, expiring URL never leaks into a repository-level cache.</p>
|
||||
*/
|
||||
@Repository
|
||||
public class PetRepository {
|
||||
@@ -24,10 +29,13 @@ public class PetRepository {
|
||||
SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name,
|
||||
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
|
||||
p.personality, p.microchip_no, p.sterilized_on, p.status,
|
||||
p.avatar_asset_id,
|
||||
av.bucket AS avatar_bucket, av.object_key AS avatar_object_key,
|
||||
po.role, p.created_at, p.updated_at, p.version
|
||||
FROM pet_health.pets p
|
||||
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
|
||||
LEFT JOIN pet_health.breeds b ON b.id = p.breed_id
|
||||
LEFT JOIN media.assets av ON av.id = p.avatar_asset_id AND av.status = 'ready'
|
||||
WHERE p.status <> 'deleted'
|
||||
""";
|
||||
|
||||
@@ -37,6 +45,37 @@ public class PetRepository {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pet as stored, from the calling user's perspective.
|
||||
* {@code avatarAssetId} is the raw column (so a PATCH that does not touch
|
||||
* the avatar can carry it through unchanged), while the two storage
|
||||
* columns are already narrowed to a READY asset — a dangling or
|
||||
* still-uploading avatar yields nulls there (→ {@code avatarUrl: null})
|
||||
* rather than a signed URL that would 404 at the object store.
|
||||
*/
|
||||
public record PetRow(
|
||||
UUID id,
|
||||
String name,
|
||||
String species,
|
||||
UUID breedId,
|
||||
String breedDisplayName,
|
||||
String customBreedName,
|
||||
String sex,
|
||||
LocalDate birthDate,
|
||||
Boolean birthDateEstimated,
|
||||
String personality,
|
||||
String microchipNo,
|
||||
LocalDate sterilizedOn,
|
||||
String status,
|
||||
UUID avatarAssetId,
|
||||
String avatarBucket,
|
||||
String avatarObjectKey,
|
||||
String myRole,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
Integer version) {
|
||||
}
|
||||
|
||||
public void insertPet(UUID petId, String name, String species, UUID breedId,
|
||||
String customBreedName, String sex, LocalDate birthDate,
|
||||
boolean birthDateEstimated, String personality,
|
||||
@@ -72,14 +111,14 @@ public class PetRepository {
|
||||
.update();
|
||||
}
|
||||
|
||||
public List<PetResponse> listByUser(UUID userId) {
|
||||
public List<PetRow> listByUser(UUID userId) {
|
||||
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
|
||||
.param("userId", userId)
|
||||
.query(PetRepository::mapPet)
|
||||
.list();
|
||||
}
|
||||
|
||||
public Optional<PetResponse> findByIdForUser(UUID petId, UUID userId) {
|
||||
public Optional<PetRow> findByIdForUser(UUID petId, UUID userId) {
|
||||
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
|
||||
.param("userId", userId)
|
||||
.param("petId", petId)
|
||||
@@ -109,14 +148,16 @@ public class PetRepository {
|
||||
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
|
||||
String customBreedName, String sex, LocalDate birthDate,
|
||||
boolean birthDateEstimated, String personality,
|
||||
String microchipNo, LocalDate sterilizedOn, String status) {
|
||||
String microchipNo, LocalDate sterilizedOn, String status,
|
||||
UUID avatarAssetId) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE pet_health.pets
|
||||
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
|
||||
sex = :sex, birth_date = :birthDate,
|
||||
birth_date_estimated = :birthDateEstimated, personality = :personality,
|
||||
microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
|
||||
status = :status, version = version + 1
|
||||
status = :status, avatar_asset_id = :avatarAssetId,
|
||||
version = version + 1
|
||||
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
|
||||
""")
|
||||
.param("petId", petId)
|
||||
@@ -131,11 +172,12 @@ public class PetRepository {
|
||||
.param("microchipNo", microchipNo)
|
||||
.param("sterilizedOn", sterilizedOn)
|
||||
.param("status", status)
|
||||
.param("avatarAssetId", avatarAssetId)
|
||||
.update();
|
||||
}
|
||||
|
||||
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new PetResponse(
|
||||
private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new PetRow(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("species"),
|
||||
@@ -149,6 +191,9 @@ public class PetRepository {
|
||||
rs.getString("microchip_no"),
|
||||
rs.getObject("sterilized_on", LocalDate.class),
|
||||
rs.getString("status"),
|
||||
rs.getObject("avatar_asset_id", UUID.class),
|
||||
rs.getString("avatar_bucket"),
|
||||
rs.getString("avatar_object_key"),
|
||||
rs.getString("role"),
|
||||
rs.getObject("created_at", OffsetDateTime.class),
|
||||
rs.getObject("updated_at", OffsetDateTime.class),
|
||||
|
||||
@@ -7,8 +7,12 @@ import com.patbond.patbond.pet.access.PetAccessService;
|
||||
import com.patbond.patbond.pet.dto.CreatePetRequest;
|
||||
import com.patbond.patbond.pet.dto.PetResponse;
|
||||
import com.patbond.patbond.pet.dto.UpdatePetRequest;
|
||||
import com.patbond.patbond.pet.media.MediaAssetGateway;
|
||||
import com.patbond.patbond.pet.media.MediaAssetRef;
|
||||
import com.patbond.patbond.pet.media.MediaUrlSigner;
|
||||
import com.patbond.patbond.pet.repository.BreedRepository;
|
||||
import com.patbond.patbond.pet.repository.PetRepository;
|
||||
import com.patbond.patbond.pet.repository.PetRepository.PetRow;
|
||||
import com.patbond.patbond.pet.support.UuidV7;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,19 +27,47 @@ import java.util.UUID;
|
||||
* uq_pets_microchip) so clients get a stable business error instead of a
|
||||
* constraint-violation 500 — the constraints stay as the last line of
|
||||
* defense.
|
||||
*
|
||||
* <p>Avatar semantics (T3.5-05, ADR-022):
|
||||
* <ul>
|
||||
* <li>The write level is decided per REQUEST, not per endpoint: an
|
||||
* avatar-only PATCH needs {@link AccessLevel#WRITE} (owner + caregiver
|
||||
* — the avatar is day-to-day care information, same tier as weights and
|
||||
* vaccinations), everything else stays {@link AccessLevel#MANAGE}
|
||||
* (owner only). A body touching both is judged by the stricter half.
|
||||
* Viewers are refused either way (403/40300).</li>
|
||||
* <li>The referenced asset must exist, belong to the CALLER, carry
|
||||
* {@code purpose='pet_avatar'} and be {@code ready} — the T3-03
|
||||
* referencing protocol: unknown / someone else's / deleted → 404/40405
|
||||
* (merged, anti-enumeration), wrong purpose → 404/40405 (a post image
|
||||
* is not an avatar; reachable only for the caller's own assets, so the
|
||||
* message may be specific), own pet_avatar asset still uploading or
|
||||
* failed → 422/42203.</li>
|
||||
* <li>The optimistic lock is unchanged: the avatar rides the same
|
||||
* version-guarded UPDATE, so a stale version loses with 409/40902 even
|
||||
* when only the avatar changes.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class PetService {
|
||||
|
||||
/** The only media purpose acceptable as a pet avatar (ADR-022). */
|
||||
private static final String AVATAR_PURPOSE = "pet_avatar";
|
||||
|
||||
private final PetRepository petRepository;
|
||||
private final BreedRepository breedRepository;
|
||||
private final PetAccessService petAccessService;
|
||||
private final MediaAssetGateway mediaAssetGateway;
|
||||
private final MediaUrlSigner mediaUrlSigner;
|
||||
|
||||
public PetService(PetRepository petRepository, BreedRepository breedRepository,
|
||||
PetAccessService petAccessService) {
|
||||
PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway,
|
||||
MediaUrlSigner mediaUrlSigner) {
|
||||
this.petRepository = petRepository;
|
||||
this.breedRepository = breedRepository;
|
||||
this.petAccessService = petAccessService;
|
||||
this.mediaAssetGateway = mediaAssetGateway;
|
||||
this.mediaUrlSigner = mediaUrlSigner;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,18 +95,18 @@ public class PetService {
|
||||
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
||||
}
|
||||
petRepository.insertPrimaryOwner(petId, userId);
|
||||
return petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
return toResponse(petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
|
||||
}
|
||||
|
||||
public List<PetResponse> list(UUID userId) {
|
||||
return petRepository.listByUser(userId);
|
||||
return petRepository.listByUser(userId).stream().map(this::toResponse).toList();
|
||||
}
|
||||
|
||||
public PetResponse get(UUID userId, UUID petId) {
|
||||
petAccessService.require(userId, petId, AccessLevel.READ);
|
||||
return petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||
return toResponse(petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +117,8 @@ public class PetService {
|
||||
*/
|
||||
@Transactional
|
||||
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
|
||||
petAccessService.require(userId, petId, AccessLevel.MANAGE);
|
||||
PetResponse current = petRepository.findByIdForUser(petId, userId)
|
||||
petAccessService.require(userId, petId, requiredLevel(request));
|
||||
PetRow current = petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||
|
||||
UUID breedId = current.breedId();
|
||||
@@ -102,6 +134,16 @@ public class PetService {
|
||||
String sex = request.getSex() != null ? request.getSex() : current.sex();
|
||||
String status = request.getStatus() != null ? request.getStatus() : current.status();
|
||||
|
||||
// Three-state avatar: absent → carry the stored id through; explicit
|
||||
// null → clear; value → validate then set.
|
||||
UUID avatarAssetId = current.avatarAssetId();
|
||||
if (request.isAvatarAssetIdPresent()) {
|
||||
avatarAssetId = request.getAvatarAssetId();
|
||||
if (avatarAssetId != null) {
|
||||
requireOwnReadyAvatarAsset(userId, avatarAssetId);
|
||||
}
|
||||
}
|
||||
|
||||
int updated;
|
||||
try {
|
||||
updated = petRepository.updateWithVersion(
|
||||
@@ -120,7 +162,8 @@ public class PetService {
|
||||
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
|
||||
request.getSterilizedOn() != null
|
||||
? request.getSterilizedOn() : current.sterilizedOn(),
|
||||
status);
|
||||
status,
|
||||
avatarAssetId);
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
||||
}
|
||||
@@ -129,8 +172,34 @@ public class PetService {
|
||||
// missed conditional update means the version is stale.
|
||||
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||
}
|
||||
return petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
return toResponse(petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
|
||||
}
|
||||
|
||||
/**
|
||||
* MANAGE for anything that edits the pet profile, WRITE when the request
|
||||
* touches nothing but the avatar (ADR-022). A body carrying only
|
||||
* {@code version} keeps the historical MANAGE level — it is a
|
||||
* profile-shaped no-op, not an avatar edit.
|
||||
*/
|
||||
private static AccessLevel requiredLevel(UpdatePetRequest request) {
|
||||
boolean avatarOnly = request.isAvatarAssetIdPresent() && !request.touchesProfileFields();
|
||||
return avatarOnly ? AccessLevel.WRITE : AccessLevel.MANAGE;
|
||||
}
|
||||
|
||||
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
|
||||
MediaAssetRef asset = mediaAssetGateway.findById(assetId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
|
||||
if (!userId.equals(asset.ownerUserId()) || "deleted".equals(asset.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
|
||||
}
|
||||
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
|
||||
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为宠物头像");
|
||||
}
|
||||
if (!"ready".equals(asset.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +225,33 @@ public class PetService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the avatar URL fresh on every response (never cached, never
|
||||
* persisted) and drops the storage coordinates — the DTO exposes a URL,
|
||||
* not a bucket layout.
|
||||
*/
|
||||
private PetResponse toResponse(PetRow row) {
|
||||
return new PetResponse(
|
||||
row.id(),
|
||||
row.name(),
|
||||
row.species(),
|
||||
row.breedId(),
|
||||
row.breedDisplayName(),
|
||||
row.customBreedName(),
|
||||
row.sex(),
|
||||
row.birthDate(),
|
||||
row.birthDateEstimated(),
|
||||
row.personality(),
|
||||
row.microchipNo(),
|
||||
row.sterilizedOn(),
|
||||
row.status(),
|
||||
mediaUrlSigner.signGet(row.avatarBucket(), row.avatarObjectKey()),
|
||||
row.myRole(),
|
||||
row.createdAt(),
|
||||
row.updatedAt(),
|
||||
row.version());
|
||||
}
|
||||
|
||||
private static String trimOrNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
@@ -19,3 +19,12 @@ patbond:
|
||||
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||
# 私钥只给 patbond-auth,绝不入库。
|
||||
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
||||
media:
|
||||
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
|
||||
# 签名计算生成宠物头像访问 URL,从不直连对象存储;写入流程在 patbond-user。
|
||||
# 环境变量与 patbond-user/patbond-community 共用同一组(一套部署一套旋钮)。
|
||||
# public-endpoint 为空时服务照常启动,宠物响应中 avatarUrl 为 null。
|
||||
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
|
||||
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
|
||||
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
|
||||
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
|
||||
|
||||
+59
-8
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@@ -27,8 +28,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.4.0(快照
|
||||
* {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
|
||||
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
|
||||
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
|
||||
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
|
||||
@@ -38,8 +39,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
* 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程
|
||||
* MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。
|
||||
*
|
||||
* <p>auth 域 6 个既有操作(register/login/refresh/logout/me/trackEvents)
|
||||
* 不在本单范围(M1 交付无契约测试,补齐另立工单)。
|
||||
* <p>auth/user 域 7 个操作(register/login/refresh/logout/me 读写/trackEvents)
|
||||
* 的契约测试在 patbond-auth(T3-19 补齐);快照同一份。
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ContractConformanceTest extends PetIntegrationTestSupport {
|
||||
@@ -122,6 +123,29 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
/**
|
||||
* 一枚 media.assets 行,用途/状态/归属可控(T3.5-07:头像引用侧的四态校验
|
||||
* 需要,造法与 PetAvatarIntegrationTest 同构——测试数据,不触碰实现)。
|
||||
*/
|
||||
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
|
||||
UUID id = 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', :purpose, 'object', 'patbond-media',
|
||||
:objectKey, 'image/jpeg', 2048, :status, :readyAt)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("purpose", purpose)
|
||||
.param("objectKey", purpose + "/2026/09/" + id)
|
||||
.param("status", status)
|
||||
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---- 成功路径:18 操作全覆盖 ---------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -594,6 +618,33 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
|
||||
.content("{\"version\":0,\"personality\":\"皮\"}"),
|
||||
"PATCH", "/api/v1/pets/{petId}", 409, 40902);
|
||||
|
||||
// -- 宠物头像(v1.4.0 新增两格,T3.5-07)--
|
||||
// 404/40405:与 40401 同一单元格的第二种业务码——幽灵 asset 与用途不符
|
||||
// 的 asset 合并同答(防枚举)
|
||||
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
|
||||
.formatted(UUID.randomUUID())),
|
||||
"PATCH", "/api/v1/pets/{petId}", 404, 40405);
|
||||
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
|
||||
.formatted(insertAsset(owner, "post_image", "ready"))),
|
||||
"PATCH", "/api/v1/pets/{petId}", 404, 40405);
|
||||
// 422/42203:本人的 pet_avatar asset 仍在 uploading(新增状态码单元格)
|
||||
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
|
||||
.formatted(insertAsset(owner, "pet_avatar", "uploading"))),
|
||||
"PATCH", "/api/v1/pets/{petId}", 422, 42203);
|
||||
// 200:挂上 ready 头像(avatarUrl 的非 null 分支不在本模块——未配置对象
|
||||
// 存储时恒 null,真实签名 URL 由 PetAvatarIntegrationTest 覆盖)
|
||||
verified(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
|
||||
.formatted(insertAsset(owner, "pet_avatar", "ready"))),
|
||||
"PATCH", "/api/v1/pets/{petId}", 200);
|
||||
|
||||
// -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 --
|
||||
verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -703,10 +754,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.4.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(32);
|
||||
assertThat(CONTRACT.operations()).hasSize(45);
|
||||
assertThat(CONTRACT.schemas()).hasSize(75);
|
||||
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.4.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.4.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.4.0.yaml";
|
||||
|
||||
private static final Set<String> HTTP_METHODS =
|
||||
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
|
||||
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
package com.patbond.patbond.pet.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* T3.5-05 宠物头像读写:PATCH /api/v1/pets/{petId} 的 {@code avatarAssetId}
|
||||
* 三态(缺省不改 / 显式 null 清空 / 赋值设置),详情与列表的 {@code avatarUrl}
|
||||
* 预签名 GET,以及六类路径 —— 成功 / 参数错(asset 非法四态)/ 不存在(防枚举
|
||||
* 404)/ 无权限(viewer 拒写、caregiver 只能改头像)/ 并发冲突(乐观锁 40902)
|
||||
* / 重放(同版本重放必冲突、新版本重放幂等)。
|
||||
*
|
||||
* <p>预签名 GET 是纯本地 SigV4 计算,故这里用占位端点与占位凭证即可断言 URL
|
||||
* 形态(与 patbond-community 的 PostApiTestBase 同先例),无需 MinIO 容器;
|
||||
* 「签名真能下载」的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担。</p>
|
||||
*/
|
||||
class PetAvatarIntegrationTest extends PetIntegrationTestSupport {
|
||||
|
||||
private static final String SIGNED_PREFIX = "http://127.0.0.1:9000/patbond-media/pet_avatar/";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void wireMediaSigning(DynamicPropertyRegistry registry) {
|
||||
// 占位值(dummy):仅用于本地 SigV4 计算,不连任何真实存储
|
||||
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");
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------
|
||||
|
||||
private String createPetAs(UUID ownerId) throws Exception {
|
||||
String body = mockMvc.perform(post("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(ownerId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"name":"头像猫","species":"cat","sex":"female",
|
||||
"customBreedName":"狸花"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
// 新建宠物尚无头像
|
||||
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 上传的产物)。 */
|
||||
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
|
||||
UUID id = 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, deleted_at)
|
||||
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
|
||||
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("purpose", purpose)
|
||||
.param("objectKey", purpose + "/2026/09/" + id)
|
||||
.param("status", status)
|
||||
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
|
||||
// ck_media_deleted:status='deleted' 必带 deleted_at
|
||||
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private UUID readyPetAvatar(UUID ownerUserId) {
|
||||
return insertAsset(ownerUserId, "pet_avatar", "ready");
|
||||
}
|
||||
|
||||
private String patchPet(UUID actor, String petId, String body, int expectedStatus)
|
||||
throws Exception {
|
||||
return mockMvc.perform(patch("/api/v1/pets/{id}", petId)
|
||||
.header("Authorization", "Bearer " + tokenFor(actor))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().is(expectedStatus))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
private void patchPetExpectingCode(UUID actor, String petId, String body,
|
||||
int httpStatus, int bizCode) throws Exception {
|
||||
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
|
||||
.header("Authorization", "Bearer " + tokenFor(actor))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().is(httpStatus))
|
||||
.andExpect(jsonPath("$.code").value(bizCode));
|
||||
}
|
||||
|
||||
private static String setAvatarBody(int version, UUID assetId) {
|
||||
return "{\"version\":%d,\"avatarAssetId\":\"%s\"}".formatted(version, assetId);
|
||||
}
|
||||
|
||||
private String detail(UUID actor, String petId) throws Exception {
|
||||
return mockMvc.perform(get("/api/v1/pets/{id}", petId)
|
||||
.header("Authorization", "Bearer " + tokenFor(actor)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
private UUID dbAvatarAssetId(String petId) {
|
||||
return jdbcClient.sql("SELECT avatar_asset_id FROM pet_health.pets WHERE id = :id")
|
||||
.param("id", UUID.fromString(petId))
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// ---- 成功路径 -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void ownerSetsAvatarAndDetailAndListBothCarryASignedUrl() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_owner");
|
||||
String petId = createPetAs(owner);
|
||||
UUID asset = readyPetAvatar(owner);
|
||||
|
||||
String patched = patchPet(owner, petId, setAvatarBody(0, asset), 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl"))
|
||||
.startsWith(SIGNED_PREFIX)
|
||||
.contains("X-Amz-Signature=");
|
||||
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
|
||||
// 头像也吃乐观锁:写入后 version 前进
|
||||
assertThat((int) JsonPath.read(patched, "$.data.version")).isEqualTo(1);
|
||||
|
||||
assertThat((String) JsonPath.read(detail(owner, petId), "$.data.avatarUrl"))
|
||||
.startsWith(SIGNED_PREFIX);
|
||||
|
||||
String listBody = mockMvc.perform(get("/api/v1/pets")
|
||||
.header("Authorization", "Bearer " + tokenFor(owner)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
List<Map<String, Object>> pets = JsonPath.read(listBody, "$.data");
|
||||
assertThat((String) pets.get(0).get("avatarUrl")).startsWith(SIGNED_PREFIX);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearsAvatarWithAnExplicitNull() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_clear");
|
||||
String petId = createPetAs(owner);
|
||||
patchPet(owner, petId, setAvatarBody(0, readyPetAvatar(owner)), 200);
|
||||
|
||||
String cleared = patchPet(owner, petId, "{\"version\":1,\"avatarAssetId\":null}", 200);
|
||||
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
|
||||
assertThat(dbAvatarAssetId(petId)).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* 缺省即不改:只改名字的 PATCH 不得把头像顺手清掉(这正是三态语义存在的
|
||||
* 理由——若 null 与缺省同义,就无法既保留又能清空)。
|
||||
*/
|
||||
@Test
|
||||
void absentAvatarFieldLeavesItUntouched() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_absent");
|
||||
String petId = createPetAs(owner);
|
||||
UUID asset = readyPetAvatar(owner);
|
||||
patchPet(owner, petId, setAvatarBody(0, asset), 200);
|
||||
|
||||
String renamed = patchPet(owner, petId, "{\"version\":1,\"name\":\"改个名\"}", 200);
|
||||
assertThat((String) JsonPath.read(renamed, "$.data.name")).isEqualTo("改个名");
|
||||
assertThat((String) JsonPath.read(renamed, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
|
||||
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指针在、资源却退出 ready(如后台清理置 failed):URL 降级为 null 而不是
|
||||
* 签一个下载必 404 的地址;数据库指针本身保留,不做隐式清理。
|
||||
*/
|
||||
@Test
|
||||
void avatarUrlDegradesToNullWhenTheAssetLeavesReady() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_degrade");
|
||||
String petId = createPetAs(owner);
|
||||
UUID asset = readyPetAvatar(owner);
|
||||
patchPet(owner, petId, setAvatarBody(0, asset), 200);
|
||||
|
||||
jdbcClient.sql("UPDATE media.assets SET status = 'failed' WHERE id = :id")
|
||||
.param("id", asset)
|
||||
.update();
|
||||
|
||||
assertThat((Object) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")).isNull();
|
||||
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- 权限:WRITE 档(ADR-022) ---------------------------------------
|
||||
|
||||
/**
|
||||
* caregiver 可改头像(WRITE 档:头像属日常照护信息,与体重/疫苗同档),
|
||||
* 但资料本体仍是 MANAGE —— 同一端点按「本次请求碰了哪些字段」定档。
|
||||
*/
|
||||
@Test
|
||||
void caregiverMayChangeTheAvatarButNotTheProfile() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_owner2");
|
||||
UUID caregiver = newUser("pet_avatar_caregiver");
|
||||
String petId = createPetAs(owner);
|
||||
grantRole(UUID.fromString(petId), caregiver, "caregiver");
|
||||
UUID asset = readyPetAvatar(caregiver);
|
||||
|
||||
String patched = patchPet(caregiver, petId, setAvatarBody(0, asset), 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
|
||||
|
||||
// 资料字段仍需 MANAGE
|
||||
patchPetExpectingCode(caregiver, petId, "{\"version\":1,\"name\":\"照护人改名\"}", 403, 40300);
|
||||
// 头像 + 资料混合按更严的那一半判(MANAGE)
|
||||
patchPetExpectingCode(caregiver, petId,
|
||||
"{\"version\":1,\"name\":\"夹带改名\",\"avatarAssetId\":null}", 403, 40300);
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewerCannotChangeTheAvatar() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_owner3");
|
||||
UUID viewer = newUser("pet_avatar_viewer");
|
||||
String petId = createPetAs(owner);
|
||||
grantRole(UUID.fromString(petId), viewer, "viewer");
|
||||
UUID asset = readyPetAvatar(viewer);
|
||||
|
||||
patchPetExpectingCode(viewer, petId, setAvatarBody(0, asset), 403, 40300);
|
||||
assertThat(dbAvatarAssetId(petId)).isNull();
|
||||
// 只读仍可见
|
||||
mockMvc.perform(get("/api/v1/pets/{id}", petId)
|
||||
.header("Authorization", "Bearer " + tokenFor(viewer)))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ---- 不存在路径(防枚举) --------------------------------------------
|
||||
|
||||
@Test
|
||||
void strangerAndGhostPetAnswerTheSame404() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_owner4");
|
||||
UUID stranger = newUser("pet_avatar_stranger");
|
||||
String petId = createPetAs(owner);
|
||||
UUID asset = readyPetAvatar(stranger);
|
||||
|
||||
patchPetExpectingCode(stranger, petId, setAvatarBody(0, asset), 404, 40401);
|
||||
patchPetExpectingCode(stranger, UUID.randomUUID().toString(),
|
||||
setAvatarBody(0, asset), 404, 40401);
|
||||
}
|
||||
|
||||
// ---- 参数错:asset 非法四态 ------------------------------------------
|
||||
|
||||
@Test
|
||||
void rejectsUnknownForeignOrWrongPurposeAsset() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_asset");
|
||||
UUID stranger = newUser("pet_avatar_assetowner");
|
||||
String petId = createPetAs(owner);
|
||||
|
||||
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
|
||||
patchPetExpectingCode(owner, petId, setAvatarBody(0, UUID.randomUUID()), 404, 40405);
|
||||
patchPetExpectingCode(owner, petId,
|
||||
setAvatarBody(0, insertAsset(stranger, "pet_avatar", "ready")), 404, 40405);
|
||||
// 用途不符:帖子配图不能当宠物头像
|
||||
patchPetExpectingCode(owner, petId,
|
||||
setAvatarBody(0, insertAsset(owner, "post_image", "ready")), 404, 40405);
|
||||
// 用户头像也不行:两种头像用途各归各
|
||||
patchPetExpectingCode(owner, petId,
|
||||
setAvatarBody(0, insertAsset(owner, "user_avatar", "ready")), 404, 40405);
|
||||
// 已删资源对引用方即不存在
|
||||
patchPetExpectingCode(owner, petId,
|
||||
setAvatarBody(0, insertAsset(owner, "pet_avatar", "deleted")), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(petId)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssetThatIsNotReadyYet() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_state");
|
||||
String petId = createPetAs(owner);
|
||||
|
||||
patchPetExpectingCode(owner, petId,
|
||||
setAvatarBody(0, insertAsset(owner, "pet_avatar", "uploading")), 422, 42203);
|
||||
patchPetExpectingCode(owner, petId,
|
||||
setAvatarBody(0, insertAsset(owner, "pet_avatar", "failed")), 422, 42203);
|
||||
assertThat(dbAvatarAssetId(petId)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedAvatarAssetIdAndMissingVersion() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_malformed");
|
||||
String petId = createPetAs(owner);
|
||||
|
||||
patchPetExpectingCode(owner, petId,
|
||||
"{\"version\":0,\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
|
||||
// version 仍是必填(乐观锁不可绕过),哪怕只改头像
|
||||
patchPetExpectingCode(owner, petId,
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(readyPetAvatar(owner)), 400, 40000);
|
||||
}
|
||||
|
||||
// ---- 并发冲突与重放 --------------------------------------------------
|
||||
|
||||
/**
|
||||
* 头像写入走同一把乐观锁:拿旧 version 的第二个写者必败 40902(并发冲突),
|
||||
* 而带同一 body 的重放正是「旧 version 再来一次」,因此必须同样冲突——这
|
||||
* 是 pets 域自 M2 起的一致语义,头像不另开后门。
|
||||
*/
|
||||
@Test
|
||||
void staleVersionLosesAndReplayOfTheSameBodyConflicts() throws Exception {
|
||||
UUID owner = newUser("pet_avatar_version");
|
||||
String petId = createPetAs(owner);
|
||||
UUID first = readyPetAvatar(owner);
|
||||
UUID second = readyPetAvatar(owner);
|
||||
|
||||
patchPet(owner, petId, setAvatarBody(0, first), 200);
|
||||
// 重放(同 body、同旧 version)→ 40902
|
||||
patchPetExpectingCode(owner, petId, setAvatarBody(0, first), 409, 40902);
|
||||
// 另一个写者拿旧 version 抢改 → 同样 40902
|
||||
patchPetExpectingCode(owner, petId, setAvatarBody(0, second), 409, 40902);
|
||||
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
|
||||
|
||||
// 用新 version 重放同一头像 → 幂等地仍是这张图(version 继续前进)
|
||||
String again = patchPet(owner, petId, setAvatarBody(1, first), 200);
|
||||
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
|
||||
assertThat((int) JsonPath.read(again, "$.data.version")).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,50 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.common.user.UserProfile;
|
||||
import com.patbond.patbond.user.dto.MeResponse;
|
||||
import com.patbond.patbond.user.dto.UpdateMeRequest;
|
||||
import com.patbond.patbond.user.security.BearerAuthFilter;
|
||||
import com.patbond.patbond.user.service.UserService;
|
||||
import com.patbond.patbond.user.service.MeProfileService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Public profile endpoint. Authentication happens in BearerAuthFilter (RS256
|
||||
* verification against the auth service's public key); by the time this
|
||||
* controller runs, the user id attribute is guaranteed to be present.
|
||||
* The account owner's own profile (T3.5-04). Authentication happens in
|
||||
* BearerAuthFilter (RS256 verification against the auth service's public
|
||||
* key); by the time this controller runs, the user id attribute is guaranteed
|
||||
* to be present, so there is no "other user" case here — the resource is
|
||||
* always the caller's own.
|
||||
*
|
||||
* <p>PATCH is intentionally not {@code @Valid}-annotated: the three-state
|
||||
* fields of {@link UpdateMeRequest} (absent / null / value) need
|
||||
* presence-aware checks that bean validation cannot express, so all rules
|
||||
* live in {@link MeProfileService} and answer 400/40000 with a precise
|
||||
* message.</p>
|
||||
*/
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
private final UserService userService;
|
||||
private final MeProfileService meProfileService;
|
||||
|
||||
public MeController(UserService userService) {
|
||||
this.userService = userService;
|
||||
public MeController(MeProfileService meProfileService) {
|
||||
this.meProfileService = meProfileService;
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/me")
|
||||
public ApiResponse<MeResponse> me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
UserProfile profile = userService.getById(userId);
|
||||
return ApiResponse.success(new MeResponse(
|
||||
profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt()));
|
||||
public ApiResponse<MeResponse> me(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
return ApiResponse.success(meProfileService.get(userId));
|
||||
}
|
||||
|
||||
@PatchMapping("/api/v1/me")
|
||||
public ApiResponse<MeResponse> updateMe(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@RequestBody UpdateMeRequest request) {
|
||||
return ApiResponse.success(meProfileService.update(userId, request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,25 @@ import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Public /api/v1/me payload — exactly the frozen contract fields
|
||||
* {userId, username, phone, createdAt}; nothing else leaks out.
|
||||
* The owner's own profile — payload of both GET and PATCH /api/v1/me
|
||||
* (T3.5-04).
|
||||
*
|
||||
* <p>{@code nickname} is the RAW stored value and is null when the user never
|
||||
* set one: unlike {@link PublicProfileResponse}, this endpoint deliberately
|
||||
* does NOT apply the nickname→username fallback. /me is the editing surface
|
||||
* of the account owner, so it must report what is actually stored; a fallback
|
||||
* here would prefill the edit form with a username the user never chose and
|
||||
* the next save would silently promote it into a real nickname. The display
|
||||
* fallback belongs where display happens — /internal/users/profiles for other
|
||||
* people's view (SQL COALESCE, T3-05) and the client's own greeting.</p>
|
||||
*
|
||||
* <p>{@code avatarUrl} is a freshly signed presigned GET (private bucket,
|
||||
* T3-03 定型): it EXPIRES and must never be persisted client-side. It is null
|
||||
* both when no avatar is set and when the avatar asset is not (or no longer)
|
||||
* ready, so the client's "has an avatar" test is exactly "avatarUrl != null".
|
||||
* The asset id itself is deliberately not echoed — the client only ever
|
||||
* writes it (PATCH) and renders the URL.</p>
|
||||
*/
|
||||
public record MeResponse(UUID userId, String username, String phone, OffsetDateTime createdAt) {
|
||||
public record MeResponse(UUID userId, String username, String nickname, String phone,
|
||||
String avatarUrl, OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.patbond.patbond.user.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/me (T3.5-04). Partial update with an EXPLICIT三态 semantics
|
||||
* per field, which is what the avatar and nickname features need and what the
|
||||
* pets domain's "absent-or-null means unchanged" convention (M2, see
|
||||
* UpdatePetRequest) cannot express:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>key absent</b> → leave the column untouched;</li>
|
||||
* <li><b>key present with null</b> → clear the column (remove the nickname /
|
||||
* remove the avatar);</li>
|
||||
* <li><b>key present with a value</b> → set it.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Why the difference from pets: name/species/sex have no meaningful empty
|
||||
* state (their CHECK constraints forbid it), so M2 could afford to conflate
|
||||
* null with absent. A nickname and an avatar are genuinely optional and
|
||||
* "remove what I set" is a first-class user action — with only two states
|
||||
* there would be no way to express it at all. Presence is tracked by the
|
||||
* setters: Jackson calls a setter exactly when the JSON key is present,
|
||||
* including when its value is null.</p>
|
||||
*
|
||||
* <p>A body that touches neither field is rejected with 400/40000 rather than
|
||||
* answering a silent 200 — an empty PATCH is a client bug, not an intent.</p>
|
||||
*/
|
||||
public class UpdateMeRequest {
|
||||
|
||||
private String nickname;
|
||||
private boolean nicknamePresent;
|
||||
|
||||
private UUID avatarAssetId;
|
||||
private boolean avatarAssetIdPresent;
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
}
|
||||
|
||||
public void setNickname(String nickname) {
|
||||
this.nickname = nickname;
|
||||
this.nicknamePresent = true;
|
||||
}
|
||||
|
||||
public boolean isNicknamePresent() {
|
||||
return nicknamePresent;
|
||||
}
|
||||
|
||||
public UUID getAvatarAssetId() {
|
||||
return avatarAssetId;
|
||||
}
|
||||
|
||||
public void setAvatarAssetId(UUID avatarAssetId) {
|
||||
this.avatarAssetId = avatarAssetId;
|
||||
this.avatarAssetIdPresent = true;
|
||||
}
|
||||
|
||||
public boolean isAvatarAssetIdPresent() {
|
||||
return avatarAssetIdPresent;
|
||||
}
|
||||
|
||||
/** True when the body carries no updatable field at all. */
|
||||
public boolean isEmptyPatch() {
|
||||
return !nicknamePresent && !avatarAssetIdPresent;
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,16 @@ public class MediaProperties {
|
||||
/** Mime whitelist for kind=image (M3: jpeg/png/webp). */
|
||||
private List<String> allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
|
||||
|
||||
/** Purpose whitelist; decides the objectKey prefix. M3: post_image. */
|
||||
private List<String> allowedPurposes = List.of("post_image");
|
||||
/**
|
||||
* Purpose whitelist; decides the objectKey prefix. M3 shipped
|
||||
* {@code post_image}; M3.5 adds the two avatar purposes (ADR-022 —
|
||||
* media.assets.purpose has no CHECK constraint, so a new use case is a
|
||||
* configuration + contract-enum change, never a migration). The purpose
|
||||
* is also the referencing side's type check: only a {@code user_avatar}
|
||||
* asset may become a user avatar, only a {@code pet_avatar} asset a pet
|
||||
* avatar, so a post image can never be silently reused as an avatar.
|
||||
*/
|
||||
private List<String> allowedPurposes = List.of("post_image", "user_avatar", "pet_avatar");
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
|
||||
@@ -4,6 +4,9 @@ import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -36,6 +39,20 @@ public class UserRepository {
|
||||
OffsetDateTime lockedUntil) {
|
||||
}
|
||||
|
||||
/** Public-profile projection; nickname already carries the username fallback. */
|
||||
public record PublicProfileRow(UUID id, String nickname, UUID avatarAssetId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner's-own-profile projection for /api/v1/me (T3.5-04). {@code nickname}
|
||||
* is the RAW column — no username fallback here (see MeResponse for why);
|
||||
* {@code avatarObjectKey} is already narrowed to a READY asset, so the
|
||||
* caller only has to sign it.
|
||||
*/
|
||||
public record MeRow(UUID id, String username, String nickname, String phone,
|
||||
String avatarObjectKey, OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
/** 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 +103,94 @@ 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner's own profile plus the object key of a READY avatar asset.
|
||||
* The LEFT JOIN carries the readiness condition, so a dangling or
|
||||
* still-uploading avatar simply yields a null key (→ {@code avatarUrl:
|
||||
* null}) instead of a broken signed URL.
|
||||
*/
|
||||
public Optional<MeRow> findMeById(UUID id) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT u.id, u.username::text AS username, u.nickname, u.phone_e164,
|
||||
u.created_at, a.object_key AS avatar_object_key
|
||||
FROM identity.users u
|
||||
LEFT JOIN media.assets a
|
||||
ON a.id = u.avatar_asset_id AND a.status = 'ready'
|
||||
WHERE u.id = :id AND u.deleted_at IS NULL
|
||||
""")
|
||||
.param("id", id)
|
||||
.query((rs, rowNum) -> new MeRow(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("username"),
|
||||
rs.getString("nickname"),
|
||||
rs.getString("phone_e164"),
|
||||
rs.getString("avatar_object_key"),
|
||||
rs.getObject("created_at", OffsetDateTime.class)))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Column-selective profile update for PATCH /api/v1/me: only the columns
|
||||
* the request actually carried appear in the SET list. This is
|
||||
* deliberately NOT a read-merge-write — two concurrent PATCHes, one
|
||||
* changing the nickname and one the avatar, both survive, whereas a
|
||||
* merged full-row write would let the later one silently revert the
|
||||
* other's field. /me has a single legitimate writer (the account owner),
|
||||
* so no optimistic-lock version is exposed; last write wins per column.
|
||||
*
|
||||
* @return rows updated — 0 means the user is gone (or soft-deleted)
|
||||
*/
|
||||
public int updateOwnProfile(UUID id, boolean setNickname, String nickname,
|
||||
boolean setAvatarAssetId, UUID avatarAssetId) {
|
||||
List<String> assignments = new ArrayList<>(2);
|
||||
if (setNickname) {
|
||||
assignments.add("nickname = :nickname");
|
||||
}
|
||||
if (setAvatarAssetId) {
|
||||
assignments.add("avatar_asset_id = :avatarAssetId");
|
||||
}
|
||||
if (assignments.isEmpty()) {
|
||||
throw new IllegalArgumentException("updateOwnProfile 需至少一个待更新列");
|
||||
}
|
||||
JdbcClient.StatementSpec spec = jdbcClient.sql("""
|
||||
UPDATE identity.users SET %s
|
||||
WHERE id = :id AND deleted_at IS NULL
|
||||
""".formatted(String.join(", ", assignments)))
|
||||
.param("id", id);
|
||||
if (setNickname) {
|
||||
spec = spec.param("nickname", nickname);
|
||||
}
|
||||
if (setAvatarAssetId) {
|
||||
spec = spec.param("avatarAssetId", avatarAssetId);
|
||||
}
|
||||
return spec.update();
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.patbond.patbond.user.service;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.user.dto.MeResponse;
|
||||
import com.patbond.patbond.user.dto.UpdateMeRequest;
|
||||
import com.patbond.patbond.user.media.MediaAssetRepository;
|
||||
import com.patbond.patbond.user.media.MediaProperties;
|
||||
import com.patbond.patbond.user.media.ObjectStorage;
|
||||
import com.patbond.patbond.user.repository.UserRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The account owner's own profile: GET and PATCH /api/v1/me (T3.5-04).
|
||||
*
|
||||
* <p>Semantics frozen with this ticket:
|
||||
* <ul>
|
||||
* <li><b>No nickname fallback on /me</b> — the raw stored value, null when
|
||||
* unset (rationale in {@link MeResponse}). The fallback stays in
|
||||
* /internal/users/profiles' SQL, where other people's display name is
|
||||
* produced.</li>
|
||||
* <li><b>PATCH is三态 per field</b> — absent = unchanged, explicit null =
|
||||
* clear, value = set (see {@link UpdateMeRequest}). An empty patch is
|
||||
* 400/40000.</li>
|
||||
* <li><b>nickname validation mirrors ck_users_nickname</b> — trimmed, 1..32
|
||||
* CODE POINTS (PostgreSQL char_length counts code points, so a Java
|
||||
* String.length() bound would reject 32 emoji the database accepts);
|
||||
* whitespace-only is 400/40000, never an implicit clear, so clearing is
|
||||
* expressible exactly one way.</li>
|
||||
* <li><b>avatarAssetId validation mirrors the T3-03 referencing protocol</b>
|
||||
* — unknown / someone else's / deleted asset → 404/40405 (one merged
|
||||
* anti-enumeration answer); the caller's own asset with the wrong
|
||||
* purpose → 404/40405 as well (from the avatar domain's point of view a
|
||||
* post image is not an avatar; no enumeration risk, since the branch is
|
||||
* only reachable for assets the caller owns, so the message may be
|
||||
* specific); the caller's own user_avatar asset still uploading or
|
||||
* failed → 422/42203.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>No optimistic lock is exposed: /me has one legitimate writer and the
|
||||
* update is column-selective, so concurrent nickname/avatar patches cannot
|
||||
* clobber each other (see {@link UserRepository#updateOwnProfile}).</p>
|
||||
*/
|
||||
@Service
|
||||
public class MeProfileService {
|
||||
|
||||
/** The only media purpose acceptable as a user avatar (ADR-022). */
|
||||
private static final String AVATAR_PURPOSE = "user_avatar";
|
||||
|
||||
private static final int NICKNAME_MAX_CODE_POINTS = 32;
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final MediaAssetRepository mediaAssetRepository;
|
||||
private final ObjectStorage objectStorage;
|
||||
private final MediaProperties mediaProperties;
|
||||
|
||||
public MeProfileService(UserRepository userRepository,
|
||||
MediaAssetRepository mediaAssetRepository,
|
||||
ObjectStorage objectStorage,
|
||||
MediaProperties mediaProperties) {
|
||||
this.userRepository = userRepository;
|
||||
this.mediaAssetRepository = mediaAssetRepository;
|
||||
this.objectStorage = objectStorage;
|
||||
this.mediaProperties = mediaProperties;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MeResponse get(UUID userId) {
|
||||
return toResponse(userRepository.findMeById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MeResponse update(UUID userId, UpdateMeRequest request) {
|
||||
if (request.isEmptyPatch()) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"请至少提交一个可更新字段:nickname 或 avatarAssetId");
|
||||
}
|
||||
|
||||
String nickname = null;
|
||||
if (request.isNicknamePresent() && request.getNickname() != null) {
|
||||
nickname = requireNickname(request.getNickname());
|
||||
}
|
||||
if (request.isAvatarAssetIdPresent() && request.getAvatarAssetId() != null) {
|
||||
requireOwnReadyAvatarAsset(userId, request.getAvatarAssetId());
|
||||
}
|
||||
|
||||
int updated = userRepository.updateOwnProfile(userId,
|
||||
request.isNicknamePresent(), nickname,
|
||||
request.isAvatarAssetIdPresent(), request.getAvatarAssetId());
|
||||
if (updated == 0) {
|
||||
// The token is valid but the account is gone (or 注销) — same
|
||||
// answer as a GET of a soft-deleted user.
|
||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
return get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims like {@code btrim} and enforces the ck_users_nickname width. A
|
||||
* blank-after-trim value is a validation error rather than a clear: an
|
||||
* explicit JSON null is the single, unambiguous way to remove a nickname.
|
||||
*/
|
||||
private static String requireNickname(String raw) {
|
||||
String trimmed = raw.trim();
|
||||
int codePoints = trimmed.codePointCount(0, trimmed.length());
|
||||
if (codePoints < 1 || codePoints > NICKNAME_MAX_CODE_POINTS) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"nickname 去除首尾空白后长度须在 1~" + NICKNAME_MAX_CODE_POINTS
|
||||
+ " 字符;如需清空请显式提交 null");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
|
||||
MediaAssetRepository.AssetRow asset = mediaAssetRepository
|
||||
.findByIdAndOwner(assetId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
|
||||
if ("deleted".equals(asset.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
|
||||
}
|
||||
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
|
||||
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为头像");
|
||||
}
|
||||
if (!"ready".equals(asset.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
|
||||
}
|
||||
}
|
||||
|
||||
private MeResponse toResponse(UserRepository.MeRow row) {
|
||||
return new MeResponse(row.id(), row.username(), row.nickname(), row.phone(),
|
||||
signAvatar(row.avatarObjectKey()), row.createdAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a short-lived GET for the avatar object. Storage being
|
||||
* unconfigured degrades to {@code avatarUrl: null} (same precedent as the
|
||||
* community read side and the missing JWT public key) instead of failing
|
||||
* the whole profile read — the condition mirrors MediaStorageConfig's.
|
||||
*/
|
||||
private String signAvatar(String objectKey) {
|
||||
String endpoint = mediaProperties.getEndpoint();
|
||||
if (objectKey == null || endpoint == null || endpoint.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return objectStorage.presignGet(objectKey, mediaProperties.getDownloadTtl());
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,10 @@ patbond:
|
||||
# 预签名 PUT 凭据与 GET URL 的有效期
|
||||
upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m}
|
||||
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
|
||||
# 单文件上限(字节)与 mime/purpose 白名单(M3 首版:图片、帖子配图)
|
||||
# 单文件上限(字节)与 mime/purpose 白名单(M3.5:帖子配图 + 用户/宠物头像)
|
||||
max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760}
|
||||
allowed-mime-types: image/jpeg,image/png,image/webp
|
||||
allowed-purposes: post_image
|
||||
allowed-purposes: post_image,user_avatar,pet_avatar
|
||||
|
||||
# Development seed data (regions reference rows) is opt-in. To load it,
|
||||
# activate a dev profile that widens the Flyway locations:
|
||||
|
||||
+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.4.0(快照 {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在
|
||||
* doc 仓 {@code docs/api/openapi.yaml})逐操作真实起服务发请求(真实 MinIO
|
||||
* Testcontainer,直传走真实 HTTP PUT),用 {@link ContractValidator} 严格校验
|
||||
* 响应结构,最后以全响应矩阵门禁兜底(8 个单元格,无豁免)。
|
||||
*
|
||||
* <p>auth/user 域 7 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域
|
||||
* 18 操作在 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.4.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(32);
|
||||
assertThat(CONTRACT.operations()).hasSize(45);
|
||||
assertThat(CONTRACT.schemas()).hasSize(75);
|
||||
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.4.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.4.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.4.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));
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
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.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
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.time.Duration;
|
||||
import java.util.Map;
|
||||
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.request.MockMvcRequestBuilders.patch;
|
||||
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.5-04 头像全链路(真实 MinIO Testcontainer,镜像 tag 与 compose 一致):
|
||||
* 以 {@code purpose=user_avatar} 创建上传 → 凭据直传 → complete 置 ready →
|
||||
* PATCH /me 挂头像 → GET /me 的 {@code avatarUrl} 是可真实下载的预签名 GET →
|
||||
* 清空后回到 null。同时实证新加入白名单的 user_avatar 用途端到端可用,以及
|
||||
* 「真实未就绪 asset 被拒 42203」(非 SQL 造数据的那一版)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MeAvatarSigningIntegrationTest {
|
||||
|
||||
/** 与 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 static byte[] fakeJpeg() {
|
||||
byte[] bytes = new byte[1024];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = (byte) (i * 17);
|
||||
}
|
||||
bytes[0] = (byte) 0xFF;
|
||||
bytes[1] = (byte) 0xD8; // JPEG SOI
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private UUID newUser(String username) {
|
||||
UUID id = UuidV7.generate();
|
||||
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));
|
||||
}
|
||||
|
||||
/** 以 user_avatar 用途申请上传凭据(本单新加入白名单)。 */
|
||||
private String createAvatarUpload(UUID user) throws Exception {
|
||||
return mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"user_avatar",
|
||||
"mimeType":"image/jpeg","byteSize":%d}
|
||||
""".formatted(FAKE_JPEG.length)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.data.assetId").isNotEmpty())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
private void directPut(String createdBody) 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(FAKE_JPEG));
|
||||
headers.forEach(put::header);
|
||||
assertThat(HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode())
|
||||
.isEqualTo(200);
|
||||
}
|
||||
|
||||
/** 走完 T3-03 两步上传,返回 ready 的 assetId。 */
|
||||
private String uploadReadyAvatar(UUID user) throws Exception {
|
||||
String created = createAvatarUpload(user);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
directPut(created);
|
||||
mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
|
||||
.header("Authorization", bearer(user)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("ready"));
|
||||
return assetId;
|
||||
}
|
||||
|
||||
@Test
|
||||
void avatarUrlIsAFreshPresignedGetThatActuallyDownloads() throws Exception {
|
||||
UUID user = newUser("avatar_signed");
|
||||
String assetId = uploadReadyAvatar(user);
|
||||
|
||||
String patched = mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
String avatarUrl = JsonPath.read(patched, "$.data.avatarUrl");
|
||||
assertThat(avatarUrl)
|
||||
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/")
|
||||
.contains("X-Amz-Signature=");
|
||||
|
||||
// 真实下载:签名有效,私有桶靠签名而非公开读
|
||||
HttpResponse<byte[]> download = HTTP.send(
|
||||
HttpRequest.newBuilder(URI.create(avatarUrl)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertThat(download.statusCode()).isEqualTo(200);
|
||||
assertThat(download.body()).isEqualTo(FAKE_JPEG);
|
||||
|
||||
// GET /me 每次重新签发(URL 会过期,客户端不得持久化)
|
||||
String fetched = mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertThat((String) JsonPath.read(fetched, "$.data.avatarUrl"))
|
||||
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/");
|
||||
|
||||
// 清空后回到 null
|
||||
String cleared = mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实的「凭据已发但还没直传」状态:asset 存在、属本人、用途正确,但仍是
|
||||
* uploading —— 引用侧必须 422/42203,不能挂上一个下载会 404 的头像。
|
||||
*/
|
||||
@Test
|
||||
void refusesAnAvatarAssetWhoseUploadNeverCompleted() throws Exception {
|
||||
UUID user = newUser("avatar_pending");
|
||||
String assetId = JsonPath.read(createAvatarUpload(user), "$.data.assetId");
|
||||
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value(42203));
|
||||
}
|
||||
|
||||
/** purpose 白名单:M3.5 之后 pet_avatar 同样可申请(宠物侧引用方在 pet 服务)。 */
|
||||
@Test
|
||||
void petAvatarPurposeIsAlsoWhitelisted() throws Exception {
|
||||
UUID user = newUser("avatar_purposes");
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"pet_avatar",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
// 白名单外的用途仍是 400/40000
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"avatar",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
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;
|
||||
@@ -25,8 +26,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
/**
|
||||
* GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally
|
||||
* against the configured public key (generated per test run — no committed
|
||||
* key material). Response shape is the frozen contract:
|
||||
* {userId, username, phone, createdAt} and nothing else.
|
||||
* key material). Response shape is the M3.5 surface
|
||||
* {userId, username, nickname, phone, avatarUrl, createdAt} and nothing else;
|
||||
* the profile-write semantics live in MeProfileIntegrationTest.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@@ -65,9 +67,14 @@ class MeEndpointTest {
|
||||
.andExpect(jsonPath("$.data.username").value("me_happy"))
|
||||
.andExpect(jsonPath("$.data.phone").value("+8613800000401"))
|
||||
.andExpect(jsonPath("$.data.createdAt").isNotEmpty())
|
||||
// Frozen contract: no other identity fields leak out.
|
||||
// 注册不收昵称(ADR-022 决策 D3.5-5),且 /me 不做 username 回退:
|
||||
// 本人编辑态必须如实反映「我还没设过昵称」。
|
||||
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
|
||||
// No other identity field leaks out.
|
||||
.andExpect(jsonPath("$.data.id").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.nickname").doesNotExist());
|
||||
.andExpect(jsonPath("$.data.avatarAssetId").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.bio").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
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 com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
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.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* T3.5-04 用户资料读写:GET/PATCH /api/v1/me 的六类路径(成功 / 参数错 /
|
||||
* 不存在 / 无权限 / 并发 / 重放)与三项专项(昵称边界值与清空、头像 asset
|
||||
* 非法三态、/me 不回退 而 /internal 回退)。
|
||||
*
|
||||
* <p>本类不配置对象存储,因此断言 {@code avatarUrl} 恒为 null——这正是
|
||||
* 「存储未配置时资料读取整体降级而不失败」的实证;真实签名 URL 的全链路
|
||||
* (创建上传 → 直传 → complete → 挂头像 → URL 可访问)在
|
||||
* MeAvatarSigningIntegrationTest 用真实 MinIO 覆盖。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MeProfileIntegrationTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "test-internal-token";
|
||||
|
||||
/** 32 个 CJK 码点:恰好压在 ck_users_nickname 的上界上。 */
|
||||
private static final String NICKNAME_32_CJK = "豆".repeat(32);
|
||||
|
||||
/**
|
||||
* 32 个 emoji 码点(UTF-16 长度 64):证明长度校验按码点而非 Java
|
||||
* String.length() 计——PostgreSQL char_length 数的是码点,若按 UTF-16
|
||||
* 长度校验,这个数据库能存的昵称会被应用层误拒。
|
||||
*/
|
||||
private static final String NICKNAME_32_EMOJI = "🐶".repeat(32);
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtPublicKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------
|
||||
|
||||
private UUID newUser(String username) {
|
||||
UUID id = UuidV7.generate();
|
||||
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));
|
||||
}
|
||||
|
||||
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 的产物)。 */
|
||||
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO media.assets
|
||||
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
|
||||
mime_type, byte_size, status, ready_at, deleted_at)
|
||||
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
|
||||
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("purpose", purpose)
|
||||
.param("objectKey", purpose + "/2026/09/" + id)
|
||||
.param("status", status)
|
||||
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
|
||||
// ck_media_deleted:status='deleted' 必带 deleted_at
|
||||
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private String patchMe(UUID userId, String body, int expectedStatus) throws Exception {
|
||||
return mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(userId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().is(expectedStatus))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
private void patchMeExpectingCode(UUID userId, String body, int httpStatus, int bizCode)
|
||||
throws Exception {
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(userId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().is(httpStatus))
|
||||
.andExpect(jsonPath("$.code").value(bizCode));
|
||||
}
|
||||
|
||||
private String getMe(UUID userId) throws Exception {
|
||||
return mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(userId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
/** 他人视角的展示名(/internal 批量接口,回退在 SQL 层)。 */
|
||||
private String publicNickname(UUID userId) throws Exception {
|
||||
String body = mockMvc.perform(get("/internal/users/profiles")
|
||||
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
|
||||
.queryParam("ids", userId.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
|
||||
return (String) rows.get(0).get("nickname");
|
||||
}
|
||||
|
||||
private UUID dbAvatarAssetId(UUID userId) {
|
||||
return jdbcClient.sql("SELECT avatar_asset_id FROM identity.users WHERE id = :id")
|
||||
.param("id", userId)
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// ---- 成功路径 -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void setsNicknameAndBothSidesAgree() throws Exception {
|
||||
UUID user = newUser("me_nick_set");
|
||||
|
||||
String patched = patchMe(user, "{\"nickname\":\"豆豆家长\"}", 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆家长");
|
||||
|
||||
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("豆豆家长");
|
||||
// 双端一致:他人看到的展示名也立即是昵称(Feed 作者名同链路)
|
||||
assertThat(publicNickname(user)).isEqualTo("豆豆家长");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trimsNicknameLikeBtrim() throws Exception {
|
||||
UUID user = newUser("me_nick_trim");
|
||||
String patched = patchMe(user, "{\"nickname\":\" 豆豆 \"}", 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆");
|
||||
}
|
||||
|
||||
/**
|
||||
* /me 是本人编辑态,故 nickname 为 DB 原值(清空后为 null);他人视角的
|
||||
* /internal 才回退 username。这两者的差异是本单的核心语义定型。
|
||||
*/
|
||||
@Test
|
||||
void clearingNicknameIsNullOnMeButFallsBackForOtherPeople() throws Exception {
|
||||
UUID user = newUser("me_nick_clear");
|
||||
patchMe(user, "{\"nickname\":\"临时昵称\"}", 200);
|
||||
|
||||
String cleared = patchMe(user, "{\"nickname\":null}", 200);
|
||||
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
|
||||
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
|
||||
assertThat(publicNickname(user)).isEqualTo("me_nick_clear");
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentFieldIsLeftUnchanged() throws Exception {
|
||||
UUID user = newUser("me_absent");
|
||||
patchMe(user, "{\"nickname\":\"保持不动\"}", 200);
|
||||
|
||||
// 只提交 avatarAssetId=null:nickname 未出现在 body 里,必须不受影响
|
||||
String patched = patchMe(user, "{\"avatarAssetId\":null}", 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("保持不动");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setsAndClearsAvatar() throws Exception {
|
||||
UUID user = newUser("me_avatar_ok");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
|
||||
String patched = patchMe(user, "{\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
// 对象存储未配置 → 整体降级为 null URL,而不是 500
|
||||
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
|
||||
|
||||
patchMe(user, "{\"avatarAssetId\":null}", 200);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsNicknameAndAvatarInOneRequest() throws Exception {
|
||||
UUID user = newUser("me_both");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
|
||||
String patched = patchMe(user,
|
||||
"{\"nickname\":\"一次改两样\",\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("一次改两样");
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- 参数错路径(昵称边界值 + 畸形入参 + 空 patch) -------------------
|
||||
|
||||
@Test
|
||||
void acceptsNicknameAtBothBoundaries() throws Exception {
|
||||
UUID user = newUser("me_nick_bounds");
|
||||
|
||||
assertThat((String) JsonPath.read(patchMe(user, "{\"nickname\":\"豆\"}", 200),
|
||||
"$.data.nickname")).isEqualTo("豆");
|
||||
assertThat((String) JsonPath.read(
|
||||
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_CJK), 200),
|
||||
"$.data.nickname")).isEqualTo(NICKNAME_32_CJK);
|
||||
assertThat((String) JsonPath.read(
|
||||
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_EMOJI), 200),
|
||||
"$.data.nickname")).isEqualTo(NICKNAME_32_EMOJI);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNicknameOverThirtyTwoCodePoints() throws Exception {
|
||||
UUID user = newUser("me_nick_long");
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("豆".repeat(33)), 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("🐶".repeat(33)), 400, 40000);
|
||||
// 拒绝后 DB 未被写入
|
||||
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* 空白昵称是参数错,不是「隐式清空」——清空只有显式 null 一种表达,
|
||||
* 否则「用户不小心提交了空格」与「用户想删昵称」无法区分。
|
||||
*/
|
||||
@Test
|
||||
void rejectsWhitespaceOnlyNicknameInsteadOfClearing() throws Exception {
|
||||
UUID user = newUser("me_nick_blank");
|
||||
patchMe(user, "{\"nickname\":\"原昵称\"}", 200);
|
||||
|
||||
patchMeExpectingCode(user, "{\"nickname\":\" \"}", 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"\"}", 400, 40000);
|
||||
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("原昵称");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPatchThatTouchesNothing() throws Exception {
|
||||
UUID user = newUser("me_empty_patch");
|
||||
patchMeExpectingCode(user, "{}", 400, 40000);
|
||||
// 只带未声明字段同样等于「什么都没改」
|
||||
patchMeExpectingCode(user, "{\"unknownField\":\"x\"}", 400, 40000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedBody() throws Exception {
|
||||
UUID user = newUser("me_malformed");
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"nickname\":", 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":42}", 400, 40000);
|
||||
}
|
||||
|
||||
// ---- 头像 asset 非法三态(不存在/非本人/错用途/未就绪) ---------------
|
||||
|
||||
@Test
|
||||
void rejectsUnknownOrForeignAvatarAssetWithMergedNotFound() throws Exception {
|
||||
UUID user = newUser("me_asset_foreign");
|
||||
UUID stranger = newUser("me_asset_stranger");
|
||||
UUID strangerAsset = insertAsset(stranger, "user_avatar", "ready");
|
||||
|
||||
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
|
||||
patchMeExpectingCode(user,
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), 404, 40405);
|
||||
patchMeExpectingCode(user,
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(strangerAsset), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssetWithWrongPurpose() throws Exception {
|
||||
UUID user = newUser("me_asset_purpose");
|
||||
UUID postImage = insertAsset(user, "post_image", "ready");
|
||||
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(postImage), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAvatarAssetThatIsNotReady() throws Exception {
|
||||
UUID user = newUser("me_asset_state");
|
||||
UUID uploading = insertAsset(user, "user_avatar", "uploading");
|
||||
UUID failed = insertAsset(user, "user_avatar", "failed");
|
||||
UUID deleted = insertAsset(user, "user_avatar", "deleted");
|
||||
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(uploading), 422, 42203);
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(failed), 422, 42203);
|
||||
// deleted 归入防枚举合并的 40405:已删资源对引用方就是不存在
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(deleted), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
// ---- 无权限路径 -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
void patchWithoutOrWithBadTokenIs40101() throws Exception {
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"无票乘车\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", "Bearer not.a.jwt")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"伪票\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
// ---- 不存在路径 -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
void softDeletedUserGetsUserNotFoundOnBothVerbs() throws Exception {
|
||||
UUID user = newUser("me_gone");
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.users
|
||||
SET status = 'deleted', deleted_at = now()
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("id", user)
|
||||
.update();
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40400));
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"亡者昵称\"}", 404, 40400);
|
||||
}
|
||||
|
||||
// ---- 并发路径 -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 两个并发 PATCH 各改一个字段:都必须留下。这是「列级选择性 UPDATE 而非
|
||||
* 读-合并-写」的实证——若走整行合并写,后到的那个会把对方刚写的字段
|
||||
* 悄悄还原(丢失更新)。/me 无版本号乐观锁,靠的正是这个性质。
|
||||
*/
|
||||
@Test
|
||||
void concurrentDisjointPatchesBothSurvive() throws Exception {
|
||||
UUID user = newUser("me_concurrent");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
CyclicBarrier startTogether = new CyclicBarrier(2);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Integer> nicknameWriter = () -> {
|
||||
startTogether.await();
|
||||
return mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"并发昵称\"}"))
|
||||
.andReturn().getResponse().getStatus();
|
||||
};
|
||||
Callable<Integer> avatarWriter = () -> {
|
||||
startTogether.await();
|
||||
return mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":\"%s\"}".formatted(asset)))
|
||||
.andReturn().getResponse().getStatus();
|
||||
};
|
||||
Future<Integer> first = pool.submit(nicknameWriter);
|
||||
Future<Integer> second = pool.submit(avatarWriter);
|
||||
assertThat(first.get()).isEqualTo(200);
|
||||
assertThat(second.get()).isEqualTo(200);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("并发昵称");
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- 重放路径 -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PATCH /me 是幂等的(无版本号、无幂等键):同一请求重放两次,第二次同样
|
||||
* 200 且状态与首次完全一致——重复点「保存」不会产生二次副作用。
|
||||
*/
|
||||
@Test
|
||||
void repeatingTheSamePatchIsStable() throws Exception {
|
||||
UUID user = newUser("me_replay");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
String body = "{\"nickname\":\"重放昵称\",\"avatarAssetId\":\"%s\"}".formatted(asset);
|
||||
|
||||
String firstNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
|
||||
String secondNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
|
||||
|
||||
assertThat(secondNickname).isEqualTo(firstNickname).isEqualTo("重放昵称");
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- GET 形态回归 ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
void meCarriesNickAndAvatarUrlFieldsAlways() throws Exception {
|
||||
UUID user = newUser("me_shape");
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.username").value("me_shape"));
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -241,6 +241,8 @@ class MediaUploadIntegrationTest {
|
||||
@Test
|
||||
void rejectsKindAndPurposeOutsideWhitelist() throws Exception {
|
||||
UUID user = newUser("media_bad_enum");
|
||||
// M3.5 起 user_avatar/pet_avatar 已进白名单(ADR-022),反例改用
|
||||
// 一个仍未开放的用途,保持本用例「白名单外必拒」的语义。
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -254,7 +256,7 @@ class MediaUploadIntegrationTest {
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"pet_avatar",
|
||||
{"kind":"image","purpose":"id_card",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user