添加storage
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
|
||||
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.presigner.S3Presigner;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class DogeCloudS3SessionProvider implements S3SessionProvider {
|
||||
|
||||
private static final Duration REFRESH_WINDOW = Duration.ofMinutes(1);
|
||||
|
||||
private final Supplier<DogeCloudTemporaryS3Session> sessionSupplier;
|
||||
private final Clock clock;
|
||||
private final Function<DogeCloudTemporaryS3Session, S3FileRuntimeSession> runtimeFactory;
|
||||
|
||||
private CachedSession cachedSession;
|
||||
|
||||
DogeCloudS3SessionProvider(FileStorageProperties.S3 properties, DogeCloudTmpTokenClient tmpTokenClient) {
|
||||
this(
|
||||
properties,
|
||||
tmpTokenClient::fetchSession,
|
||||
Clock.systemUTC(),
|
||||
session -> createRuntimeSession(properties, session)
|
||||
);
|
||||
}
|
||||
|
||||
DogeCloudS3SessionProvider(
|
||||
FileStorageProperties.S3 properties,
|
||||
Supplier<DogeCloudTemporaryS3Session> sessionSupplier,
|
||||
Clock clock,
|
||||
Function<DogeCloudTemporaryS3Session, S3FileRuntimeSession> runtimeFactory
|
||||
) {
|
||||
this.sessionSupplier = sessionSupplier;
|
||||
this.clock = clock;
|
||||
this.runtimeFactory = runtimeFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized S3FileRuntimeSession currentSession() {
|
||||
if (cachedSession != null && clock.instant().isBefore(cachedSession.expiresAt().minus(REFRESH_WINDOW))) {
|
||||
return cachedSession.runtimeSession();
|
||||
}
|
||||
|
||||
closeCachedSession();
|
||||
DogeCloudTemporaryS3Session nextSession = sessionSupplier.get();
|
||||
S3FileRuntimeSession runtimeSession = runtimeFactory.apply(nextSession);
|
||||
cachedSession = new CachedSession(nextSession.expiresAt(), runtimeSession);
|
||||
return runtimeSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
closeCachedSession();
|
||||
}
|
||||
|
||||
private void closeCachedSession() {
|
||||
if (cachedSession == null) {
|
||||
return;
|
||||
}
|
||||
cachedSession.runtimeSession().s3Presigner().close();
|
||||
cachedSession.runtimeSession().s3Client().close();
|
||||
cachedSession = null;
|
||||
}
|
||||
|
||||
private static S3FileRuntimeSession createRuntimeSession(FileStorageProperties.S3 properties, DogeCloudTemporaryS3Session session) {
|
||||
StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsSessionCredentials.create(
|
||||
session.accessKeyId(),
|
||||
session.secretAccessKey(),
|
||||
session.sessionToken()
|
||||
));
|
||||
Region region = Region.of(resolveRegion(properties));
|
||||
URI endpoint = URI.create(session.endpoint());
|
||||
return new S3FileRuntimeSession(
|
||||
session.bucket(),
|
||||
S3Client.builder()
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.region(region)
|
||||
.endpointOverride(endpoint)
|
||||
.serviceConfiguration(S3Configuration.builder().build())
|
||||
.build(),
|
||||
S3Presigner.builder()
|
||||
.credentialsProvider(credentialsProvider)
|
||||
.region(region)
|
||||
.endpointOverride(endpoint)
|
||||
.serviceConfiguration(S3Configuration.builder().build())
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private static String resolveRegion(FileStorageProperties.S3 properties) {
|
||||
return properties.getRegion() == null || properties.getRegion().isBlank()
|
||||
? "automatic"
|
||||
: properties.getRegion();
|
||||
}
|
||||
|
||||
private record CachedSession(Instant expiresAt, S3FileRuntimeSession runtimeSession) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
record DogeCloudTemporaryS3Session(
|
||||
String bucket,
|
||||
String endpoint,
|
||||
String accessKeyId,
|
||||
String secretAccessKey,
|
||||
String sessionToken,
|
||||
Instant expiresAt
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class DogeCloudTmpTokenClient {
|
||||
|
||||
private static final String API_PATH = "/auth/tmp_token.json";
|
||||
|
||||
private final FileStorageProperties.S3 properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Transport transport;
|
||||
|
||||
DogeCloudTmpTokenClient(FileStorageProperties.S3 properties, ObjectMapper objectMapper) {
|
||||
this(properties, objectMapper, new HttpTransport());
|
||||
}
|
||||
|
||||
DogeCloudTmpTokenClient(FileStorageProperties.S3 properties, ObjectMapper objectMapper, Transport transport) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.transport = transport;
|
||||
}
|
||||
|
||||
DogeCloudTemporaryS3Session fetchSession() {
|
||||
validateConfiguration();
|
||||
String body = buildRequestBody();
|
||||
Map<String, String> headers = Map.of(
|
||||
"Content-Type", "application/json",
|
||||
"Authorization", buildAuthorization(body)
|
||||
);
|
||||
|
||||
TransportResponse response = post(body, headers);
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new IllegalStateException("多吉云临时密钥请求失败: HTTP " + response.statusCode() + " " + response.body());
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
if (root.path("code").asInt() != 200) {
|
||||
throw new IllegalStateException("多吉云临时密钥请求失败: " + root.path("msg").asText("unknown"));
|
||||
}
|
||||
|
||||
JsonNode data = root.path("data");
|
||||
JsonNode credentials = data.path("Credentials");
|
||||
JsonNode bucketNode = resolveBucketNode(data.path("Buckets"));
|
||||
return new DogeCloudTemporaryS3Session(
|
||||
requiredText(bucketNode, "s3Bucket"),
|
||||
requiredText(bucketNode, "s3Endpoint"),
|
||||
requiredText(credentials, "accessKeyId"),
|
||||
requiredText(credentials, "secretAccessKey"),
|
||||
requiredText(credentials, "sessionToken"),
|
||||
resolveExpiresAt(data.path("ExpiredAt"))
|
||||
);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("解析多吉云临时密钥响应失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private TransportResponse post(String body, Map<String, String> headers) {
|
||||
try {
|
||||
return transport.post(resolveBaseUrl(), API_PATH, body, headers);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("请求多吉云临时密钥失败", ex);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("请求多吉云临时密钥被中断", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateConfiguration() {
|
||||
if (!StringUtils.hasText(properties.getApiAccessKey())
|
||||
|| !StringUtils.hasText(properties.getApiSecretKey())
|
||||
|| !StringUtils.hasText(properties.getScope())) {
|
||||
throw new IllegalStateException("多吉云存储配置不完整");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildRequestBody() {
|
||||
LinkedHashMap<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("channel", "OSS_FULL");
|
||||
payload.put("ttl", properties.getTtlSeconds());
|
||||
payload.put("scopes", List.of(properties.getScope()));
|
||||
try {
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("构建多吉云临时密钥请求失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildAuthorization(String body) {
|
||||
String signTarget = API_PATH + "\n" + body;
|
||||
return "TOKEN " + properties.getApiAccessKey() + ":" + hmacSha1Hex(properties.getApiSecretKey(), signTarget);
|
||||
}
|
||||
|
||||
private String resolveBaseUrl() {
|
||||
String configured = properties.getApiBaseUrl();
|
||||
if (!StringUtils.hasText(configured)) {
|
||||
return "https://api.dogecloud.com";
|
||||
}
|
||||
return configured.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
private JsonNode resolveBucketNode(JsonNode bucketsNode) {
|
||||
if (!bucketsNode.isArray() || bucketsNode.isEmpty()) {
|
||||
throw new IllegalStateException("多吉云临时密钥响应缺少 Buckets");
|
||||
}
|
||||
|
||||
String bucketName = extractBucketName(properties.getScope());
|
||||
for (JsonNode node : bucketsNode) {
|
||||
if (bucketName.equals(node.path("name").asText())) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
if (bucketsNode.size() == 1) {
|
||||
return bucketsNode.get(0);
|
||||
}
|
||||
throw new IllegalStateException("多吉云临时密钥响应中未找到匹配的存储桶: " + bucketName);
|
||||
}
|
||||
|
||||
static String extractBucketName(String scope) {
|
||||
int separatorIndex = scope.indexOf(':');
|
||||
return separatorIndex >= 0 ? scope.substring(0, separatorIndex) : scope;
|
||||
}
|
||||
|
||||
private static Instant resolveExpiresAt(JsonNode node) {
|
||||
long epochSeconds = node.asLong(0L);
|
||||
if (epochSeconds <= 0L) {
|
||||
throw new IllegalStateException("多吉云临时密钥响应缺少 ExpiredAt");
|
||||
}
|
||||
return Instant.ofEpochSecond(epochSeconds);
|
||||
}
|
||||
|
||||
private static String requiredText(JsonNode node, String fieldName) {
|
||||
String value = node.path(fieldName).asText();
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalStateException("多吉云临时密钥响应缺少字段: " + fieldName);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String hmacSha1Hex(String secret, String content) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
|
||||
byte[] digest = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(digest.length * 2);
|
||||
for (byte current : digest) {
|
||||
builder.append(String.format("%02x", current));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("生成多吉云 API 签名失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
interface Transport {
|
||||
TransportResponse post(String baseUrl, String apiPath, String body, Map<String, String> headers) throws IOException, InterruptedException;
|
||||
}
|
||||
|
||||
record TransportResponse(int statusCode, String body) {
|
||||
}
|
||||
|
||||
private static final class HttpTransport implements Transport {
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
|
||||
@Override
|
||||
public TransportResponse post(String baseUrl, String apiPath, String body, Map<String, String> headers) throws IOException, InterruptedException {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(baseUrl + apiPath))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8));
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
requestBuilder.header(entry.getKey(), entry.getValue());
|
||||
}
|
||||
HttpResponse<String> response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
return new TransportResponse(response.statusCode(), response.body());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
|
||||
record S3FileRuntimeSession(
|
||||
String bucket,
|
||||
S3Client s3Client,
|
||||
S3Presigner s3Presigner
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
@FunctionalInterface
|
||||
interface S3SessionProvider extends AutoCloseable {
|
||||
|
||||
S3FileRuntimeSession currentSession();
|
||||
|
||||
@Override
|
||||
default void close() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class DogeCloudS3SessionProviderTest {
|
||||
|
||||
@Test
|
||||
void currentSessionCachesSessionUntilRefreshWindow() {
|
||||
FileStorageProperties.S3 properties = new FileStorageProperties.S3();
|
||||
properties.setRegion("automatic");
|
||||
|
||||
AtomicInteger fetchCount = new AtomicInteger();
|
||||
AtomicInteger runtimeCount = new AtomicInteger();
|
||||
MutableClock clock = new MutableClock(Instant.parse("2026-04-01T10:00:00Z"));
|
||||
|
||||
DogeCloudS3SessionProvider provider = new DogeCloudS3SessionProvider(
|
||||
properties,
|
||||
() -> {
|
||||
int index = fetchCount.incrementAndGet();
|
||||
return new DogeCloudTemporaryS3Session(
|
||||
"bucket-" + index,
|
||||
"https://cos.ap-chengdu.myqcloud.com",
|
||||
"ak-" + index,
|
||||
"sk-" + index,
|
||||
"token-" + index,
|
||||
clock.instant().plusSeconds(index == 1 ? 600 : 1200)
|
||||
);
|
||||
},
|
||||
clock,
|
||||
session -> new S3FileRuntimeSession(
|
||||
session.bucket(),
|
||||
mock(S3Client.class, "s3Client-" + runtimeCount.incrementAndGet()),
|
||||
mock(S3Presigner.class, "presigner-" + runtimeCount.get())
|
||||
)
|
||||
);
|
||||
|
||||
S3FileRuntimeSession first = provider.currentSession();
|
||||
S3FileRuntimeSession second = provider.currentSession();
|
||||
assertThat(first).isSameAs(second);
|
||||
assertThat(fetchCount.get()).isEqualTo(1);
|
||||
|
||||
clock.setInstant(Instant.parse("2026-04-01T10:09:30Z"));
|
||||
S3FileRuntimeSession refreshed = provider.currentSession();
|
||||
assertThat(refreshed).isNotSameAs(first);
|
||||
assertThat(refreshed.bucket()).isEqualTo("bucket-2");
|
||||
assertThat(fetchCount.get()).isEqualTo(2);
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private Instant instant;
|
||||
|
||||
private MutableClock(Instant instant) {
|
||||
this.instant = instant;
|
||||
}
|
||||
|
||||
void setInstant(Instant instant) {
|
||||
this.instant = instant;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(java.time.ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return instant;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class DogeCloudTmpTokenClientTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void fetchSessionSignsJsonRequestAndReturnsMatchingBucket() {
|
||||
FileStorageProperties.S3 properties = new FileStorageProperties.S3();
|
||||
properties.setApiBaseUrl("https://api.dogecloud.com");
|
||||
properties.setApiAccessKey("doge-ak");
|
||||
properties.setApiSecretKey("doge-sk");
|
||||
properties.setScope("yoyuzh-files:users/*");
|
||||
properties.setRegion("automatic");
|
||||
properties.setTtlSeconds(1800);
|
||||
|
||||
CapturingTransport transport = new CapturingTransport("""
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "OK",
|
||||
"data": {
|
||||
"Credentials": {
|
||||
"accessKeyId": "tmp-ak",
|
||||
"secretAccessKey": "tmp-sk",
|
||||
"sessionToken": "tmp-token"
|
||||
},
|
||||
"ExpiredAt": 1777777777,
|
||||
"Buckets": [
|
||||
{
|
||||
"name": "yoyuzh-files",
|
||||
"s3Bucket": "s-cd-14873-yoyuzh-files-1258813047",
|
||||
"s3Endpoint": "https://cos.ap-chengdu.myqcloud.com"
|
||||
},
|
||||
{
|
||||
"name": "yoyuzh-front",
|
||||
"s3Bucket": "s-cd-14873-yoyuzh-front-1258813047",
|
||||
"s3Endpoint": "https://cos.ap-chengdu.myqcloud.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
DogeCloudTmpTokenClient client = new DogeCloudTmpTokenClient(properties, objectMapper, transport);
|
||||
|
||||
DogeCloudTemporaryS3Session session = client.fetchSession();
|
||||
|
||||
assertThat(transport.apiPath).isEqualTo("/auth/tmp_token.json");
|
||||
assertThat(transport.body).isEqualTo("{\"channel\":\"OSS_FULL\",\"ttl\":1800,\"scopes\":[\"yoyuzh-files:users/*\"]}");
|
||||
assertThat(transport.headers).containsEntry("Content-Type", "application/json");
|
||||
assertThat(transport.headers.get("Authorization")).startsWith("TOKEN doge-ak:");
|
||||
assertThat(session.accessKeyId()).isEqualTo("tmp-ak");
|
||||
assertThat(session.secretAccessKey()).isEqualTo("tmp-sk");
|
||||
assertThat(session.sessionToken()).isEqualTo("tmp-token");
|
||||
assertThat(session.bucket()).isEqualTo("s-cd-14873-yoyuzh-files-1258813047");
|
||||
assertThat(session.endpoint()).isEqualTo("https://cos.ap-chengdu.myqcloud.com");
|
||||
assertThat(session.expiresAt()).isEqualTo(Instant.ofEpochSecond(1777777777));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchSessionRejectsApiErrors() {
|
||||
FileStorageProperties.S3 properties = new FileStorageProperties.S3();
|
||||
properties.setApiBaseUrl("https://api.dogecloud.com");
|
||||
properties.setApiAccessKey("doge-ak");
|
||||
properties.setApiSecretKey("doge-sk");
|
||||
properties.setScope("yoyuzh-files");
|
||||
|
||||
DogeCloudTmpTokenClient client = new DogeCloudTmpTokenClient(
|
||||
properties,
|
||||
objectMapper,
|
||||
new CapturingTransport("""
|
||||
{"code":401,"msg":"ERROR_UNAUTHORIZED"}
|
||||
""")
|
||||
);
|
||||
|
||||
assertThatThrownBy(client::fetchSession)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("ERROR_UNAUTHORIZED");
|
||||
}
|
||||
|
||||
private static final class CapturingTransport implements DogeCloudTmpTokenClient.Transport {
|
||||
|
||||
private final String responseBody;
|
||||
private String apiPath;
|
||||
private String body;
|
||||
private Map<String, String> headers;
|
||||
|
||||
private CapturingTransport(String responseBody) {
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DogeCloudTmpTokenClient.TransportResponse post(String baseUrl, String nextApiPath, String nextBody, Map<String, String> nextHeaders) {
|
||||
this.apiPath = nextApiPath;
|
||||
this.body = nextBody;
|
||||
this.headers = nextHeaders;
|
||||
return new DogeCloudTmpTokenClient.TransportResponse(200, responseBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.yoyuzh.files.storage;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import software.amazon.awssdk.core.ResponseBytes;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.CopyObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
|
||||
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.PresignedGetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
|
||||
|
||||
import java.net.URL;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3FileContentStorageTest {
|
||||
|
||||
@Mock
|
||||
private S3Client s3Client;
|
||||
|
||||
@Mock
|
||||
private S3Presigner s3Presigner;
|
||||
|
||||
private S3FileContentStorage storage;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
FileStorageProperties properties = new FileStorageProperties();
|
||||
properties.setProvider("s3");
|
||||
properties.getS3().setApiAccessKey("doge-ak");
|
||||
properties.getS3().setApiSecretKey("doge-sk");
|
||||
properties.getS3().setScope("yoyuzh-files");
|
||||
properties.getS3().setRegion("automatic");
|
||||
storage = new S3FileContentStorage(properties, "demo-bucket", s3Client, s3Presigner);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prepareUploadCreatesDirectPutUrl() throws Exception {
|
||||
PresignedPutObjectRequest presignedRequest = org.mockito.Mockito.mock(PresignedPutObjectRequest.class);
|
||||
when(presignedRequest.url()).thenReturn(new URL("https://upload.example.com/users/7/docs/notes.txt"));
|
||||
when(s3Presigner.presignPutObject(any(PutObjectPresignRequest.class))).thenReturn(presignedRequest);
|
||||
|
||||
PreparedUpload preparedUpload = storage.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
|
||||
|
||||
assertThat(preparedUpload.direct()).isTrue();
|
||||
assertThat(preparedUpload.method()).isEqualTo("PUT");
|
||||
assertThat(preparedUpload.uploadUrl()).isEqualTo("https://upload.example.com/users/7/docs/notes.txt");
|
||||
assertThat(preparedUpload.headers()).containsEntry("Content-Type", "text/plain");
|
||||
|
||||
ArgumentCaptor<PutObjectPresignRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectPresignRequest.class);
|
||||
verify(s3Presigner).presignPutObject(requestCaptor.capture());
|
||||
PutObjectRequest putObjectRequest = requestCaptor.getValue().putObjectRequest();
|
||||
assertThat(putObjectRequest.bucket()).isEqualTo("demo-bucket");
|
||||
assertThat(putObjectRequest.key()).isEqualTo("users/7/docs/notes.txt");
|
||||
assertThat(putObjectRequest.contentType()).isEqualTo("text/plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeUploadRejectsMissingObject() {
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(NoSuchKeyException.builder().message("missing").build());
|
||||
|
||||
assertThatThrownBy(() -> storage.completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessage("上传文件不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDownloadUrlSignsGetRequestWithDownloadFilename() throws Exception {
|
||||
PresignedGetObjectRequest presignedRequest = org.mockito.Mockito.mock(PresignedGetObjectRequest.class);
|
||||
when(presignedRequest.url()).thenReturn(new URL("https://download.example.com/object"));
|
||||
when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(presignedRequest);
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(HeadObjectResponse.builder().build());
|
||||
|
||||
String url = storage.createDownloadUrl(7L, "/docs", "notes.txt", "读书笔记.txt");
|
||||
|
||||
assertThat(url).isEqualTo("https://download.example.com/object");
|
||||
|
||||
ArgumentCaptor<GetObjectPresignRequest> requestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest.class);
|
||||
verify(s3Presigner).presignGetObject(requestCaptor.capture());
|
||||
GetObjectRequest getObjectRequest = requestCaptor.getValue().getObjectRequest();
|
||||
assertThat(getObjectRequest.bucket()).isEqualTo("demo-bucket");
|
||||
assertThat(getObjectRequest.key()).isEqualTo("users/7/docs/notes.txt");
|
||||
assertThat(getObjectRequest.responseContentDisposition())
|
||||
.isEqualTo("attachment; filename=\"download.txt\"; filename*=UTF-8''%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDownloadUrlFallsBackToGenericNameWhenOriginalFilenameHasNoAsciiCharacters() throws Exception {
|
||||
PresignedGetObjectRequest presignedRequest = org.mockito.Mockito.mock(PresignedGetObjectRequest.class);
|
||||
when(presignedRequest.url()).thenReturn(new URL("https://download.example.com/object"));
|
||||
when(s3Presigner.presignGetObject(any(GetObjectPresignRequest.class))).thenReturn(presignedRequest);
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(HeadObjectResponse.builder().build());
|
||||
|
||||
storage.createDownloadUrl(7L, "/docs", "notes.txt", "你好");
|
||||
|
||||
ArgumentCaptor<GetObjectPresignRequest> requestCaptor = ArgumentCaptor.forClass(GetObjectPresignRequest.class);
|
||||
verify(s3Presigner).presignGetObject(requestCaptor.capture());
|
||||
assertThat(requestCaptor.getValue().getObjectRequest().responseContentDisposition())
|
||||
.isEqualTo("attachment; filename=\"download\"; filename*=UTF-8''%E4%BD%A0%E5%A5%BD");
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void uploadStoresMultipartContentInConfiguredBucket() {
|
||||
org.springframework.mock.web.MockMultipartFile multipartFile = new org.springframework.mock.web.MockMultipartFile(
|
||||
"file",
|
||||
"notes.txt",
|
||||
"text/plain",
|
||||
"hello".getBytes()
|
||||
);
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenReturn(PutObjectResponse.builder().eTag("demo").build());
|
||||
|
||||
storage.upload(7L, "/docs", "notes.txt", multipartFile);
|
||||
|
||||
ArgumentCaptor<PutObjectRequest> requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class);
|
||||
verify(s3Client).putObject(requestCaptor.capture(), any(RequestBody.class));
|
||||
assertThat(requestCaptor.getValue().bucket()).isEqualTo("demo-bucket");
|
||||
assertThat(requestCaptor.getValue().key()).isEqualTo("users/7/docs/notes.txt");
|
||||
assertThat(requestCaptor.getValue().contentType()).isEqualTo("text/plain");
|
||||
}
|
||||
|
||||
@Test
|
||||
void renameFileCopiesThenDeletesSourceObject() {
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn(HeadObjectResponse.builder().build());
|
||||
|
||||
storage.renameFile(7L, "/docs", "old.txt", "new.txt");
|
||||
|
||||
ArgumentCaptor<CopyObjectRequest> copyCaptor = ArgumentCaptor.forClass(CopyObjectRequest.class);
|
||||
verify(s3Client).copyObject(copyCaptor.capture());
|
||||
assertThat(copyCaptor.getValue().sourceBucket()).isEqualTo("demo-bucket");
|
||||
assertThat(copyCaptor.getValue().sourceKey()).isEqualTo("users/7/docs/old.txt");
|
||||
assertThat(copyCaptor.getValue().destinationBucket()).isEqualTo("demo-bucket");
|
||||
assertThat(copyCaptor.getValue().destinationKey()).isEqualTo("users/7/docs/new.txt");
|
||||
ArgumentCaptor<DeleteObjectRequest> deleteCaptor = ArgumentCaptor.forClass(DeleteObjectRequest.class);
|
||||
verify(s3Client).deleteObject(deleteCaptor.capture());
|
||||
assertThat(deleteCaptor.getValue().bucket()).isEqualTo("demo-bucket");
|
||||
assertThat(deleteCaptor.getValue().key()).isEqualTo("users/7/docs/old.txt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readFileFallsBackToLegacyObjectKeyWhenNeeded() {
|
||||
when(s3Client.headObject(HeadObjectRequest.builder().bucket("demo-bucket").key("users/7/docs/notes.txt").build()))
|
||||
.thenThrow(NoSuchKeyException.builder().message("missing").build());
|
||||
when(s3Client.headObject(HeadObjectRequest.builder().bucket("demo-bucket").key("7/docs/notes.txt").build()))
|
||||
.thenReturn(HeadObjectResponse.builder().build());
|
||||
when(s3Client.getObjectAsBytes(GetObjectRequest.builder().bucket("demo-bucket").key("7/docs/notes.txt").build()))
|
||||
.thenReturn(ResponseBytes.fromByteArray(GetObjectResponse.builder().build(), "hello".getBytes()));
|
||||
|
||||
byte[] content = storage.readFile(7L, "/docs", "notes.txt");
|
||||
|
||||
assertThat(content).isEqualTo("hello".getBytes());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user