🐛 fix(connection-package): 导出/导入连接包携带 Redis DB 别名

- 连接包 payload 与导出选项支持 redisDbAliases(连接 ID → db 序号 → 别名)
- 导入返回 ConnectionPackageImportResult,一并恢复别名映射
- 前端导出时注入 appearance.redisDbAliases,导入后 merge 回 appearance
- 补充别名往返测试与 mergeRedisDbAliases 单元测试
This commit is contained in:
Syngnat
2026-07-09 13:57:32 +08:00
parent 097826508a
commit 901b436107
12 changed files with 400 additions and 68 deletions

View File

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

View File

@@ -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<string, Record<string, string>>;
}) => ({ 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) => {

View File

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

View File

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

View File

@@ -174,7 +174,7 @@ export function GetUpdateChannel():Promise<connection.QueryResult>;
export function ImportConfigFile():Promise<connection.QueryResult>;
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<Array<connection.SavedConnectionView>>;
export function ImportConnectionsPayload(arg1:string,arg2:string):Promise<app.ConnectionPackageImportResult>;
export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;

View File

@@ -443,6 +443,7 @@ export namespace app {
export class ConnectionExportOptions {
includeSecrets: boolean;
filePassword?: string;
redisDbAliases?: Record<string, any>;
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<string, any>;
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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,8 @@ func TestImportConnectionsPayloadNavicatNCXImportsConfigsAndSecrets(t *testing.T
<Connection ConnType="SQLITE" ConnectionName="History DB" DatabaseFileName="C:\navicat\history.db" />
</Connections>`, 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)
<Connection ConnType="REDIS" ConnectionName="Redis Cache" Host="redis.local" Port="6379" Database="5" UserName="default" Password="%s" SavePassword="true" />
</Connections>`, 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)
}

View File

@@ -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 == "" {