feat(files): add storage policy skeleton
This commit is contained in:
@@ -85,6 +85,7 @@ public class UploadSessionV2Controller {
|
||||
session.getFilename(),
|
||||
session.getContentType(),
|
||||
session.getSize(),
|
||||
session.getStoragePolicyId(),
|
||||
session.getStatus().name(),
|
||||
session.getChunkSize(),
|
||||
session.getChunkCount(),
|
||||
|
||||
@@ -9,6 +9,7 @@ public record UploadSessionV2Response(
|
||||
String filename,
|
||||
String contentType,
|
||||
long size,
|
||||
Long storagePolicyId,
|
||||
String status,
|
||||
long chunkSize,
|
||||
int chunkCount,
|
||||
|
||||
207
backend/src/main/java/com/yoyuzh/files/StoragePolicy.java
Normal file
207
backend/src/main/java/com/yoyuzh/files/StoragePolicy.java
Normal file
@@ -0,0 +1,207 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_storage_policy", indexes = {
|
||||
@Index(name = "idx_storage_policy_enabled", columnList = "enabled"),
|
||||
@Index(name = "idx_storage_policy_default", columnList = "default_policy")
|
||||
})
|
||||
public class StoragePolicy {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private StoragePolicyType type;
|
||||
|
||||
@Column(name = "bucket_name", length = 255)
|
||||
private String bucketName;
|
||||
|
||||
@Column(length = 512)
|
||||
private String endpoint;
|
||||
|
||||
@Column(length = 64)
|
||||
private String region;
|
||||
|
||||
@Column(name = "private_bucket", nullable = false)
|
||||
private boolean privateBucket;
|
||||
|
||||
@Column(length = 512)
|
||||
private String prefix;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "credential_mode", nullable = false, length = 32)
|
||||
private StoragePolicyCredentialMode credentialMode;
|
||||
|
||||
@Column(name = "max_size_bytes", nullable = false)
|
||||
private long maxSizeBytes;
|
||||
|
||||
@Column(name = "capabilities_json", columnDefinition = "TEXT")
|
||||
private String capabilitiesJson;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "default_policy", nullable = false)
|
||||
private boolean defaultPolicy;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (createdAt == null) {
|
||||
createdAt = now;
|
||||
}
|
||||
if (updatedAt == null) {
|
||||
updatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public StoragePolicyType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(StoragePolicyType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getBucketName() {
|
||||
return bucketName;
|
||||
}
|
||||
|
||||
public void setBucketName(String bucketName) {
|
||||
this.bucketName = bucketName;
|
||||
}
|
||||
|
||||
public String getEndpoint() {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
public String getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public void setRegion(String region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public boolean isPrivateBucket() {
|
||||
return privateBucket;
|
||||
}
|
||||
|
||||
public void setPrivateBucket(boolean privateBucket) {
|
||||
this.privateBucket = privateBucket;
|
||||
}
|
||||
|
||||
public String getPrefix() {
|
||||
return prefix;
|
||||
}
|
||||
|
||||
public void setPrefix(String prefix) {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
public StoragePolicyCredentialMode getCredentialMode() {
|
||||
return credentialMode;
|
||||
}
|
||||
|
||||
public void setCredentialMode(StoragePolicyCredentialMode credentialMode) {
|
||||
this.credentialMode = credentialMode;
|
||||
}
|
||||
|
||||
public long getMaxSizeBytes() {
|
||||
return maxSizeBytes;
|
||||
}
|
||||
|
||||
public void setMaxSizeBytes(long maxSizeBytes) {
|
||||
this.maxSizeBytes = maxSizeBytes;
|
||||
}
|
||||
|
||||
public String getCapabilitiesJson() {
|
||||
return capabilitiesJson;
|
||||
}
|
||||
|
||||
public void setCapabilitiesJson(String capabilitiesJson) {
|
||||
this.capabilitiesJson = capabilitiesJson;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isDefaultPolicy() {
|
||||
return defaultPolicy;
|
||||
}
|
||||
|
||||
public void setDefaultPolicy(boolean defaultPolicy) {
|
||||
this.defaultPolicy = defaultPolicy;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public record StoragePolicyCapabilities(
|
||||
boolean directUpload,
|
||||
boolean multipartUpload,
|
||||
boolean signedDownloadUrl,
|
||||
boolean serverProxyDownload,
|
||||
boolean thumbnailNative,
|
||||
boolean friendlyDownloadName,
|
||||
boolean requiresCors,
|
||||
boolean supportsInternalEndpoint,
|
||||
long maxObjectSize
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public enum StoragePolicyCredentialMode {
|
||||
NONE,
|
||||
STATIC,
|
||||
DOGECLOUD_TEMP
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StoragePolicyRepository extends JpaRepository<StoragePolicy, Long> {
|
||||
|
||||
Optional<StoragePolicy> findFirstByDefaultPolicyTrueOrderByIdAsc();
|
||||
}
|
||||
121
backend/src/main/java/com/yoyuzh/files/StoragePolicyService.java
Normal file
121
backend/src/main/java/com/yoyuzh/files/StoragePolicyService.java
Normal file
@@ -0,0 +1,121 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
@Order(-1)
|
||||
@RequiredArgsConstructor
|
||||
public class StoragePolicyService implements CommandLineRunner {
|
||||
|
||||
private final StoragePolicyRepository storagePolicyRepository;
|
||||
private final FileStorageProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
ensureDefaultPolicy();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public StoragePolicy ensureDefaultPolicy() {
|
||||
return storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()
|
||||
.orElseGet(() -> storagePolicyRepository.save(createDefaultPolicy()));
|
||||
}
|
||||
|
||||
public StoragePolicyCapabilities readCapabilities(StoragePolicy policy) {
|
||||
try {
|
||||
return objectMapper.readValue(policy.getCapabilitiesJson(), StoragePolicyCapabilities.class);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Storage policy capabilities are invalid", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private StoragePolicy createDefaultPolicy() {
|
||||
if ("s3".equalsIgnoreCase(properties.getProvider())) {
|
||||
return createDefaultS3Policy();
|
||||
}
|
||||
return createDefaultLocalPolicy();
|
||||
}
|
||||
|
||||
private StoragePolicy createDefaultS3Policy() {
|
||||
StoragePolicy policy = new StoragePolicy();
|
||||
policy.setName("Default S3 Compatible Storage");
|
||||
policy.setType(StoragePolicyType.S3_COMPATIBLE);
|
||||
policy.setBucketName(extractScopeBucketName(properties.getS3().getScope()));
|
||||
policy.setRegion(properties.getS3().getRegion());
|
||||
policy.setPrivateBucket(true);
|
||||
policy.setPrefix(extractScopePrefix(properties.getS3().getScope()));
|
||||
policy.setCredentialMode(StoragePolicyCredentialMode.DOGECLOUD_TEMP);
|
||||
policy.setMaxSizeBytes(properties.getMaxFileSize());
|
||||
policy.setCapabilitiesJson(writeCapabilities(new StoragePolicyCapabilities(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
properties.getMaxFileSize()
|
||||
)));
|
||||
policy.setEnabled(true);
|
||||
policy.setDefaultPolicy(true);
|
||||
return policy;
|
||||
}
|
||||
|
||||
private StoragePolicy createDefaultLocalPolicy() {
|
||||
StoragePolicy policy = new StoragePolicy();
|
||||
policy.setName("Default Local Storage");
|
||||
policy.setType(StoragePolicyType.LOCAL);
|
||||
policy.setPrivateBucket(true);
|
||||
policy.setPrefix(properties.getLocal().getRootDir());
|
||||
policy.setCredentialMode(StoragePolicyCredentialMode.NONE);
|
||||
policy.setMaxSizeBytes(properties.getMaxFileSize());
|
||||
policy.setCapabilitiesJson(writeCapabilities(new StoragePolicyCapabilities(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
properties.getMaxFileSize()
|
||||
)));
|
||||
policy.setEnabled(true);
|
||||
policy.setDefaultPolicy(true);
|
||||
return policy;
|
||||
}
|
||||
|
||||
private String writeCapabilities(StoragePolicyCapabilities capabilities) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(capabilities);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Storage policy capabilities cannot be serialized", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String extractScopeBucketName(String scope) {
|
||||
if (!StringUtils.hasText(scope)) {
|
||||
return null;
|
||||
}
|
||||
int separatorIndex = scope.indexOf(':');
|
||||
return separatorIndex >= 0 ? scope.substring(0, separatorIndex) : scope;
|
||||
}
|
||||
|
||||
private String extractScopePrefix(String scope) {
|
||||
if (!StringUtils.hasText(scope)) {
|
||||
return "";
|
||||
}
|
||||
int separatorIndex = scope.indexOf(':');
|
||||
return separatorIndex >= 0 ? scope.substring(separatorIndex + 1) : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
public enum StoragePolicyType {
|
||||
LOCAL,
|
||||
S3_COMPATIBLE
|
||||
}
|
||||
@@ -55,6 +55,9 @@ public class UploadSession {
|
||||
@Column(name = "object_key", nullable = false, length = 512)
|
||||
private String objectKey;
|
||||
|
||||
@Column(name = "storage_policy_id")
|
||||
private Long storagePolicyId;
|
||||
|
||||
@Column(name = "chunk_size", nullable = false)
|
||||
private Long chunkSize;
|
||||
|
||||
@@ -160,6 +163,14 @@ public class UploadSession {
|
||||
this.objectKey = objectKey;
|
||||
}
|
||||
|
||||
public Long getStoragePolicyId() {
|
||||
return storagePolicyId;
|
||||
}
|
||||
|
||||
public void setStoragePolicyId(Long storagePolicyId) {
|
||||
this.storagePolicyId = storagePolicyId;
|
||||
}
|
||||
|
||||
public Long getChunkSize() {
|
||||
return chunkSize;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ public class UploadSessionService {
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final StoragePolicyService storagePolicyService;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final long maxFileSize;
|
||||
private final Clock clock;
|
||||
@@ -46,20 +47,23 @@ public class UploadSessionService {
|
||||
StoredFileRepository storedFileRepository,
|
||||
FileService fileService,
|
||||
FileContentStorage fileContentStorage,
|
||||
StoragePolicyService storagePolicyService,
|
||||
FileStorageProperties properties) {
|
||||
this(uploadSessionRepository, storedFileRepository, fileService, fileContentStorage, properties, Clock.systemUTC());
|
||||
this(uploadSessionRepository, storedFileRepository, fileService, fileContentStorage, storagePolicyService, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
||||
StoredFileRepository storedFileRepository,
|
||||
FileService fileService,
|
||||
FileContentStorage fileContentStorage,
|
||||
StoragePolicyService storagePolicyService,
|
||||
FileStorageProperties properties,
|
||||
Clock clock) {
|
||||
this.uploadSessionRepository = uploadSessionRepository;
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileService = fileService;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.storagePolicyService = storagePolicyService;
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
this.clock = clock;
|
||||
}
|
||||
@@ -78,6 +82,7 @@ public class UploadSessionService {
|
||||
session.setContentType(command.contentType());
|
||||
session.setSize(command.size());
|
||||
session.setObjectKey(createBlobObjectKey());
|
||||
session.setStoragePolicyId(storagePolicyService.ensureDefaultPolicy().getId());
|
||||
session.setChunkSize(DEFAULT_CHUNK_SIZE);
|
||||
session.setChunkCount(calculateChunkCount(command.size(), DEFAULT_CHUNK_SIZE));
|
||||
session.setUploadedPartsJson("[]");
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
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.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StoragePolicyServiceTest {
|
||||
|
||||
@Mock
|
||||
private StoragePolicyRepository storagePolicyRepository;
|
||||
|
||||
private FileStorageProperties properties;
|
||||
private StoragePolicyService storagePolicyService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new FileStorageProperties();
|
||||
storagePolicyService = new StoragePolicyService(storagePolicyRepository, properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateDefaultS3CompatiblePolicyFromCurrentStorageConfig() {
|
||||
properties.setProvider("s3");
|
||||
properties.setMaxFileSize(5000L);
|
||||
properties.getS3().setScope("media-bucket:portal-prefix");
|
||||
properties.getS3().setRegion("automatic");
|
||||
when(storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()).thenReturn(Optional.empty());
|
||||
when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> {
|
||||
StoragePolicy policy = invocation.getArgument(0);
|
||||
policy.setId(1L);
|
||||
return policy;
|
||||
});
|
||||
|
||||
StoragePolicy policy = storagePolicyService.ensureDefaultPolicy();
|
||||
|
||||
assertThat(policy.getName()).isEqualTo("Default S3 Compatible Storage");
|
||||
assertThat(policy.getType()).isEqualTo(StoragePolicyType.S3_COMPATIBLE);
|
||||
assertThat(policy.getCredentialMode()).isEqualTo(StoragePolicyCredentialMode.DOGECLOUD_TEMP);
|
||||
assertThat(policy.getBucketName()).isEqualTo("media-bucket");
|
||||
assertThat(policy.getPrefix()).isEqualTo("portal-prefix");
|
||||
assertThat(policy.getRegion()).isEqualTo("automatic");
|
||||
assertThat(policy.isDefaultPolicy()).isTrue();
|
||||
assertThat(policy.isEnabled()).isTrue();
|
||||
|
||||
StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(policy);
|
||||
assertThat(capabilities.directUpload()).isTrue();
|
||||
assertThat(capabilities.multipartUpload()).isFalse();
|
||||
assertThat(capabilities.signedDownloadUrl()).isTrue();
|
||||
assertThat(capabilities.serverProxyDownload()).isTrue();
|
||||
assertThat(capabilities.requiresCors()).isTrue();
|
||||
assertThat(capabilities.maxObjectSize()).isEqualTo(5000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateDefaultLocalPolicyFromCurrentStorageConfig() {
|
||||
properties.setProvider("local");
|
||||
properties.setMaxFileSize(2048L);
|
||||
properties.getLocal().setRootDir("./storage");
|
||||
when(storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()).thenReturn(Optional.empty());
|
||||
when(storagePolicyRepository.save(any(StoragePolicy.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
StoragePolicy policy = storagePolicyService.ensureDefaultPolicy();
|
||||
|
||||
assertThat(policy.getName()).isEqualTo("Default Local Storage");
|
||||
assertThat(policy.getType()).isEqualTo(StoragePolicyType.LOCAL);
|
||||
assertThat(policy.getCredentialMode()).isEqualTo(StoragePolicyCredentialMode.NONE);
|
||||
assertThat(policy.getPrefix()).isEqualTo("./storage");
|
||||
|
||||
StoragePolicyCapabilities capabilities = storagePolicyService.readCapabilities(policy);
|
||||
assertThat(capabilities.directUpload()).isFalse();
|
||||
assertThat(capabilities.multipartUpload()).isFalse();
|
||||
assertThat(capabilities.signedDownloadUrl()).isFalse();
|
||||
assertThat(capabilities.serverProxyDownload()).isTrue();
|
||||
assertThat(capabilities.requiresCors()).isFalse();
|
||||
assertThat(capabilities.maxObjectSize()).isEqualTo(2048L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReuseExistingDefaultPolicy() {
|
||||
StoragePolicy existingPolicy = new StoragePolicy();
|
||||
existingPolicy.setId(7L);
|
||||
existingPolicy.setDefaultPolicy(true);
|
||||
existingPolicy.setEnabled(true);
|
||||
when(storagePolicyRepository.findFirstByDefaultPolicyTrueOrderByIdAsc()).thenReturn(Optional.of(existingPolicy));
|
||||
|
||||
StoragePolicy policy = storagePolicyService.ensureDefaultPolicy();
|
||||
|
||||
assertThat(policy).isSameAs(existingPolicy);
|
||||
verify(storagePolicyRepository, never()).save(any(StoragePolicy.class));
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ class UploadSessionServiceTest {
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
@Mock
|
||||
private StoragePolicyService storagePolicyService;
|
||||
|
||||
private UploadSessionService uploadSessionService;
|
||||
|
||||
@@ -49,6 +51,7 @@ class UploadSessionServiceTest {
|
||||
storedFileRepository,
|
||||
fileService,
|
||||
fileContentStorage,
|
||||
storagePolicyService,
|
||||
properties,
|
||||
Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC)
|
||||
);
|
||||
@@ -58,6 +61,7 @@ class UploadSessionServiceTest {
|
||||
void shouldCreateUploadSessionWithoutChangingLegacyUploadPath() {
|
||||
User user = createUser(7L);
|
||||
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(false);
|
||||
when(storagePolicyService.ensureDefaultPolicy()).thenReturn(createDefaultStoragePolicy());
|
||||
when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> {
|
||||
UploadSession session = invocation.getArgument(0);
|
||||
session.setId(100L);
|
||||
@@ -72,6 +76,7 @@ class UploadSessionServiceTest {
|
||||
assertThat(session.getSessionId()).isNotBlank();
|
||||
assertThat(session.getObjectKey()).startsWith("blobs/");
|
||||
assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.CREATED);
|
||||
assertThat(session.getStoragePolicyId()).isEqualTo(42L);
|
||||
assertThat(session.getChunkSize()).isEqualTo(8L * 1024 * 1024);
|
||||
assertThat(session.getChunkCount()).isEqualTo(3);
|
||||
assertThat(session.getExpiresAt()).isEqualTo(LocalDateTime.of(2026, 4, 9, 6, 0));
|
||||
@@ -214,6 +219,16 @@ class UploadSessionServiceTest {
|
||||
return user;
|
||||
}
|
||||
|
||||
private StoragePolicy createDefaultStoragePolicy() {
|
||||
StoragePolicy policy = new StoragePolicy();
|
||||
policy.setId(42L);
|
||||
policy.setName("Default S3 Compatible Storage");
|
||||
policy.setType(StoragePolicyType.S3_COMPATIBLE);
|
||||
policy.setEnabled(true);
|
||||
policy.setDefaultPolicy(true);
|
||||
return policy;
|
||||
}
|
||||
|
||||
private UploadSession createSession(User user) {
|
||||
UploadSession session = new UploadSession();
|
||||
session.setSessionId("session-1");
|
||||
|
||||
@@ -445,3 +445,4 @@
|
||||
- 2026-04-08 阶段 3 第二小步 API 补充:新增 `POST /api/v2/files/upload-sessions/{sessionId}/complete`,用于把当前用户自己的上传会话提交完成。该接口当前不接收请求体,会复用会话里的 `objectKey/path/filename/contentType/size` 调用旧上传完成落库链路,成功后返回 `COMPLETED` 状态的 v2 会话响应;分片内容上传端点仍未开放。
|
||||
- 2026-04-08 阶段 3 第三小步 API 补充:新增 `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`,请求体为 `{ "etag": "...", "size": 8388608 }`,用于记录当前用户上传会话的 part 元数据并返回 v2 会话响应。该接口会校验 part 范围和会话状态,当前只更新 `uploadedPartsJson`,不接收或合并真实文件分片内容。
|
||||
- 2026-04-08 阶段 3 第四小步 API 补充:本小步没有新增对外 API。后端新增上传会话过期清理任务,只处理未完成且已过期的会话,并把它们标记为 `EXPIRED`;已完成会话和旧 `/api/files/**` 上传接口响应不变。
|
||||
- 2026-04-08 阶段 4 第一小步 API 补充:本小步没有新增存储策略管理 API。v2 上传会话响应新增 `storagePolicyId`,用于标识该会话绑定的默认存储策略;当前该字段只服务后续 multipart/多策略迁移,旧 `/api/files/**` 上传下载接口响应不变。
|
||||
|
||||
@@ -449,3 +449,4 @@ Android 壳补充说明:
|
||||
- 2026-04-08 阶段 3 第二小步补充:上传会话新增完成状态机。`UploadSessionService.completeOwnedSession()` 会复用旧 `FileService.completeUpload()` 完成对象确认、目录补齐、配额/冲突校验和 `FileBlob + StoredFile + FileEntity.VERSION` 双写落库,然后把会话标记为 `COMPLETED`;失败时标记 `FAILED`,过期时标记 `EXPIRED`。当前仍没有独立 v2 分片内容写入端点。
|
||||
- 2026-04-08 阶段 3 第三小步补充:上传会话新增 part 状态记录。`UploadSessionService.recordUploadedPart()` 会校验会话归属、状态、过期时间和 part 范围,把 `etag/size/uploadedAt` 写入 `uploadedPartsJson`,并将新会话推进到 `UPLOADING`。当前实现是会话状态跟踪,不是跨存储驱动的分片内容写入/合并实现。
|
||||
- 2026-04-08 阶段 3 第四小步补充:上传会话新增定时过期清理。`UploadSessionService.pruneExpiredSessions()` 每小时扫描未完成且已过期的 `CREATED/UPLOADING/COMPLETING` 会话,尝试删除 `objectKey` 对应的临时 blob,然后标记为 `EXPIRED`。已完成文件不参与清理,避免误删已经落库的生产对象。
|
||||
- 2026-04-08 阶段 4 第一小步补充:后端新增存储策略骨架。`StoragePolicyService` 作为 `CommandLineRunner` 在启动时确保存在默认策略,并把当前 `FileStorageProperties` 映射为 `LOCAL` 或 `S3_COMPATIBLE` 策略及 `StoragePolicyCapabilities` JSON;当前能力声明中 `multipartUpload=false`,用于明确真实对象存储分片写入/合并还没有启用。`UploadSession.storagePolicyId` 开始记录默认策略 ID,但 `FileContentStorage` 仍保持单对象上传/校验抽象,旧 `/api/files/**` 生产路径不切换。
|
||||
|
||||
@@ -483,6 +483,7 @@ WebDAV 是很有价值但不应太早做的工程。必须等文件模型、权
|
||||
- 管理后台只读列表。
|
||||
- 文件写入策略 ID。
|
||||
- 能力矩阵展示。
|
||||
- 2026-04-08 追加评估:不建议在阶段 3 末尾直接把 v2 上传会话接入真实对象存储 multipart。当前仓库的 `FileContentStorage` 只有单对象 PUT/校验/删除抽象,`UploadSession` 还没有 `multipartUploadId` 和 abort 语义;S3 multipart 未完成分片需要显式 abort,不能只靠 `deleteBlob(objectKey)` 清理。先完成本阶段的存储策略与能力声明,把 `multipartUpload`、`directUpload`、`signedDownloadUrl`、`requiresCors` 等能力落库,再按启用策略实现 S3 multipart。
|
||||
|
||||
### 阶段 5:搜索、分享、事件流
|
||||
|
||||
@@ -550,4 +551,3 @@ WebDAV 是很有价值但不应太早做的工程。必须等文件模型、权
|
||||
- Cloudreve Storage Policy Comparison:https://docs.cloudreve.org/en/usage/storage/
|
||||
- Cloudreve Search Types:https://docs.cloudreve.org/en/usage/search/
|
||||
- Cloudreve 源码关键路径:`routers/router.go`、`pkg/filemanager/driver/handler.go`、`ent/schema/file.go`、`ent/schema/entity.go`、`ent/schema/metadata.go`、`ent/schema/policy.go`、`ent/schema/task.go`、`ent/schema/share.go`
|
||||
|
||||
|
||||
@@ -163,3 +163,5 @@
|
||||
- 2026-04-08 阶段 3 第二小步:新增 `POST /api/v2/files/upload-sessions/{sessionId}/complete`,v2 上传会话可从 `CREATED` 进入 `COMPLETING` 并复用旧 `FileService.completeUpload()` 完成 `FileBlob + StoredFile + FileEntity.VERSION` 落库,成功后标记 `COMPLETED`;取消、失败、过期会话不能完成。实际分片内容上传和前端上传队列仍未切换。
|
||||
- 2026-04-08 阶段 3 第三小步:新增 `PUT /api/v2/files/upload-sessions/{sessionId}/parts/{partIndex}`,用于记录当前用户上传会话的 part 元数据到 `uploadedPartsJson`,并把会话状态从 `CREATED` 推进到 `UPLOADING`;该接口只记录 `etag/size` 等状态,不承担真正的对象存储分片内容写入或合并。
|
||||
- 2026-04-08 阶段 3 第四小步:`UploadSessionService` 新增定时过期清理,按小时扫描 `CREATED/UPLOADING/COMPLETING` 且已过期的会话,尝试删除对应临时 `blobs/...` 对象,并把会话标记为 `EXPIRED`;`COMPLETED/CANCELLED/FAILED/EXPIRED` 不在本轮清理范围内。
|
||||
- 2026-04-08 multipart 评估结论:暂不把 v2 上传会话直接接入真实对象存储分片写入/合并。当前 `FileContentStorage` 仍是单对象上传/校验抽象,缺少 multipart uploadId、part URL 预签名、complete/abort 语义;立即接入会把上传会话写死在当前多吉云 S3 配置上,并让过期清理误以为 `deleteBlob` 能释放未完成分片。下一步先做阶段 4 存储策略与能力声明骨架,再按 `multipartUpload` 能力接 S3 multipart。
|
||||
- 2026-04-08 阶段 4 第一小步:新增 `StoragePolicy`、`StoragePolicyType`、`StoragePolicyCredentialMode`、`StoragePolicyCapabilities` 与 `StoragePolicyService`,启动时把当前 `app.storage.provider` 映射成一条默认策略;本地策略声明 `serverProxyDownload=true`、`multipartUpload=false`,多吉云/S3 兼容策略声明 `directUpload=true`、`signedDownloadUrl=true`、`requiresCors=true`、`multipartUpload=false`。新 v2 上传会话会记录默认 `storagePolicyId`,但旧上传下载路径和前端上传队列仍未切换。
|
||||
|
||||
Reference in New Issue
Block a user