Compare commits
27 Commits
0d81c38fc6
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd8005577 | |||
| d98a400f47 | |||
| 15c2e66519 | |||
| a5634c5f85 | |||
| 8089c06a73 | |||
| 0569585434 | |||
| 7f1dd33097 | |||
| 19e8cba59f | |||
| 99a3c1f8ab | |||
| 40bac85543 | |||
| 101ac0fbbc | |||
| 263cd88451 | |||
| 10a43f876c | |||
| 8330885b06 | |||
| 3c671fcaf5 | |||
| a97814ac1c | |||
| 64c9b72fd1 | |||
| d026f2f63d | |||
| 00f7dbdb69 | |||
| 3b27f9fcbe | |||
| d8303bf446 | |||
| 4c2653c643 | |||
| 825dde3928 | |||
| 8fbf44472d | |||
| 58576f8ebc | |||
| 0eae1c9bec | |||
| 49299fb4eb |
@@ -36,6 +36,10 @@ jobs:
|
||||
git remote add origin "$AUTH_URL/${{ github.repository }}.git"
|
||||
git fetch -q --depth 1 origin "+${{ github.ref }}:refs/ci-head"
|
||||
git checkout -q refs/ci-head
|
||||
# 凭证防泄漏兜底(ADR-021):与本地 pre-commit 同一脚本、同一规则表,
|
||||
# 扫全部已跟踪文件(覆盖本次 push 变更的超集),纯 shell 零外部依赖。
|
||||
- name: Secret scan
|
||||
run: sh scripts/check-secrets.sh --all
|
||||
# 不用 actions/setup-java:它从 github.com 下载 JDK 二进制,
|
||||
# 服务器无法访问 GitHub(实测 ETIMEDOUT)。改用 apt 装 OpenJDK 17,
|
||||
# 并优先切换到腾讯云镜像源(runner 在腾讯云,内网加速)。
|
||||
|
||||
@@ -7,13 +7,14 @@ Patbond API is a Spring Boot multi-module backend.
|
||||
- `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies.
|
||||
- `patbond-user`: user service for user creation, profile lookup, and password verification.
|
||||
- `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002).
|
||||
- `patbond-pet`: pet profile and health record service (M2, ADR-009). Shares the database with `patbond-user` and only reads/writes the `pet_health` schema; the Flyway migration chain stays owned by `patbond-user`. First-wave skeleton: liveness endpoint only.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- Java 17 (build baseline; use JDK 17 for release builds)
|
||||
- Spring Boot 3.5.16
|
||||
- Spring Cloud 2025.0.3 (OpenFeign only)
|
||||
- PostgreSQL 18 + Flyway (patbond-user owns the `identity`/`media` schemas)
|
||||
- PostgreSQL 18 + Flyway (patbond-user owns the migration chain: `identity`/`media`/`platform`/`pet_health` schemas)
|
||||
- Maven (use the committed Maven Wrapper `./mvnw`)
|
||||
|
||||
## Build and Test
|
||||
@@ -49,6 +50,8 @@ cp patbond-user/src/main/resources/application.yml.sample \
|
||||
patbond-user/src/main/resources/application.yml
|
||||
cp patbond-auth/src/main/resources/application.yml.sample \
|
||||
patbond-auth/src/main/resources/application.yml
|
||||
cp patbond-pet/src/main/resources/application.yml.sample \
|
||||
patbond-pet/src/main/resources/application.yml
|
||||
```
|
||||
|
||||
Install the shared module once, then run each service in its own terminal:
|
||||
|
||||
@@ -20,6 +20,17 @@ if [ ! -f .env ]; then
|
||||
echo "已生成 .env(随机 DB 口令与内部令牌)"
|
||||
fi
|
||||
|
||||
# M3(ADR-016/021):MinIO 根凭证。按键幂等追加,兼容已有 .env;
|
||||
# 凭证只存在于被 gitignore 的 .env 中,绝不入库。
|
||||
if ! grep -q '^PATBOND_MINIO_ROOT_USER=' .env; then
|
||||
echo "PATBOND_MINIO_ROOT_USER=patbond-minio-$(openssl rand -hex 4)" >> .env
|
||||
echo "已追加 PATBOND_MINIO_ROOT_USER 到 .env"
|
||||
fi
|
||||
if ! grep -q '^PATBOND_MINIO_ROOT_PASSWORD=' .env; then
|
||||
echo "PATBOND_MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)" >> .env
|
||||
echo "已追加 PATBOND_MINIO_ROOT_PASSWORD 到 .env"
|
||||
fi
|
||||
|
||||
# 容器内以 uid 10001 运行,密钥需可读
|
||||
chmod 644 deploy/keys/jwt-public.pem deploy/keys/jwt-private.pem
|
||||
echo "OK:deploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)"
|
||||
|
||||
@@ -25,6 +25,27 @@ services:
|
||||
retries: 30
|
||||
# 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"]
|
||||
|
||||
# M3(ADR-016):自托管 MinIO 对象存储。镜像 tag 与集成测试的 MinIO
|
||||
# Testcontainer 钉同一版本(三环境零分叉);对象数据落 volume(ADR-007,
|
||||
# 应用容器保持无状态)。桶初始化由 user 服务启动时执行(ensureBucket,
|
||||
# 本地/compose/CI 同一条路径),无需 mc 初始化容器。9000 端口必须对客户端
|
||||
# 可达:预签名直传/读取 URL 都直接指向 MinIO,不经应用服务器。
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
command: server /data
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
MINIO_ROOT_PASSWORD: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:9000/minio/health/live"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- "${PATBOND_MINIO_PORT:-9000}:9000"
|
||||
|
||||
user:
|
||||
build: ./patbond-user
|
||||
environment:
|
||||
@@ -34,6 +55,14 @@ services:
|
||||
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||
# ADR-016/017:media 上传流程在 user 服务。服务自身走内网端点;
|
||||
# 预签名 URL 按 PATBOND_MINIO_PUBLIC_ENDPOINT 签发(默认本机回环,
|
||||
# 真机联调/生产改为客户端可达地址)。
|
||||
PATBOND_MINIO_ENDPOINT: http://minio:9000
|
||||
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}
|
||||
PATBOND_MINIO_BUCKET: ${PATBOND_MINIO_BUCKET:-patbond-media}
|
||||
volumes:
|
||||
- ./patbond-user/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||
- ./deploy/keys:/run/patbond/keys:ro
|
||||
@@ -44,6 +73,8 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
auth:
|
||||
build: ./patbond-auth
|
||||
@@ -60,5 +91,65 @@ services:
|
||||
depends_on:
|
||||
- user
|
||||
|
||||
# M2(ADR-009):宠物健康档案服务。第二波起提供 /api/v1/pets、/api/v1/breeds
|
||||
# 业务端点(RS256 校验,与 user 同一公钥)。与 user 共库;Flyway 迁移链由
|
||||
# user 服务统一执行,故依赖 user 先起,保证 pet_health schema 已就绪。
|
||||
pet:
|
||||
build: ./patbond-pet
|
||||
environment:
|
||||
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||
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
|
||||
ports:
|
||||
- "${PATBOND_PET_PORT:-8083}:8083"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
user:
|
||||
condition: service_started
|
||||
|
||||
# M3(ADR-017):社区服务(Feed/帖子/评论/互动)。骨架起 /api/v1/** 即接
|
||||
# RS256 校验(与 user/pet 同一公钥);只读写 community schema。与 user 共库;
|
||||
# Flyway 迁移链(V1..V5,含 community 基线)由 user 服务统一执行,故依赖
|
||||
# user 先起,保证 community schema 已就绪。
|
||||
community:
|
||||
build: ./patbond-community
|
||||
environment:
|
||||
SPRING_CONFIG_LOCATION: file:/config/application.yml
|
||||
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||
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}
|
||||
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-community/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||
- ./deploy/keys:/run/patbond/keys:ro
|
||||
ports:
|
||||
- "${PATBOND_COMMUNITY_PORT:-8084}:8084"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
user:
|
||||
condition: service_started
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
minio-data:
|
||||
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
package com.patbond.patbond.auth.contract;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.UserApplication;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
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.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.http.HttpEntity;
|
||||
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;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.PrivateKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 的编排先例),
|
||||
* 因此这些操作是跨服务的真实纵切,不是 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(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||
class AuthContractConformanceTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "contract-internal-token";
|
||||
|
||||
private static final OpenApiContract CONTRACT = OpenApiContract.load();
|
||||
private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
|
||||
|
||||
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
|
||||
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** 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");
|
||||
private static ConfigurableApplicationContext userApp;
|
||||
private static String userBaseUrl;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
|
||||
POSTGRES.start();
|
||||
userApp = new SpringApplicationBuilder(UserApplication.class).run(
|
||||
"--server.port=0",
|
||||
"--spring.application.name=patbond-user",
|
||||
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||
"--spring.datasource.username=" + POSTGRES.getUsername(),
|
||||
"--spring.datasource.password=" + POSTGRES.getPassword(),
|
||||
"--patbond.internal-token=" + INTERNAL_TOKEN,
|
||||
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
|
||||
"--patbond.login-lock.max-failures=3");
|
||||
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
|
||||
|
||||
registry.add("patbond.user-service.url", () -> userBaseUrl);
|
||||
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
|
||||
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void shutdown() {
|
||||
if (userApp != null) {
|
||||
userApp.close();
|
||||
}
|
||||
POSTGRES.stop();
|
||||
}
|
||||
|
||||
// ---- 校验骨架 ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 真实发请求,断言 HTTP 状态并将响应体对照冻结契约严格校验;通过后把
|
||||
* (操作, 状态码) 记入覆盖表。url 为相对路径时打 auth 服务,绝对 URL
|
||||
* (userBaseUrl 前缀)打 user 服务。
|
||||
*/
|
||||
private String verified(HttpMethod method, String url, String pathTemplate,
|
||||
String body, String bearerToken, int expectedStatus) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (body != null) {
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
if (bearerToken != null) {
|
||||
headers.setBearerAuth(bearerToken);
|
||||
}
|
||||
ResponseEntity<byte[]> response = restTemplate.exchange(
|
||||
url, method, new HttpEntity<>(body, headers), byte[].class);
|
||||
String responseBody = response.getBody() == null
|
||||
? "" : new String(response.getBody(), StandardCharsets.UTF_8);
|
||||
assertThat(response.getStatusCode().value())
|
||||
.as("%s %s 的 HTTP 状态(响应体: %s)", method, pathTemplate, responseBody)
|
||||
.isEqualTo(expectedStatus);
|
||||
List<String> drift = VALIDATOR.validateResponse(
|
||||
method.name(), pathTemplate, expectedStatus, responseBody);
|
||||
assertThat(drift)
|
||||
.as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, expectedStatus)
|
||||
.isEmpty();
|
||||
COVERED.add(method.name() + " " + pathTemplate + " " + expectedStatus);
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
/** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
|
||||
private String verifiedError(HttpMethod method, String url, String pathTemplate,
|
||||
String body, String bearerToken, int status, int bizCode) {
|
||||
String responseBody = verified(method, url, pathTemplate, body, bearerToken, status);
|
||||
assertThat((Integer) JsonPath.read(responseBody, "$.code"))
|
||||
.as("%s %s %d 的业务错误码", method, pathTemplate, status)
|
||||
.isEqualTo(bizCode);
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
private String register(String username, String phone) {
|
||||
return verified(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
|
||||
.formatted(username, phone),
|
||||
null, 200);
|
||||
}
|
||||
|
||||
/** 用正确私钥签任意 subject 的 access token(404 场景等)。 */
|
||||
private static String signToken(PrivateKey key, String subject, Duration ttl) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.subject(subject)
|
||||
.issuer("patbond-auth")
|
||||
.claim("sid", UUID.randomUUID().toString())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.signWith(key, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
// ---- 成功路径 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void registerRefreshLogoutSuccessShapes() {
|
||||
String registered = register("contract_auth_alice", "+8613800000601");
|
||||
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
|
||||
|
||||
// refresh 轮换出新令牌对
|
||||
String rotated = verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
|
||||
String rotatedAccess = JsonPath.read(rotated, "$.data.accessToken");
|
||||
String rotatedRefresh = JsonPath.read(rotated, "$.data.refreshToken");
|
||||
|
||||
// logout 撤销当前会话
|
||||
verified(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(rotatedRefresh), rotatedAccess, 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void loginAndMeSuccessShapes() {
|
||||
register("contract_auth_bob", "+8613800000602");
|
||||
String loggedIn = verified(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||
"{\"username\":\"contract_auth_bob\",\"password\":\"secret123\"}", null, 200);
|
||||
String accessToken = JsonPath.read(loggedIn, "$.data.accessToken");
|
||||
|
||||
String me = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
null, accessToken, 200);
|
||||
assertThat((String) JsonPath.read(me, "$.data.username")).isEqualTo("contract_auth_bob");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void trackEventsSuccessShape() {
|
||||
// 匿名合法批次:202 + 与请求等长的逐条结果
|
||||
String body = verified(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
|
||||
"""
|
||||
{"events": [{
|
||||
"eventId": "%s",
|
||||
"eventName": "auth_register_started",
|
||||
"eventVersion": 1,
|
||||
"anonymousId": "%s",
|
||||
"sessionId": "%s",
|
||||
"clientTs": "2026-09-08T10:00:00Z",
|
||||
"appVersion": "1.0.0+1",
|
||||
"platform": "android",
|
||||
"osVersion": "android-14",
|
||||
"props": {"entryPoint": "onboarding"}
|
||||
}]}
|
||||
""".formatted(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID()),
|
||||
null, 202);
|
||||
assertThat((String) JsonPath.read(body, "$.data.results[0].status")).isEqualTo("accepted");
|
||||
}
|
||||
|
||||
// ---- 错误信封 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void validationErrorsAnswer40000() {
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||
"{}", null, 400, 40000);
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||
"{}", null, 400, 40000);
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||
"{}", null, 400, 40000);
|
||||
|
||||
// logout 400:token 合法但 body 缺 refreshToken
|
||||
String registered = register("contract_auth_carol", "+8613800000603");
|
||||
String accessToken = JsonPath.read(registered, "$.data.accessToken");
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
|
||||
"{}", accessToken, 400, 40000);
|
||||
|
||||
// events 400:空批次整批拒绝
|
||||
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
|
||||
"{\"events\":[]}", null, 400, 40000);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void conflictAndCredentialErrorsMatchContract() {
|
||||
register("contract_auth_dave", "+8613800000604");
|
||||
|
||||
// register 409:用户名占用 40900 / 手机号占用 40901(同一单元格的两种业务码)
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||
"{\"username\":\"contract_auth_dave\",\"phone\":\"+8613800000605\",\"password\":\"secret123\"}",
|
||||
null, 409, 40900);
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/register",
|
||||
"{\"username\":\"contract_auth_dave2\",\"phone\":\"+8613800000604\",\"password\":\"secret123\"}",
|
||||
null, 409, 40901);
|
||||
|
||||
// login 401:密码错误 40100
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||
"{\"username\":\"contract_auth_dave\",\"password\":\"wrong-pass\"}",
|
||||
null, 401, 40100);
|
||||
|
||||
// refresh 401:已轮换 token 重放 40102
|
||||
String registered = register("contract_auth_erin", "+8613800000606");
|
||||
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
|
||||
verified(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 200);
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/refresh",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null, 401, 40102);
|
||||
|
||||
// logout 401:缺 access token
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/logout", "/api/v1/auth/logout",
|
||||
"{\"refreshToken\":\"whatever\"}", null, 401, 40101);
|
||||
|
||||
// me 401:无 token;404:合法签名但用户不存在(40400)
|
||||
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
null, null, 401, 40101);
|
||||
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.randomUUID().toString(), Duration.ofMinutes(15));
|
||||
verifiedError(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
|
||||
null, ghostToken, 404, 40400);
|
||||
|
||||
// events 401:携带了 Authorization 但 token 无效
|
||||
verifiedError(HttpMethod.POST, userBaseUrl + "/api/v1/events", "/api/v1/events",
|
||||
"{\"events\":[]}", "not-a-token", 401, 40101);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void loginLockoutAnswers42300() {
|
||||
register("contract_auth_locked", "+8613800000607");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||
"{\"username\":\"contract_auth_locked\",\"password\":\"wrong-pass\"}",
|
||||
null, 401, 40100);
|
||||
}
|
||||
// 窗口内失败达到阈值(测试将阈值降为 3):即使密码正确也锁定
|
||||
verifiedError(HttpMethod.POST, "/api/v1/auth/login", "/api/v1/auth/login",
|
||||
"{\"username\":\"contract_auth_locked\",\"password\":\"secret123\"}",
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||
|
||||
/**
|
||||
* 冻结快照守卫:与 pet 侧同一纪律——正典契约升版时必须同步复制新快照
|
||||
* 并更新期望值,忘记同步在 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("auth", "user", "analytics")))
|
||||
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全矩阵覆盖门禁:auth 域 7 个操作声明的每个 (操作, 状态码) 都必须被
|
||||
* 前面的测试真实触发并通过契约校验(24 个单元格,无豁免)。
|
||||
*/
|
||||
@Test
|
||||
@Order(99)
|
||||
void everyDeclaredResponseCellIsExercised() {
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String op : AUTH_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,256 @@
|
||||
package com.patbond.patbond.auth.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.auth.contract.OpenApiContract.cast;
|
||||
import static com.patbond.patbond.auth.contract.OpenApiContract.list;
|
||||
import static com.patbond.patbond.auth.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.patbond.patbond.auth.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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,26 @@ public enum ErrorCode {
|
||||
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
|
||||
TOKEN_INVALID(40101, 401, "token 无效或过期"),
|
||||
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
|
||||
PET_ACCESS_DENIED(40300, 403, "无权操作该宠物"),
|
||||
POST_ACCESS_DENIED(40301, 403, "无权限执行该操作"),
|
||||
USER_NOT_FOUND(40400, 404, "用户不存在"),
|
||||
PET_NOT_FOUND(40401, 404, "宠物不存在"),
|
||||
RECORD_NOT_FOUND(40402, 404, "记录不存在"),
|
||||
POST_NOT_FOUND(40403, 404, "帖子不存在"),
|
||||
USERNAME_EXISTS(40900, 409, "用户名已存在"),
|
||||
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
||||
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
||||
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
||||
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, "服务器内部错误"),
|
||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Runtime image only — build the jar first: ./mvnw -pl patbond-community -am package
|
||||
# Stateless by design (ADR-007): no local state, config via env / mounted files.
|
||||
FROM eclipse-temurin:17-jre
|
||||
RUN useradd --system --uid 10001 patbond
|
||||
USER patbond
|
||||
WORKDIR /app
|
||||
COPY target/patbond-community-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||
EXPOSE 8084
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-api</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>patbond-community</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>patbond-community</name>
|
||||
<description>Community feed, posts and interactions service for Patbond (ADR-017)</description>
|
||||
|
||||
<!-- M3 first-wave skeleton: RS256 bearer auth on /api/v1/** (same JWT
|
||||
verification stack as patbond-user/pet), community schema access via
|
||||
JDBC. Flyway remains absent — the migration chain is owned by
|
||||
patbond-user. -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<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>
|
||||
<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). 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/pet. -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Tests need the community schema. The migration chain (V1..V5) is
|
||||
owned by patbond-user (single flyway_schema_history); pulling its
|
||||
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
|
||||
auto-config apply the same chain to the disposable container.
|
||||
Production wiring is unchanged: this module still ships without
|
||||
Flyway and the chain runs in patbond-user's startup path (same
|
||||
mechanism as patbond-pet). -->
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-user</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<!-- No spring-boot-starter-parent in this build, so the
|
||||
executable-jar repackaging must be bound explicitly. -->
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<!-- Keep the plain jar as the main artifact so other
|
||||
modules can depend on this one; the runnable fat
|
||||
jar gets the -exec classifier and is what the
|
||||
Dockerfile ships (same pattern as user/auth/pet). -->
|
||||
<classifier>exec</classifier>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
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). 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) {
|
||||
SpringApplication.run(CommunityApplication.class, args);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.patbond.patbond.community.access;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Cross-schema visibility probe for posts.pet_id references. Semantics
|
||||
* follow patbond-pet's PetAccessService anti-enumeration rule: a pet the
|
||||
* caller has no pet_owners row for is indistinguishable from a nonexistent
|
||||
* one — both answer 404/40401. Any role (owner/caregiver/viewer) may
|
||||
* reference a visible pet from a post; referencing needs no write power
|
||||
* over the pet itself.
|
||||
*/
|
||||
@Service
|
||||
public class PetVisibilityGateway {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public PetVisibilityGateway(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
/** @throws BusinessException 40401 when the pet is invisible to the caller */
|
||||
public void requireVisible(UUID userId, UUID petId) {
|
||||
boolean visible = jdbcClient.sql("""
|
||||
SELECT 1
|
||||
FROM pet_health.pets p
|
||||
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
|
||||
WHERE p.id = :petId AND p.status <> 'deleted'
|
||||
""")
|
||||
.param("userId", userId)
|
||||
.param("petId", petId)
|
||||
.query(Integer.class)
|
||||
.optional()
|
||||
.isPresent();
|
||||
if (!visible) {
|
||||
throw new BusinessException(ErrorCode.PET_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.patbond.patbond.community.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 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}), 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;
|
||||
}
|
||||
|
||||
public static class Jwt {
|
||||
|
||||
/**
|
||||
* RS256 public key for verifying access tokens signed by
|
||||
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
|
||||
* filesystem path. The private key never reaches this service.
|
||||
*/
|
||||
private String publicKey;
|
||||
|
||||
public String getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
public void setPublicKey(String publicKey) {
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.patbond.patbond.community.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Integer fields must reject decimal input (development-plan 4.3) — Jackson's
|
||||
* default is to silently truncate 45.5 to 45 in an integer field, which
|
||||
* would corrupt values instead of rejecting them. Same setting as the other
|
||||
* services so all envelopes behave identically.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
|
||||
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.patbond.patbond.community.config;
|
||||
|
||||
import com.patbond.patbond.community.media.CommunityMediaProperties;
|
||||
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Read-side media wiring: a presigned-GET signer over the same MinIO
|
||||
* configuration patbond-user uses (ADR-016). Bean destruction closes the
|
||||
* underlying presigner.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(CommunityMediaProperties.class)
|
||||
public class MediaConfig {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
public MediaUrlSigner mediaUrlSigner(CommunityMediaProperties properties) {
|
||||
return new MediaUrlSigner(properties);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Wires bearer authentication for /api/v1/** without pulling in
|
||||
* spring-security — the same single-filter pattern patbond-user and
|
||||
* patbond-pet use. The /health probe stays outside /api/v1 and therefore
|
||||
* unauthenticated.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({CommunitySecurityProperties.class, AuthorProfileProperties.class})
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public JwtVerifier jwtVerifier(CommunitySecurityProperties properties) {
|
||||
return new JwtVerifier(properties.getJwt().getPublicKey());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
|
||||
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
|
||||
new BearerAuthFilter(jwtVerifier, objectMapper));
|
||||
registration.addUrlPatterns("/api/v1/*");
|
||||
registration.setOrder(20);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.patbond.patbond.community.controller;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Liveness/readiness probe for the skeleton phase: confirms the service is
|
||||
* up and that its datasource can reach the shared PostgreSQL. Deliberately
|
||||
* outside /api/v1 so it stays unauthenticated (same reasoning as compose's
|
||||
* pg_isready: infrastructure probes carry no business data).
|
||||
*/
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public HealthController(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<Map<String, String>> health() {
|
||||
String db;
|
||||
try {
|
||||
jdbcClient.sql("SELECT 1").query(Integer.class).single();
|
||||
db = "up";
|
||||
} catch (Exception e) {
|
||||
db = "down";
|
||||
}
|
||||
return ApiResponse.success(Map.of("status", "ok", "db", db));
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
import com.patbond.patbond.community.dto.UpdatePostRequest;
|
||||
import com.patbond.patbond.community.security.BearerAuthFilter;
|
||||
import com.patbond.patbond.community.service.PostService;
|
||||
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.PatchMapping;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Post lifecycle endpoints (T3-04). Idempotency-Key is MANDATORY on create
|
||||
* (ADR-019 — deliberately different from the pets domain's optional key;
|
||||
* a missing header answers 400/40000). Publishing is a PATCH state
|
||||
* transition, not a separate endpoint. All permission and error semantics
|
||||
* live in PostService.
|
||||
*/
|
||||
@RestController
|
||||
@Validated
|
||||
public class PostController {
|
||||
|
||||
private final PostService postService;
|
||||
|
||||
public PostController(PostService postService) {
|
||||
this.postService = postService;
|
||||
}
|
||||
|
||||
@PostMapping("/api/v1/posts")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ApiResponse<PostResponse> create(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody CreatePostRequest request) {
|
||||
return ApiResponse.success(postService.create(userId, idempotencyKey, request));
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/posts/{postId}")
|
||||
public ApiResponse<PostResponse> get(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@PathVariable UUID postId) {
|
||||
return ApiResponse.success(postService.get(userId, postId));
|
||||
}
|
||||
|
||||
@PatchMapping("/api/v1/posts/{postId}")
|
||||
public ApiResponse<PostResponse> update(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@PathVariable UUID postId,
|
||||
@Valid @RequestBody UpdatePostRequest request) {
|
||||
return ApiResponse.success(postService.update(userId, postId, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/api/v1/posts/{postId}")
|
||||
public ApiResponse<Void> delete(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@PathVariable UUID postId) {
|
||||
postService.delete(userId, postId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/me/posts")
|
||||
public ApiResponse<CursorPage<PostResponse>> listMine(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@RequestParam(required = false) String status,
|
||||
@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(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;
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* POST /api/v1/posts. Field widths mirror the ck_posts_* constraints;
|
||||
* category and status enums are the write-side whitelists (ai_creation is an
|
||||
* M4 read-side reservation and hidden/archived are operational states with
|
||||
* no open endpoint, D3-7).
|
||||
*/
|
||||
public class CreatePostRequest {
|
||||
|
||||
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
|
||||
private String title;
|
||||
|
||||
@NotBlank(message = "content 不能为空")
|
||||
@Size(max = 10000, message = "content 最长 10000 字符")
|
||||
private String content;
|
||||
|
||||
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
|
||||
private String category;
|
||||
|
||||
@Pattern(regexp = "draft|published", message = "status 仅支持 draft/published")
|
||||
private String status;
|
||||
|
||||
/** Optional pet reference; must be a pet visible to the caller (40401). */
|
||||
private UUID petId;
|
||||
|
||||
@Size(max = 9, message = "media 最多 9 张图")
|
||||
@Valid
|
||||
private List<PostMediaAttachRequest> media;
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public void setCategory(String category) {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public UUID getPetId() {
|
||||
return petId;
|
||||
}
|
||||
|
||||
public void setPetId(UUID petId) {
|
||||
this.petId = petId;
|
||||
}
|
||||
|
||||
public List<PostMediaAttachRequest> getMedia() {
|
||||
return media;
|
||||
}
|
||||
|
||||
public void setMedia(List<PostMediaAttachRequest> media) {
|
||||
this.media = media;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Cursor-pagination envelope body — the pagination canon of the whole API
|
||||
* (openapi v1.2.0 通用约定): {@code nextCursor} is null exactly when
|
||||
* {@code hasMore} is false.
|
||||
*/
|
||||
public record CursorPage<T>(
|
||||
List<T> items,
|
||||
String nextCursor,
|
||||
boolean hasMore) {
|
||||
}
|
||||
+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) {
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* One attached image in a create/update request. The asset must be owned by
|
||||
* the caller and {@code status='ready'} (T3-03 联调协议), enforced in
|
||||
* PostService against a read-only view of media.assets.
|
||||
*/
|
||||
public class PostMediaAttachRequest {
|
||||
|
||||
@NotNull(message = "assetId 不能为空")
|
||||
private UUID assetId;
|
||||
|
||||
/**
|
||||
* 0-based position. Either every item carries a position (together
|
||||
* forming exactly 0..n-1) or none does (array order applies) — a mix is
|
||||
* a 40000.
|
||||
*/
|
||||
@Min(value = 0, message = "position 最小为 0")
|
||||
@Max(value = 8, message = "position 最大为 8")
|
||||
private Integer position;
|
||||
|
||||
/** At most one true per post (uq_post_media_cover); none → position 0. */
|
||||
private Boolean isCover;
|
||||
|
||||
@Size(max = 300, message = "caption 最长 300 字符")
|
||||
private String caption;
|
||||
|
||||
public UUID getAssetId() {
|
||||
return assetId;
|
||||
}
|
||||
|
||||
public void setAssetId(UUID assetId) {
|
||||
this.assetId = assetId;
|
||||
}
|
||||
|
||||
public Integer getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
public void setPosition(Integer position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public Boolean getIsCover() {
|
||||
return isCover;
|
||||
}
|
||||
|
||||
public void setIsCover(Boolean isCover) {
|
||||
this.isCover = isCover;
|
||||
}
|
||||
|
||||
public String getCaption() {
|
||||
return caption;
|
||||
}
|
||||
|
||||
public void setCaption(String caption) {
|
||||
this.caption = caption;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* One attached image in a post response. {@code url} is a presigned GET URL
|
||||
* signed per response (T3-03: the bucket stays private, clients never
|
||||
* persist it); null when object storage is unconfigured in this service.
|
||||
*/
|
||||
public record PostMediaItemResponse(
|
||||
UUID assetId,
|
||||
int position,
|
||||
boolean isCover,
|
||||
String url,
|
||||
Integer widthPx,
|
||||
Integer heightPx,
|
||||
String caption) {
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
AuthorSummaryResponse author,
|
||||
UUID petId,
|
||||
String category,
|
||||
String title,
|
||||
String content,
|
||||
String status,
|
||||
String visibility,
|
||||
List<PostMediaItemResponse> media,
|
||||
long likeCount,
|
||||
long commentCount,
|
||||
long bookmarkCount,
|
||||
boolean likedByMe,
|
||||
boolean bookmarkedByMe,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
OffsetDateTime publishedAt,
|
||||
int version) {
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.patbond.patbond.community.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/posts/{postId}. Partial update: an absent (or null) field is
|
||||
* left unchanged — no clearing back to null (M2 惯例). {@code version} is
|
||||
* mandatory, it is the optimistic lock. {@code status} accepts only
|
||||
* "published": draft→published is the single open state transition (发布即
|
||||
* 状态迁移, no separate /publish endpoint); publishing an already-published
|
||||
* post is a no-op. {@code media}, when present, replaces the whole set
|
||||
* (整组替换).
|
||||
*/
|
||||
public class UpdatePostRequest {
|
||||
|
||||
@NotNull(message = "version 不能为空")
|
||||
@PositiveOrZero(message = "version 必须为非负整数")
|
||||
private Integer version;
|
||||
|
||||
@Size(min = 1, max = 120, message = "title 长度须在 1~120 字符")
|
||||
private String title;
|
||||
|
||||
@Size(min = 1, max = 10000, message = "content 长度须在 1~10000 字符")
|
||||
private String content;
|
||||
|
||||
@Pattern(regexp = "general|help", message = "category 仅支持 general/help")
|
||||
private String category;
|
||||
|
||||
private UUID petId;
|
||||
|
||||
@Pattern(regexp = "published", message = "status 仅支持 published(唯一开放的状态迁移)")
|
||||
private String status;
|
||||
|
||||
@Size(max = 9, message = "media 最多 9 张图")
|
||||
@Valid
|
||||
private List<PostMediaAttachRequest> media;
|
||||
|
||||
public Integer getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(Integer version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
public void setCategory(String category) {
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
public UUID getPetId() {
|
||||
return petId;
|
||||
}
|
||||
|
||||
public void setPetId(UUID petId) {
|
||||
this.petId = petId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public List<PostMediaAttachRequest> getMedia() {
|
||||
return media;
|
||||
}
|
||||
|
||||
public void setMedia(List<PostMediaAttachRequest> media) {
|
||||
this.media = media;
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.patbond.patbond.community.media;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Read-side subset of the media object-storage configuration (the write
|
||||
* side — upload flow, whitelists — lives in patbond-user's MediaProperties).
|
||||
* This service only signs GET URLs, a purely local SigV4 computation, so no
|
||||
* bucket/HEAD client is needed. Values reuse the same PATBOND_MINIO_* /
|
||||
* PATBOND_MEDIA_* environment variables as patbond-user, keeping one set of
|
||||
* knobs per deployment (ADR-016/021).
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "patbond.media")
|
||||
public class CommunityMediaProperties {
|
||||
|
||||
/**
|
||||
* Endpoint presigned GET URLs are issued against — the address CLIENTS
|
||||
* can reach. Empty means media is unconfigured for this service:
|
||||
* responses carry {@code url: 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;
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.patbond.patbond.community.media;
|
||||
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Read-only cross-schema access to media.assets — the community side of the
|
||||
* T3-03 联调协议 (business references accept only assets owned by the caller
|
||||
* with status='ready'). Same-database read was chosen over an internal HTTP
|
||||
* call to patbond-user (ADR-017 precedent: author data is likewise a
|
||||
* cross-schema read while the schemas share one database; splitting the
|
||||
* database later moves both to internal APIs 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 Map<UUID, MediaAssetRef> findByIds(Collection<UUID> ids) {
|
||||
if (ids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return jdbcClient.sql("""
|
||||
SELECT id, owner_user_id, status, bucket, object_key, width_px, height_px
|
||||
FROM media.assets
|
||||
WHERE id IN (:ids)
|
||||
""")
|
||||
.param("ids", List.copyOf(ids))
|
||||
.query(MediaAssetGateway::mapRef)
|
||||
.list()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(MediaAssetRef::id, Function.identity()));
|
||||
}
|
||||
|
||||
private static MediaAssetRef mapRef(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new MediaAssetRef(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("owner_user_id", UUID.class),
|
||||
rs.getString("status"),
|
||||
rs.getString("bucket"),
|
||||
rs.getString("object_key"),
|
||||
rs.getObject("width_px", Integer.class),
|
||||
rs.getObject("height_px", Integer.class));
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.patbond.patbond.community.media;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-only view of one media.assets row — exactly the columns the post
|
||||
* domain needs for attach validation and response URL signing.
|
||||
*/
|
||||
public record MediaAssetRef(
|
||||
UUID id,
|
||||
UUID ownerUserId,
|
||||
String status,
|
||||
String bucket,
|
||||
String objectKey,
|
||||
Integer widthPx,
|
||||
Integer heightPx) {
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.patbond.patbond.community.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 media objects referenced by posts (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). When unconfigured, {@link #signGet}
|
||||
* returns null and post responses degrade to {@code url: null}.
|
||||
*/
|
||||
public class MediaUrlSigner implements AutoCloseable {
|
||||
|
||||
private final CommunityMediaProperties properties;
|
||||
private final S3Presigner presigner;
|
||||
|
||||
public MediaUrlSigner(CommunityMediaProperties 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
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;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* community.posts / community.post_media access. Visibility and authorship
|
||||
* decisions live in PostService — every query here is still explicitly
|
||||
* scoped (viewer-dependent flags are parameters, never session state).
|
||||
*/
|
||||
@Repository
|
||||
public class PostRepository {
|
||||
|
||||
/**
|
||||
* The full post projection: row columns plus the two viewer-relative
|
||||
* flags, each a primary-key probe into its relation table (post_likes /
|
||||
* post_bookmarks composite PKs), so no N+1 and no separate round trip.
|
||||
*/
|
||||
private static final String SELECT_POST = """
|
||||
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,
|
||||
EXISTS (SELECT 1 FROM community.post_bookmarks pb
|
||||
WHERE pb.post_id = p.id AND pb.user_id = :viewerId) AS bookmarked_by_me
|
||||
FROM community.posts p
|
||||
""";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public PostRepository(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts one post; {@code ON CONFLICT ON CONSTRAINT
|
||||
* uq_posts_author_idempotency DO NOTHING} makes a keyed replay a no-op —
|
||||
* the caller then loads the first-write row by (author, key) and settles
|
||||
* the retry-vs-mismatch question on request_hash (ADR-019).
|
||||
*
|
||||
* @return rows inserted — 0 means this author already used the key
|
||||
*/
|
||||
public int insertPost(UUID id, UUID authorUserId, UUID petId, String category, String title,
|
||||
String content, String status, OffsetDateTime publishedAt,
|
||||
String idempotencyKey, byte[] requestHash) {
|
||||
return jdbcClient.sql("""
|
||||
INSERT INTO community.posts
|
||||
(id, author_user_id, pet_id, category, title, content, status,
|
||||
published_at, idempotency_key, request_hash)
|
||||
VALUES (:id, :authorUserId, :petId, :category, :title, :content, :status,
|
||||
:publishedAt, :idempotencyKey, :requestHash)
|
||||
ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("authorUserId", authorUserId)
|
||||
.param("petId", petId)
|
||||
.param("category", category)
|
||||
.param("title", title)
|
||||
.param("content", content)
|
||||
.param("status", status)
|
||||
.param("publishedAt", publishedAt)
|
||||
.param("idempotencyKey", idempotencyKey)
|
||||
.param("requestHash", requestHash)
|
||||
.update();
|
||||
}
|
||||
|
||||
/** First-write row for a (author, Idempotency-Key) pair, deleted or not. */
|
||||
public Optional<PostRow> findByAuthorAndIdempotencyKey(UUID authorUserId, String idempotencyKey,
|
||||
UUID viewerId) {
|
||||
return jdbcClient.sql(SELECT_POST
|
||||
+ " WHERE p.author_user_id = :authorUserId AND p.idempotency_key = :idempotencyKey")
|
||||
.param("authorUserId", authorUserId)
|
||||
.param("idempotencyKey", idempotencyKey)
|
||||
.param("viewerId", viewerId)
|
||||
.query(PostRepository::mapPost)
|
||||
.optional();
|
||||
}
|
||||
|
||||
/** Any live (not soft-deleted) row by id; visibility is the service's call. */
|
||||
public Optional<PostRow> findLiveById(UUID id, UUID viewerId) {
|
||||
return jdbcClient.sql(SELECT_POST + " WHERE p.id = :id AND p.deleted_at IS NULL")
|
||||
.param("id", id)
|
||||
.param("viewerId", viewerId)
|
||||
.query(PostRepository::mapPost)
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the live row for a write (PATCH/DELETE): concurrent writers
|
||||
* serialize here, so the later one sees the earlier one's version bump
|
||||
* and fails its version condition deterministically.
|
||||
*/
|
||||
public Optional<LockedPost> lockLiveById(UUID id) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT id, author_user_id, pet_id, category, title, content, status,
|
||||
published_at, version
|
||||
FROM community.posts
|
||||
WHERE id = :id AND deleted_at IS NULL
|
||||
FOR UPDATE
|
||||
""")
|
||||
.param("id", id)
|
||||
.query((rs, rowNum) -> new LockedPost(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("author_user_id", UUID.class),
|
||||
rs.getObject("pet_id", UUID.class),
|
||||
rs.getString("category"),
|
||||
rs.getString("title"),
|
||||
rs.getString("content"),
|
||||
rs.getString("status"),
|
||||
rs.getObject("published_at", OffsetDateTime.class),
|
||||
rs.getInt("version")))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* The optimistic-locked merge write: one conditional UPDATE, version
|
||||
* bumped only when the expected version still stands.
|
||||
*
|
||||
* @return rows updated — 0 means the version went stale
|
||||
*/
|
||||
public int updatePost(UUID id, int expectedVersion, UUID petId, String category, String title,
|
||||
String content, String status, OffsetDateTime publishedAt) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE community.posts
|
||||
SET pet_id = :petId, category = :category, title = :title,
|
||||
content = :content, status = :status, published_at = :publishedAt,
|
||||
version = version + 1
|
||||
WHERE id = :id AND deleted_at IS NULL AND version = :expectedVersion
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("expectedVersion", expectedVersion)
|
||||
.param("petId", petId)
|
||||
.param("category", category)
|
||||
.param("title", title)
|
||||
.param("content", content)
|
||||
.param("status", status)
|
||||
.param("publishedAt", publishedAt)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete (deleted_at is THE deletion marker everywhere). A deleted
|
||||
* published post must also leave status='published' to satisfy
|
||||
* ck_posts_publish_state, so it is parked as 'archived'; drafts keep
|
||||
* their status. Once deleted the post answers 404 on every read path,
|
||||
* so the parked status is internal bookkeeping only (D3-7 定型).
|
||||
*/
|
||||
public int softDelete(UUID id) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE community.posts
|
||||
SET deleted_at = now(),
|
||||
status = CASE WHEN status = 'published' THEN 'archived' ELSE status END,
|
||||
version = version + 1
|
||||
WHERE id = :id AND deleted_at IS NULL
|
||||
""")
|
||||
.param("id", id)
|
||||
.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;
|
||||
* hidden/archived are excluded (the contract exposes draft/published
|
||||
* only). The caller asks for limit+1 rows to learn whether more exist.
|
||||
*/
|
||||
public List<PostRow> pageByAuthor(UUID authorUserId, String statusFilter, PostCursor after,
|
||||
int limitPlusOne) {
|
||||
String sql = SELECT_POST + """
|
||||
WHERE p.author_user_id = :authorUserId AND p.deleted_at IS NULL
|
||||
AND p.status IN ('draft', 'published')
|
||||
""";
|
||||
if (statusFilter != null) {
|
||||
sql += " AND p.status = :statusFilter";
|
||||
}
|
||||
if (after != null) {
|
||||
sql += " AND (p.created_at, p.id) < (:cursorCreatedAt, :cursorId)";
|
||||
}
|
||||
sql += " ORDER BY p.created_at DESC, p.id DESC LIMIT :limit";
|
||||
var spec = jdbcClient.sql(sql)
|
||||
.param("authorUserId", authorUserId)
|
||||
.param("viewerId", authorUserId)
|
||||
.param("limit", limitPlusOne);
|
||||
if (statusFilter != null) {
|
||||
spec = spec.param("statusFilter", statusFilter);
|
||||
}
|
||||
if (after != null) {
|
||||
spec = spec.param("cursorCreatedAt", after.createdAt())
|
||||
.param("cursorId", after.id());
|
||||
}
|
||||
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)
|
||||
VALUES (:postId, :position, :assetId, :isCover, :caption)
|
||||
""")
|
||||
.param("postId", postId)
|
||||
.param("position", position)
|
||||
.param("assetId", assetId)
|
||||
.param("isCover", isCover)
|
||||
.param("caption", caption)
|
||||
.update();
|
||||
}
|
||||
|
||||
/** Whole-set replacement (PATCH media 整组替换): clear, then re-insert. */
|
||||
public void deleteMedia(UUID postId) {
|
||||
jdbcClient.sql("DELETE FROM community.post_media WHERE post_id = :postId")
|
||||
.param("postId", postId)
|
||||
.update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Media of many posts in one query (position order within each post),
|
||||
* joined with media.assets for the response-side url/dimension fields.
|
||||
*/
|
||||
public List<PostMediaRow> findMediaByPostIds(Collection<UUID> postIds) {
|
||||
if (postIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return jdbcClient.sql("""
|
||||
SELECT pm.post_id, pm.position, pm.asset_id, pm.is_cover, pm.caption,
|
||||
a.bucket, a.object_key, a.width_px, a.height_px
|
||||
FROM community.post_media pm
|
||||
JOIN media.assets a ON a.id = pm.asset_id
|
||||
WHERE pm.post_id IN (:postIds)
|
||||
ORDER BY pm.post_id, pm.position
|
||||
""")
|
||||
.param("postIds", List.copyOf(postIds))
|
||||
.query((rs, rowNum) -> new PostMediaRow(
|
||||
rs.getObject("post_id", UUID.class),
|
||||
rs.getInt("position"),
|
||||
rs.getObject("asset_id", UUID.class),
|
||||
rs.getBoolean("is_cover"),
|
||||
rs.getString("caption"),
|
||||
rs.getString("bucket"),
|
||||
rs.getString("object_key"),
|
||||
rs.getObject("width_px", Integer.class),
|
||||
rs.getObject("height_px", Integer.class)))
|
||||
.list();
|
||||
}
|
||||
|
||||
private static PostRow mapPost(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new PostRow(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("author_user_id", UUID.class),
|
||||
rs.getObject("pet_id", UUID.class),
|
||||
rs.getString("category"),
|
||||
rs.getString("title"),
|
||||
rs.getString("content"),
|
||||
rs.getString("status"),
|
||||
rs.getString("visibility"),
|
||||
rs.getLong("like_count"),
|
||||
rs.getLong("comment_count"),
|
||||
rs.getLong("bookmark_count"),
|
||||
rs.getBoolean("liked_by_me"),
|
||||
rs.getBoolean("bookmarked_by_me"),
|
||||
rs.getObject("created_at", OffsetDateTime.class),
|
||||
rs.getObject("updated_at", OffsetDateTime.class),
|
||||
rs.getObject("published_at", OffsetDateTime.class),
|
||||
rs.getObject("deleted_at", OffsetDateTime.class),
|
||||
rs.getInt("version"),
|
||||
rs.getBytes("request_hash"));
|
||||
}
|
||||
|
||||
/** Full projection of one post as seen by a given viewer. */
|
||||
public record PostRow(
|
||||
UUID id,
|
||||
UUID authorUserId,
|
||||
UUID petId,
|
||||
String category,
|
||||
String title,
|
||||
String content,
|
||||
String status,
|
||||
String visibility,
|
||||
long likeCount,
|
||||
long commentCount,
|
||||
long bookmarkCount,
|
||||
boolean likedByMe,
|
||||
boolean bookmarkedByMe,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
OffsetDateTime publishedAt,
|
||||
OffsetDateTime deletedAt,
|
||||
int version,
|
||||
byte[] requestHash) {
|
||||
}
|
||||
|
||||
/** Row image under FOR UPDATE, the merge base of a PATCH. */
|
||||
public record LockedPost(
|
||||
UUID id,
|
||||
UUID authorUserId,
|
||||
UUID petId,
|
||||
String category,
|
||||
String title,
|
||||
String content,
|
||||
String status,
|
||||
OffsetDateTime publishedAt,
|
||||
int version) {
|
||||
}
|
||||
|
||||
/** One post_media row joined with its asset's storage location. */
|
||||
public record PostMediaRow(
|
||||
UUID postId,
|
||||
int position,
|
||||
UUID assetId,
|
||||
boolean isCover,
|
||||
String caption,
|
||||
String bucket,
|
||||
String objectKey,
|
||||
Integer widthPx,
|
||||
Integer heightPx) {
|
||||
}
|
||||
|
||||
/** A bookmarked post plus the relation row's timestamp (the page key). */
|
||||
public record BookmarkedPostRow(PostRow post, OffsetDateTime bookmarkedAt) {
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.patbond.patbond.community.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Bearer authentication for /api/v1/** community endpoints. Verifies the
|
||||
* RS256 signature locally with the auth service's public key and exposes the
|
||||
* authenticated user id as a request attribute. Missing, forged or expired
|
||||
* tokens all answer 401/40101 without detail. Third copy of the user/pet
|
||||
* filter — sinking the shared pure-Java parts into patbond-common stays a
|
||||
* separate decision (iteration-3/02 P9, not ratified in ADR-016~021).
|
||||
*/
|
||||
public class BearerAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
/** Request attribute holding the authenticated user's UUID. */
|
||||
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
|
||||
|
||||
private final JwtVerifier jwtVerifier;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
this.jwtVerifier = jwtVerifier;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !request.getRequestURI().startsWith("/api/v1/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
reject(response, ErrorCode.TOKEN_INVALID);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
|
||||
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
|
||||
} catch (BusinessException e) {
|
||||
reject(response, ErrorCode.TOKEN_INVALID);
|
||||
return;
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
log.error("Access token verification unavailable: {}", e.getMessage());
|
||||
reject(response, ErrorCode.INTERNAL_ERROR);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
|
||||
response.setStatus(errorCode.getHttpStatus());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.patbond.patbond.community.security;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.JwtParser;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
/**
|
||||
* Verifies RS256 access tokens issued by patbond-auth against the configured
|
||||
* public key. The key is optional at startup so contexts that never serve
|
||||
* protected routes (most tests) can boot without one; any verification
|
||||
* attempt without a key fails loudly as a server misconfiguration instead of
|
||||
* being reported to the client as an authentication problem.
|
||||
*/
|
||||
public class JwtVerifier {
|
||||
|
||||
private final JwtParser parser;
|
||||
|
||||
public JwtVerifier(String publicKeyLocation) {
|
||||
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
|
||||
? null
|
||||
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the verified claims
|
||||
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||
*/
|
||||
public Claims verify(String token) {
|
||||
if (parser == null) {
|
||||
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
|
||||
}
|
||||
try {
|
||||
return parser.parseSignedClaims(token).getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.patbond.patbond.community.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Loads an RSA public key for JWT signature verification. Accepts either
|
||||
* inline PEM or a filesystem path; the inline form (environment variable
|
||||
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
|
||||
* because mounted secrets beat files-in-the-image.
|
||||
*/
|
||||
public final class RsaPublicKeyLoader {
|
||||
|
||||
private RsaPublicKeyLoader() {
|
||||
}
|
||||
|
||||
public static PublicKey load(String pemOrPath) {
|
||||
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
|
||||
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(stripped);
|
||||
try {
|
||||
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException("Invalid RSA public key", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readFile(String path) {
|
||||
try {
|
||||
return Files.readString(Path.of(path));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Cannot read public key from " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
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;
|
||||
import com.patbond.patbond.community.dto.PostMediaItemResponse;
|
||||
import com.patbond.patbond.community.dto.PostResponse;
|
||||
import com.patbond.patbond.community.dto.UpdatePostRequest;
|
||||
import com.patbond.patbond.community.media.MediaAssetGateway;
|
||||
import com.patbond.patbond.community.media.MediaAssetRef;
|
||||
import com.patbond.patbond.community.media.MediaUrlSigner;
|
||||
import com.patbond.patbond.community.repository.PostRepository;
|
||||
import com.patbond.patbond.community.repository.PostRepository.LockedPost;
|
||||
import com.patbond.patbond.community.repository.PostRepository.PostMediaRow;
|
||||
import com.patbond.patbond.community.repository.PostRepository.PostRow;
|
||||
import com.patbond.patbond.community.support.PostCursor;
|
||||
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.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Post lifecycle use-cases (T3-04): draft/edit/publish/soft-delete/detail/
|
||||
* my-posts. The permission and error semantics implemented here are the
|
||||
* T3-10 freeze input:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Visibility</b> — published is visible to every authenticated
|
||||
* user; draft only to its author; hidden/archived (operational
|
||||
* states, D3-7) and soft-deleted answer 404/40403 to EVERYONE, the
|
||||
* author included. Every invisible case is byte-identical
|
||||
* (anti-enumeration).</li>
|
||||
* <li><b>403 vs 404</b> — 403/40301 goes only to callers the post is
|
||||
* VISIBLE to (non-author PATCH/DELETE of a published post); anything
|
||||
* invisible is 404/40403, never 403.</li>
|
||||
* <li><b>Idempotent create (ADR-019)</b> — Idempotency-Key mandatory;
|
||||
* same key + same normalized payload returns the first write (201
|
||||
* again), same key + different payload answers 409/40905, keys are
|
||||
* scoped per author (two users may reuse a key).</li>
|
||||
* <li><b>Publish</b> — a PATCH carrying {@code status: published}; the
|
||||
* only open transition is draft→published (publishedAt written once);
|
||||
* re-publishing a published post is a no-op. published→draft does not
|
||||
* exist (the request enum rejects it as 40000).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class PostService {
|
||||
|
||||
private static final int MAX_MEDIA = 9;
|
||||
|
||||
private final PostRepository postRepository;
|
||||
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,
|
||||
AuthorProfileGateway authorProfileGateway) {
|
||||
this.postRepository = postRepository;
|
||||
this.mediaAssetGateway = mediaAssetGateway;
|
||||
this.mediaUrlSigner = mediaUrlSigner;
|
||||
this.petVisibilityGateway = petVisibilityGateway;
|
||||
this.authorProfileGateway = authorProfileGateway;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostResponse create(UUID userId, String idempotencyKey, CreatePostRequest request) {
|
||||
String key = normalizeIdempotencyKey(idempotencyKey);
|
||||
String title = requireTitleOrNull(request.getTitle());
|
||||
String content = requireContent(request.getContent());
|
||||
String category = request.getCategory() == null ? "general" : request.getCategory();
|
||||
String status = request.getStatus() == null ? "draft" : request.getStatus();
|
||||
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
|
||||
|
||||
if (request.getPetId() != null) {
|
||||
petVisibilityGateway.requireVisible(userId, request.getPetId());
|
||||
}
|
||||
validateAssets(userId, media);
|
||||
|
||||
byte[] requestHash = RequestHashes.sha256(
|
||||
canonicalize(category, status, title, content, request.getPetId(), media));
|
||||
|
||||
UUID id = UuidV7.generate();
|
||||
OffsetDateTime publishedAt = "published".equals(status) ? OffsetDateTime.now() : null;
|
||||
int inserted = postRepository.insertPost(id, userId, request.getPetId(), category, title,
|
||||
content, status, publishedAt, key, requestHash);
|
||||
if (inserted == 0) {
|
||||
// The author used this key before (or a concurrent retry won the
|
||||
// race): settle retry-vs-mismatch on the stored request_hash.
|
||||
PostRow first = postRepository.findByAuthorAndIdempotencyKey(userId, key, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
if (!Arrays.equals(first.requestHash(), requestHash)) {
|
||||
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
|
||||
}
|
||||
if (first.deletedAt() != null) {
|
||||
// The first write was deleted meanwhile — the resource the
|
||||
// retry asks about is gone, same anti-enumeration 404.
|
||||
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||
}
|
||||
return assembleOne(first);
|
||||
}
|
||||
for (NormalizedMedia item : media) {
|
||||
postRepository.insertMedia(id, item.position(), item.assetId(), item.isCover(),
|
||||
item.caption());
|
||||
}
|
||||
PostRow row = postRepository.findLiveById(id, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
return assembleOne(row);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PostResponse get(UUID userId, UUID postId) {
|
||||
PostRow row = postRepository.findLiveById(postId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
|
||||
if (!visibleTo(row.status(), row.authorUserId(), userId)) {
|
||||
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||
}
|
||||
return assembleOne(row);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PostResponse update(UUID userId, UUID postId, UpdatePostRequest request) {
|
||||
LockedPost current = requireAuthorEditable(userId, postId);
|
||||
if (request.getVersion() != current.version()) {
|
||||
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||
}
|
||||
|
||||
String title = request.getTitle() != null
|
||||
? requireTitleOrNull(request.getTitle())
|
||||
: current.title();
|
||||
String content = request.getContent() != null
|
||||
? requireContent(request.getContent())
|
||||
: current.content();
|
||||
String category = request.getCategory() != null ? request.getCategory() : current.category();
|
||||
UUID petId = current.petId();
|
||||
if (request.getPetId() != null) {
|
||||
petVisibilityGateway.requireVisible(userId, request.getPetId());
|
||||
petId = request.getPetId();
|
||||
}
|
||||
|
||||
String status = current.status();
|
||||
OffsetDateTime publishedAt = current.publishedAt();
|
||||
if ("published".equals(request.getStatus()) && "draft".equals(current.status())) {
|
||||
// The single open transition: draft→published, publishedAt
|
||||
// written exactly once (ck_posts_publish_state). Publishing an
|
||||
// already-published post falls through as a no-op.
|
||||
status = "published";
|
||||
publishedAt = OffsetDateTime.now();
|
||||
}
|
||||
|
||||
if (request.getMedia() != null) {
|
||||
List<NormalizedMedia> media = normalizeMedia(request.getMedia());
|
||||
validateAssets(userId, media);
|
||||
postRepository.deleteMedia(postId);
|
||||
for (NormalizedMedia item : media) {
|
||||
postRepository.insertMedia(postId, item.position(), item.assetId(), item.isCover(),
|
||||
item.caption());
|
||||
}
|
||||
}
|
||||
|
||||
int updated = postRepository.updatePost(postId, request.getVersion(), petId, category,
|
||||
title, content, status, publishedAt);
|
||||
if (updated == 0) {
|
||||
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||
}
|
||||
PostRow row = postRepository.findLiveById(postId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
return assembleOne(row);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID userId, UUID postId) {
|
||||
requireAuthorEditable(userId, postId);
|
||||
postRepository.softDelete(postId);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public CursorPage<PostResponse> listMine(UUID userId, String status, int limit, String cursor) {
|
||||
if (status != null && !status.equals("draft") && !status.equals("published")) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "status 仅支持 draft/published");
|
||||
}
|
||||
PostCursor after = cursor == null ? null : PostCursor.decode(cursor);
|
||||
List<PostRow> rows = postRepository.pageByAuthor(userId, status, after, limit + 1);
|
||||
boolean hasMore = rows.size() > limit;
|
||||
List<PostRow> page = hasMore ? rows.subList(0, limit) : rows;
|
||||
String nextCursor = hasMore
|
||||
? new PostCursor(page.get(limit - 1).createdAt(), page.get(limit - 1).id()).encode()
|
||||
: null;
|
||||
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,
|
||||
* someone else's draft) → 40403; visible but not the author's
|
||||
* (published, someone else's) → 40301.
|
||||
*/
|
||||
private LockedPost requireAuthorEditable(UUID userId, UUID postId) {
|
||||
LockedPost current = postRepository.lockLiveById(postId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND));
|
||||
boolean visible = visibleTo(current.status(), current.authorUserId(), userId);
|
||||
if (!visible) {
|
||||
throw new BusinessException(ErrorCode.POST_NOT_FOUND);
|
||||
}
|
||||
if (!current.authorUserId().equals(userId)) {
|
||||
throw new BusinessException(ErrorCode.POST_ACCESS_DENIED);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The visibility matrix (T3-10 freeze input): published → everyone;
|
||||
* draft → author only; hidden/archived → no one, the author included
|
||||
* (no operational state leaks through the M3 contract, whose status
|
||||
* enum stays [draft, published]).
|
||||
*/
|
||||
private static boolean visibleTo(String status, UUID authorUserId, UUID viewerId) {
|
||||
return switch (status) {
|
||||
case "published" -> true;
|
||||
case "draft" -> authorUserId.equals(viewerId);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- create-side normalization ----------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provided title must survive trimming (ck_posts_title width) — a
|
||||
* whitespace-only title is a 40000, NOT a clear-to-null: PATCH does not
|
||||
* support clearing optional fields back to null (M2 惯例), and create
|
||||
* stays symmetric.
|
||||
*/
|
||||
private static String requireTitleOrNull(String title) {
|
||||
if (title == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = title.trim();
|
||||
if (trimmed.isEmpty() || trimmed.length() > 120) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "title 长度须在 1~120 字符");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String requireContent(String content) {
|
||||
String trimmed = content == null ? "" : content.trim();
|
||||
if (trimmed.isEmpty() || trimmed.length() > 10000) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "content 长度须在 1~10000 字符");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves positions and the cover flag: either every item names a
|
||||
* position (together exactly 0..n-1) or none does (array order); at
|
||||
* most one isCover=true (uq_post_media_cover), none → position 0 gets
|
||||
* the flag (草案预设:全 false 服务端取 position 0).
|
||||
*/
|
||||
private static List<NormalizedMedia> normalizeMedia(List<PostMediaAttachRequest> requested) {
|
||||
if (requested == null || requested.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
long withPosition = requested.stream().filter(m -> m.getPosition() != null).count();
|
||||
if (withPosition != 0 && withPosition != requested.size()) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"media position 须全部提供或全部省略");
|
||||
}
|
||||
long covers = requested.stream().filter(m -> Boolean.TRUE.equals(m.getIsCover())).count();
|
||||
if (covers > 1) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "isCover 至多一个");
|
||||
}
|
||||
|
||||
List<NormalizedMedia> items = new ArrayList<>(requested.size());
|
||||
Set<Integer> seenPositions = new HashSet<>();
|
||||
Set<UUID> seenAssets = new HashSet<>();
|
||||
for (int i = 0; i < requested.size(); i++) {
|
||||
PostMediaAttachRequest m = requested.get(i);
|
||||
int position = m.getPosition() != null ? m.getPosition() : i;
|
||||
if (!seenPositions.add(position) || position >= requested.size()) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"media position 须为 0 起连续且不重复");
|
||||
}
|
||||
if (!seenAssets.add(m.getAssetId())) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 中 assetId 重复");
|
||||
}
|
||||
items.add(new NormalizedMedia(m.getAssetId(), position,
|
||||
Boolean.TRUE.equals(m.getIsCover()), trimOrNull(m.getCaption())));
|
||||
}
|
||||
items.sort((a, b) -> Integer.compare(a.position(), b.position()));
|
||||
if (covers == 0) {
|
||||
items.set(0, items.get(0).asCover());
|
||||
}
|
||||
if (items.size() > MAX_MEDIA) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "media 最多 9 张图");
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* The T3-03 联调协议 on the referencing side: an asset that does not
|
||||
* exist, is not the caller's or is deleted answers 404/40405 (one merged
|
||||
* anti-enumeration case); the caller's own asset in uploading/failed
|
||||
* answers 422/42203.
|
||||
*/
|
||||
private void validateAssets(UUID userId, List<NormalizedMedia> media) {
|
||||
if (media.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<UUID, MediaAssetRef> assets = mediaAssetGateway.findByIds(
|
||||
media.stream().map(NormalizedMedia::assetId).collect(Collectors.toSet()));
|
||||
for (NormalizedMedia item : media) {
|
||||
MediaAssetRef ref = assets.get(item.assetId());
|
||||
if (ref == null || !userId.equals(ref.ownerUserId()) || "deleted".equals(ref.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
|
||||
}
|
||||
if (!"ready".equals(ref.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical form fed to the request hash — see {@link RequestHashes}. */
|
||||
private static String canonicalize(String category, String status, String title, String content,
|
||||
UUID petId, List<NormalizedMedia> media) {
|
||||
StringBuilder sb = new StringBuilder("post.v1\n")
|
||||
.append(category).append('\n')
|
||||
.append(status).append('\n')
|
||||
.append(title == null ? "" : title).append('\n')
|
||||
.append(content).append('\n')
|
||||
.append(petId == null ? "" : petId).append('\n');
|
||||
for (NormalizedMedia item : media) {
|
||||
sb.append(item.assetId()).append(':').append(item.position()).append(':')
|
||||
.append(item.isCover()).append(':')
|
||||
.append(item.caption() == null ? "" : item.caption()).append('\n');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String trimOrNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
// ---------- response assembly ----------
|
||||
|
||||
private PostResponse assembleOne(PostRow row) {
|
||||
return assemble(List.of(row)).get(0);
|
||||
}
|
||||
|
||||
private List<PostResponse> assemble(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 -> new PostResponse(
|
||||
row.id(),
|
||||
authors.getOrDefault(row.authorUserId(),
|
||||
AuthorSummaryResponse.idOnly(row.authorUserId())),
|
||||
row.petId(),
|
||||
row.category(),
|
||||
row.title(),
|
||||
row.content(),
|
||||
row.status(),
|
||||
row.visibility(),
|
||||
mediaByPost.getOrDefault(row.id(), List.of()).stream()
|
||||
.map(m -> new PostMediaItemResponse(
|
||||
m.assetId(),
|
||||
m.position(),
|
||||
m.isCover(),
|
||||
mediaUrlSigner.signGet(m.bucket(), m.objectKey()),
|
||||
m.widthPx(),
|
||||
m.heightPx(),
|
||||
m.caption()))
|
||||
.toList(),
|
||||
row.likeCount(),
|
||||
row.commentCount(),
|
||||
row.bookmarkCount(),
|
||||
row.likedByMe(),
|
||||
row.bookmarkedByMe(),
|
||||
row.createdAt(),
|
||||
row.updatedAt(),
|
||||
row.publishedAt(),
|
||||
row.version())).toList();
|
||||
}
|
||||
|
||||
private record NormalizedMedia(UUID assetId, int position, boolean isCover, String caption) {
|
||||
|
||||
NormalizedMedia asCover() {
|
||||
return new NormalizedMedia(assetId, position, true, caption);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 for the my-posts list (created_at DESC, id DESC — the exact
|
||||
* key of ix_posts_author_created), isomorphic to patbond-pet's EventCursor:
|
||||
* 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 PostCursor(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 PostCursor 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 PostCursor(createdAt, id);
|
||||
} catch (RuntimeException e) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.patbond.patbond.community.support;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
/**
|
||||
* SHA-256 of the canonical form of a create request (ADR-019: creation-type
|
||||
* writes carry a mandatory Idempotency-Key and the request body's hash is
|
||||
* stored next to it — a keyed retry with the same payload returns the first
|
||||
* write, a different payload answers 40905). Hashing the NORMALIZED command
|
||||
* (trimmed fields, defaults applied, media positions resolved) rather than
|
||||
* the raw bytes makes the comparison insensitive to JSON formatting while
|
||||
* still catching every semantic difference. 32 bytes, matching
|
||||
* ck_posts_idempotency's octet_length(request_hash) = 32.
|
||||
*/
|
||||
public final class RequestHashes {
|
||||
|
||||
private RequestHashes() {
|
||||
}
|
||||
|
||||
public static byte[] sha256(String canonical) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256")
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.patbond.patbond.community.support;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
|
||||
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
|
||||
* B-tree page churn low on uuid primary keys; the database DEFAULT
|
||||
* gen_random_uuid() remains the fallback for rows not inserted through the
|
||||
* application. Third copy after patbond-user/pet — the services deploy
|
||||
* independently and patbond-common stays contract-only.
|
||||
*/
|
||||
public final class UuidV7 {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private UuidV7() {
|
||||
}
|
||||
|
||||
public static UUID generate() {
|
||||
long timestampMs = System.currentTimeMillis();
|
||||
long randA = RANDOM.nextLong() & 0x0FFFL;
|
||||
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
|
||||
|
||||
long msb = (timestampMs << 16) | 0x7000L | randA;
|
||||
long lsb = 0x8000000000000000L | randB;
|
||||
return new UUID(msb, lsb);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.patbond.patbond.community.web;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
/**
|
||||
* Single place that turns exceptions into the {code, message, data} envelope
|
||||
* with a matching HTTP status (development-plan 6.1) — same contract as the
|
||||
* user/auth/pet handlers. Unexpected exceptions are logged in full but never
|
||||
* leak internals to the client.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||
return ResponseEntity.status(e.getHttpStatus())
|
||||
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
|
||||
String message = e.getBindingResult().getFieldErrors().stream()
|
||||
.findFirst()
|
||||
.map(FieldError::getDefaultMessage)
|
||||
.orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||
return failure(ErrorCode.VALIDATION_ERROR, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
|
||||
ConstraintViolationException.class, HandlerMethodValidationException.class,
|
||||
MissingRequestHeaderException.class})
|
||||
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
|
||||
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception e) {
|
||||
log.error("Unhandled exception", e);
|
||||
return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage());
|
||||
}
|
||||
|
||||
private static ResponseEntity<ApiResponse<Void>> failure(ErrorCode errorCode, String message) {
|
||||
return ResponseEntity.status(errorCode.getHttpStatus())
|
||||
.body(ApiResponse.failure(errorCode.getCode(), message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
server:
|
||||
port: ${PATBOND_COMMUNITY_PORT:8084}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: patbond-community
|
||||
datasource:
|
||||
# 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 community schema
|
||||
# (作者公开资料按 D3-9 方案 B 走 user 的 /internal 批量接口,后续波次落地)。
|
||||
# Flyway 迁移链(V1..V5,含 community 基线)由 patbond-user 启动时统一执行,
|
||||
# 本服务不携带 Flyway —— 单一 flyway_schema_history 归属不拆。
|
||||
url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond}
|
||||
username: ${PATBOND_DB_USER:patbond}
|
||||
password: ${PATBOND_DB_PASSWORD:patbond}
|
||||
|
||||
# /api/v1/** 业务端点自骨架起即接 RS256 校验,与 patbond-user/pet 同一约定。
|
||||
patbond:
|
||||
jwt:
|
||||
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
|
||||
# 值可以是 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。
|
||||
# 环境变量与 patbond-user 共用同一组(一套部署一套旋钮)。
|
||||
# public-endpoint 为空时服务照常启动,帖子响应中 media[].url 为 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}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.patbond.patbond.community;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* Context smoke test: the skeleton boots against a clean postgres:18 with
|
||||
* the full V1..V5 migration chain applied from the test classpath.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class CommunityApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.patbond.patbond.community;
|
||||
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* Shared Testcontainers setup: a disposable postgres:18 (the production
|
||||
* target version) wired into the Spring context via @ServiceConnection.
|
||||
* The production module carries no Flyway (the single migration chain is
|
||||
* owned by patbond-user), but the TEST classpath adds patbond-user's jar
|
||||
* plus Flyway, so Boot applies the full V1..V5 chain — including the
|
||||
* community schema these tests exercise — to the fresh container exactly
|
||||
* as the shared database gets it in production (same mechanism as
|
||||
* patbond-pet).
|
||||
*/
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
public class TestcontainersConfiguration {
|
||||
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
PostgreSQLContainer<?> postgresContainer() {
|
||||
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.patbond.patbond.community.controller;
|
||||
|
||||
import com.patbond.patbond.community.TestcontainersConfiguration;
|
||||
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.test.web.servlet.MockMvc;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The liveness probe answers 200 with the standard envelope and reports the
|
||||
* datasource as reachable (the Testcontainers database is up by definition).
|
||||
* It sits outside /api/v1, so no token is needed.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class HealthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void healthReportsServiceAndDatabaseUp() throws Exception {
|
||||
mockMvc.perform(get("/health"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("ok"))
|
||||
.andExpect(jsonPath("$.data.db").value("up"));
|
||||
}
|
||||
}
|
||||
+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());
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.patbond.patbond.community.post;
|
||||
|
||||
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;
|
||||
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 org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
/**
|
||||
* Shared plumbing of the T3-04 post-lifecycle tests: one cached Spring
|
||||
* context over a disposable postgres:18 with the full V1..V5 chain, JWT
|
||||
* material minted per run, and media signing configured with fake
|
||||
* credentials — presigning a GET URL is a local SigV4 computation, so URL
|
||||
* assertions need no MinIO container.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import({TestcontainersConfiguration.class, StubAuthorProfileConfig.class})
|
||||
public abstract class PostApiTestBase {
|
||||
|
||||
@Autowired
|
||||
protected MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
protected ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
protected JdbcClient jdbcClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void properties(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
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");
|
||||
}
|
||||
|
||||
protected UUID newUser() {
|
||||
return CommunityTestData.insertUser(jdbcClient,
|
||||
"u" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL));
|
||||
}
|
||||
|
||||
protected String token(UUID userId) {
|
||||
return TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId,
|
||||
Duration.ofMinutes(15));
|
||||
}
|
||||
|
||||
protected MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||
UUID userId) {
|
||||
return builder.header("Authorization", "Bearer " + token(userId))
|
||||
.contentType(APPLICATION_JSON)
|
||||
.characterEncoding("UTF-8");
|
||||
}
|
||||
|
||||
protected MockHttpServletRequestBuilder createPostRequest(UUID userId, String idempotencyKey,
|
||||
String body) {
|
||||
return authed(post("/api/v1/posts"), userId)
|
||||
.header("Idempotency-Key", idempotencyKey)
|
||||
.content(body);
|
||||
}
|
||||
|
||||
/** Creates a post and returns the response `data` node. */
|
||||
protected JsonNode createPost(UUID userId, String body) throws Exception {
|
||||
MvcResult result = mockMvc.perform(
|
||||
createPostRequest(userId, UUID.randomUUID().toString(), body))
|
||||
.andReturn();
|
||||
if (result.getResponse().getStatus() != 201) {
|
||||
throw new AssertionError("createPost failed: " + result.getResponse().getStatus()
|
||||
+ " " + result.getResponse().getContentAsString());
|
||||
}
|
||||
return data(result);
|
||||
}
|
||||
|
||||
protected JsonNode data(MvcResult result) throws Exception {
|
||||
return objectMapper.readTree(result.getResponse().getContentAsString()).get("data");
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.patbond.patbond.community.post;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* ADR-019 幂等专项: creation-type idempotency on the posts table itself —
|
||||
* mandatory Idempotency-Key falling on uq_posts_author_idempotency with a
|
||||
* stored request_hash. Same key + same payload replays the first write,
|
||||
* same key + different payload answers 40905, keys are scoped per author.
|
||||
*/
|
||||
class PostIdempotencyIntegrationTest extends PostApiTestBase {
|
||||
|
||||
@Test
|
||||
void sameKeySamePayloadReturnsTheFirstWrite() throws Exception {
|
||||
UUID author = newUser();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String body = "{\"title\": \"幂等\", \"content\": \"同键同体\"}";
|
||||
JsonNode first = data(mockMvc.perform(createPostRequest(author, key, body))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn());
|
||||
JsonNode retry = data(mockMvc.perform(createPostRequest(author, key, body))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn());
|
||||
assertThat(retry.get("id").asText()).isEqualTo(first.get("id").asText());
|
||||
Long rows = jdbcClient.sql(
|
||||
"SELECT count(*) FROM community.posts WHERE author_user_id = :author")
|
||||
.param("author", author)
|
||||
.query(Long.class)
|
||||
.single();
|
||||
assertThat(rows).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void semanticallyIdenticalPayloadStillReplays() throws Exception {
|
||||
// The hash covers the NORMALIZED command, so whitespace-only
|
||||
// differences (or a spelled-out default) do not break a retry.
|
||||
UUID author = newUser();
|
||||
String key = UUID.randomUUID().toString();
|
||||
JsonNode first = data(mockMvc.perform(createPostRequest(author, key,
|
||||
"{\"content\": \"规范化\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn());
|
||||
JsonNode retry = data(mockMvc.perform(createPostRequest(author, key,
|
||||
"{\"content\": \" 规范化 \", \"category\": \"general\", \"status\": \"draft\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn());
|
||||
assertThat(retry.get("id").asText()).isEqualTo(first.get("id").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameKeyDifferentPayloadAnswers40905() throws Exception {
|
||||
UUID author = newUser();
|
||||
String key = UUID.randomUUID().toString();
|
||||
mockMvc.perform(createPostRequest(author, key, "{\"content\": \"版本甲\"}"))
|
||||
.andExpect(status().isCreated());
|
||||
mockMvc.perform(createPostRequest(author, key, "{\"content\": \"版本乙\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value(40905));
|
||||
}
|
||||
|
||||
@Test
|
||||
void keysAreScopedPerAuthor() throws Exception {
|
||||
UUID alice = newUser();
|
||||
UUID bob = newUser();
|
||||
String key = "shared-client-key";
|
||||
JsonNode alicesPost = data(mockMvc.perform(createPostRequest(alice, key,
|
||||
"{\"content\": \"撞键\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn());
|
||||
JsonNode bobsPost = data(mockMvc.perform(createPostRequest(bob, key,
|
||||
"{\"content\": \"撞键\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn());
|
||||
assertThat(bobsPost.get("id").asText()).isNotEqualTo(alicesPost.get("id").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryAfterTheFirstWriteWasDeletedAnswers404() throws Exception {
|
||||
// Edge frozen for the contract: the retry asks about a resource that
|
||||
// is gone — same anti-enumeration 404 as any other deleted post.
|
||||
UUID author = newUser();
|
||||
String key = UUID.randomUUID().toString();
|
||||
String body = "{\"content\": \"建了又删\"}";
|
||||
String id = data(mockMvc.perform(createPostRequest(author, key, body))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn()).get("id").asText();
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(createPostRequest(author, key, body))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
}
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
package com.patbond.patbond.community.post;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.patbond.patbond.community.support.CommunityTestData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* T3-04 lifecycle walk on the real database: draft → edit → publish →
|
||||
* detail → delete, the 403/404 permission boundary, the draft-visibility
|
||||
* matrix, the optimistic lock (including a true concurrent race) and the
|
||||
* my-posts cursor list. These assertions ARE the T3-10 freeze input for the
|
||||
* post domain's permission and error semantics.
|
||||
*/
|
||||
class PostLifecycleIntegrationTest extends PostApiTestBase {
|
||||
|
||||
@Test
|
||||
void createDraftReturnsFullShape() throws Exception {
|
||||
UUID author = newUser();
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"""
|
||||
{"title": "第一帖", "content": "大家好"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.author.userId").value(author.toString()))
|
||||
.andExpect(jsonPath("$.data.title").value("第一帖"))
|
||||
.andExpect(jsonPath("$.data.content").value("大家好"))
|
||||
.andExpect(jsonPath("$.data.category").value("general"))
|
||||
.andExpect(jsonPath("$.data.status").value("draft"))
|
||||
.andExpect(jsonPath("$.data.visibility").value("public"))
|
||||
.andExpect(jsonPath("$.data.media").isEmpty())
|
||||
.andExpect(jsonPath("$.data.likeCount").value(0))
|
||||
.andExpect(jsonPath("$.data.commentCount").value(0))
|
||||
.andExpect(jsonPath("$.data.bookmarkCount").value(0))
|
||||
.andExpect(jsonPath("$.data.likedByMe").value(false))
|
||||
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false))
|
||||
.andExpect(jsonPath("$.data.publishedAt").isEmpty())
|
||||
.andExpect(jsonPath("$.data.version").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPublishedDirectlyWritesPublishedAt() throws Exception {
|
||||
UUID author = newUser();
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"""
|
||||
{"content": "直接发布", "status": "published", "category": "help"}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.data.status").value("published"))
|
||||
.andExpect(jsonPath("$.data.category").value("help"))
|
||||
.andExpect(jsonPath("$.data.publishedAt").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithVisiblePetAttachesIt() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID petId = CommunityTestData.insertPetOwnedBy(jdbcClient, author);
|
||||
JsonNode created = createPost(author,
|
||||
"{\"content\": \"我家猫\", \"petId\": \"" + petId + "\"}");
|
||||
assertThat(created.get("petId").asText()).isEqualTo(petId.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createWithInvisibleOrMissingPetAnswers40401() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID stranger = newUser();
|
||||
UUID strangersPet = CommunityTestData.insertPetOwnedBy(jdbcClient, stranger);
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"{\"content\": \"x\", \"petId\": \"" + strangersPet + "\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40401));
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"{\"content\": \"x\", \"petId\": \"" + UUID.randomUUID() + "\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40401));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createValidationFailuresAnswer40000() throws Exception {
|
||||
UUID author = newUser();
|
||||
// content is mandatory
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"{\"title\": \"无正文\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
// ai_creation is an M4 read-side reservation, not writable in M3
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"{\"content\": \"x\", \"category\": \"ai_creation\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
// hidden/archived are not creatable states
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"{\"content\": \"x\", \"status\": \"hidden\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
// a whitespace-only title is rejected, not silently cleared to null
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
"{\"content\": \"x\", \"title\": \" \"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotencyKeyHeaderIsMandatoryAndBounded() throws Exception {
|
||||
UUID author = newUser();
|
||||
mockMvc.perform(authed(
|
||||
org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.post("/api/v1/posts"), author)
|
||||
.content("{\"content\": \"没带幂等键\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
mockMvc.perform(createPostRequest(author, " ", "{\"content\": \"空白键\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
mockMvc.perform(createPostRequest(author, "k".repeat(129), "{\"content\": \"超长键\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void draftVisibilityMatrix() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID other = newUser();
|
||||
JsonNode draft = createPost(author, "{\"content\": \"草稿\"}");
|
||||
String draftId = draft.get("id").asText();
|
||||
JsonNode published = createPost(author,
|
||||
"{\"content\": \"已发布\", \"status\": \"published\"}");
|
||||
String publishedId = published.get("id").asText();
|
||||
|
||||
// author sees the draft; anyone else gets the anti-enumeration 404
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + draftId), author))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("draft"));
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + draftId), other))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
|
||||
// published is open to any authenticated user
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + publishedId), other))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("published"));
|
||||
|
||||
// hidden (operational state, D3-7) answers 404 to EVERYONE — the
|
||||
// author included: the M3 contract's status enum stays two-valued
|
||||
jdbcClient.sql("UPDATE community.posts SET status = 'hidden', published_at = NULL WHERE id = :id")
|
||||
.param("id", UUID.fromString(publishedId))
|
||||
.update();
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + publishedId), author))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
|
||||
// nonexistent id — identical 404; malformed id — 40000
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + UUID.randomUUID()), author))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
mockMvc.perform(authed(get("/api/v1/posts/not-a-uuid"), author))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchEditsFieldsAndBumpsVersion() throws Exception {
|
||||
UUID author = newUser();
|
||||
String id = createPost(author, "{\"title\": \"旧标题\", \"content\": \"旧正文\"}")
|
||||
.get("id").asText();
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 0, \"title\": \"新标题\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.title").value("新标题"))
|
||||
.andExpect(jsonPath("$.data.content").value("旧正文"))
|
||||
.andExpect(jsonPath("$.data.version").value(1));
|
||||
// the spent version is stale now — optimistic lock answers 40902
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 0, \"title\": \"迟到的编辑\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value(40902));
|
||||
// version is mandatory
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"title\": \"没带 version\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishIsThePatchStateTransition() throws Exception {
|
||||
UUID author = newUser();
|
||||
String id = createPost(author, "{\"content\": \"待发布\"}").get("id").asText();
|
||||
String publishedAt = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 0, \"status\": \"published\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("published"))
|
||||
.andExpect(jsonPath("$.data.publishedAt").isNotEmpty())
|
||||
.andReturn()).get("publishedAt").asText();
|
||||
// re-publishing an already-published post is a no-op (publishedAt
|
||||
// written exactly once), not an error
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 1, \"status\": \"published\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.publishedAt").value(publishedAt))
|
||||
.andExpect(jsonPath("$.data.version").value(2));
|
||||
// published→draft does not exist: the request enum rejects it
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 2, \"status\": \"draft\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAuthorWritesWalkThe403404Boundary() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID other = newUser();
|
||||
String draftId = createPost(author, "{\"content\": \"草稿\"}").get("id").asText();
|
||||
String publishedId = createPost(author,
|
||||
"{\"content\": \"已发布\", \"status\": \"published\"}").get("id").asText();
|
||||
|
||||
// visible but not yours → 403/40301
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + publishedId), other)
|
||||
.content("{\"version\": 0, \"title\": \"篡改\"}"))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(40301));
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/" + publishedId), other))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(40301));
|
||||
|
||||
// invisible (someone else's draft) → 404/40403, never 403
|
||||
mockMvc.perform(authed(patch("/api/v1/posts/" + draftId), other)
|
||||
.content("{\"version\": 0, \"title\": \"篡改\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/" + draftId), other))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteIsSoftAndMergesWithNotFoundAfterwards() throws Exception {
|
||||
UUID author = newUser();
|
||||
String id = createPost(author,
|
||||
"{\"content\": \"要删的\", \"status\": \"published\"}").get("id").asText();
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
// gone from every read path…
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
// …and a repeated delete merges with not-found (anti-enumeration)
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/" + id), author))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40403));
|
||||
// the row survives as a soft-deleted tombstone; the published row is
|
||||
// parked as archived so ck_posts_publish_state holds
|
||||
var row = jdbcClient.sql(
|
||||
"SELECT status, deleted_at FROM community.posts WHERE id = :id")
|
||||
.param("id", UUID.fromString(id))
|
||||
.query((rs, n) -> List.of(rs.getString("status"),
|
||||
String.valueOf(rs.getObject("deleted_at") != null)))
|
||||
.single();
|
||||
assertThat(row).containsExactly("archived", "true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentPatchesLetExactlyOneWin() throws Exception {
|
||||
UUID author = newUser();
|
||||
String id = createPost(author, "{\"content\": \"并发对象\"}").get("id").asText();
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
List<Future<Integer>> results = List.of("甲", "乙").stream()
|
||||
.map(tag -> pool.submit(() -> {
|
||||
start.await();
|
||||
return mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 0, \"title\": \"" + tag + "\"}"))
|
||||
.andReturn().getResponse().getStatus();
|
||||
}))
|
||||
.toList();
|
||||
start.countDown();
|
||||
List<Integer> statuses = List.of(results.get(0).get(30, TimeUnit.SECONDS),
|
||||
results.get(1).get(30, TimeUnit.SECONDS));
|
||||
assertThat(statuses).containsExactlyInAnyOrder(200, 409);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
|
||||
.andExpect(jsonPath("$.data.version").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void myPostsListPagesWithCursorAndFilters() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID other = newUser();
|
||||
String draft1 = createPost(author, "{\"content\": \"甲\"}").get("id").asText();
|
||||
String published = createPost(author,
|
||||
"{\"content\": \"乙\", \"status\": \"published\"}").get("id").asText();
|
||||
String draft2 = createPost(author, "{\"content\": \"丙\"}").get("id").asText();
|
||||
String deleted = createPost(author, "{\"content\": \"已删\"}").get("id").asText();
|
||||
mockMvc.perform(authed(delete("/api/v1/posts/" + deleted), author))
|
||||
.andExpect(status().isOk());
|
||||
createPost(other, "{\"content\": \"别人的\"}");
|
||||
|
||||
// full list: own drafts + published, deleted excluded, newest first
|
||||
JsonNode page = data(mockMvc.perform(authed(get("/api/v1/me/posts"), author))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.hasMore").value(false))
|
||||
.andExpect(jsonPath("$.data.nextCursor").isEmpty())
|
||||
.andReturn());
|
||||
assertThat(page.get("items")).hasSize(3);
|
||||
assertThat(page.get("items").findValues("id").stream().map(JsonNode::asText))
|
||||
.containsExactly(draft2, published, draft1);
|
||||
|
||||
// keyset pagination: limit 2 → cursor → remaining 1, no loss/overlap
|
||||
JsonNode first = data(mockMvc.perform(authed(get("/api/v1/me/posts?limit=2"), author))
|
||||
.andExpect(jsonPath("$.data.hasMore").value(true))
|
||||
.andReturn());
|
||||
JsonNode second = data(mockMvc.perform(authed(
|
||||
get("/api/v1/me/posts?limit=2&cursor=" + first.get("nextCursor").asText()),
|
||||
author))
|
||||
.andExpect(jsonPath("$.data.hasMore").value(false))
|
||||
.andReturn());
|
||||
assertThat(second.get("items")).hasSize(1);
|
||||
assertThat(second.get("items").get(0).get("id").asText()).isEqualTo(draft1);
|
||||
|
||||
// status filter, bad filter, bad cursor, bad limit
|
||||
mockMvc.perform(authed(get("/api/v1/me/posts?status=draft"), author))
|
||||
.andExpect(jsonPath("$.data.items.length()").value(2));
|
||||
mockMvc.perform(authed(get("/api/v1/me/posts?status=hidden"), author))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
mockMvc.perform(authed(get("/api/v1/me/posts?cursor=%21%21"), author))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
mockMvc.perform(authed(get("/api/v1/me/posts?limit=0"), author))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void viewerRelativeFlagsReflectRelationRows() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID fan = newUser();
|
||||
String id = createPost(author,
|
||||
"{\"content\": \"有人点赞\", \"status\": \"published\"}").get("id").asText();
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO community.post_likes (post_id, user_id) VALUES (:postId, :userId)
|
||||
""")
|
||||
.param("postId", UUID.fromString(id))
|
||||
.param("userId", fan)
|
||||
.update();
|
||||
jdbcClient.sql("UPDATE community.posts SET like_count = 1 WHERE id = :id")
|
||||
.param("id", UUID.fromString(id))
|
||||
.update();
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + id), fan))
|
||||
.andExpect(jsonPath("$.data.likedByMe").value(true))
|
||||
.andExpect(jsonPath("$.data.likeCount").value(1))
|
||||
.andExpect(jsonPath("$.data.bookmarkedByMe").value(false));
|
||||
mockMvc.perform(authed(get("/api/v1/posts/" + id), author))
|
||||
.andExpect(jsonPath("$.data.likedByMe").value(false))
|
||||
.andExpect(jsonPath("$.data.likeCount").value(1));
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package com.patbond.patbond.community.post;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.patbond.patbond.community.support.CommunityTestData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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;
|
||||
|
||||
/**
|
||||
* post_media attach semantics (T3-03 联调协议 on the referencing side):
|
||||
* only the caller's own ready assets; position/is_cover normalization
|
||||
* consistent with uq_post_media_cover; PATCH media replaces the whole set.
|
||||
*/
|
||||
class PostMediaAttachIntegrationTest extends PostApiTestBase {
|
||||
|
||||
@Test
|
||||
void attachOrdersItemsAndDefaultsTheCover() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
JsonNode post = createPost(author, """
|
||||
{"content": "两张图", "media": [
|
||||
{"assetId": "%s", "caption": " 封面图 "},
|
||||
{"assetId": "%s"}
|
||||
]}
|
||||
""".formatted(assetA, assetB));
|
||||
JsonNode media = post.get("media");
|
||||
assertThat(media).hasSize(2);
|
||||
// array order → positions 0/1; no isCover given → position 0 is cover
|
||||
assertThat(media.get(0).get("assetId").asText()).isEqualTo(assetA.toString());
|
||||
assertThat(media.get(0).get("position").asInt()).isZero();
|
||||
assertThat(media.get(0).get("isCover").asBoolean()).isTrue();
|
||||
assertThat(media.get(0).get("caption").asText()).isEqualTo("封面图");
|
||||
assertThat(media.get(1).get("isCover").asBoolean()).isFalse();
|
||||
// presigned GET URL, signed against the configured public endpoint
|
||||
assertThat(media.get(0).get("url").asText())
|
||||
.startsWith("http://127.0.0.1:9000/patbond-media/post_image/")
|
||||
.contains("X-Amz-Signature=");
|
||||
assertThat(media.get(0).get("widthPx").asInt()).isEqualTo(640);
|
||||
assertThat(media.get(0).get("heightPx").asInt()).isEqualTo(480);
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitPositionsAndCoverAreRespected() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
JsonNode post = createPost(author, """
|
||||
{"content": "指定顺序", "media": [
|
||||
{"assetId": "%s", "position": 1},
|
||||
{"assetId": "%s", "position": 0, "isCover": true}
|
||||
]}
|
||||
""".formatted(assetA, assetB));
|
||||
JsonNode media = post.get("media");
|
||||
assertThat(media.get(0).get("assetId").asText()).isEqualTo(assetB.toString());
|
||||
assertThat(media.get(0).get("isCover").asBoolean()).isTrue();
|
||||
assertThat(media.get(1).get("assetId").asText()).isEqualTo(assetA.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void foreignMissingOrDeletedAssetsMergeInto40405() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID stranger = newUser();
|
||||
UUID strangersAsset = CommunityTestData.insertReadyAsset(jdbcClient, stranger);
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
mediaBody(strangersAsset)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40405));
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
mediaBody(UUID.randomUUID())))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40405));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownButNotReadyAssetAnswers42203() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID uploading = CommunityTestData.insertAsset(jdbcClient, author, "uploading");
|
||||
UUID failed = CommunityTestData.insertAsset(jdbcClient, author, "failed");
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
mediaBody(uploading)))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value(42203));
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(),
|
||||
mediaBody(failed)))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value(42203));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mediaShapeViolationsAnswer40000() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
// duplicate assetId
|
||||
expectBadRequest(author, """
|
||||
{"content": "x", "media": [{"assetId": "%s"}, {"assetId": "%s"}]}
|
||||
""".formatted(assetA, assetA));
|
||||
// two covers (uq_post_media_cover)
|
||||
expectBadRequest(author, """
|
||||
{"content": "x", "media": [
|
||||
{"assetId": "%s", "isCover": true}, {"assetId": "%s", "isCover": true}]}
|
||||
""".formatted(assetA, assetB));
|
||||
// positions must be 0-based contiguous
|
||||
expectBadRequest(author, """
|
||||
{"content": "x", "media": [
|
||||
{"assetId": "%s", "position": 0}, {"assetId": "%s", "position": 2}]}
|
||||
""".formatted(assetA, assetB));
|
||||
// all-or-none positions
|
||||
expectBadRequest(author, """
|
||||
{"content": "x", "media": [
|
||||
{"assetId": "%s", "position": 0}, {"assetId": "%s"}]}
|
||||
""".formatted(assetA, assetB));
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchMediaReplacesTheWholeSet() throws Exception {
|
||||
UUID author = newUser();
|
||||
UUID assetA = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
UUID assetB = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
UUID assetC = CommunityTestData.insertReadyAsset(jdbcClient, author);
|
||||
String id = createPost(author, """
|
||||
{"content": "初始两图", "media": [{"assetId": "%s"}, {"assetId": "%s"}]}
|
||||
""".formatted(assetA, assetB)).get("id").asText();
|
||||
|
||||
// media present in the PATCH → whole-set replacement (整组替换)
|
||||
JsonNode replaced = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 0, \"media\": [{\"assetId\": \"" + assetC + "\"}]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn());
|
||||
assertThat(replaced.get("media")).hasSize(1);
|
||||
assertThat(replaced.get("media").get(0).get("assetId").asText())
|
||||
.isEqualTo(assetC.toString());
|
||||
assertThat(replaced.get("media").get(0).get("isCover").asBoolean()).isTrue();
|
||||
|
||||
// media absent → untouched
|
||||
JsonNode untouched = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 1, \"title\": \"只改标题\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn());
|
||||
assertThat(untouched.get("media")).hasSize(1);
|
||||
|
||||
// empty array → clears down to a text-only post
|
||||
JsonNode cleared = data(mockMvc.perform(authed(patch("/api/v1/posts/" + id), author)
|
||||
.content("{\"version\": 2, \"media\": []}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn());
|
||||
assertThat(cleared.get("media")).isEmpty();
|
||||
Long rows = jdbcClient.sql(
|
||||
"SELECT count(*) FROM community.post_media WHERE post_id = :id")
|
||||
.param("id", UUID.fromString(id))
|
||||
.query(Long.class)
|
||||
.single();
|
||||
assertThat(rows).isZero();
|
||||
}
|
||||
|
||||
private void expectBadRequest(UUID author, String body) throws Exception {
|
||||
mockMvc.perform(createPostRequest(author, UUID.randomUUID().toString(), body))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
private static String mediaBody(UUID assetId) {
|
||||
return "{\"content\": \"带图\", \"media\": [{\"assetId\": \"" + assetId + "\"}]}";
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package com.patbond.patbond.community.security;
|
||||
|
||||
import com.patbond.patbond.community.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.community.support.TestJwtKeys;
|
||||
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.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The BearerAuthFilter guards every /api/v1/** route from the skeleton on
|
||||
* (T3-02 acceptance): missing, forged, wrong-key and expired tokens all
|
||||
* answer 401 with the 40101 envelope, while a valid token passes the filter
|
||||
* (and reaches the 404 of a not-yet-implemented route instead of a 401).
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class BearerAuthIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtPublicKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenAnswers401() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/posts"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedTokenAnswers401() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/posts")
|
||||
.header("Authorization", "Bearer not-a-jwt"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongKeyTokenAnswers401() throws Exception {
|
||||
String forged = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(),
|
||||
UUID.randomUUID(), Duration.ofMinutes(15));
|
||||
mockMvc.perform(get("/api/v1/posts")
|
||||
.header("Authorization", "Bearer " + forged))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenAnswers401() throws Exception {
|
||||
String expired = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.randomUUID(), Duration.ofMinutes(-5));
|
||||
mockMvc.perform(get("/api/v1/posts")
|
||||
.header("Authorization", "Bearer " + expired))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenPassesTheFilter() throws Exception {
|
||||
// An unmapped route: an authenticated request reaches the 404
|
||||
// envelope — proving the filter let it in. (/api/v1/posts is a real
|
||||
// route since T3-04, so the probe moved to a path that stays free.)
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.randomUUID(), Duration.ofMinutes(15));
|
||||
mockMvc.perform(get("/api/v1/not-a-route")
|
||||
.header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40400));
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.patbond.patbond.community.support;
|
||||
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Direct-SQL fixtures for the cross-schema rows community tests depend on
|
||||
* (identity.users, media.assets, pet_health.pets/pet_owners). The community
|
||||
* service never writes those schemas in production — tests seed them the
|
||||
* way the owning services would.
|
||||
*/
|
||||
public final class CommunityTestData {
|
||||
|
||||
private CommunityTestData() {
|
||||
}
|
||||
|
||||
public static UUID insertUser(JdbcClient jdbc, String username) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbc.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
|
||||
.param("id", id)
|
||||
.param("username", username)
|
||||
.update();
|
||||
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");
|
||||
}
|
||||
|
||||
public static UUID insertAsset(JdbcClient jdbc, UUID ownerUserId, String status) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbc.sql("""
|
||||
INSERT INTO media.assets
|
||||
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
|
||||
mime_type, byte_size, width_px, height_px, status, ready_at)
|
||||
VALUES (:id, :owner, 'image', 'post_image', 'object', 'patbond-media',
|
||||
:objectKey, 'image/jpeg', 123, 640, 480, :status, :readyAt)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("objectKey", "post_image/2026/09/" + id + ".jpg")
|
||||
.param("status", status)
|
||||
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
|
||||
.update();
|
||||
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("""
|
||||
INSERT INTO pet_health.pets (id, name, species, custom_breed_name)
|
||||
VALUES (:id, '毛毛', 'cat', '中华田园猫')
|
||||
""")
|
||||
.param("id", id)
|
||||
.update();
|
||||
jdbc.sql("""
|
||||
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
|
||||
VALUES (:petId, :userId, 'owner', true)
|
||||
""")
|
||||
.param("petId", id)
|
||||
.param("userId", ownerUserId)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.patbond.patbond.community.support;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Runtime-generated RSA material for JWT tests. Nothing here is committed
|
||||
* key material (git-workflow: no credentials in the repository) — every test
|
||||
* run mints a fresh pair and injects the public key via
|
||||
* {@code @DynamicPropertySource}.
|
||||
*/
|
||||
public final class TestJwtKeys {
|
||||
|
||||
public static final KeyPair KEY_PAIR = generate();
|
||||
/** A second pair, for signing tokens the service must reject. */
|
||||
public static final KeyPair WRONG_KEY_PAIR = generate();
|
||||
|
||||
private TestJwtKeys() {
|
||||
}
|
||||
|
||||
public static String publicPem() {
|
||||
return "-----BEGIN PUBLIC KEY-----\n"
|
||||
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
|
||||
+ "\n-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
/** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
|
||||
public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.subject(userId.toString())
|
||||
.issuer("patbond-auth")
|
||||
.claim("sid", UUID.randomUUID().toString())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.signWith(key, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
private static KeyPair generate() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Test-only configuration: keeps @SpringBootTest deterministic on a clean
|
||||
# checkout, where the git-ignored main application.yml does not exist. The
|
||||
# datasource comes from Testcontainers (@ServiceConnection).
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
# Runtime image only — build the jar first: ./mvnw -pl patbond-pet -am package
|
||||
# Stateless by design (ADR-007): no local state, config via env / mounted files.
|
||||
FROM eclipse-temurin:17-jre
|
||||
RUN useradd --system --uid 10001 patbond
|
||||
USER patbond
|
||||
WORKDIR /app
|
||||
COPY target/patbond-pet-1.0.0-SNAPSHOT-exec.jar app.jar
|
||||
EXPOSE 8083
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,142 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-api</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>patbond-pet</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>patbond-pet</name>
|
||||
<description>Pet profile and health record service for Patbond (ADR-009)</description>
|
||||
|
||||
<!-- M2 second-wave: business endpoints with RS256 bearer auth (same JWT
|
||||
verification stack as patbond-user), pet_health schema CRUD. Flyway
|
||||
remains absent — the migration chain is owned by patbond-user. -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jdbc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<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>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Tests need the pet_health schema. The migration chain (V1..V4) is
|
||||
owned by patbond-user (single flyway_schema_history); pulling its
|
||||
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
|
||||
auto-config apply the same chain to the disposable container.
|
||||
Production wiring is unchanged: this module still ships without
|
||||
Flyway and the chain runs in patbond-user's startup path. -->
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-user</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<!-- No spring-boot-starter-parent in this build, so the
|
||||
executable-jar repackaging must be bound explicitly. -->
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<!-- Keep the plain jar as the main artifact so other
|
||||
modules can depend on this one; the runnable fat
|
||||
jar gets the -exec classifier and is what the
|
||||
Dockerfile ships (same pattern as user/auth). -->
|
||||
<classifier>exec</classifier>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.patbond.patbond.pet;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* Pet profile and health record service (M2, ADR-009: the pet_health domain
|
||||
* lives in its own Maven module, not inside patbond-user). First-wave
|
||||
* skeleton: configuration wiring, datasource and a liveness endpoint only —
|
||||
* business endpoints follow the frozen OpenAPI contract in the second wave.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class PetApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PetApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.patbond.patbond.pet.access;
|
||||
|
||||
/**
|
||||
* What a request wants to do with a pet. Every /pets/** endpoint maps to
|
||||
* exactly one level, checked by {@link PetAccessService}:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code READ} — owner, caregiver and viewer.</li>
|
||||
* <li>{@code WRITE} — owner and caregiver; the level for health-record
|
||||
* style sub-resources (weights, vaccinations, health events,
|
||||
* reminders — T2-04~07 reuse this).</li>
|
||||
* <li>{@code MANAGE} — owner only; the pet profile itself (PATCH /pets/
|
||||
* {petId}, status transitions) and, later, member management.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum AccessLevel {
|
||||
|
||||
READ,
|
||||
WRITE,
|
||||
MANAGE;
|
||||
|
||||
boolean allowedFor(PetRole role) {
|
||||
return switch (this) {
|
||||
case READ -> true;
|
||||
case WRITE -> role == PetRole.OWNER || role == PetRole.CAREGIVER;
|
||||
case MANAGE -> role == PetRole.OWNER;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.patbond.patbond.pet.access;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The single permission gate for every /pets/{petId}/** request — T2-04~07
|
||||
* call {@link #require} first and then trust the returned {@link PetAccess}.
|
||||
*
|
||||
* <p>Semantics (frozen for the M2 contract, iteration-2/02 P7):
|
||||
* <ul>
|
||||
* <li>Pet does not exist, is soft-deleted, or the caller has NO row in
|
||||
* pet_owners → {@code 404/40401 PET_NOT_FOUND}. A caller without a
|
||||
* relationship cannot distinguish "someone else's pet" from "no such
|
||||
* pet" — anti-enumeration: the response must not leak that a random
|
||||
* UUID hits a real record.</li>
|
||||
* <li>Relationship exists but the role does not cover the requested
|
||||
* {@link AccessLevel} (e.g. viewer tries to write) →
|
||||
* {@code 403/40300 PET_ACCESS_DENIED}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The check is one indexed query (pets ⋈ pet_owners, both on their
|
||||
* primary keys) per request — no caching, so revoking a caregiver row takes
|
||||
* effect immediately (iteration-2/02 §6: this is why pet permissions don't
|
||||
* need an access-token blacklist).
|
||||
*/
|
||||
@Service
|
||||
public class PetAccessService {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public PetAccessService(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the caller may act on the pet at the given level.
|
||||
*
|
||||
* @return the caller's resolved relationship, for handlers that need the
|
||||
* role (e.g. pet detail echoes {@code myRole})
|
||||
* @throws BusinessException 40401 (pet invisible to this caller) or
|
||||
* 40300 (visible but insufficient role)
|
||||
*/
|
||||
public PetAccess require(UUID userId, UUID petId, AccessLevel level) {
|
||||
PetAccess access = jdbcClient.sql("""
|
||||
SELECT po.role
|
||||
FROM pet_health.pets p
|
||||
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
|
||||
WHERE p.id = :petId AND p.status <> 'deleted'
|
||||
""")
|
||||
.param("userId", userId)
|
||||
.param("petId", petId)
|
||||
.query((rs, rowNum) -> new PetAccess(petId, PetRole.fromDb(rs.getString("role"))))
|
||||
.optional()
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||
if (!level.allowedFor(access.role())) {
|
||||
throw new BusinessException(ErrorCode.PET_ACCESS_DENIED);
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
/** The caller's verified relationship to a pet. */
|
||||
public record PetAccess(UUID petId, PetRole role) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.patbond.patbond.pet.access;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
|
||||
/**
|
||||
* The caller's relationship to a pet, straight from pet_owners.role
|
||||
* (ADR-015 three-tier model: owner / caregiver / viewer).
|
||||
*/
|
||||
public enum PetRole {
|
||||
|
||||
OWNER,
|
||||
CAREGIVER,
|
||||
VIEWER;
|
||||
|
||||
public static PetRole fromDb(String value) {
|
||||
try {
|
||||
return valueOf(value.toUpperCase());
|
||||
} catch (IllegalArgumentException | NullPointerException e) {
|
||||
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "未知的照护角色: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
public String toWire() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.patbond.patbond.pet.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Money fields travel as integer cents (development-plan 4.3), so a decimal
|
||||
* like 45.5 in an integer field must be a 40000 — Jackson's default is to
|
||||
* silently truncate it to 45, which would corrupt amounts instead of
|
||||
* rejecting them.
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
|
||||
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user