diff --git a/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudS3SessionProvider.java b/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudS3SessionProvider.java new file mode 100644 index 0000000..8dc81b2 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudS3SessionProvider.java @@ -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 sessionSupplier; + private final Clock clock; + private final Function 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 sessionSupplier, + Clock clock, + Function 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) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudTemporaryS3Session.java b/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudTemporaryS3Session.java new file mode 100644 index 0000000..999d4f6 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudTemporaryS3Session.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudTmpTokenClient.java b/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudTmpTokenClient.java new file mode 100644 index 0000000..e64170b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/storage/DogeCloudTmpTokenClient.java @@ -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 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 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 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 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 headers) throws IOException, InterruptedException { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(baseUrl + apiPath)) + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.header(entry.getKey(), entry.getValue()); + } + HttpResponse response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return new TransportResponse(response.statusCode(), response.body()); + } + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/storage/S3FileRuntimeSession.java b/backend/src/main/java/com/yoyuzh/files/storage/S3FileRuntimeSession.java new file mode 100644 index 0000000..e59f016 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/storage/S3FileRuntimeSession.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/storage/S3SessionProvider.java b/backend/src/main/java/com/yoyuzh/files/storage/S3SessionProvider.java new file mode 100644 index 0000000..374c5c6 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/storage/S3SessionProvider.java @@ -0,0 +1,11 @@ +package com.yoyuzh.files.storage; + +@FunctionalInterface +interface S3SessionProvider extends AutoCloseable { + + S3FileRuntimeSession currentSession(); + + @Override + default void close() { + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java b/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java new file mode 100644 index 0000000..3c44948 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudS3SessionProviderTest.java @@ -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; + } + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudTmpTokenClientTest.java b/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudTmpTokenClientTest.java new file mode 100644 index 0000000..baf69a5 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/storage/DogeCloudTmpTokenClientTest.java @@ -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 headers; + + private CapturingTransport(String responseBody) { + this.responseBody = responseBody; + } + + @Override + public DogeCloudTmpTokenClient.TransportResponse post(String baseUrl, String nextApiPath, String nextBody, Map nextHeaders) { + this.apiPath = nextApiPath; + this.body = nextBody; + this.headers = nextHeaders; + return new DogeCloudTmpTokenClient.TransportResponse(200, responseBody); + } + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/storage/S3FileContentStorageTest.java b/backend/src/test/java/com/yoyuzh/files/storage/S3FileContentStorageTest.java new file mode 100644 index 0000000..d828a7d --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/storage/S3FileContentStorageTest.java @@ -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 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 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 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 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 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 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()); + } +}