feat(api): add v2 phase one skeleton

This commit is contained in:
yoyuzh
2026-04-08 14:28:01 +08:00
parent 3afebbb338
commit 9d5fdd9ea3
20 changed files with 1585 additions and 2 deletions

View File

@@ -0,0 +1,24 @@
package com.yoyuzh.api.v2;
import org.springframework.http.HttpStatus;
public enum ApiV2ErrorCode {
FILE_NOT_FOUND(2404, HttpStatus.NOT_FOUND),
INTERNAL_ERROR(2500, HttpStatus.INTERNAL_SERVER_ERROR);
private final int code;
private final HttpStatus httpStatus;
ApiV2ErrorCode(int code, HttpStatus httpStatus) {
this.code = code;
this.httpStatus = httpStatus;
}
public int getCode() {
return code;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}

View File

@@ -0,0 +1,15 @@
package com.yoyuzh.api.v2;
public class ApiV2Exception extends RuntimeException {
private final ApiV2ErrorCode errorCode;
public ApiV2Exception(ApiV2ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public ApiV2ErrorCode getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,24 @@
package com.yoyuzh.api.v2;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackages = "com.yoyuzh.api.v2")
public class ApiV2ExceptionHandler {
@ExceptionHandler(ApiV2Exception.class)
public ResponseEntity<ApiV2Response<Void>> handleApiV2Exception(ApiV2Exception ex) {
ApiV2ErrorCode errorCode = ex.getErrorCode();
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiV2Response.error(errorCode, ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiV2Response<Void>> handleUnknownException(Exception ex) {
return ResponseEntity
.status(ApiV2ErrorCode.INTERNAL_ERROR.getHttpStatus())
.body(ApiV2Response.error(ApiV2ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.api.v2;
public record ApiV2Response<T>(int code, String msg, T data) {
public static <T> ApiV2Response<T> success(T data) {
return new ApiV2Response<>(0, "success", data);
}
public static ApiV2Response<Void> error(ApiV2ErrorCode errorCode, String msg) {
return new ApiV2Response<>(errorCode.getCode(), msg, null);
}
}

View File

@@ -0,0 +1,16 @@
package com.yoyuzh.api.v2.site;
import com.yoyuzh.api.v2.ApiV2Response;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v2/site")
public class SiteV2Controller {
@GetMapping("/ping")
public ApiV2Response<SiteV2PingResponse> ping() {
return ApiV2Response.success(new SiteV2PingResponse("ok", "v2"));
}
}

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.api.v2.site;
public record SiteV2PingResponse(String status, String apiVersion) {
}

View File

@@ -52,6 +52,8 @@ public class SecurityConfig {
.permitAll()
.requestMatchers("/api/app/android/latest", "/api/app/android/download", "/api/app/android/download/*")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/v2/site/ping")
.permitAll()
.requestMatchers("/api/transfer/**")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/files/share-links/*")

View File

@@ -0,0 +1,68 @@
package com.yoyuzh.files.storage;
import org.springframework.web.multipart.MultipartFile;
public interface FileContentStorage {
PreparedUpload prepareUpload(Long userId, String path, String storageName, String contentType, long size);
void upload(Long userId, String path, String storageName, MultipartFile file);
void completeUpload(Long userId, String path, String storageName, String contentType, long size);
byte[] readFile(Long userId, String path, String storageName);
void deleteFile(Long userId, String path, String storageName);
String createDownloadUrl(Long userId, String path, String storageName, String filename);
default void renameFile(Long userId, String path, String oldStorageName, String newStorageName) {
throw new UnsupportedOperationException("File content rename is not supported by this storage");
}
default void renameDirectory(Long userId, String oldPath, String oldStorageName, String newStorageName) {
throw new UnsupportedOperationException("Directory content rename is not supported by this storage");
}
default void moveFile(Long userId, String oldPath, String storageName, String newPath) {
throw new UnsupportedOperationException("File content move is not supported by this storage");
}
default void copyFile(Long userId, String path, String storageName, String targetPath) {
throw new UnsupportedOperationException("File content copy is not supported by this storage");
}
default void storeImportedFile(Long userId, String path, String storageName, String contentType, byte[] content) {
throw new UnsupportedOperationException("Imported file storage is not supported by this storage");
}
PreparedUpload prepareBlobUpload(String path, String filename, String objectKey, String contentType, long size);
void uploadBlob(String objectKey, MultipartFile file);
void completeBlobUpload(String objectKey, String contentType, long size);
void storeBlob(String objectKey, String contentType, byte[] content);
byte[] readBlob(String objectKey);
void deleteBlob(String objectKey);
String createBlobDownloadUrl(String objectKey, String filename);
void createDirectory(Long userId, String logicalPath);
void ensureDirectory(Long userId, String logicalPath);
void storeTransferFile(String sessionId, String storageName, String contentType, byte[] content);
byte[] readTransferFile(String sessionId, String storageName);
void deleteTransferFile(String sessionId, String storageName);
String createTransferDownloadUrl(String sessionId, String storageName, String filename);
boolean supportsDirectDownload();
String resolveLegacyFileObjectKey(Long userId, String path, String storageName);
}

View File

@@ -0,0 +1,230 @@
package com.yoyuzh.files.storage;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Map;
public class LocalFileContentStorage implements FileContentStorage {
private final Path rootPath;
public LocalFileContentStorage(FileStorageProperties properties) {
this.rootPath = Path.of(properties.getLocal().getRootDir()).toAbsolutePath().normalize();
try {
Files.createDirectories(rootPath);
} catch (IOException ex) {
throw new IllegalStateException("Failed to initialize local storage root", ex);
}
}
@Override
public PreparedUpload prepareUpload(Long userId, String path, String storageName, String contentType, long size) {
return new PreparedUpload(false, "", "POST", Map.of(), storageName);
}
@Override
public void upload(Long userId, String path, String storageName, MultipartFile file) {
write(resolveLegacyPath(userId, path, storageName), file);
}
@Override
public void completeUpload(Long userId, String path, String storageName, String contentType, long size) {
ensureReadable(resolveLegacyPath(userId, path, storageName));
}
@Override
public byte[] readFile(Long userId, String path, String storageName) {
return read(resolveLegacyPath(userId, path, storageName));
}
@Override
public void deleteFile(Long userId, String path, String storageName) {
delete(resolveLegacyPath(userId, path, storageName));
}
@Override
public String createDownloadUrl(Long userId, String path, String storageName, String filename) {
throw new UnsupportedOperationException("Local storage does not support direct download URLs");
}
@Override
public PreparedUpload prepareBlobUpload(String path, String filename, String objectKey, String contentType, long size) {
return new PreparedUpload(false, "", "POST", Map.of(), objectKey);
}
@Override
public void uploadBlob(String objectKey, MultipartFile file) {
write(resolveObjectKey(objectKey), file);
}
@Override
public void completeBlobUpload(String objectKey, String contentType, long size) {
ensureReadable(resolveObjectKey(objectKey));
}
@Override
public void storeBlob(String objectKey, String contentType, byte[] content) {
write(resolveObjectKey(objectKey), content);
}
@Override
public byte[] readBlob(String objectKey) {
return read(resolveObjectKey(objectKey));
}
@Override
public void deleteBlob(String objectKey) {
delete(resolveObjectKey(objectKey));
}
@Override
public String createBlobDownloadUrl(String objectKey, String filename) {
throw new UnsupportedOperationException("Local storage does not support direct download URLs");
}
@Override
public void createDirectory(Long userId, String logicalPath) {
ensureDirectory(userId, logicalPath);
}
@Override
public void ensureDirectory(Long userId, String logicalPath) {
createDirectories(resolveUserDirectory(userId, logicalPath));
}
@Override
public void storeTransferFile(String sessionId, String storageName, String contentType, byte[] content) {
write(resolveTransferPath(sessionId, storageName), content);
}
@Override
public byte[] readTransferFile(String sessionId, String storageName) {
return read(resolveTransferPath(sessionId, storageName));
}
@Override
public void deleteTransferFile(String sessionId, String storageName) {
delete(resolveTransferPath(sessionId, storageName));
}
@Override
public String createTransferDownloadUrl(String sessionId, String storageName, String filename) {
throw new UnsupportedOperationException("Local storage does not support direct download URLs");
}
@Override
public boolean supportsDirectDownload() {
return false;
}
@Override
public String resolveLegacyFileObjectKey(Long userId, String path, String storageName) {
return "users/" + userId + "/" + normalizeRelativePath(path) + "/" + normalizeName(storageName);
}
private Path resolveLegacyPath(Long userId, String path, String storageName) {
return resolveObjectKey(resolveLegacyFileObjectKey(userId, path, storageName));
}
private Path resolveTransferPath(String sessionId, String storageName) {
return resolveObjectKey("transfers/" + normalizeName(sessionId) + "/" + normalizeName(storageName));
}
private Path resolveUserDirectory(Long userId, String logicalPath) {
return resolveObjectKey("users/" + userId + "/" + normalizeRelativePath(logicalPath));
}
private Path resolveObjectKey(String objectKey) {
Path resolved = rootPath.resolve(normalizeObjectKey(objectKey)).normalize();
if (!resolved.startsWith(rootPath)) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage path");
}
return resolved;
}
private String normalizeObjectKey(String objectKey) {
String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage object key");
}
return cleaned;
}
private String normalizeRelativePath(String path) {
String cleaned = StringUtils.cleanPath(path == null ? "" : path).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || "/".equals(cleaned)) {
return "";
}
if (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
if (cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage path");
}
return cleaned;
}
private String normalizeName(String name) {
String cleaned = StringUtils.cleanPath(name == null ? "" : name).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage filename");
}
return cleaned;
}
private void write(Path target, MultipartFile file) {
try {
createDirectories(target.getParent());
file.transferTo(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
private void write(Path target, byte[] content) {
try {
createDirectories(target.getParent());
Files.write(target, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
private byte[] read(Path target) {
try {
return Files.readAllBytes(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
}
}
private void delete(Path target) {
try {
Files.deleteIfExists(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File delete failed");
}
}
private void ensureReadable(Path target) {
if (!Files.isRegularFile(target)) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
}
}
private void createDirectories(Path path) {
try {
Files.createDirectories(path);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "Directory create failed");
}
}
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.files.storage;
import java.util.Map;
public record PreparedUpload(
boolean direct,
String uploadUrl,
String method,
Map<String, String> headers,
String storageName
) {
}

View File

@@ -0,0 +1,437 @@
package com.yoyuzh.files.storage;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.SdkHttpMethod;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
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.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class S3FileContentStorage implements FileContentStorage {
private static final String DOGECLOUD_TMP_TOKEN_PATH = "/auth/tmp_token.json";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final FileStorageProperties.S3 properties;
private final HttpClient httpClient = HttpClient.newHttpClient();
private TemporaryS3Session cachedSession;
public S3FileContentStorage(FileStorageProperties storageProperties) {
this.properties = storageProperties.getS3();
}
@Override
public PreparedUpload prepareUpload(Long userId, String path, String storageName, String contentType, long size) {
return prepareBlobUpload(path, storageName, resolveLegacyFileObjectKey(userId, path, storageName), contentType, size);
}
@Override
public void upload(Long userId, String path, String storageName, MultipartFile file) {
uploadBlob(resolveLegacyFileObjectKey(userId, path, storageName), file);
}
@Override
public void completeUpload(Long userId, String path, String storageName, String contentType, long size) {
completeBlobUpload(resolveLegacyFileObjectKey(userId, path, storageName), contentType, size);
}
@Override
public byte[] readFile(Long userId, String path, String storageName) {
return readBlob(resolveLegacyFileObjectKey(userId, path, storageName));
}
@Override
public void deleteFile(Long userId, String path, String storageName) {
deleteBlob(resolveLegacyFileObjectKey(userId, path, storageName));
}
@Override
public String createDownloadUrl(Long userId, String path, String storageName, String filename) {
return createBlobDownloadUrl(resolveLegacyFileObjectKey(userId, path, storageName), filename);
}
@Override
public PreparedUpload prepareBlobUpload(String path, String filename, String objectKey, String contentType, long size) {
PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
.bucket(getSession().bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(contentType)) {
requestBuilder.contentType(contentType);
}
try (S3Presigner presigner = createPresigner()) {
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(Math.max(1, properties.getTtlSeconds())))
.putObjectRequest(requestBuilder.build())
.build();
PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest);
return new PreparedUpload(
true,
presignedRequest.url().toString(),
presignedRequest.httpRequest().method() == SdkHttpMethod.PUT ? "PUT" : "POST",
flattenSignedHeaders(presignedRequest.signedHeaders()),
objectKey
);
}
}
@Override
public void uploadBlob(String objectKey, MultipartFile file) {
try {
putObject(objectKey, file.getContentType(), file.getBytes());
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
@Override
public void completeBlobUpload(String objectKey, String contentType, long size) {
try (S3Client s3Client = createClient()) {
s3Client.headObject(HeadObjectRequest.builder()
.bucket(getSession().bucket())
.key(normalizeObjectKey(objectKey))
.build());
} catch (NoSuchKeyException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File content verification failed");
}
}
@Override
public void storeBlob(String objectKey, String contentType, byte[] content) {
putObject(objectKey, contentType, content);
}
@Override
public byte[] readBlob(String objectKey) {
try (S3Client s3Client = createClient()) {
ResponseBytes<?> response = s3Client.getObjectAsBytes(GetObjectRequest.builder()
.bucket(getSession().bucket())
.key(normalizeObjectKey(objectKey))
.build());
return response.asByteArray();
} catch (NoSuchKeyException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "File content does not exist");
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File read failed");
}
}
@Override
public void deleteBlob(String objectKey) {
try (S3Client s3Client = createClient()) {
s3Client.deleteObject(DeleteObjectRequest.builder()
.bucket(getSession().bucket())
.key(normalizeObjectKey(objectKey))
.build());
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File delete failed");
}
}
@Override
public String createBlobDownloadUrl(String objectKey, String filename) {
GetObjectRequest.Builder requestBuilder = GetObjectRequest.builder()
.bucket(getSession().bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(filename)) {
requestBuilder.responseContentDisposition(
"attachment; filename*=UTF-8''" + URLEncoder.encode(filename, StandardCharsets.UTF_8)
);
}
try (S3Presigner presigner = createPresigner()) {
GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofSeconds(Math.max(1, properties.getTtlSeconds())))
.getObjectRequest(requestBuilder.build())
.build();
PresignedGetObjectRequest presignedRequest = presigner.presignGetObject(presignRequest);
return presignedRequest.url().toString();
}
}
@Override
public void createDirectory(Long userId, String logicalPath) {
}
@Override
public void ensureDirectory(Long userId, String logicalPath) {
}
@Override
public void storeTransferFile(String sessionId, String storageName, String contentType, byte[] content) {
putObject(resolveTransferObjectKey(sessionId, storageName), contentType, content);
}
@Override
public byte[] readTransferFile(String sessionId, String storageName) {
return readBlob(resolveTransferObjectKey(sessionId, storageName));
}
@Override
public void deleteTransferFile(String sessionId, String storageName) {
deleteBlob(resolveTransferObjectKey(sessionId, storageName));
}
@Override
public String createTransferDownloadUrl(String sessionId, String storageName, String filename) {
return createBlobDownloadUrl(resolveTransferObjectKey(sessionId, storageName), filename);
}
@Override
public boolean supportsDirectDownload() {
return true;
}
@Override
public String resolveLegacyFileObjectKey(Long userId, String path, String storageName) {
return "users/" + userId + "/" + normalizeRelativePath(path) + "/" + normalizeName(storageName);
}
private void putObject(String objectKey, String contentType, byte[] content) {
PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
.bucket(getSession().bucket())
.key(normalizeObjectKey(objectKey));
if (StringUtils.hasText(contentType)) {
requestBuilder.contentType(contentType);
}
try (S3Client s3Client = createClient()) {
s3Client.putObject(requestBuilder.build(), RequestBody.fromBytes(content));
} catch (S3Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "File write failed");
}
}
private String resolveTransferObjectKey(String sessionId, String storageName) {
return "transfers/" + normalizeName(sessionId) + "/" + normalizeName(storageName);
}
private S3Client createClient() {
TemporaryS3Session session = getSession();
return S3Client.builder()
.endpointOverride(session.endpointUri())
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(session.credentials()))
.build();
}
private S3Presigner createPresigner() {
TemporaryS3Session session = getSession();
return S3Presigner.builder()
.endpointOverride(session.endpointUri())
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(session.credentials()))
.build();
}
private synchronized TemporaryS3Session getSession() {
if (cachedSession != null && cachedSession.expiresAt().isAfter(Instant.now().plusSeconds(60))) {
return cachedSession;
}
cachedSession = requestTemporaryS3Session();
return cachedSession;
}
private TemporaryS3Session requestTemporaryS3Session() {
requireText(properties.getApiAccessKey(), "Missing DogeCloud API access key");
requireText(properties.getApiSecretKey(), "Missing DogeCloud API secret key");
requireText(properties.getScope(), "Missing DogeCloud storage scope");
String body = "{\"channel\":\"OSS_FULL\",\"ttl\":" + Math.max(1, properties.getTtlSeconds())
+ ",\"scopes\":[\"" + escapeJson(properties.getScope()) + "\"]}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(trimTrailingSlash(properties.getApiBaseUrl()) + DOGECLOUD_TMP_TOKEN_PATH))
.header("Content-Type", "application/json")
.header("Authorization", createDogeCloudApiAuthorization(body))
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new BusinessException(ErrorCode.UNKNOWN, "DogeCloud temporary credential request failed");
}
JsonNode payload = OBJECT_MAPPER.readTree(response.body());
if (payload.path("code").asInt() != 200) {
throw new BusinessException(ErrorCode.UNKNOWN, "DogeCloud temporary credential request failed");
}
JsonNode data = payload.path("data");
JsonNode credentials = data.path("Credentials");
JsonNode bucket = selectBucket(data.path("Buckets"), extractScopeBucketName(properties.getScope()));
Instant expiresAt = data.hasNonNull("ExpiredAt")
? Instant.ofEpochSecond(data.path("ExpiredAt").asLong())
: Instant.now().plusSeconds(Math.max(1, properties.getTtlSeconds()));
return new TemporaryS3Session(
requireText(credentials.path("accessKeyId").asText(null), "Missing DogeCloud temporary access key"),
requireText(credentials.path("secretAccessKey").asText(null), "Missing DogeCloud temporary secret key"),
requireText(credentials.path("sessionToken").asText(null), "Missing DogeCloud temporary session token"),
requireText(bucket.path("s3Bucket").asText(null), "Missing DogeCloud S3 bucket"),
toEndpointUri(requireText(bucket.path("s3Endpoint").asText(null), "Missing DogeCloud S3 endpoint")),
expiresAt
);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "DogeCloud temporary credential response is invalid");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new BusinessException(ErrorCode.UNKNOWN, "DogeCloud temporary credential request interrupted");
}
}
private JsonNode selectBucket(JsonNode buckets, String bucketName) {
if (!buckets.isArray() || buckets.isEmpty()) {
throw new BusinessException(ErrorCode.UNKNOWN, "DogeCloud temporary credential response has no bucket");
}
Iterator<JsonNode> iterator = buckets.elements();
JsonNode first = buckets.get(0);
while (iterator.hasNext()) {
JsonNode bucket = iterator.next();
if (bucketName.equals(bucket.path("name").asText())) {
return bucket;
}
}
return first;
}
private Map<String, String> flattenSignedHeaders(Map<String, java.util.List<String>> headers) {
Map<String, String> flattened = new HashMap<>();
headers.forEach((key, values) -> {
if (!values.isEmpty()) {
flattened.put(key, String.join(",", values));
}
});
return flattened;
}
private String createDogeCloudApiAuthorization(String body) {
return "TOKEN " + properties.getApiAccessKey() + ":" + hmacSha1Hex(
properties.getApiSecretKey(),
DOGECLOUD_TMP_TOKEN_PATH + "\n" + body
);
}
private String hmacSha1Hex(String secret, String value) {
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"));
byte[] digest = mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(digest.length * 2);
for (byte item : digest) {
result.append(String.format("%02x", item));
}
return result.toString();
} catch (Exception ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "DogeCloud authorization signing failed");
}
}
private String normalizeObjectKey(String objectKey) {
String cleaned = StringUtils.cleanPath(objectKey == null ? "" : objectKey).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage object key");
}
return cleaned;
}
private String normalizeRelativePath(String path) {
String cleaned = StringUtils.cleanPath(path == null ? "" : path).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || "/".equals(cleaned)) {
return "";
}
if (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
if (cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage path");
}
return cleaned;
}
private String normalizeName(String name) {
String cleaned = StringUtils.cleanPath(name == null ? "" : name).replace("\\", "/");
if (!StringUtils.hasText(cleaned) || cleaned.startsWith("/") || cleaned.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "Invalid storage filename");
}
return cleaned;
}
private String extractScopeBucketName(String scope) {
int separatorIndex = scope.indexOf(':');
return separatorIndex >= 0 ? scope.substring(0, separatorIndex) : scope;
}
private URI toEndpointUri(String endpoint) {
return URI.create(endpoint.startsWith("http://") || endpoint.startsWith("https://")
? endpoint
: "https://" + endpoint);
}
private String trimTrailingSlash(String value) {
return value.replaceAll("/+$", "");
}
private String escapeJson(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private String requireText(String value, String message) {
if (!StringUtils.hasText(value)) {
throw new BusinessException(ErrorCode.UNKNOWN, message);
}
return value;
}
private record TemporaryS3Session(
String accessKeyId,
String secretAccessKey,
String sessionToken,
String bucket,
URI endpointUri,
Instant expiresAt
) {
AwsSessionCredentials credentials() {
return AwsSessionCredentials.create(accessKeyId, secretAccessKey, sessionToken);
}
}
}

View File

@@ -0,0 +1,36 @@
package com.yoyuzh.api.v2;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class ApiV2ExceptionHandlerTest {
private final ApiV2ExceptionHandler handler = new ApiV2ExceptionHandler();
@Test
void shouldMapV2BusinessErrorsToV2ResponseEnvelope() {
ResponseEntity<ApiV2Response<Void>> response = handler.handleApiV2Exception(
new ApiV2Exception(ApiV2ErrorCode.FILE_NOT_FOUND, "文件不存在")
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ApiV2ErrorCode.FILE_NOT_FOUND.getCode());
assertThat(response.getBody().msg()).isEqualTo("文件不存在");
assertThat(response.getBody().data()).isNull();
}
@Test
void shouldKeepUnknownV2ErrorsInsideTheV2ErrorCodeRange() {
ResponseEntity<ApiV2Response<Void>> response = handler.handleUnknownException(new RuntimeException("boom"));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(ApiV2ErrorCode.INTERNAL_ERROR.getCode());
assertThat(response.getBody().msg()).isEqualTo("服务器内部错误");
assertThat(response.getBody().data()).isNull();
}
}

View File

@@ -0,0 +1,30 @@
package com.yoyuzh.api.v2.site;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class SiteV2ControllerTest {
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(new SiteV2Controller()).build();
}
@Test
void shouldExposeV2PingWithV2ResponseEnvelope() throws Exception {
mockMvc.perform(get("/api/v2/site/ping"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.msg").value("success"))
.andExpect(jsonPath("$.data.status").value("ok"))
.andExpect(jsonPath("$.data.apiVersion").value("v2"));
}
}