添加storage

This commit is contained in:
yoyuzh
2026-04-08 21:11:18 +08:00
parent 6da0d196ee
commit 19c296a212
8 changed files with 708 additions and 0 deletions

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}