feat(api): add v2 phase one skeleton
This commit is contained in:
24
backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java
Normal file
24
backend/src/main/java/com/yoyuzh/api/v2/ApiV2ErrorCode.java
Normal 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;
|
||||
}
|
||||
}
|
||||
15
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Exception.java
Normal file
15
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Exception.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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, "服务器内部错误"));
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Response.java
Normal file
12
backend/src/main/java/com/yoyuzh/api/v2/ApiV2Response.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.api.v2.site;
|
||||
|
||||
public record SiteV2PingResponse(String status, String apiVersion) {
|
||||
}
|
||||
@@ -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/*")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user