Compare commits
2 Commits
8330885b06
...
263cd88451
| Author | SHA1 | Date | |
|---|---|---|---|
| 263cd88451 | |||
| 10a43f876c |
@@ -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
|
||||
@@ -107,3 +138,4 @@ services:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
minio-data:
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
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.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 域 6 个 M1 操作补进契约一致性保障,机制与
|
||||
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.2.0(快照
|
||||
* {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
|
||||
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
|
||||
* 严格校验响应结构,最后以全响应矩阵门禁兜底。
|
||||
*
|
||||
* <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP
|
||||
* 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内
|
||||
* 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例),
|
||||
* 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。
|
||||
*/
|
||||
@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 域 6 个操作(= 契约中 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",
|
||||
"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);
|
||||
}
|
||||
|
||||
// ---- 快照与覆盖门禁 -------------------------------------------------
|
||||
|
||||
/**
|
||||
* 冻结快照守卫:与 pet 侧同一纪律——正典契约升版时必须同步复制新快照
|
||||
* 并更新期望值,忘记同步在 CI 立即变红。
|
||||
*/
|
||||
@Test
|
||||
@Order(98)
|
||||
void frozenSnapshotIsTheExpectedContractVersion() {
|
||||
assertThat(CONTRACT.version()).isEqualTo("1.2.0");
|
||||
assertThat(CONTRACT.paths()).hasSize(18);
|
||||
assertThat(CONTRACT.operations()).hasSize(24);
|
||||
assertThat(CONTRACT.schemas()).hasSize(45);
|
||||
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
|
||||
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被
|
||||
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。
|
||||
*/
|
||||
@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,229 @@
|
||||
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.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 = contract.resolve(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);
|
||||
}
|
||||
}
|
||||
|
||||
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,148 @@
|
||||
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.2.0 OpenAPI contract, loaded from the test-resource snapshot
|
||||
* {@code /contract/openapi-v1.2.0.yaml}.
|
||||
*
|
||||
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
|
||||
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
|
||||
* snapshot is a byte-identical copy taken at freeze time, and this class is
|
||||
* the module-local copy of the pet module's contract framework (same
|
||||
* per-module duplication discipline as BearerAuthFilter). Whenever the
|
||||
* canonical contract changes, copy it here AND in patbond-pet under the new
|
||||
* version's file name and update both conformance tests (expected version +
|
||||
* snapshot counts). The guard test on {@code info.version} makes a forgotten
|
||||
* sync fail loudly in CI instead of silently testing against a stale
|
||||
* contract.
|
||||
*
|
||||
* <p>Only the subset of OpenAPI 3.0 this contract actually uses is supported:
|
||||
* local {@code #/} refs, plain types, {@code nullable}, {@code enum},
|
||||
* {@code required}, {@code properties}, {@code items} — no allOf/oneOf.
|
||||
*/
|
||||
final class OpenApiContract {
|
||||
|
||||
static final String RESOURCE = "/contract/openapi-v1.2.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
@@ -22,8 +22,10 @@ public enum ErrorCode {
|
||||
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
||||
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
||||
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
|
||||
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
|
||||
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
|
||||
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
|
||||
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
|
||||
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||
|
||||
@@ -71,6 +71,12 @@
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- ADR-016:对象存储适配层的 S3 兼容实现(自托管 MinIO / 迁云同一套代码)。
|
||||
版本经根 pom 的 awssdk bom 管理。 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@@ -86,6 +92,13 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- 媒体链路集成测试:MinIO Testcontainer,与 docker-compose 钉同一镜像
|
||||
tag(ADR-016 三环境零分叉),CI 无外部依赖。 -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.patbond.patbond.user.analytics;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -106,7 +107,9 @@ public class AnalyticsService {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
public record EventResult(UUID eventId, String status, String reason) {
|
||||
/** reason 仅 status=rejected 时出现(契约 EventResult:accepted/duplicate 不含该字段)。 */
|
||||
public record EventResult(UUID eventId, String status,
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL) String reason) {
|
||||
public static EventResult accepted(UUID eventId) {
|
||||
return new EventResult(eventId, "accepted", null);
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
/**
|
||||
* Body of POST /api/v1/media/uploads. Fixed enums (kind) are bean-validated;
|
||||
* configuration-driven whitelists (purpose, mimeType, byteSize cap) are
|
||||
* checked in {@link MediaService} so limits stay configuration, not code.
|
||||
*/
|
||||
public class CreateMediaUploadRequest {
|
||||
|
||||
@NotBlank(message = "kind 不能为空")
|
||||
@Pattern(regexp = "image", message = "kind 仅支持 image")
|
||||
private String kind;
|
||||
|
||||
@NotBlank(message = "purpose 不能为空")
|
||||
private String purpose;
|
||||
|
||||
@NotBlank(message = "mimeType 不能为空")
|
||||
private String mimeType;
|
||||
|
||||
@Positive(message = "byteSize 必须为正整数")
|
||||
private long byteSize;
|
||||
|
||||
@Pattern(regexp = "^[0-9a-f]{64}$", message = "sha256 须为 64 位小写十六进制")
|
||||
private String sha256;
|
||||
|
||||
public String getKind() {
|
||||
return kind;
|
||||
}
|
||||
|
||||
public void setKind(String kind) {
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public String getPurpose() {
|
||||
return purpose;
|
||||
}
|
||||
|
||||
public void setPurpose(String purpose) {
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
public String getMimeType() {
|
||||
return mimeType;
|
||||
}
|
||||
|
||||
public void setMimeType(String mimeType) {
|
||||
this.mimeType = mimeType;
|
||||
}
|
||||
|
||||
public long getByteSize() {
|
||||
return byteSize;
|
||||
}
|
||||
|
||||
public void setByteSize(long byteSize) {
|
||||
this.byteSize = byteSize;
|
||||
}
|
||||
|
||||
public String getSha256() {
|
||||
return sha256;
|
||||
}
|
||||
|
||||
public void setSha256(String sha256) {
|
||||
this.sha256 = sha256;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* media.assets access for the upload flow. All writes keep the V1 CHECK
|
||||
* constraints as the last line of defence: storage_type='object' rows always
|
||||
* carry bucket + object_key and no external_url (ck_media_location), and the
|
||||
* ready transition is the only place ready_at is set (ck_media_ready).
|
||||
*/
|
||||
@Repository
|
||||
public class MediaAssetRepository {
|
||||
|
||||
/** The columns the upload flow reads back. */
|
||||
public record AssetRow(UUID id, UUID ownerUserId, String kind, String purpose,
|
||||
String bucket, String objectKey, String mimeType, Long byteSize,
|
||||
Integer widthPx, Integer heightPx, String status,
|
||||
OffsetDateTime createdAt, OffsetDateTime readyAt) {
|
||||
}
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public MediaAssetRepository(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
public void insertUploading(UUID id, UUID ownerUserId, String kind, String purpose,
|
||||
String bucket, String objectKey, String mimeType,
|
||||
long byteSize, byte[] sha256) {
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO media.assets
|
||||
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
|
||||
mime_type, byte_size, sha256, status)
|
||||
VALUES (:id, :owner, :kind, :purpose, 'object', :bucket, :objectKey,
|
||||
:mimeType, :byteSize, :sha256, 'uploading')
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("kind", kind)
|
||||
.param("purpose", purpose)
|
||||
.param("bucket", bucket)
|
||||
.param("objectKey", objectKey)
|
||||
.param("mimeType", mimeType)
|
||||
.param("byteSize", byteSize)
|
||||
.param("sha256", sha256)
|
||||
.update();
|
||||
}
|
||||
|
||||
public Optional<AssetRow> findByIdAndOwner(UUID id, UUID ownerUserId) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT id, owner_user_id, kind, purpose, bucket, object_key, mime_type,
|
||||
byte_size, width_px, height_px, status, created_at, ready_at
|
||||
FROM media.assets
|
||||
WHERE id = :id AND owner_user_id = :owner
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.query((rs, rowNum) -> new AssetRow(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("owner_user_id", UUID.class),
|
||||
rs.getString("kind"),
|
||||
rs.getString("purpose"),
|
||||
rs.getString("bucket"),
|
||||
rs.getString("object_key"),
|
||||
rs.getString("mime_type"),
|
||||
rs.getObject("byte_size", Long.class),
|
||||
rs.getObject("width_px", Integer.class),
|
||||
rs.getObject("height_px", Integer.class),
|
||||
rs.getString("status"),
|
||||
rs.getObject("created_at", OffsetDateTime.class),
|
||||
rs.getObject("ready_at", OffsetDateTime.class)))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/** uploading → ready, guarded so a concurrent complete cannot double-fire. */
|
||||
public boolean markReady(UUID id) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE media.assets
|
||||
SET status = 'ready', ready_at = now(), updated_at = now()
|
||||
WHERE id = :id AND status = 'uploading'
|
||||
""")
|
||||
.param("id", id)
|
||||
.update() > 0;
|
||||
}
|
||||
|
||||
/** uploading → failed (server-side verification rejected the object). */
|
||||
public boolean markFailed(UUID id) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE media.assets
|
||||
SET status = 'failed', updated_at = now()
|
||||
WHERE id = :id AND status = 'uploading'
|
||||
""")
|
||||
.param("id", id)
|
||||
.update() > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Public shape of a media asset (openapi draft MediaAsset). The bucket and
|
||||
* object key never leave the server; {@code url} is a short-lived presigned
|
||||
* GET link, non-null only for ready assets (the bucket stays private —
|
||||
* T3-03 #4, a recorded deviation from the draft's public-read example).
|
||||
*/
|
||||
public record MediaAssetResponse(
|
||||
UUID id,
|
||||
String kind,
|
||||
String purpose,
|
||||
String mimeType,
|
||||
Long byteSize,
|
||||
Integer widthPx,
|
||||
Integer heightPx,
|
||||
String status,
|
||||
String url,
|
||||
OffsetDateTime readyAt,
|
||||
OffsetDateTime createdAt) {
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.user.security.BearerAuthFilter;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Media two-step upload endpoints (openapi community draft, media tag).
|
||||
* Authentication is mandatory and happens in BearerAuthFilter; the media
|
||||
* bytes themselves never touch this service (presigned direct PUT).
|
||||
*/
|
||||
@RestController
|
||||
public class MediaController {
|
||||
|
||||
private final MediaService mediaService;
|
||||
|
||||
public MediaController(MediaService mediaService) {
|
||||
this.mediaService = mediaService;
|
||||
}
|
||||
|
||||
@PostMapping("/api/v1/media/uploads")
|
||||
public ResponseEntity<ApiResponse<MediaUploadCredentialsResponse>> createUpload(
|
||||
@Valid @RequestBody CreateMediaUploadRequest request,
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(mediaService.createUpload(userId, request)));
|
||||
}
|
||||
|
||||
@PostMapping("/api/v1/media/uploads/{assetId}/complete")
|
||||
public ApiResponse<MediaAssetResponse> completeUpload(
|
||||
@PathVariable UUID assetId,
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
return ApiResponse.success(mediaService.completeUpload(userId, assetId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Object-storage and upload-policy knobs of the media domain (ADR-016).
|
||||
* Vendor specifics stay behind {@link ObjectStorage}; everything here is
|
||||
* plain S3-compatible configuration, so moving from self-hosted MinIO to a
|
||||
* cloud object store is a matter of changing these values (and credentials),
|
||||
* never code.
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "patbond.media")
|
||||
public class MediaProperties {
|
||||
|
||||
/**
|
||||
* S3-compatible API endpoint the service itself talks to (bucket init,
|
||||
* HEAD checks), e.g. http://minio:9000 inside compose. Empty means media
|
||||
* is unconfigured: the service still starts, but the /api/v1/media/**
|
||||
* endpoints answer 500 (same precedent as the missing JWT public key).
|
||||
*/
|
||||
private String endpoint = "";
|
||||
|
||||
/**
|
||||
* Endpoint presigned URLs are issued against — the address CLIENTS can
|
||||
* reach (e.g. the host's public address), which inside Docker differs
|
||||
* from {@link #endpoint}. Empty falls back to {@link #endpoint}.
|
||||
*/
|
||||
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 = "";
|
||||
|
||||
/** Bucket holding all media objects; created at startup when missing. */
|
||||
private String bucket = "patbond-media";
|
||||
|
||||
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
|
||||
private String region = "us-east-1";
|
||||
|
||||
/** TTL of presigned PUT credentials (contract: expiresAt). */
|
||||
private Duration uploadTtl = Duration.ofMinutes(10);
|
||||
|
||||
/** TTL of presigned GET URLs (the bucket stays private, T3-03 range #4). */
|
||||
private Duration downloadTtl = Duration.ofHours(1);
|
||||
|
||||
/** Per-file upload cap in bytes (contract cap for byteSize). */
|
||||
private long maxByteSize = 10 * 1024 * 1024;
|
||||
|
||||
/** Mime whitelist for kind=image (M3: jpeg/png/webp). */
|
||||
private List<String> allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
|
||||
|
||||
/** Purpose whitelist; decides the objectKey prefix. M3: post_image. */
|
||||
private List<String> allowedPurposes = List.of("post_image");
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
public String getPublicEndpoint() {
|
||||
return publicEndpoint;
|
||||
}
|
||||
|
||||
public void setPublicEndpoint(String publicEndpoint) {
|
||||
this.publicEndpoint = publicEndpoint;
|
||||
}
|
||||
|
||||
public String getAccessKey() {
|
||||
return accessKey;
|
||||
}
|
||||
|
||||
public void setAccessKey(String accessKey) {
|
||||
this.accessKey = accessKey;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public String getBucket() {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
public void setBucket(String bucket) {
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public Duration getUploadTtl() {
|
||||
return uploadTtl;
|
||||
}
|
||||
|
||||
public void setUploadTtl(Duration uploadTtl) {
|
||||
this.uploadTtl = uploadTtl;
|
||||
}
|
||||
|
||||
public Duration getDownloadTtl() {
|
||||
return downloadTtl;
|
||||
}
|
||||
|
||||
public void setDownloadTtl(Duration downloadTtl) {
|
||||
this.downloadTtl = downloadTtl;
|
||||
}
|
||||
|
||||
public long getMaxByteSize() {
|
||||
return maxByteSize;
|
||||
}
|
||||
|
||||
public void setMaxByteSize(long maxByteSize) {
|
||||
this.maxByteSize = maxByteSize;
|
||||
}
|
||||
|
||||
public List<String> getAllowedMimeTypes() {
|
||||
return allowedMimeTypes;
|
||||
}
|
||||
|
||||
public void setAllowedMimeTypes(List<String> allowedMimeTypes) {
|
||||
this.allowedMimeTypes = allowedMimeTypes;
|
||||
}
|
||||
|
||||
public List<String> getAllowedPurposes() {
|
||||
return allowedPurposes;
|
||||
}
|
||||
|
||||
public void setAllowedPurposes(List<String> allowedPurposes) {
|
||||
this.allowedPurposes = allowedPurposes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.user.media.MediaAssetRepository.AssetRow;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Two-step upload flow of the media domain (T3-03, ADR-016/017):
|
||||
*
|
||||
* <ol>
|
||||
* <li>createUpload — whitelist checks, media.assets row in 'uploading',
|
||||
* presigned PUT credentials; the client uploads directly to the store,
|
||||
* no media bytes ever pass through this service;</li>
|
||||
* <li>completeUpload — server-side HEAD verification (existence, byte size,
|
||||
* content type), then uploading → ready. A missing object keeps the row
|
||||
* in 'uploading' (the client may still be uploading — retryable); an
|
||||
* object that exists but contradicts the declaration goes to 'failed'
|
||||
* (terminal: the credentials were used for something else).</li>
|
||||
* </ol>
|
||||
*
|
||||
* Cleanup of uploading rows that never complete is a documented follow-up
|
||||
* (ix_media_uploading_created is reserved for it), not implemented in M3
|
||||
* wave 1 — see iteration-3 report 13.
|
||||
*/
|
||||
@Service
|
||||
public class MediaService {
|
||||
|
||||
private static final DateTimeFormatter KEY_MONTH =
|
||||
DateTimeFormatter.ofPattern("yyyy/MM").withZone(ZoneOffset.UTC);
|
||||
|
||||
private final MediaAssetRepository repository;
|
||||
private final ObjectStorage storage;
|
||||
private final MediaProperties properties;
|
||||
|
||||
public MediaService(MediaAssetRepository repository, ObjectStorage storage,
|
||||
MediaProperties properties) {
|
||||
this.repository = repository;
|
||||
this.storage = storage;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public MediaUploadCredentialsResponse createUpload(UUID userId, CreateMediaUploadRequest request) {
|
||||
String mimeType = request.getMimeType().toLowerCase(Locale.ROOT);
|
||||
if (!properties.getAllowedPurposes().contains(request.getPurpose())) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"purpose 仅支持: " + String.join(", ", properties.getAllowedPurposes()));
|
||||
}
|
||||
if (!properties.getAllowedMimeTypes().contains(mimeType)) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"mimeType 仅支持: " + String.join(", ", properties.getAllowedMimeTypes()));
|
||||
}
|
||||
if (request.getByteSize() > properties.getMaxByteSize()) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"byteSize 超过单文件上限 " + properties.getMaxByteSize() + " 字节");
|
||||
}
|
||||
|
||||
UUID assetId = UuidV7.generate();
|
||||
// objectKey 完全由服务端生成,不含任何用户输入(评估 §5.4)
|
||||
String objectKey = request.getPurpose() + "/"
|
||||
+ KEY_MONTH.format(OffsetDateTime.now(ZoneOffset.UTC)) + "/" + assetId;
|
||||
byte[] sha256 = request.getSha256() == null ? null : HexFormat.of().parseHex(request.getSha256());
|
||||
repository.insertUploading(assetId, userId, request.getKind(), request.getPurpose(),
|
||||
properties.getBucket(), objectKey, mimeType, request.getByteSize(), sha256);
|
||||
|
||||
ObjectStorage.PresignedPut put =
|
||||
storage.presignPut(objectKey, mimeType, properties.getUploadTtl());
|
||||
return new MediaUploadCredentialsResponse(assetId, put.url(), "PUT", put.headers(),
|
||||
put.expiresAt());
|
||||
}
|
||||
|
||||
public MediaAssetResponse completeUpload(UUID userId, UUID assetId) {
|
||||
AssetRow asset = repository.findByIdAndOwner(assetId, userId)
|
||||
// 防枚举:不存在与非本人所有同答 40405(草案错误码表)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
|
||||
switch (asset.status()) {
|
||||
case "ready" -> {
|
||||
return toResponse(asset); // 幂等重复确认
|
||||
}
|
||||
case "deleted" -> throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
|
||||
case "failed" -> throw new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID,
|
||||
"该上传已失败,请重新创建上传");
|
||||
default -> {
|
||||
// uploading:走服务端校验
|
||||
}
|
||||
}
|
||||
|
||||
ObjectStorage.ObjectStat stat = storage.stat(asset.objectKey())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID,
|
||||
"对象尚未上传完成,请先完成直传再确认"));
|
||||
boolean sizeMatches = asset.byteSize() != null && stat.byteSize() == asset.byteSize();
|
||||
boolean mimeMatches = stat.contentType() == null
|
||||
|| stat.contentType().startsWith(asset.mimeType());
|
||||
if (!sizeMatches || !mimeMatches) {
|
||||
repository.markFailed(asset.id());
|
||||
throw new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID,
|
||||
"对象与登记不符(大小或类型),该上传已置为失败");
|
||||
}
|
||||
|
||||
if (!repository.markReady(asset.id())) {
|
||||
// 并发确认竞争:重读终态,ready 则幂等成功
|
||||
AssetRow raced = repository.findByIdAndOwner(assetId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
|
||||
if (!"ready".equals(raced.status())) {
|
||||
throw new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID);
|
||||
}
|
||||
return toResponse(raced);
|
||||
}
|
||||
return toResponse(repository.findByIdAndOwner(assetId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)));
|
||||
}
|
||||
|
||||
private MediaAssetResponse toResponse(AssetRow asset) {
|
||||
String url = "ready".equals(asset.status())
|
||||
? storage.presignGet(asset.objectKey(), properties.getDownloadTtl())
|
||||
: null;
|
||||
return new MediaAssetResponse(asset.id(), asset.kind(), asset.purpose(), asset.mimeType(),
|
||||
asset.byteSize(), asset.widthPx(), asset.heightPx(), asset.status(), url,
|
||||
asset.readyAt(), asset.createdAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Wires the media storage adapter. With a configured endpoint the bucket is
|
||||
* ensured right at startup — the one and only bucket-init path, shared by
|
||||
* local runs, compose and Testcontainers (ADR-016: three environments, zero
|
||||
* divergence). Without one (endpoint empty) the service still starts and
|
||||
* only the media endpoints fail with 500 — the same precedent as the
|
||||
* missing JWT public key.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(MediaProperties.class)
|
||||
public class MediaStorageConfig {
|
||||
|
||||
@Bean(destroyMethod = "close")
|
||||
public ObjectStorage objectStorage(MediaProperties properties) {
|
||||
if (properties.getEndpoint() == null || properties.getEndpoint().isBlank()) {
|
||||
return new UnconfiguredObjectStorage();
|
||||
}
|
||||
S3ObjectStorage storage = new S3ObjectStorage(properties);
|
||||
storage.ensureBucket();
|
||||
return storage;
|
||||
}
|
||||
|
||||
/** Fails every use with a clear message instead of a dead connection. */
|
||||
static final class UnconfiguredObjectStorage implements ObjectStorage, AutoCloseable {
|
||||
|
||||
private static final String MESSAGE =
|
||||
"对象存储未配置:请设置 patbond.media.endpoint(PATBOND_MINIO_ENDPOINT)等属性";
|
||||
|
||||
@Override
|
||||
public void ensureBucket() {
|
||||
throw new IllegalStateException(MESSAGE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PresignedPut presignPut(String objectKey, String contentType, Duration ttl) {
|
||||
throw new IllegalStateException(MESSAGE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ObjectStat> stat(String objectKey) {
|
||||
throw new IllegalStateException(MESSAGE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignGet(String objectKey, Duration ttl) {
|
||||
throw new IllegalStateException(MESSAGE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* data of the 201 answer to POST /api/v1/media/uploads: the registered asset
|
||||
* id plus the presigned direct-PUT credentials (openapi draft
|
||||
* MediaUploadCredentials; the exact shape is T3-10 freeze input).
|
||||
*/
|
||||
public record MediaUploadCredentialsResponse(
|
||||
UUID assetId,
|
||||
String uploadUrl,
|
||||
String method,
|
||||
Map<String, String> requiredHeaders,
|
||||
Instant expiresAt) {
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Storage adapter of the media domain (ADR-016): the ONLY seam through which
|
||||
* media code touches an object store. Callers deal in object keys and plain
|
||||
* values; bucket, endpoint, credentials and vendor SDK types stay inside the
|
||||
* implementation, so swapping self-hosted MinIO for a cloud S3-compatible
|
||||
* store must never require a change on the caller side.
|
||||
*/
|
||||
public interface ObjectStorage {
|
||||
|
||||
/** Creates the configured bucket when missing; idempotent. */
|
||||
void ensureBucket();
|
||||
|
||||
/**
|
||||
* Issues short-lived credentials for a direct client PUT of the object.
|
||||
* The Content-Type is part of the signature: the client must send the
|
||||
* returned headers verbatim or the store rejects the upload.
|
||||
*/
|
||||
PresignedPut presignPut(String objectKey, String contentType, Duration ttl);
|
||||
|
||||
/** Metadata of the stored object, or empty when nothing was uploaded. */
|
||||
Optional<ObjectStat> stat(String objectKey);
|
||||
|
||||
/** Short-lived read URL; the bucket itself stays private (T3-03 #4). */
|
||||
String presignGet(String objectKey, Duration ttl);
|
||||
|
||||
record PresignedPut(String url, Map<String, String> headers, Instant expiresAt) {
|
||||
}
|
||||
|
||||
record ObjectStat(long byteSize, String contentType) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.patbond.patbond.user.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.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* S3-compatible {@link ObjectStorage} over the AWS SDK v2, pointed at
|
||||
* self-hosted MinIO for now (ADR-016). Two endpoints are in play: the SDK
|
||||
* client talks to the internal endpoint (compose network), while presigned
|
||||
* URLs are signed against the public endpoint clients can actually reach —
|
||||
* SigV4 signs the Host header, so the two must be kept apart. Path-style
|
||||
* addressing is forced because MinIO has no wildcard DNS for
|
||||
* virtual-host-style buckets.
|
||||
*/
|
||||
public class S3ObjectStorage implements ObjectStorage, AutoCloseable {
|
||||
|
||||
private final String bucket;
|
||||
private final S3Client client;
|
||||
private final S3Presigner presigner;
|
||||
|
||||
public S3ObjectStorage(MediaProperties properties) {
|
||||
this.bucket = properties.getBucket();
|
||||
StaticCredentialsProvider credentials = StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()));
|
||||
Region region = Region.of(properties.getRegion());
|
||||
S3Configuration pathStyle = S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(true)
|
||||
.build();
|
||||
this.client = S3Client.builder()
|
||||
.endpointOverride(URI.create(properties.getEndpoint()))
|
||||
.region(region)
|
||||
.credentialsProvider(credentials)
|
||||
.serviceConfiguration(pathStyle)
|
||||
.build();
|
||||
String publicEndpoint = properties.getPublicEndpoint().isBlank()
|
||||
? properties.getEndpoint()
|
||||
: properties.getPublicEndpoint();
|
||||
this.presigner = S3Presigner.builder()
|
||||
.endpointOverride(URI.create(publicEndpoint))
|
||||
.region(region)
|
||||
.credentialsProvider(credentials)
|
||||
.serviceConfiguration(pathStyle)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureBucket() {
|
||||
try {
|
||||
client.headBucket(b -> b.bucket(bucket));
|
||||
} catch (NoSuchBucketException e) {
|
||||
try {
|
||||
client.createBucket(b -> b.bucket(bucket));
|
||||
} catch (S3Exception raced) {
|
||||
// 与并行启动的实例竞争建同名桶:对方赢了即目标达成
|
||||
if (raced.statusCode() != 409) {
|
||||
throw raced;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PresignedPut presignPut(String objectKey, String contentType, Duration ttl) {
|
||||
PresignedPutObjectRequest presigned = presigner.presignPutObject(
|
||||
PutObjectPresignRequest.builder()
|
||||
.signatureDuration(ttl)
|
||||
.putObjectRequest(b -> b.bucket(bucket).key(objectKey).contentType(contentType))
|
||||
.build());
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
// Content-Type 恒定回传(大小写规范化):客户端必须原样携带;SDK 的
|
||||
// signedHeaders 键名大小写与是否包含它随版本浮动,不作为唯一来源
|
||||
headers.put("Content-Type", contentType);
|
||||
presigned.signedHeaders().forEach((name, values) -> {
|
||||
if (!"host".equalsIgnoreCase(name) && !"content-type".equalsIgnoreCase(name)
|
||||
&& !values.isEmpty()) {
|
||||
headers.put(name, values.get(0));
|
||||
}
|
||||
});
|
||||
return new PresignedPut(presigned.url().toString(), headers, presigned.expiration());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ObjectStat> stat(String objectKey) {
|
||||
try {
|
||||
HeadObjectResponse head = client.headObject(b -> b.bucket(bucket).key(objectKey));
|
||||
String contentType = head.contentType() == null
|
||||
? null
|
||||
: head.contentType().toLowerCase(Locale.ROOT);
|
||||
return Optional.of(new ObjectStat(head.contentLength(), contentType));
|
||||
} catch (NoSuchKeyException e) {
|
||||
return Optional.empty();
|
||||
} catch (S3Exception e) {
|
||||
// MinIO 对 HEAD 缺失对象返回无实体的 404,SDK 未必映射为 NoSuchKeyException
|
||||
if (e.statusCode() == 404) {
|
||||
return Optional.empty();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String presignGet(String objectKey, Duration ttl) {
|
||||
return presigner.presignGetObject(
|
||||
GetObjectPresignRequest.builder()
|
||||
.signatureDuration(ttl)
|
||||
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
|
||||
.build())
|
||||
.url()
|
||||
.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
presigner.close();
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,26 @@ patbond:
|
||||
max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5}
|
||||
failure-window: ${PATBOND_LOGIN_LOCK_WINDOW:15m}
|
||||
lock-duration: ${PATBOND_LOGIN_LOCK_DURATION:15m}
|
||||
media:
|
||||
# ADR-016:S3 兼容对象存储(自托管 MinIO 起步,迁云只换这里的配置与凭证)。
|
||||
# endpoint 为空时服务可启动,但 /api/v1/media/** 返回 500。
|
||||
# 凭证一律经环境变量注入、绝不入库(ADR-021);compose 场景由
|
||||
# deploy/init-secrets.sh 生成随机值写入被 gitignore 的 .env。
|
||||
endpoint: ${PATBOND_MINIO_ENDPOINT:}
|
||||
# 预签名 URL 面向客户端可达地址签发:SigV4 会把 Host 签进签名,
|
||||
# 客户端不可达 endpoint(如 compose 内网 http://minio:9000)时必须
|
||||
# 将本项设为客户端可达地址(如 http://<服务器公网地址>:9000)。
|
||||
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
|
||||
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
|
||||
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
|
||||
bucket: ${PATBOND_MINIO_BUCKET:patbond-media}
|
||||
# 预签名 PUT 凭据与 GET URL 的有效期
|
||||
upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m}
|
||||
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
|
||||
# 单文件上限(字节)与 mime/purpose 白名单(M3 首版:图片、帖子配图)
|
||||
max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760}
|
||||
allowed-mime-types: image/jpeg,image/png,image/webp
|
||||
allowed-purposes: post_image
|
||||
|
||||
# Development seed data (regions reference rows) is opt-in. To load it,
|
||||
# activate a dev profile that widens the Flyway locations:
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
package com.patbond.patbond.user.media;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
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-03 media 域最小闭环全链路(真实 MinIO Testcontainer,与 compose 钉同一
|
||||
* 镜像 tag):创建上传 → 凭据直传(真实 HTTP PUT)→ complete 确认 →
|
||||
* ready + 预签名 GET 可访问;以及六类失败路径(非法 mime / 超限 / 未上传就
|
||||
* 确认 / 大小不符置 failed / 重复确认幂等 / 他人与不存在 asset 防枚举)与
|
||||
* 数据库约束-应用层校验一致性。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MediaUploadIntegrationTest {
|
||||
|
||||
/** 与 docker-compose.yml 的 minio 服务钉同一 tag(ADR-016 三环境零分叉)。 */
|
||||
private static final MinIOContainer MINIO = new MinIOContainer(
|
||||
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
|
||||
// 值仅为测试占位(dummy),非真实凭证
|
||||
.withUserName("minio-dummy-access")
|
||||
.withPassword("minio-dummy-secret");
|
||||
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private static final byte[] FAKE_JPEG = fakeJpeg();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void wireMedia(DynamicPropertyRegistry registry) {
|
||||
MINIO.start();
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
// 测试 JVM 直接可达容器映射端口,公网端点无需分离
|
||||
registry.add("patbond.media.endpoint", MINIO::getS3URL);
|
||||
registry.add("patbond.media.access-key", MINIO::getUserName);
|
||||
registry.add("patbond.media.secret-key", MINIO::getPassword);
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------
|
||||
|
||||
private static byte[] fakeJpeg() {
|
||||
byte[] bytes = new byte[2048];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = (byte) (i * 31);
|
||||
}
|
||||
bytes[0] = (byte) 0xFF;
|
||||
bytes[1] = (byte) 0xD8; // JPEG SOI,凑个像样的文件头
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private UUID newUser(String username) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
|
||||
.param("id", id)
|
||||
.param("username", username)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String bearer(UUID userId) {
|
||||
return "Bearer " + TestJwtKeys.accessToken(
|
||||
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
|
||||
}
|
||||
|
||||
/** 创建上传并断言 201 凭据形态,返回响应体。 */
|
||||
private String createUpload(UUID user, long byteSize) throws Exception {
|
||||
return mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/jpeg","byteSize":%d}
|
||||
""".formatted(byteSize)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.assetId").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.uploadUrl").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.method").value("PUT"))
|
||||
.andExpect(jsonPath("$.data.requiredHeaders['Content-Type']").value("image/jpeg"))
|
||||
.andExpect(jsonPath("$.data.expiresAt").isNotEmpty())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
/** 按凭据把字节真实 PUT 到 MinIO,返回 HTTP 状态码。 */
|
||||
private int directPut(String createdBody, byte[] bytes) throws Exception {
|
||||
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
|
||||
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
|
||||
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
|
||||
.PUT(HttpRequest.BodyPublishers.ofByteArray(bytes));
|
||||
headers.forEach(put::header);
|
||||
return HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode();
|
||||
}
|
||||
|
||||
private String complete(UUID user, String assetId, int expectedStatus) throws Exception {
|
||||
return mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
|
||||
.header("Authorization", bearer(user)))
|
||||
.andExpect(status().is(expectedStatus))
|
||||
.andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String dbStatus(String assetId) {
|
||||
return jdbcClient.sql("SELECT status FROM media.assets WHERE id = :id")
|
||||
.param("id", UUID.fromString(assetId))
|
||||
.query(String.class)
|
||||
.single();
|
||||
}
|
||||
|
||||
// ---- 全链路 --------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void fullChainUploadCompleteReadyAndFetch() throws Exception {
|
||||
UUID user = newUser("media_full_chain");
|
||||
String created = createUpload(user, FAKE_JPEG.length);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
|
||||
assertThat(directPut(created, FAKE_JPEG)).isEqualTo(200);
|
||||
|
||||
String completed = complete(user, assetId, 200);
|
||||
assertThat((String) JsonPath.read(completed, "$.data.status")).isEqualTo("ready");
|
||||
assertThat((String) JsonPath.read(completed, "$.data.id")).isEqualTo(assetId);
|
||||
assertThat((int) JsonPath.read(completed, "$.data.byteSize")).isEqualTo(FAKE_JPEG.length);
|
||||
assertThat((String) JsonPath.read(completed, "$.data.readyAt")).isNotNull();
|
||||
String url = JsonPath.read(completed, "$.data.url");
|
||||
assertThat(url).as("ready 资产必须带预签名 GET URL").isNotNull();
|
||||
|
||||
// 预签名 GET 真实取回,字节一致(桶保持私有,无签名访问应被拒)
|
||||
HttpResponse<byte[]> fetched = HTTP.send(
|
||||
HttpRequest.newBuilder(URI.create(url)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertThat(fetched.statusCode()).isEqualTo(200);
|
||||
assertThat(fetched.body()).isEqualTo(FAKE_JPEG);
|
||||
|
||||
String bareUrl = url.substring(0, url.indexOf('?'));
|
||||
HttpResponse<Void> unsigned = HTTP.send(
|
||||
HttpRequest.newBuilder(URI.create(bareUrl)).GET().build(),
|
||||
HttpResponse.BodyHandlers.discarding());
|
||||
assertThat(unsigned.statusCode()).as("无签名直访私有桶必须被拒").isEqualTo(403);
|
||||
|
||||
// 落库行满足 ck_media_location 的 object 分支与 ck_media_ready
|
||||
Map<String, Object> row = jdbcClient.sql("""
|
||||
SELECT storage_type, bucket, object_key, external_url, status,
|
||||
ready_at, byte_size
|
||||
FROM media.assets WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString(assetId))
|
||||
.query()
|
||||
.singleRow();
|
||||
assertThat(row.get("storage_type")).isEqualTo("object");
|
||||
assertThat(row.get("bucket")).isEqualTo("patbond-media");
|
||||
assertThat((String) row.get("object_key"))
|
||||
.startsWith("post_image/")
|
||||
.endsWith(assetId)
|
||||
.doesNotContain("..");
|
||||
assertThat(row.get("external_url")).isNull();
|
||||
assertThat(row.get("status")).isEqualTo("ready");
|
||||
assertThat(row.get("ready_at")).isNotNull();
|
||||
assertThat(((Number) row.get("byte_size")).longValue()).isEqualTo(FAKE_JPEG.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedCompleteIsIdempotent() throws Exception {
|
||||
UUID user = newUser("media_idem");
|
||||
String created = createUpload(user, FAKE_JPEG.length);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
directPut(created, FAKE_JPEG);
|
||||
complete(user, assetId, 200);
|
||||
|
||||
String again = complete(user, assetId, 200);
|
||||
assertThat((String) JsonPath.read(again, "$.data.id")).isEqualTo(assetId);
|
||||
assertThat((String) JsonPath.read(again, "$.data.status")).isEqualTo("ready");
|
||||
assertThat(dbStatus(assetId)).isEqualTo("ready");
|
||||
}
|
||||
|
||||
// ---- 失败路径 ------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void rejectsMimeOutsideWhitelist() throws Exception {
|
||||
UUID user = newUser("media_bad_mime");
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/gif","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsByteSizeOverCap() throws Exception {
|
||||
UUID user = newUser("media_too_big");
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/png","byteSize":10485761}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsKindAndPurposeOutsideWhitelist() throws Exception {
|
||||
UUID user = newUser("media_bad_enum");
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"video","purpose":"post_image",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"pet_avatar",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeBeforeUploadKeepsAssetRetryable() throws Exception {
|
||||
UUID user = newUser("media_premature");
|
||||
String created = createUpload(user, FAKE_JPEG.length);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
|
||||
String body = complete(user, assetId, 422);
|
||||
assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(42205);
|
||||
// 对象未上传只拒不置 failed:客户端补传后可重试确认
|
||||
assertThat(dbStatus(assetId)).isEqualTo("uploading");
|
||||
|
||||
directPut(created, FAKE_JPEG);
|
||||
String recovered = complete(user, assetId, 200);
|
||||
assertThat((String) JsonPath.read(recovered, "$.data.status")).isEqualTo("ready");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sizeMismatchMarksAssetFailedTerminally() throws Exception {
|
||||
UUID user = newUser("media_mismatch");
|
||||
String created = createUpload(user, FAKE_JPEG.length + 100);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
directPut(created, FAKE_JPEG); // 实传字节数与登记不符
|
||||
|
||||
String body = complete(user, assetId, 422);
|
||||
assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(42205);
|
||||
assertThat(dbStatus(assetId)).isEqualTo("failed");
|
||||
|
||||
// failed 为终态:再次确认仍 42205
|
||||
String again = complete(user, assetId, 422);
|
||||
assertThat((int) JsonPath.read(again, "$.code")).isEqualTo(42205);
|
||||
assertThat(dbStatus(assetId)).isEqualTo("failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeIsAntiEnumerationOnForeignAndGhostAssets() throws Exception {
|
||||
UUID owner = newUser("media_owner");
|
||||
UUID intruder = newUser("media_intruder");
|
||||
String created = createUpload(owner, FAKE_JPEG.length);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
directPut(created, FAKE_JPEG);
|
||||
|
||||
// 他人 asset 与不存在 asset 同答 40405(防枚举合并)
|
||||
String foreign = complete(intruder, assetId, 404);
|
||||
assertThat((int) JsonPath.read(foreign, "$.code")).isEqualTo(40405);
|
||||
String ghost = complete(owner, UUID.randomUUID().toString(), 404);
|
||||
assertThat((int) JsonPath.read(ghost, "$.code")).isEqualTo(40405);
|
||||
// 未被打扰,本人仍可正常确认
|
||||
complete(owner, assetId, 200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mediaEndpointsRequireAuthentication() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"post_image",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
mockMvc.perform(post("/api/v1/media/uploads/{id}/complete", UUID.randomUUID()))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
// ---- 数据库约束与应用层校验一致性 -----------------------------------
|
||||
|
||||
@Test
|
||||
void databaseConstraintsBackTheApplicationChecks() {
|
||||
UUID user = newUser("media_constraints");
|
||||
|
||||
// ck_media_ready:应用层以外的路径也不可能造出无 ready_at 的 ready 行
|
||||
UUID id = UuidV7.generate();
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO media.assets (id, owner_user_id, kind, purpose, storage_type,
|
||||
bucket, object_key, mime_type, status)
|
||||
VALUES (:id, :owner, 'image', 'post_image', 'object',
|
||||
'patbond-media', 'post_image/t/' || :id, 'image/jpeg', 'uploading')
|
||||
""")
|
||||
.param("id", id).param("owner", user).update();
|
||||
assertThatThrownBy(() -> jdbcClient
|
||||
.sql("UPDATE media.assets SET status = 'ready', ready_at = NULL WHERE id = :id")
|
||||
.param("id", id).update())
|
||||
.as("ck_media_ready 兜底 ready 必有 ready_at")
|
||||
.hasMessageContaining("ck_media_ready");
|
||||
|
||||
// ck_media_location:object 行缺 bucket/object_key 直接被库拒绝
|
||||
assertThatThrownBy(() -> jdbcClient.sql("""
|
||||
INSERT INTO media.assets (id, owner_user_id, kind, purpose, storage_type,
|
||||
mime_type, status)
|
||||
VALUES (:id, :owner, 'image', 'post_image', 'object', 'image/jpeg', 'uploading')
|
||||
""")
|
||||
.param("id", UuidV7.generate()).param("owner", user).update())
|
||||
.as("ck_media_location 兜底 object 行必有 bucket+object_key")
|
||||
.hasMessageContaining("ck_media_location");
|
||||
|
||||
// uq_media_object:同桶同 key 不可能登记两次
|
||||
assertThatThrownBy(() -> jdbcClient.sql("""
|
||||
INSERT INTO media.assets (id, owner_user_id, kind, purpose, storage_type,
|
||||
bucket, object_key, mime_type, status)
|
||||
VALUES (:newId, :owner, 'image', 'post_image', 'object',
|
||||
'patbond-media', 'post_image/t/' || :dupId, 'image/jpeg', 'uploading')
|
||||
""")
|
||||
.param("newId", UuidV7.generate()).param("dupId", id).param("owner", user).update())
|
||||
.as("uq_media_object 兜底对象键唯一")
|
||||
.hasMessageContaining("uq_media_object");
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadingTimeoutSweepIndexIsInPlace() {
|
||||
// 清理方案(报告 13 §6,不在本迭代实现)依赖的部分索引自 V1 就位
|
||||
Integer count = jdbcClient.sql("""
|
||||
SELECT COUNT(*) FROM pg_indexes
|
||||
WHERE schemaname = 'media' AND indexname = 'ix_media_uploading_created'
|
||||
""")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredCredentialWindowIsHonoured() throws Exception {
|
||||
UUID user = newUser("media_ttl");
|
||||
String created = createUpload(user, FAKE_JPEG.length);
|
||||
OffsetDateTime expiresAt = OffsetDateTime.parse(JsonPath.read(created, "$.data.expiresAt"));
|
||||
OffsetDateTime now = OffsetDateTime.now();
|
||||
// 配置默认 10 分钟 TTL 生效(形态定型输入:expiresAt 语义)
|
||||
assertThat(expiresAt).isAfter(now.plusMinutes(8)).isBefore(now.plusMinutes(12));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
|
||||
<spring-boot.version>3.5.16</spring-boot.version>
|
||||
<spring-cloud.version>2025.0.3</spring-cloud.version>
|
||||
<!-- ADR-016:对象存储走 S3 兼容 API(自托管 MinIO 起步),SDK 不在 Boot BOM 内 -->
|
||||
<aws-sdk.version>2.54.13</aws-sdk.version>
|
||||
<lombok.version>1.18.36</lombok.version>
|
||||
<hutool.version>5.8.35</hutool.version>
|
||||
</properties>
|
||||
@@ -47,6 +49,13 @@
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>bom</artifactId>
|
||||
<version>${aws-sdk.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
|
||||
Reference in New Issue
Block a user