diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index c25772f1..f1b41127 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -27,6 +27,7 @@ const getDefaultPortByType = (type: string) => { case 'highgo': return 5866; case 'mariadb': return 3306; case 'vastbase': return 5432; + case 'sqlite': return 0; case 'duckdb': return 0; default: return 3306; } @@ -236,6 +237,23 @@ const ConnectionModal: React.FC<{ } }; + const normalizeFileDbPath = (rawPath: string): string => { + let pathText = String(rawPath || '').trim(); + if (!pathText) { + return ''; + } + // 兼容 sqlite:///C:/... 或 sqlite:///C:\... 解析后多出的前导斜杠。 + if (/^\/[a-zA-Z]:[\\/]/.test(pathText)) { + pathText = pathText.slice(1); + } + // 兼容历史版本把 Windows 文件路径误拼成 :3306:3306。 + const legacyMatch = pathText.match(/^([a-zA-Z]:[\\/].*?)(?::\d+)+$/); + if (legacyMatch?.[1]) { + return legacyMatch[1]; + } + return pathText; + }; + const parseMultiHostUri = (uriText: string, expectedScheme: string) => { const prefix = `${expectedScheme}://`; if (!uriText.toLowerCase().startsWith(prefix)) { @@ -335,30 +353,6 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const tryExtractPath = (uri: string, scheme: string): string | null => { - const parsed = parseMultiHostUri(uri, scheme); - if (!parsed) { - return null; - } - const host = String(parsed.hosts?.[0] || '').trim(); - const dbPath = String(parsed.database || '').trim(); - if (host && dbPath) { - return `/${host}/${dbPath}`.replace(/\/+/g, '/'); - } - if (host) { - return `/${host}`.replace(/\/+/g, '/'); - } - if (dbPath) { - return dbPath.startsWith('/') ? dbPath : `/${dbPath}`; - } - return null; - }; - - const pathFromScheme = tryExtractPath(trimmedUri, type); - if (pathFromScheme) { - return { host: decodeURIComponent(pathFromScheme) }; - } - const rawPath = trimmedUri .replace(/^sqlite:\/\//i, '') .replace(/^duckdb:\/\//i, '') @@ -366,7 +360,7 @@ const ConnectionModal: React.FC<{ if (!rawPath) { return null; } - return { host: decodeURIComponent(rawPath) }; + return { host: normalizeFileDbPath(safeDecode(rawPath)) }; } if (type === 'mongodb') { @@ -481,12 +475,11 @@ const ConnectionModal: React.FC<{ } if (isFileDatabaseType(type)) { - const pathText = String(values.host || '').trim(); + const pathText = normalizeFileDbPath(String(values.host || '').trim()); if (!pathText) { return `${type}://`; } - const normalizedPath = pathText.startsWith('/') ? pathText : `/${pathText}`; - return `${type}://${encodeURI(normalizedPath)}`; + return `${type}://${encodeURI(pathText)}`; } if (type === 'mongodb') { @@ -602,13 +595,20 @@ const ConnectionModal: React.FC<{ const config: any = initialValues.config || {}; const configType = String(config.type || 'mysql'); const defaultPort = getDefaultPortByType(configType); - const normalizedHosts = normalizeAddressList(config.hosts, defaultPort); - const primaryAddress = parseHostPort( - normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = primaryAddress?.host || String(config.host || 'localhost'); - const primaryPort = primaryAddress?.port || Number(config.port || defaultPort); + const isFileDbConfigType = isFileDatabaseType(configType); + const normalizedHosts = isFileDbConfigType ? [] : normalizeAddressList(config.hosts, defaultPort); + const primaryAddress = isFileDbConfigType + ? null + : parseHostPort( + normalizedHosts[0] || toAddress(config.host || 'localhost', Number(config.port || defaultPort), defaultPort), + defaultPort + ); + const primaryHost = isFileDbConfigType + ? normalizeFileDbPath(String(config.host || '')) + : (primaryAddress?.host || String(config.host || 'localhost')); + const primaryPort = isFileDbConfigType + ? 0 + : (primaryAddress?.port || Number(config.port || defaultPort)); const mysqlReplicaHosts = (configType === 'mysql' || configType === 'mariadb' || configType === 'diros' || configType === 'sphinx') ? normalizedHosts.slice(1) : []; const mongoHosts = configType === 'mongodb' ? normalizedHosts.slice(1) : []; const mysqlIsReplica = String(config.topology || '').toLowerCase() === 'replica' || mysqlReplicaHosts.length > 0; @@ -847,12 +847,22 @@ const ConnectionModal: React.FC<{ const type = String(mergedValues.type || '').toLowerCase(); const defaultPort = getDefaultPortByType(type); - const parsedPrimary = parseHostPort( - toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), - defaultPort - ); - const primaryHost = parsedPrimary?.host || 'localhost'; - const primaryPort = parsedPrimary?.port || defaultPort; + const isFileDbType = isFileDatabaseType(type); + + let primaryHost = 'localhost'; + let primaryPort = defaultPort; + if (isFileDbType) { + // 文件型数据库(sqlite/duckdb)这里的 host 即数据库文件路径,不应参与 host:port 拼接与解析。 + primaryHost = normalizeFileDbPath(String(mergedValues.host || '').trim()); + primaryPort = 0; + } else { + const parsedPrimary = parseHostPort( + toAddress(mergedValues.host || 'localhost', Number(mergedValues.port || defaultPort), defaultPort), + defaultPort + ); + primaryHost = parsedPrimary?.host || 'localhost'; + primaryPort = parsedPrimary?.port || defaultPort; + } let hosts: string[] = []; let topology: 'single' | 'replica' | undefined; @@ -960,7 +970,36 @@ const ConnectionModal: React.FC<{ form.setFieldsValue({ type: type }); const defaultPort = getDefaultPortByType(type); - if (!isFileDatabaseType(type) && type !== 'custom') { + if (isFileDatabaseType(type)) { + setUseSSH(false); + form.setFieldsValue({ + host: '', + port: 0, + user: '', + password: '', + database: '', + useSSH: false, + sshHost: '', + sshPort: 22, + sshUser: '', + sshPassword: '', + sshKeyPath: '', + mysqlTopology: 'single', + mongoTopology: 'single', + mongoSrv: false, + mongoReadPreference: 'primary', + mongoReplicaSet: '', + mongoAuthSource: '', + mongoAuthMechanism: '', + savePassword: true, + mysqlReplicaHosts: [], + mongoHosts: [], + mysqlReplicaUser: '', + mysqlReplicaPassword: '', + mongoReplicaUser: '', + mongoReplicaPassword: '', + }); + } else if (type !== 'custom') { form.setFieldsValue({ port: defaultPort, mysqlTopology: 'single', diff --git a/internal/app/app.go b/internal/app/app.go index 80c55413..248093cf 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -133,8 +133,17 @@ func formatConnSummary(config connection.ConnectionConfig) string { } var b strings.Builder - b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", - config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) + normalizedType := strings.ToLower(strings.TrimSpace(config.Type)) + if normalizedType == "sqlite" || normalizedType == "duckdb" { + path := strings.TrimSpace(config.Host) + if path == "" { + path = "(未配置)" + } + b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) + } else { + b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", + config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) + } if len(config.Hosts) > 0 { b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts))) diff --git a/internal/db/sqlite_impl.go b/internal/db/sqlite_impl.go index 582937a2..e8e8c063 100644 --- a/internal/db/sqlite_impl.go +++ b/internal/db/sqlite_impl.go @@ -6,6 +6,9 @@ import ( "context" "database/sql" "fmt" + "os" + "path/filepath" + "strconv" "strings" "time" @@ -21,7 +24,14 @@ type SQLiteDB struct { } func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error { - dsn := config.Host + dsn, err := resolveSQLiteDSN(config) + if err != nil { + return err + } + if err := ensureSQLiteParentDir(dsn); err != nil { + return err + } + db, err := sql.Open("sqlite", dsn) if err != nil { return fmt.Errorf("打开数据库连接失败:%w", err) @@ -31,11 +41,140 @@ func (s *SQLiteDB) Connect(config connection.ConnectionConfig) error { // Force verification if err := s.Ping(); err != nil { + _ = db.Close() + s.conn = nil return fmt.Errorf("连接建立后验证失败:%w", err) } return nil } +func resolveSQLiteDSN(config connection.ConnectionConfig) (string, error) { + dsn := strings.TrimSpace(config.Host) + if dsn == "" { + dsn = strings.TrimSpace(config.Database) + } + dsn = normalizeSQLitePath(dsn) + if dsn == "" { + return "", fmt.Errorf("SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite)") + } + if strings.EqualFold(dsn, ":memory:") { + return dsn, nil + } + if looksLikeHostPort(dsn) { + return "", fmt.Errorf("SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:%s", dsn) + } + return dsn, nil +} + +func normalizeSQLitePath(raw string) string { + text := strings.TrimSpace(raw) + if strings.HasPrefix(text, "/") && len(text) > 3 && isWindowsDrivePath(text[1:]) { + text = text[1:] + } + if isWindowsDrivePath(text) { + text = trimLegacyPortSuffix(text) + } + return text +} + +func isWindowsDrivePath(path string) bool { + if len(path) < 3 { + return false + } + drive := path[0] + if !((drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z')) { + return false + } + if path[1] != ':' { + return false + } + sep := path[2] + return sep == '\\' || sep == '/' +} + +func trimLegacyPortSuffix(path string) string { + normalized := path + for { + idx := strings.LastIndex(normalized, ":") + if idx <= 1 || idx+1 >= len(normalized) { + return normalized + } + suffix := normalized[idx+1:] + validDigits := true + for _, ch := range suffix { + if ch < '0' || ch > '9' { + validDigits = false + break + } + } + if !validDigits { + return normalized + } + normalized = normalized[:idx] + } +} + +func looksLikeHostPort(raw string) bool { + text := strings.TrimSpace(raw) + if text == "" { + return false + } + if strings.ContainsAny(text, `/\`) { + return false + } + if strings.HasPrefix(strings.ToLower(text), "file:") { + return false + } + if strings.HasPrefix(text, "[") { + closing := strings.LastIndex(text, "]") + if closing <= 0 || closing+1 >= len(text) { + return false + } + portText := strings.TrimSpace(strings.TrimPrefix(text[closing+1:], ":")) + return isValidPortText(portText) + } + if strings.Count(text, ":") != 1 { + return false + } + split := strings.LastIndex(text, ":") + if split <= 0 || split+1 >= len(text) { + return false + } + return isValidPortText(strings.TrimSpace(text[split+1:])) +} + +func isValidPortText(text string) bool { + port, err := strconv.Atoi(text) + return err == nil && port > 0 && port <= 65535 +} + +func ensureSQLiteParentDir(dsn string) error { + text := strings.TrimSpace(dsn) + if text == "" || strings.EqualFold(text, ":memory:") { + return nil + } + // file: URI 由驱动处理,避免在这里误判路径格式。 + if strings.HasPrefix(strings.ToLower(text), "file:") { + return nil + } + path := text + if idx := strings.Index(path, "?"); idx >= 0 { + path = path[:idx] + } + path = strings.TrimSpace(path) + if path == "" { + return nil + } + dir := filepath.Dir(path) + if dir == "." || dir == "" { + return nil + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建 SQLite 数据文件目录失败:%w", err) + } + return nil +} + func (s *SQLiteDB) Close() error { if s.conn != nil { return s.conn.Close() diff --git a/internal/db/sqlite_impl_test.go b/internal/db/sqlite_impl_test.go new file mode 100644 index 00000000..15745b81 --- /dev/null +++ b/internal/db/sqlite_impl_test.go @@ -0,0 +1,79 @@ +//go:build gonavi_full_drivers || gonavi_sqlite_driver + +package db + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestResolveSQLiteDSNRejectsHostPort(t *testing.T) { + _, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: "localhost:3306"}) + if err == nil { + t.Fatalf("期望拦截 host:port 输入") + } + if !strings.Contains(err.Error(), "本地数据库文件路径") { + t.Fatalf("错误提示不符合预期: %v", err) + } +} + +func TestResolveSQLiteDSNFallbackDatabase(t *testing.T) { + dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Database: "/tmp/demo.sqlite"}) + if err != nil { + t.Fatalf("解析 DSN 失败: %v", err) + } + if dsn != "/tmp/demo.sqlite" { + t.Fatalf("期望使用 database 作为 DSN,实际=%s", dsn) + } +} + +func TestResolveSQLiteDSNNormalizesWindowsLegacyPath(t *testing.T) { + dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `F:\py\py\history.db:3306:3306`}) + if err != nil { + t.Fatalf("解析 DSN 失败: %v", err) + } + if dsn != `F:\py\py\history.db` { + t.Fatalf("期望清理历史端口污染,实际=%s", dsn) + } +} + +func TestResolveSQLiteDSNNormalizesWindowsPathWithLeadingSlash(t *testing.T) { + dsn, err := resolveSQLiteDSN(connection.ConnectionConfig{Type: "sqlite", Host: `/F:\py\py\history.db:3306`}) + if err != nil { + t.Fatalf("解析 DSN 失败: %v", err) + } + if dsn != `F:\py\py\history.db` { + t.Fatalf("期望清理前导斜杠与端口污染,实际=%s", dsn) + } +} + +func TestEnsureSQLiteParentDirCreatesNestedDir(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "nested", "child", "demo.sqlite") + if err := ensureSQLiteParentDir(target); err != nil { + t.Fatalf("创建目录失败: %v", err) + } + info, err := os.Stat(filepath.Dir(target)) + if err != nil { + t.Fatalf("目录不存在: %v", err) + } + if !info.IsDir() { + t.Fatalf("目标不是目录: %s", filepath.Dir(target)) + } +} + +func TestLooksLikeHostPort(t *testing.T) { + if !looksLikeHostPort("localhost:3306") { + t.Fatalf("localhost:3306 应识别为 host:port") + } + if looksLikeHostPort("/tmp/demo.sqlite") { + t.Fatalf("/tmp/demo.sqlite 不应识别为 host:port") + } + if looksLikeHostPort(`C:\sqlite\demo.db`) { + t.Fatalf("Windows 路径不应识别为 host:port") + } +}