diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f2861fb..d620201b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,6 +65,11 @@ import { resolveConnectionPackageExportResult, normalizeConnectionPackagePassword, } from './utils/connectionExport'; +import { + mergeRedisDbAliases, + sanitizeRedisDbAliases, + type RedisDbAliasMap, +} from './utils/redisDbAlias'; import { bootstrapSecureConfig, finalizeSecurityUpdateStatus, @@ -400,6 +405,32 @@ const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnec return Array.from(merged.values()); }; +type ConnectionPackageImportPayload = { + connections: SavedConnection[]; + redisDbAliases: RedisDbAliasMap; +}; + +/** Normalize ImportConnectionsPayload results: object (new) or bare array (legacy/mock). */ +const normalizeConnectionPackageImportPayload = (value: unknown): ConnectionPackageImportPayload | null => { + if (Array.isArray(value)) { + return { + connections: value as SavedConnection[], + redisDbAliases: {}, + }; + } + if (!value || typeof value !== 'object') { + return null; + } + const record = value as { connections?: unknown; redisDbAliases?: unknown }; + if (!Array.isArray(record.connections)) { + return null; + } + return { + connections: record.connections as SavedConnection[], + redisDbAliases: sanitizeRedisDbAliases(record.redisDbAliases), + }; +}; + type ConnectionPackageDialogMode = 'import' | 'export'; type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace'; type ToolCenterPaneKey = @@ -2363,9 +2394,9 @@ function App() { throw new Error(t('app.connection_package.error.import_capability_unavailable')); } - let importedViews: unknown; + let importedRaw: unknown; try { - importedViews = await backendApp.ImportConnectionsPayload(raw, password); + importedRaw = await backendApp.ImportConnectionsPayload(raw, password); } catch (error) { if (isConnectionPackagePasswordRequiredError(error)) { throw error; @@ -2377,11 +2408,19 @@ function App() { : t('app.connection_package.message.import_failed'), ); } - if (!Array.isArray(importedViews)) { + const imported = normalizeConnectionPackageImportPayload(importedRaw); + if (!imported) { throw new Error(t('app.connection_package.error.import_no_connections')); } - await refreshConnectionsAfterImport(importedViews as SavedConnection[]); - return importedViews as SavedConnection[]; + await refreshConnectionsAfterImport(imported.connections); + // Redis DB 别名存在前端 appearance,需随连接包一并恢复 + if (Object.keys(imported.redisDbAliases).length > 0) { + const currentAliases = useStore.getState().appearance.redisDbAliases; + useStore.getState().setAppearance({ + redisDbAliases: mergeRedisDbAliases(currentAliases, imported.redisDbAliases), + }); + } + return imported.connections; }, [refreshConnectionsAfterImport, t]); const handleImportConnections = async (sourceGroup?: ToolCenterGroupKey) => { @@ -2502,6 +2541,8 @@ function App() { connectionPackageDialog.includeSecrets && connectionPackageDialog.useFilePassword ) ? password : '', + // Redis DB 别名仅存前端,导出时注入连接包 + redisDbAliases: useStore.getState().appearance.redisDbAliases, }); } catch (error) { const detail = error instanceof Error ? error.message : String(error ?? '').trim(); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a7897b03..292eff4a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -420,14 +420,29 @@ if ( try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { - return parsed.map((item) => saveMockConnection(item)); + return { + connections: parsed.map((item) => saveMockConnection(item)), + redisDbAliases: {}, + }; + } + if (parsed && typeof parsed === 'object' && Array.isArray(parsed.connections)) { + return { + connections: parsed.connections.map((item: unknown) => saveMockConnection(item)), + redisDbAliases: parsed.redisDbAliases && typeof parsed.redisDbAliases === 'object' + ? parsed.redisDbAliases + : {}, + }; } } catch { throw new Error(t('app.browser_mock.import_connection_package_unsupported')); } throw new Error(t('app.browser_mock.import_connection_package_unsupported')); }, - ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: t('app.browser_mock.export_connection_package_unsupported') }), + ExportConnectionsPackage: async (_options?: { + includeSecrets?: boolean; + filePassword?: string; + redisDbAliases?: Record>; + }) => ({ success: false, message: t('app.browser_mock.export_connection_package_unsupported') }), ExportData: async () => ({ success: false }), GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }), SetUpdateChannel: async (channel: string) => { diff --git a/frontend/src/utils/redisDbAlias.test.ts b/frontend/src/utils/redisDbAlias.test.ts index fd455699..ee808161 100644 --- a/frontend/src/utils/redisDbAlias.test.ts +++ b/frontend/src/utils/redisDbAlias.test.ts @@ -4,6 +4,7 @@ import { MAX_REDIS_DB_ALIAS_LENGTH, buildRedisDbNodeLabel, getRedisDbAlias, + mergeRedisDbAliases, sanitizeRedisDbAlias, sanitizeRedisDbAliases, setRedisDbAlias, @@ -76,4 +77,20 @@ describe('redisDbAlias helpers', () => { it('does not append key counts to the sidebar label', () => { expect(buildRedisDbNodeLabel(0, '12')).toBe('db0 12'); }); + + it('merges imported aliases over local ones without dropping unrelated labels', () => { + const current = { + 'conn-a': { '0': 'local-cache', '1': 'local-queue' }, + 'conn-b': { '0': 'sessions' }, + }; + const incoming = { + 'conn-a': { '0': 'imported-cache' }, + 'conn-c': { '2': 'metrics' }, + }; + expect(mergeRedisDbAliases(current, incoming)).toEqual({ + 'conn-a': { '0': 'imported-cache', '1': 'local-queue' }, + 'conn-b': { '0': 'sessions' }, + 'conn-c': { '2': 'metrics' }, + }); + }); }); diff --git a/frontend/src/utils/redisDbAlias.ts b/frontend/src/utils/redisDbAlias.ts index f8d13823..23270c1e 100644 --- a/frontend/src/utils/redisDbAlias.ts +++ b/frontend/src/utils/redisDbAlias.ts @@ -126,6 +126,26 @@ export const setRedisDbAlias = ( return next; }; +/** + * Merge imported Redis DB aliases into the local map. Imported labels win for + * the same connection + DB index; unrelated local aliases are preserved. + */ +export const mergeRedisDbAliases = ( + current: RedisDbAliasMap | undefined, + incoming: RedisDbAliasMap | undefined, +): RedisDbAliasMap => { + const base = sanitizeRedisDbAliases(current); + const imported = sanitizeRedisDbAliases(incoming); + const next: RedisDbAliasMap = { ...base }; + Object.entries(imported).forEach(([connectionId, aliases]) => { + next[connectionId] = { + ...(base[connectionId] || {}), + ...aliases, + }; + }); + return next; +}; + /** * Build the sidebar label for a Redis DB node. Returns `dbN` when there is no * alias, and `dbN alias` when one is set. Key counts are intentionally not diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 44f0a6a3..4b31f1cd 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -174,7 +174,7 @@ export function GetUpdateChannel():Promise; export function ImportConfigFile():Promise; -export function ImportConnectionsPayload(arg1:string,arg2:string):Promise>; +export function ImportConnectionsPayload(arg1:string,arg2:string):Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b0b3f3d3..afe2cf7c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -443,6 +443,7 @@ export namespace app { export class ConnectionExportOptions { includeSecrets: boolean; filePassword?: string; + redisDbAliases?: Record; static createFrom(source: any = {}) { return new ConnectionExportOptions(source); @@ -452,8 +453,41 @@ export namespace app { if ('string' === typeof source) source = JSON.parse(source); this.includeSecrets = source["includeSecrets"]; this.filePassword = source["filePassword"]; + this.redisDbAliases = source["redisDbAliases"]; } } + export class ConnectionPackageImportResult { + connections: connection.SavedConnectionView[]; + redisDbAliases?: Record; + + static createFrom(source: any = {}) { + return new ConnectionPackageImportResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.connections = this.convertValues(source["connections"], connection.SavedConnectionView); + this.redisDbAliases = source["redisDbAliases"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class ExportFileOptions { format: string; xlsxMaxRowsPerSheet?: number; diff --git a/internal/app/connection_package_crypto.go b/internal/app/connection_package_crypto.go index 1b7b7485..457887ea 100644 --- a/internal/app/connection_package_crypto.go +++ b/internal/app/connection_package_crypto.go @@ -131,11 +131,12 @@ func encryptConnectionPackageV2AppManaged(payload connectionPackagePayload) (con } return connectionPackageFileV2{ - V: connectionPackageSchemaVersionV2, - Kind: connectionPackageKind, - P: connectionPackageProtectionAppManaged, - ExportedAt: encryptedPayload.ExportedAt, - Connections: encryptedPayload.Connections, + V: connectionPackageSchemaVersionV2, + Kind: connectionPackageKind, + P: connectionPackageProtectionAppManaged, + ExportedAt: encryptedPayload.ExportedAt, + Connections: encryptedPayload.Connections, + RedisDbAliases: encryptedPayload.RedisDbAliases, }, nil } @@ -212,8 +213,9 @@ func decryptConnectionPackageV2AppManaged(file connectionPackageFileV2) (connect } payload, err := decryptConnectionPackagePayloadSecrets(connectionPackagePayload{ - ExportedAt: file.ExportedAt, - Connections: file.Connections, + ExportedAt: file.ExportedAt, + Connections: file.Connections, + RedisDbAliases: file.RedisDbAliases, }, appKey) if err != nil { return connectionPackagePayload{}, errConnectionPackageDecryptFailed @@ -538,12 +540,15 @@ func decryptConnectionPackageV2ProtectedPlaintext(file connectionPackageFileV2Pr func encryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, appKey []byte) (connectionPackagePayload, error) { encrypted := connectionPackagePayload{ - ExportedAt: payload.ExportedAt, - Connections: make([]connectionPackageItem, len(payload.Connections)), + ExportedAt: payload.ExportedAt, + Connections: make([]connectionPackageItem, len(payload.Connections)), + RedisDbAliases: sanitizeConnectionPackageRedisDbAliases(payload.RedisDbAliases), } for index, item := range payload.Connections { encryptedItem := item + // 确保别名被拷贝进加密后的连接项(不加密,仅展示偏好) + encryptedItem.RedisDbAliases = cloneStringMap(item.RedisDbAliases) bundle, err := encryptSecretBundle(appKey, item.Secrets, connectionPackageItemAAD(item)) if err != nil { return connectionPackagePayload{}, err @@ -557,12 +562,14 @@ func encryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, ap func decryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, appKey []byte) (connectionPackagePayload, error) { decrypted := connectionPackagePayload{ - ExportedAt: payload.ExportedAt, - Connections: make([]connectionPackageItem, len(payload.Connections)), + ExportedAt: payload.ExportedAt, + Connections: make([]connectionPackageItem, len(payload.Connections)), + RedisDbAliases: sanitizeConnectionPackageRedisDbAliases(payload.RedisDbAliases), } for index, item := range payload.Connections { decryptedItem := item + decryptedItem.RedisDbAliases = cloneStringMap(item.RedisDbAliases) bundle, err := decryptSecretBundle(appKey, item.Secrets, connectionPackageItemAAD(item)) if err != nil { return connectionPackagePayload{}, err diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go index 530f6994..ee27ad91 100644 --- a/internal/app/connection_package_transfer.go +++ b/internal/app/connection_package_transfer.go @@ -15,12 +15,13 @@ import ( "github.com/google/uuid" ) -func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle) connectionPackageItem { +func newConnectionPackageItem(view connection.SavedConnectionView, bundle connectionSecretBundle, redisDbAliases map[string]string) connectionPackageItem { return connectionPackageItem{ ID: view.ID, Name: view.Name, IncludeDatabases: cloneStringSlice(view.IncludeDatabases), IncludeRedisDatabases: cloneIntSlice(view.IncludeRedisDatabases), + RedisDbAliases: cloneStringMap(redisDbAliases), IconType: view.IconType, IconColor: view.IconColor, Config: stripConnectionSecretFields(view.Config), @@ -28,30 +29,98 @@ func newConnectionPackageItem(view connection.SavedConnectionView, bundle connec } } -func (a *App) buildConnectionPackagePayload() (connectionPackagePayload, error) { +func sanitizeConnectionPackageRedisDbAliases(value map[string]map[string]string) map[string]map[string]string { + if len(value) == 0 { + return nil + } + result := make(map[string]map[string]string, len(value)) + for connectionID, aliases := range value { + id := strings.TrimSpace(connectionID) + if id == "" || len(aliases) == 0 { + continue + } + cleaned := make(map[string]string, len(aliases)) + for dbIndex, alias := range aliases { + dbKey := strings.TrimSpace(dbIndex) + if dbKey == "" { + continue + } + // 仅接受数字下标,与前端 redisDbAlias 一致 + if _, err := strconv.Atoi(dbKey); err != nil { + continue + } + label := strings.Join(strings.Fields(strings.TrimSpace(alias)), " ") + if label == "" { + continue + } + if len(label) > 64 { + label = label[:64] + } + cleaned[dbKey] = label + } + if len(cleaned) > 0 { + result[id] = cleaned + } + } + if len(result) == 0 { + return nil + } + return result +} + +func collectRedisDbAliasesFromPackagePayload(payload connectionPackagePayload) map[string]map[string]string { + merged := make(map[string]map[string]string) + // 顶层字段(若有) + for connectionID, aliases := range sanitizeConnectionPackageRedisDbAliases(payload.RedisDbAliases) { + merged[connectionID] = cloneStringMap(aliases) + } + // 连接项内嵌字段优先覆盖(导出时与连接同级,更可靠) + for _, item := range payload.Connections { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + cleaned := sanitizeConnectionPackageRedisDbAliases(map[string]map[string]string{id: item.RedisDbAliases}) + if aliases, ok := cleaned[id]; ok && len(aliases) > 0 { + merged[id] = aliases + } + } + if len(merged) == 0 { + return nil + } + return merged +} + +func (a *App) buildConnectionPackagePayload(redisDbAliases map[string]map[string]string) (connectionPackagePayload, error) { repo := a.savedConnectionRepository() items, err := repo.List() if err != nil { return connectionPackagePayload{}, err } + aliasesByConnection := sanitizeConnectionPackageRedisDbAliases(redisDbAliases) connections := make([]connectionPackageItem, 0, len(items)) for _, item := range items { bundle, bundleErr := repo.loadSecretBundle(item) if bundleErr != nil { return connectionPackagePayload{}, bundleErr } - connections = append(connections, newConnectionPackageItem(item, bundle)) + var itemAliases map[string]string + if aliasesByConnection != nil { + itemAliases = aliasesByConnection[strings.TrimSpace(item.ID)] + } + connections = append(connections, newConnectionPackageItem(item, bundle, itemAliases)) } return connectionPackagePayload{ - ExportedAt: time.Now().UTC().Format(time.RFC3339), - Connections: connections, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Connections: connections, + RedisDbAliases: aliasesByConnection, }, nil } func (a *App) buildExportedConnectionPackage(options ConnectionExportOptions) ([]byte, error) { - payload, err := a.buildConnectionPackagePayload() + payload, err := a.buildConnectionPackagePayload(options.RedisDbAliases) if err != nil { return nil, err } @@ -215,7 +284,14 @@ func (a *App) importConnectionPackagePayload(payload connectionPackagePayload) ( return a.importSavedConnectionsAtomically(inputs) } -func (a *App) ImportConnectionsPayload(raw string, password string) ([]connection.SavedConnectionView, error) { +func connectionPackageImportResultFromViews(views []connection.SavedConnectionView, redisDbAliases map[string]map[string]string) ConnectionPackageImportResult { + return ConnectionPackageImportResult{ + Connections: sanitizeSavedConnectionViews(views), + RedisDbAliases: sanitizeConnectionPackageRedisDbAliases(redisDbAliases), + } +} + +func (a *App) ImportConnectionsPayload(raw string, password string) (ConnectionPackageImportResult, error) { localizeError := func(err error) error { return localizeConnectionPackageError(a.appText, err) } @@ -223,97 +299,102 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio return errors.New(a.appText(key, params)) } + empty := ConnectionPackageImportResult{} trimmed := strings.TrimSpace(raw) if trimmed == "" { - return nil, localizeError(errConnectionPackageUnsupported) + return empty, localizeError(errConnectionPackageUnsupported) } if len(trimmed) > connectionImportMaxFileBytes { - return nil, localizeError(errConnectionImportFileTooLarge) + return empty, localizeError(errConnectionImportFileTooLarge) } if isConnectionPackageV2AppManaged(trimmed) { var file connectionPackageFileV2 if err := json.Unmarshal([]byte(trimmed), &file); err != nil { - return nil, localizeError(errConnectionPackageUnsupported) + return empty, localizeError(errConnectionPackageUnsupported) } payload, err := decryptConnectionPackageV2AppManaged(file) if err != nil { - return nil, localizeError(err) + return empty, localizeError(err) } views, err := a.importConnectionPackagePayload(payload) if err != nil { - return nil, localizeError(err) + return empty, localizeError(err) } - return sanitizeSavedConnectionViews(views), nil + return connectionPackageImportResultFromViews(views, collectRedisDbAliasesFromPackagePayload(payload)), nil } if isConnectionPackageV2Protected(trimmed) { var file connectionPackageFileV2Protected if err := json.Unmarshal([]byte(trimmed), &file); err != nil { - return nil, localizeError(errConnectionPackageUnsupported) + return empty, localizeError(errConnectionPackageUnsupported) } payload, err := decryptConnectionPackageV2Protected(file, password) if err != nil { - return nil, localizeError(err) + return empty, localizeError(err) } views, err := a.importConnectionPackagePayload(payload) if err != nil { - return nil, localizeError(err) + return empty, localizeError(err) } - return sanitizeSavedConnectionViews(views), nil + return connectionPackageImportResultFromViews(views, collectRedisDbAliasesFromPackagePayload(payload)), nil } if isConnectionPackageEnvelope(trimmed) { var file connectionPackageFile if err := json.Unmarshal([]byte(trimmed), &file); err != nil { - return nil, localizeError(errConnectionPackageUnsupported) + return empty, localizeError(errConnectionPackageUnsupported) } payload, err := decryptConnectionPackage(file, password) if err != nil { - return nil, localizeError(err) + return empty, localizeError(err) } views, err := a.importConnectionPackagePayload(payload) if err != nil { - return nil, localizeError(err) + return empty, localizeError(err) } - return sanitizeSavedConnectionViews(views), nil + return connectionPackageImportResultFromViews(views, collectRedisDbAliasesFromPackagePayload(payload)), nil } if isMySQLWorkbenchXML(trimmed) { inputs, err := parseMySQLWorkbenchXML(trimmed) if err != nil { - return nil, mysqlWorkbenchError("file.backend.error.mysql_workbench_parse_failed", map[string]any{"detail": err.Error()}) + return empty, mysqlWorkbenchError("file.backend.error.mysql_workbench_parse_failed", map[string]any{"detail": err.Error()}) } if len(inputs) == 0 { - return nil, mysqlWorkbenchError("file.backend.error.mysql_workbench_no_connections", nil) + return empty, mysqlWorkbenchError("file.backend.error.mysql_workbench_no_connections", nil) } views, err := a.importSavedConnectionsAtomically(inputs) if err != nil { - return nil, err + return empty, err } - return sanitizeSavedConnectionViews(views), nil + return connectionPackageImportResultFromViews(views, nil), nil } if isNavicatNCX(trimmed) { inputs, err := parseNavicatNCXWithText(trimmed, a.appText) if err != nil { - return nil, navicatWrapError(a.appText, "file.backend.error.navicat_ncx_parse_failed", nil, err) + return empty, navicatWrapError(a.appText, "file.backend.error.navicat_ncx_parse_failed", nil, err) } if len(inputs) == 0 { - return nil, navicatError(a.appText, "file.backend.error.navicat_ncx_no_connections", nil) + return empty, navicatError(a.appText, "file.backend.error.navicat_ncx_no_connections", nil) } views, err := a.importSavedConnectionsAtomically(inputs) if err != nil { - return nil, err + return empty, err } - return sanitizeSavedConnectionViews(views), nil + return connectionPackageImportResultFromViews(views, nil), nil } var legacy []connection.LegacySavedConnection if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil { - return nil, localizeError(errConnectionPackageUnsupported) + return empty, localizeError(errConnectionPackageUnsupported) } - return a.ImportLegacyConnections(legacy) + views, err := a.ImportLegacyConnections(legacy) + if err != nil { + return empty, err + } + return connectionPackageImportResultFromViews(views, nil), nil } type connectionPackageImportRollbackSnapshot struct { diff --git a/internal/app/connection_package_transfer_test.go b/internal/app/connection_package_transfer_test.go index 4d1513c9..44f48ae4 100644 --- a/internal/app/connection_package_transfer_test.go +++ b/internal/app/connection_package_transfer_test.go @@ -40,7 +40,7 @@ func TestBuildConnectionPackagePayloadIncludesSecretBundles(t *testing.T) { t.Fatalf("SaveConnection returned error: %v", err) } - payload, err := app.buildConnectionPackagePayload() + payload, err := app.buildConnectionPackagePayload(nil) if err != nil { t.Fatalf("buildConnectionPackagePayload returned error: %v", err) } @@ -128,7 +128,8 @@ func TestBuildExportedConnectionPackageWithoutSecretsUsesV2AppManagedAndImportsW importApp := NewAppWithSecretStore(newFakeAppSecretStore()) importApp.configDir = t.TempDir() - imported, err := importApp.ImportConnectionsPayload(string(raw), "") + importedResult, err := importApp.ImportConnectionsPayload(string(raw), "") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -353,7 +354,8 @@ func TestImportConnectionsPayloadLegacyJSONRollsBackOnSaveFailure(t *testing.T) t.Fatalf("json.Marshal returned error: %v", err) } - imported, err := app.ImportConnectionsPayload(string(raw), "ignored") + importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored") + imported := importedResult.Connections if err != nil { t.Fatalf("expected ImportConnectionsPayload to succeed without secret store, got %v", err) } @@ -574,7 +576,8 @@ func TestImportConnectionsPayloadLegacyJSONClearsExistingSecretWhenMissing(t *te t.Fatalf("json.Marshal returned error: %v", err) } - imported, err := app.ImportConnectionsPayload(string(raw), "ignored") + importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -628,7 +631,8 @@ func TestImportConnectionsPayloadLegacyJSONLatestEntryWinsForSameID(t *testing.T t.Fatalf("json.Marshal returned error: %v", err) } - imported, err := app.ImportConnectionsPayload(string(raw), "ignored") + importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -692,7 +696,8 @@ func TestImportConnectionsPayloadLegacyJSONLatestEntryWithoutPasswordDoesNotKeep t.Fatalf("json.Marshal returned error: %v", err) } - imported, err := app.ImportConnectionsPayload(string(raw), "ignored") + importedResult, err := app.ImportConnectionsPayload(string(raw), "ignored") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -828,7 +833,8 @@ func TestImportConnectionsPayloadEnvelopeImportsAndOverwritesSecrets(t *testing. t.Fatalf("json.Marshal returned error: %v", err) } - imported, err := app.ImportConnectionsPayload(string(raw), "package-password") + importedResult, err := app.ImportConnectionsPayload(string(raw), "package-password") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -890,7 +896,8 @@ func TestBuildExportedConnectionPackageWithSecretsUsesV2AppManagedEncryption(t * } } - imported, err := app.ImportConnectionsPayload(rawString, "") + importedResult, err := app.ImportConnectionsPayload(rawString, "") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -933,7 +940,8 @@ func TestBuildExportedConnectionPackageWithFilePasswordUsesV2ProtectedEnvelope(t t.Fatalf("wrong v2 p=2 password should return unified error, got %v", err) } - imported, err := app.ImportConnectionsPayload(rawString, "package-password") + importedResult, err := app.ImportConnectionsPayload(rawString, "package-password") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -962,6 +970,86 @@ func TestNormalizeConnectionPackageExportFilenameAddsExtension(t *testing.T) { } } +func TestBuildExportedConnectionPackageCarriesRedisDbAliases(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "redis-1", + Name: "Cache", + Config: connection.ConnectionConfig{ + ID: "redis-1", + Type: "redis", + Host: "127.0.0.1", + Port: 6379, + }, + IncludeRedisDatabases: []int{0, 1}, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + + aliases := map[string]map[string]string{ + "redis-1": { + "0": "cache", + "1": "sessions", + }, + // malformed entries must be dropped by sanitize + "": {"0": "orphan"}, + "other": {"x": "bad-index", "2": " "}, + } + + raw, err := app.buildExportedConnectionPackage(ConnectionExportOptions{ + IncludeSecrets: false, + RedisDbAliases: aliases, + }) + if err != nil { + t.Fatalf("buildExportedConnectionPackage returned error: %v", err) + } + + importApp := NewAppWithSecretStore(newFakeAppSecretStore()) + importApp.configDir = t.TempDir() + + importedResult, err := importApp.ImportConnectionsPayload(string(raw), "") + if err != nil { + t.Fatalf("ImportConnectionsPayload returned error: %v", err) + } + if len(importedResult.Connections) != 1 { + t.Fatalf("expected 1 imported connection, got %d", len(importedResult.Connections)) + } + if importedResult.Connections[0].ID != "redis-1" { + t.Fatalf("expected redis-1, got %q", importedResult.Connections[0].ID) + } + if importedResult.RedisDbAliases == nil { + t.Fatal("expected RedisDbAliases in import result") + } + got := importedResult.RedisDbAliases["redis-1"] + if got == nil { + t.Fatalf("expected aliases for redis-1, got %#v", importedResult.RedisDbAliases) + } + if got["0"] != "cache" || got["1"] != "sessions" { + t.Fatalf("unexpected redis aliases: %#v", got) + } + if _, ok := importedResult.RedisDbAliases[""]; ok { + t.Fatal("empty connection id should not appear in imported aliases") + } + if _, ok := importedResult.RedisDbAliases["other"]; ok { + t.Fatal("malformed aliases should be dropped") + } + + // payload 内连接项也应携带别名(便于单项迁移) + payload, err := app.buildConnectionPackagePayload(aliases) + if err != nil { + t.Fatalf("buildConnectionPackagePayload returned error: %v", err) + } + if payload.Connections[0].RedisDbAliases["0"] != "cache" { + t.Fatalf("expected item-level alias cache, got %#v", payload.Connections[0].RedisDbAliases) + } + if payload.RedisDbAliases["redis-1"]["1"] != "sessions" { + t.Fatalf("expected top-level alias sessions, got %#v", payload.RedisDbAliases) + } +} + type failOnPutSecretStore struct { base *fakeAppSecretStore failRef string diff --git a/internal/app/connection_package_types.go b/internal/app/connection_package_types.go index cbbdba19..7d348845 100644 --- a/internal/app/connection_package_types.go +++ b/internal/app/connection_package_types.go @@ -133,11 +133,12 @@ type connectionPackageKDFSpec struct { } type connectionPackageFileV2 struct { - V int `json:"v"` - Kind string `json:"kind"` - P int `json:"p"` - ExportedAt string `json:"exportedAt,omitempty"` - Connections []connectionPackageItem `json:"connections"` + V int `json:"v"` + Kind string `json:"kind"` + P int `json:"p"` + ExportedAt string `json:"exportedAt,omitempty"` + Connections []connectionPackageItem `json:"connections"` + RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"` } type connectionPackageFileV2Protected struct { @@ -158,8 +159,10 @@ type connectionPackageKDFSpecV2 struct { } type connectionPackagePayload struct { - ExportedAt string `json:"exportedAt,omitempty"` - Connections []connectionPackageItem `json:"connections"` + ExportedAt string `json:"exportedAt,omitempty"` + Connections []connectionPackageItem `json:"connections"` + // RedisDbAliases:连接 ID → (db 序号字符串 → 别名)。前端展示偏好,随连接包一并迁移。 + RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"` } type connectionPackageItem struct { @@ -167,6 +170,8 @@ type connectionPackageItem struct { Name string `json:"name"` IncludeDatabases []string `json:"includeDatabases,omitempty"` IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + // RedisDbAliases:该连接下 db 序号 → 别名(如 "0"→"cache"),与前端 redisDbAliases 对齐。 + RedisDbAliases map[string]string `json:"redisDbAliases,omitempty"` IconType string `json:"iconType,omitempty"` IconColor string `json:"iconColor,omitempty"` Config connection.ConnectionConfig `json:"config"` @@ -179,6 +184,7 @@ func (i connectionPackageItem) MarshalJSON() ([]byte, error) { Name string `json:"name"` IncludeDatabases []string `json:"includeDatabases,omitempty"` IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + RedisDbAliases map[string]string `json:"redisDbAliases,omitempty"` IconType string `json:"iconType,omitempty"` IconColor string `json:"iconColor,omitempty"` Config connection.ConnectionConfig `json:"config"` @@ -190,6 +196,7 @@ func (i connectionPackageItem) MarshalJSON() ([]byte, error) { Name: i.Name, IncludeDatabases: i.IncludeDatabases, IncludeRedisDatabases: i.IncludeRedisDatabases, + RedisDbAliases: cloneStringMap(i.RedisDbAliases), IconType: i.IconType, IconColor: i.IconColor, Config: i.Config, @@ -204,6 +211,15 @@ func (i connectionPackageItem) MarshalJSON() ([]byte, error) { type ConnectionExportOptions struct { IncludeSecrets bool `json:"includeSecrets"` FilePassword string `json:"filePassword,omitempty"` + // RedisDbAliases 由前端传入(appearance.redisDbAliases),导出时写入连接包。 + RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"` +} + +// ConnectionPackageImportResult 连接包导入结果;兼容仅返回 connections 数组的旧前端解析逻辑时, +// 新字段 redisDbAliases 可一并恢复 Redis DB 别名。 +type ConnectionPackageImportResult struct { + Connections []connection.SavedConnectionView `json:"connections"` + RedisDbAliases map[string]map[string]string `json:"redisDbAliases,omitempty"` } func defaultConnectionPackageKDFSpec() connectionPackageKDFSpec { diff --git a/internal/app/navicat_ncx_import_test.go b/internal/app/navicat_ncx_import_test.go index 0a746f21..7236ed08 100644 --- a/internal/app/navicat_ncx_import_test.go +++ b/internal/app/navicat_ncx_import_test.go @@ -24,7 +24,8 @@ func TestImportConnectionsPayloadNavicatNCXImportsConfigsAndSecrets(t *testing.T `, mysqlPassword, postgresPassword, sshPassword, proxyPassword) - imported, err := app.ImportConnectionsPayload(raw, "") + importedResult, err := app.ImportConnectionsPayload(raw, "") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } @@ -225,7 +226,8 @@ func TestImportConnectionsPayloadNavicatNCXMapsOracleSIDAndRedisDB(t *testing.T) `, oraclePassword, redisPassword) - imported, err := app.ImportConnectionsPayload(raw, "") + importedResult, err := app.ImportConnectionsPayload(raw, "") + imported := importedResult.Connections if err != nil { t.Fatalf("ImportConnectionsPayload returned error: %v", err) } diff --git a/internal/app/saved_connections.go b/internal/app/saved_connections.go index ff570bdc..165c022f 100644 --- a/internal/app/saved_connections.go +++ b/internal/app/saved_connections.go @@ -148,6 +148,17 @@ func cloneIntSlice(input []int) []int { return cloned } +func cloneStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + cloned := make(map[string]string, len(input)) + for key, value := range input { + cloned[key] = value + } + return cloned +} + func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.SavedConnectionView, connectionSecretBundle) { id := strings.TrimSpace(input.ID) if id == "" {