feat(files): add storage policy skeleton

This commit is contained in:
yoyuzh
2026-04-08 15:37:43 +08:00
parent f582e600aa
commit 6da0d196ee
16 changed files with 507 additions and 2 deletions

View File

@@ -85,6 +85,7 @@ public class UploadSessionV2Controller {
session.getFilename(),
session.getContentType(),
session.getSize(),
session.getStoragePolicyId(),
session.getStatus().name(),
session.getChunkSize(),
session.getChunkCount(),

View File

@@ -9,6 +9,7 @@ public record UploadSessionV2Response(
String filename,
String contentType,
long size,
Long storagePolicyId,
String status,
long chunkSize,
int chunkCount,

View 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;
}
}

View File

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

View File

@@ -0,0 +1,7 @@
package com.yoyuzh.files;
public enum StoragePolicyCredentialMode {
NONE,
STATIC,
DOGECLOUD_TEMP
}

View File

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

View 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) : "";
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files;
public enum StoragePolicyType {
LOCAL,
S3_COMPATIBLE
}

View File

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

View File

@@ -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("[]");

View File

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

View File

@@ -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");

View File

@@ -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/**` 上传下载接口响应不变。

View File

@@ -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/**` 生产路径不切换。

View File

@@ -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 Comparisonhttps://docs.cloudreve.org/en/usage/storage/
- Cloudreve Search Typeshttps://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`

View File

@@ -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`,但旧上传下载路径和前端上传队列仍未切换。