diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java index a2a9696..b7c4898 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Controller.java @@ -85,6 +85,7 @@ public class UploadSessionV2Controller { session.getFilename(), session.getContentType(), session.getSize(), + session.getStoragePolicyId(), session.getStatus().name(), session.getChunkSize(), session.getChunkCount(), diff --git a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java index da79b6b..1200175 100644 --- a/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java +++ b/backend/src/main/java/com/yoyuzh/api/v2/files/UploadSessionV2Response.java @@ -9,6 +9,7 @@ public record UploadSessionV2Response( String filename, String contentType, long size, + Long storagePolicyId, String status, long chunkSize, int chunkCount, diff --git a/backend/src/main/java/com/yoyuzh/files/StoragePolicy.java b/backend/src/main/java/com/yoyuzh/files/StoragePolicy.java new file mode 100644 index 0000000..eca58da --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/StoragePolicy.java @@ -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; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoragePolicyCapabilities.java b/backend/src/main/java/com/yoyuzh/files/StoragePolicyCapabilities.java new file mode 100644 index 0000000..82e3251 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/StoragePolicyCapabilities.java @@ -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 +) { +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoragePolicyCredentialMode.java b/backend/src/main/java/com/yoyuzh/files/StoragePolicyCredentialMode.java new file mode 100644 index 0000000..38d32a9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/StoragePolicyCredentialMode.java @@ -0,0 +1,7 @@ +package com.yoyuzh.files; + +public enum StoragePolicyCredentialMode { + NONE, + STATIC, + DOGECLOUD_TEMP +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoragePolicyRepository.java b/backend/src/main/java/com/yoyuzh/files/StoragePolicyRepository.java new file mode 100644 index 0000000..7d395cd --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/StoragePolicyRepository.java @@ -0,0 +1,10 @@ +package com.yoyuzh.files; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StoragePolicyRepository extends JpaRepository { + + Optional findFirstByDefaultPolicyTrueOrderByIdAsc(); +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoragePolicyService.java b/backend/src/main/java/com/yoyuzh/files/StoragePolicyService.java new file mode 100644 index 0000000..ef846f9 --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/StoragePolicyService.java @@ -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) : ""; + } +} diff --git a/backend/src/main/java/com/yoyuzh/files/StoragePolicyType.java b/backend/src/main/java/com/yoyuzh/files/StoragePolicyType.java new file mode 100644 index 0000000..869180b --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/files/StoragePolicyType.java @@ -0,0 +1,6 @@ +package com.yoyuzh.files; + +public enum StoragePolicyType { + LOCAL, + S3_COMPATIBLE +} diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSession.java b/backend/src/main/java/com/yoyuzh/files/UploadSession.java index 55c0f40..47214ab 100644 --- a/backend/src/main/java/com/yoyuzh/files/UploadSession.java +++ b/backend/src/main/java/com/yoyuzh/files/UploadSession.java @@ -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; } diff --git a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java index 3e1d4b3..560335f 100644 --- a/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java +++ b/backend/src/main/java/com/yoyuzh/files/UploadSessionService.java @@ -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("[]"); diff --git a/backend/src/test/java/com/yoyuzh/files/StoragePolicyServiceTest.java b/backend/src/test/java/com/yoyuzh/files/StoragePolicyServiceTest.java new file mode 100644 index 0000000..dfb17eb --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/files/StoragePolicyServiceTest.java @@ -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)); + } +} diff --git a/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java index e35567b..0a0c954 100644 --- a/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/UploadSessionServiceTest.java @@ -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"); diff --git a/docs/api-reference.md b/docs/api-reference.md index c9fc685..6867c38 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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/**` 上传下载接口响应不变。 diff --git a/docs/architecture.md b/docs/architecture.md index 5598b94..021601f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/**` 生产路径不切换。 diff --git a/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md b/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md index 001f919..9f30c34 100644 --- a/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md +++ b/docs/superpowers/plans/2026-04-08-cloudreve-inspired-upgrade-and-refactor.md @@ -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` - diff --git a/memory.md b/memory.md index e728326..1cbeb64 100644 --- a/memory.md +++ b/memory.md @@ -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`,但旧上传下载路径和前端上传队列仍未切换。