diff --git a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx index 1b31c613..b6fdd5a0 100644 --- a/frontend/app/(main)/admin/files/components/storage-config-tab.tsx +++ b/frontend/app/(main)/admin/files/components/storage-config-tab.tsx @@ -149,13 +149,8 @@ export function StorageConfigTab() { const saveMutation = useMutation({ mutationFn: async (value: StorageConfig) => { - const activeDriver = query.data?.config.driver || value.driver - const valueToSave = { - ...value, - driver: activeDriver, - } await services.adminSystemConfig.updateSystemConfig(storageConfigKey, { - value: JSON.stringify(valueToSave), + value: JSON.stringify(value), }) }, onSuccess: () => { diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index ed92c7a9..6fdbc21f 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -307,19 +307,12 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon // 合并被掩码屏蔽的敏感信息,获取完整的真实配置 targetCfg := storage.MergeMaskedSecrets(newCfg, currentCfg) - - // 校验配置参数是否合法 - if err := storage.ValidateConfig(targetCfg); err != nil { - return "", fmt.Errorf("验证存储配置参数失败: %w", err) + if err := validateMergedStorageConfig(ctx, currentCfg, newCfg, targetCfg); err != nil { + return "", err } - - // 进行连通性测试验证,如果测试失败则拒绝保存 - testBackend, err := storage.NewBackend(ctx, targetCfg, targetCfg.Driver) - if err != nil { - return "", fmt.Errorf("初始化测试存储实例失败: %w", err) - } - if err := testBackend.Test(ctx); err != nil { - return "", fmt.Errorf("存储连通性测试失败: %w", err) + if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver { + // 切换存储类型时仅暂存目标后端参数,活动 driver 由迁移任务正式切换。 + targetCfg.Driver = currentCfg.Driver } // 序列化为最终保存的真实明文配置,防止保存屏蔽的 ****** 字符 @@ -330,3 +323,35 @@ func validateAndMergeStorageConfig(ctx context.Context, value string, currentCon return string(unmaskedVal), nil } + +func validateMergedStorageConfig(ctx context.Context, currentCfg, newCfg, targetCfg storage.Config) error { + if newCfg.Driver != "" && newCfg.Driver != currentCfg.Driver { + if err := validateDriverConfig(targetCfg, newCfg.Driver); err != nil { + return fmt.Errorf("验证目标存储配置参数失败: %w", err) + } + pendingCfg := targetCfg + pendingCfg.Driver = newCfg.Driver + return testStorageBackend(ctx, pendingCfg, newCfg.Driver) + } + + if err := storage.ValidateConfig(targetCfg); err != nil { + return fmt.Errorf("验证存储配置参数失败: %w", err) + } + return testStorageBackend(ctx, targetCfg, targetCfg.Driver) +} + +func validateDriverConfig(cfg storage.Config, driver storage.Driver) error { + cfg.Driver = driver + return storage.ValidateConfig(cfg) +} + +func testStorageBackend(ctx context.Context, cfg storage.Config, driver storage.Driver) error { + testBackend, err := storage.NewBackend(ctx, cfg, driver) + if err != nil { + return fmt.Errorf("初始化测试存储实例失败: %w", err) + } + if err := testBackend.Test(ctx); err != nil { + return fmt.Errorf("存储连通性测试失败: %w", err) + } + return nil +} diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index fd423736..b6e9270c 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -440,4 +440,57 @@ func TestUpdateStorageConfigValidation(t *testing.T) { t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String()) } }) + + t.Run("stage local config while active s3 is unreachable", func(t *testing.T) { + activeCfg := storage.DefaultConfig() + activeCfg.Driver = storage.DriverS3 + activeCfg.S3.Endpoint = "http://127.0.0.1:9999" + activeCfg.S3.Region = "us-east-1" + activeCfg.S3.Bucket = "wavelet" + activeCfg.S3.AccessKeyID = "test" + activeCfg.S3.SecretAccessKey = "test" + activeBytes, _ := json.Marshal(activeCfg) + seedCfg := model.SystemConfig{ + Key: "storage_config", + Value: string(activeBytes), + Type: "system", + } + if err := dbConn.Where("key = ?", "storage_config"). + Assign(map[string]any{"value": seedCfg.Value, "type": seedCfg.Type}). + FirstOrCreate(&seedCfg).Error; err != nil { + t.Fatalf("seed active storage config failed: %v", err) + } + + tempDir := t.TempDir() + stagedCfg := activeCfg + stagedCfg.Driver = storage.DriverLocal + stagedCfg.Local.Root = tempDir + + cfgBytes, _ := json.Marshal(stagedCfg) + payload := UpdateSystemConfigRequest{Value: string(cfgBytes)} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/storage_config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + var dbCfg model.SystemConfig + if err := dbConn.Where("key = ?", "storage_config").First(&dbCfg).Error; err != nil { + t.Fatalf("load saved storage config failed: %v", err) + } + var savedCfg storage.Config + if err := json.Unmarshal([]byte(dbCfg.Value), &savedCfg); err != nil { + t.Fatalf("parse saved storage config failed: %v", err) + } + if savedCfg.Driver != storage.DriverS3 { + t.Fatalf("active driver = %q, want %q until migration completes", savedCfg.Driver, storage.DriverS3) + } + if savedCfg.Local.Root != tempDir { + t.Fatalf("staged local root = %q, want %q", savedCfg.Local.Root, tempDir) + } + }) }