添加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,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) {
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.files.storage;
@FunctionalInterface
interface S3SessionProvider extends AutoCloseable {
S3FileRuntimeSession currentSession();
@Override
default void close() {
}
}