From 26a7aacfece4b1f33d9ed3ca9cf55cd3da995979 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 13 Feb 2026 17:23:38 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feat(drivers):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E9=9C=80=E5=90=AF=E5=8A=A8=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E5=B9=B6=E9=80=9A=E8=BF=87=E5=A4=96=E7=BD=AE=E9=A9=B1=E5=8A=A8?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=87=8F=E5=B0=91=E5=8F=91=E8=A1=8C=E5=8C=85?= =?UTF-8?q?=E4=BD=93=E7=A7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL/Redis/Oracle/PostgreSQL 内置可用,其余数据源改为“安装启用”后可用 - 新建连接对未安装驱动做弹窗内拦截提示,并支持一键跳转驱动管理安装 - 驱动管理展示安装包真实大小(从 Release 资产元数据读取)并优化加载性能 - Release 工作流发布各平台驱动代理资产,主程序构建启用 -s -w 精简 --- .github/workflows/release.yml | 58 +- build-release.sh | 2 +- cmd/mysql-driver-agent/main.go | 227 +++ cmd/optional-driver-agent/main.go | 236 +++ cmd/optional-driver-agent/provider_dameng.go | 12 + cmd/optional-driver-agent/provider_diros.go | 12 + cmd/optional-driver-agent/provider_duckdb.go | 12 + cmd/optional-driver-agent/provider_highgo.go | 12 + .../provider_kingbase.go | 12 + cmd/optional-driver-agent/provider_mariadb.go | 12 + cmd/optional-driver-agent/provider_mongodb.go | 12 + cmd/optional-driver-agent/provider_mysql.go | 12 + cmd/optional-driver-agent/provider_sphinx.go | 12 + cmd/optional-driver-agent/provider_sqlite.go | 12 + .../provider_sqlserver.go | 12 + .../provider_tdengine.go | 12 + .../provider_vastbase.go | 12 + docs/HighGo_Optional_Code_Changes.md | 164 -- docs/HighGo_SM3_Integration_Guide.md | 196 -- docs/driver-manifest.json | 83 + frontend/src/App.tsx | 19 + frontend/src/components/ConnectionModal.tsx | 166 +- .../src/components/DriverManagerModal.tsx | 299 +++ frontend/wailsjs/go/app/App.d.ts | 20 + frontend/wailsjs/go/app/App.js | 40 + internal/app/app.go | 14 + internal/app/methods_driver.go | 1657 +++++++++++++++++ internal/db/dameng_impl.go | 2 + internal/db/database.go | 96 +- .../db/database_optional_factories_full.go | 18 + .../db/database_optional_factories_lite.go | 18 + internal/db/diros_impl.go | 2 + internal/db/driver_support.go | 222 +++ internal/db/driver_support_test.go | 89 + internal/db/dsn_test.go | 2 + internal/db/duckdb_driver_import.go | 2 +- internal/db/duckdb_impl.go | 2 + internal/db/duckdb_platform_supported.go | 2 +- internal/db/duckdb_platform_unsupported.go | 2 +- internal/db/highgo_impl.go | 2 + internal/db/kingbase_impl.go | 2 + internal/db/mariadb_impl.go | 2 + internal/db/mongodb_impl.go | 2 + internal/db/mysql_agent_impl.go | 430 +++++ internal/db/mysql_agent_path.go | 40 + internal/db/optional_driver_agent_impl.go | 440 +++++ internal/db/optional_driver_build_full.go | 9 + internal/db/optional_driver_build_lite.go | 8 + internal/db/postgres_impl.go | 10 +- internal/db/sphinx_impl.go | 2 + internal/db/sqlite_impl.go | 2 + internal/db/sqlserver_impl.go | 2 + internal/db/tdengine_impl.go | 2 + internal/db/vastbase_impl.go | 2 + 54 files changed, 4334 insertions(+), 415 deletions(-) create mode 100644 cmd/mysql-driver-agent/main.go create mode 100644 cmd/optional-driver-agent/main.go create mode 100644 cmd/optional-driver-agent/provider_dameng.go create mode 100644 cmd/optional-driver-agent/provider_diros.go create mode 100644 cmd/optional-driver-agent/provider_duckdb.go create mode 100644 cmd/optional-driver-agent/provider_highgo.go create mode 100644 cmd/optional-driver-agent/provider_kingbase.go create mode 100644 cmd/optional-driver-agent/provider_mariadb.go create mode 100644 cmd/optional-driver-agent/provider_mongodb.go create mode 100644 cmd/optional-driver-agent/provider_mysql.go create mode 100644 cmd/optional-driver-agent/provider_sphinx.go create mode 100644 cmd/optional-driver-agent/provider_sqlite.go create mode 100644 cmd/optional-driver-agent/provider_sqlserver.go create mode 100644 cmd/optional-driver-agent/provider_tdengine.go create mode 100644 cmd/optional-driver-agent/provider_vastbase.go delete mode 100644 docs/HighGo_Optional_Code_Changes.md delete mode 100644 docs/HighGo_SM3_Integration_Guide.md create mode 100644 docs/driver-manifest.json create mode 100644 frontend/src/components/DriverManagerModal.tsx create mode 100644 internal/app/methods_driver.go create mode 100644 internal/db/database_optional_factories_full.go create mode 100644 internal/db/database_optional_factories_lite.go create mode 100644 internal/db/driver_support.go create mode 100644 internal/db/driver_support_test.go create mode 100644 internal/db/mysql_agent_impl.go create mode 100644 internal/db/mysql_agent_path.go create mode 100644 internal/db/optional_driver_agent_impl.go create mode 100644 internal/db/optional_driver_build_full.go create mode 100644 internal/db/optional_driver_build_lite.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4803c0..ee25303 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,7 +94,48 @@ jobs: - name: Build shell: bash run: | - wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}" + wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}" + + - name: Build Optional Driver Agents + shell: bash + run: | + set -euo pipefail + TARGET_PLATFORM="${{ matrix.platform }}" + GOOS="${TARGET_PLATFORM%%/*}" + GOARCH="${TARGET_PLATFORM##*/}" + DRIVERS=(mariadb diros sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine) + + for DRIVER in "${DRIVERS[@]}"; do + TAG="gonavi_${DRIVER}_driver" + OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}" + if [ "$GOOS" = "windows" ]; then + OUTPUT="${OUTPUT}.exe" + fi + echo "🔧 构建 ${OUTPUT} (tag=${TAG})" + if [ "$DRIVER" = "duckdb" ]; then + set +e + CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${TAG}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT}" \ + ./cmd/optional-driver-agent + DUCKDB_RC=$? + set -e + if [ "${DUCKDB_RC}" -ne 0 ]; then + echo "⚠️ DuckDB 代理构建失败(平台 ${GOOS}/${GOARCH}),跳过该资产,不阻断发布" + rm -f "${OUTPUT}" + continue + fi + else + CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \ + -tags "${TAG}" \ + -trimpath \ + -ldflags "-s -w" \ + -o "${OUTPUT}" \ + ./cmd/optional-driver-agent + fi + done # macOS Packaging - name: Package macOS DMG @@ -254,6 +295,7 @@ jobs: GoNavi-*.zip GoNavi-*.tar.gz GoNavi-*.AppImage + *-driver-agent-* retention-days: 1 # Phase 2: Collect all artifacts and Publish Release (Single Job) @@ -273,9 +315,21 @@ jobs: run: ls -R release-assets - name: Generate SHA256SUMS + shell: bash run: | cd release-assets - sha256sum * > SHA256SUMS + FILES=() + while IFS= read -r file; do + if [ -n "$file" ]; then + FILES+=("$file") + fi + done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort) + if [ ${#FILES[@]} -eq 0 ]; then + echo "⚠️ 未找到可签名资产,生成空 SHA256SUMS" + : > SHA256SUMS + else + sha256sum "${FILES[@]}" > SHA256SUMS + fi - name: Create Release uses: softprops/action-gh-release@v2 diff --git a/build-release.sh b/build-release.sh index ce9d56e..4be9a67 100755 --- a/build-release.sh +++ b/build-release.sh @@ -12,7 +12,7 @@ if [ -z "$VERSION" ]; then VERSION="0.0.0" fi echo "ℹ️ 检测到版本号: $VERSION" -LDFLAGS="-X GoNavi-Wails/internal/app.AppVersion=$VERSION" +LDFLAGS="-s -w -X GoNavi-Wails/internal/app.AppVersion=$VERSION" # 颜色配置 GREEN='\033[0;32m' diff --git a/cmd/mysql-driver-agent/main.go b/cmd/mysql-driver-agent/main.go new file mode 100644 index 0000000..27fb702 --- /dev/null +++ b/cmd/mysql-driver-agent/main.go @@ -0,0 +1,227 @@ +//go:build gonavi_mysql_driver + +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" +) + +type mysqlAgentRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Config *connection.ConnectionConfig `json:"config,omitempty"` + Query string `json:"query,omitempty"` + DBName string `json:"dbName,omitempty"` + TableName string `json:"tableName,omitempty"` + Changes *connection.ChangeSet `json:"changes,omitempty"` +} + +type mysqlAgentResponse struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Data interface{} `json:"data,omitempty"` + Fields []string `json:"fields,omitempty"` + RowsAffected int64 `json:"rowsAffected,omitempty"` +} + +const ( + mysqlAgentMethodConnect = "connect" + mysqlAgentMethodClose = "close" + mysqlAgentMethodPing = "ping" + mysqlAgentMethodQuery = "query" + mysqlAgentMethodExec = "exec" + mysqlAgentMethodGetDatabases = "getDatabases" + mysqlAgentMethodGetTables = "getTables" + mysqlAgentMethodGetCreateStmt = "getCreateStatement" + mysqlAgentMethodGetColumns = "getColumns" + mysqlAgentMethodGetAllColumns = "getAllColumns" + mysqlAgentMethodGetIndexes = "getIndexes" + mysqlAgentMethodGetForeignKey = "getForeignKeys" + mysqlAgentMethodGetTriggers = "getTriggers" + mysqlAgentMethodApplyChanges = "applyChanges" +) + +func main() { + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 16<<10), 8<<20) + writer := bufio.NewWriter(os.Stdout) + defer writer.Flush() + + var inst *db.MySQLDB + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var req mysqlAgentRequest + if err := json.Unmarshal([]byte(line), &req); err != nil { + _ = writeResponse(writer, mysqlAgentResponse{ + ID: req.ID, + Success: false, + Error: fmt.Sprintf("解析请求失败:%v", err), + }) + continue + } + + resp := handleRequest(&inst, req) + if err := writeResponse(writer, resp); err != nil { + fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err) + break + } + } + + if inst != nil { + _ = inst.Close() + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err) + } +} + +func handleRequest(inst **db.MySQLDB, req mysqlAgentRequest) mysqlAgentResponse { + resp := mysqlAgentResponse{ + ID: req.ID, + Success: true, + } + + switch strings.TrimSpace(req.Method) { + case mysqlAgentMethodConnect: + if req.Config == nil { + return fail(resp, "连接配置为空") + } + if *inst != nil { + _ = (*inst).Close() + } + next := &db.MySQLDB{} + if err := next.Connect(*req.Config); err != nil { + return fail(resp, err.Error()) + } + *inst = next + return resp + case mysqlAgentMethodClose: + if *inst != nil { + if err := (*inst).Close(); err != nil { + return fail(resp, err.Error()) + } + *inst = nil + } + return resp + } + + if *inst == nil { + return fail(resp, "connection not open") + } + + switch strings.TrimSpace(req.Method) { + case mysqlAgentMethodPing: + if err := (*inst).Ping(); err != nil { + return fail(resp, err.Error()) + } + case mysqlAgentMethodQuery: + data, fields, err := (*inst).Query(req.Query) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + resp.Fields = fields + case mysqlAgentMethodExec: + affected, err := (*inst).Exec(req.Query) + if err != nil { + return fail(resp, err.Error()) + } + resp.RowsAffected = affected + case mysqlAgentMethodGetDatabases: + data, err := (*inst).GetDatabases() + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetTables: + data, err := (*inst).GetTables(req.DBName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetCreateStmt: + data, err := (*inst).GetCreateStatement(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetColumns: + data, err := (*inst).GetColumns(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetAllColumns: + data, err := (*inst).GetAllColumns(req.DBName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetIndexes: + data, err := (*inst).GetIndexes(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetForeignKey: + data, err := (*inst).GetForeignKeys(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodGetTriggers: + data, err := (*inst).GetTriggers(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case mysqlAgentMethodApplyChanges: + if req.Changes == nil { + return fail(resp, "变更集为空") + } + applier, ok := interface{}(*inst).(interface { + ApplyChanges(tableName string, changes connection.ChangeSet) error + }) + if !ok { + return fail(resp, "当前驱动不支持 ApplyChanges") + } + if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil { + return fail(resp, err.Error()) + } + default: + return fail(resp, "不支持的方法") + } + + return resp +} + +func writeResponse(writer *bufio.Writer, resp mysqlAgentResponse) error { + payload, err := json.Marshal(resp) + if err != nil { + return err + } + payload = append(payload, '\n') + if _, err := writer.Write(payload); err != nil { + return err + } + return writer.Flush() +} + +func fail(resp mysqlAgentResponse, errText string) mysqlAgentResponse { + resp.Success = false + resp.Error = strings.TrimSpace(errText) + return resp +} diff --git a/cmd/optional-driver-agent/main.go b/cmd/optional-driver-agent/main.go new file mode 100644 index 0000000..20c7316 --- /dev/null +++ b/cmd/optional-driver-agent/main.go @@ -0,0 +1,236 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" +) + +type agentRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Config *connection.ConnectionConfig `json:"config,omitempty"` + Query string `json:"query,omitempty"` + DBName string `json:"dbName,omitempty"` + TableName string `json:"tableName,omitempty"` + Changes *connection.ChangeSet `json:"changes,omitempty"` +} + +type agentResponse struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Data interface{} `json:"data,omitempty"` + Fields []string `json:"fields,omitempty"` + RowsAffected int64 `json:"rowsAffected,omitempty"` +} + +const ( + agentMethodConnect = "connect" + agentMethodClose = "close" + agentMethodPing = "ping" + agentMethodQuery = "query" + agentMethodExec = "exec" + agentMethodGetDatabases = "getDatabases" + agentMethodGetTables = "getTables" + agentMethodGetCreateStmt = "getCreateStatement" + agentMethodGetColumns = "getColumns" + agentMethodGetAllColumns = "getAllColumns" + agentMethodGetIndexes = "getIndexes" + agentMethodGetForeignKey = "getForeignKeys" + agentMethodGetTriggers = "getTriggers" + agentMethodApplyChanges = "applyChanges" +) + +var ( + agentDriverType string + agentDatabaseFactory func() db.Database +) + +func main() { + if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" { + fmt.Fprintf(os.Stderr, "未配置驱动代理 provider,请使用 gonavi__driver 标签构建\n") + os.Exit(2) + } + + scanner := bufio.NewScanner(os.Stdin) + scanner.Buffer(make([]byte, 0, 16<<10), 8<<20) + writer := bufio.NewWriter(os.Stdout) + defer writer.Flush() + + var inst db.Database + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var req agentRequest + if err := json.Unmarshal([]byte(line), &req); err != nil { + _ = writeResponse(writer, agentResponse{ + ID: req.ID, + Success: false, + Error: fmt.Sprintf("解析请求失败:%v", err), + }) + continue + } + + resp := handleRequest(&inst, req) + if err := writeResponse(writer, resp); err != nil { + fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err) + break + } + } + + if inst != nil { + _ = inst.Close() + } + + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err) + } +} + +func handleRequest(inst *db.Database, req agentRequest) agentResponse { + resp := agentResponse{ID: req.ID, Success: true} + method := strings.TrimSpace(req.Method) + + switch method { + case agentMethodConnect: + if req.Config == nil { + return fail(resp, "连接配置为空") + } + if *inst != nil { + _ = (*inst).Close() + } + next := agentDatabaseFactory() + if next == nil { + return fail(resp, "驱动代理初始化失败") + } + if err := next.Connect(*req.Config); err != nil { + return fail(resp, err.Error()) + } + *inst = next + return resp + case agentMethodClose: + if *inst != nil { + if err := (*inst).Close(); err != nil { + return fail(resp, err.Error()) + } + *inst = nil + } + return resp + } + + if *inst == nil { + return fail(resp, "connection not open") + } + + switch method { + case agentMethodPing: + if err := (*inst).Ping(); err != nil { + return fail(resp, err.Error()) + } + case agentMethodQuery: + data, fields, err := (*inst).Query(req.Query) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + resp.Fields = fields + case agentMethodExec: + affected, err := (*inst).Exec(req.Query) + if err != nil { + return fail(resp, err.Error()) + } + resp.RowsAffected = affected + case agentMethodGetDatabases: + data, err := (*inst).GetDatabases() + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetTables: + data, err := (*inst).GetTables(req.DBName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetCreateStmt: + data, err := (*inst).GetCreateStatement(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetColumns: + data, err := (*inst).GetColumns(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetAllColumns: + data, err := (*inst).GetAllColumns(req.DBName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetIndexes: + data, err := (*inst).GetIndexes(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetForeignKey: + data, err := (*inst).GetForeignKeys(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodGetTriggers: + data, err := (*inst).GetTriggers(req.DBName, req.TableName) + if err != nil { + return fail(resp, err.Error()) + } + resp.Data = data + case agentMethodApplyChanges: + if req.Changes == nil { + return fail(resp, "变更集为空") + } + applier, ok := (*inst).(interface { + ApplyChanges(tableName string, changes connection.ChangeSet) error + }) + if !ok { + return fail(resp, "当前驱动不支持 ApplyChanges") + } + if err := applier.ApplyChanges(req.TableName, *req.Changes); err != nil { + return fail(resp, err.Error()) + } + default: + return fail(resp, "不支持的方法") + } + + return resp +} + +func writeResponse(writer *bufio.Writer, resp agentResponse) error { + payload, err := json.Marshal(resp) + if err != nil { + return err + } + payload = append(payload, '\n') + if _, err := writer.Write(payload); err != nil { + return err + } + return writer.Flush() +} + +func fail(resp agentResponse, errText string) agentResponse { + resp.Success = false + resp.Error = strings.TrimSpace(errText) + return resp +} diff --git a/cmd/optional-driver-agent/provider_dameng.go b/cmd/optional-driver-agent/provider_dameng.go new file mode 100644 index 0000000..6b80413 --- /dev/null +++ b/cmd/optional-driver-agent/provider_dameng.go @@ -0,0 +1,12 @@ +//go:build gonavi_dameng_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "dameng" + agentDatabaseFactory = func() db.Database { + return &db.DamengDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_diros.go b/cmd/optional-driver-agent/provider_diros.go new file mode 100644 index 0000000..af74132 --- /dev/null +++ b/cmd/optional-driver-agent/provider_diros.go @@ -0,0 +1,12 @@ +//go:build gonavi_diros_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "diros" + agentDatabaseFactory = func() db.Database { + return &db.DirosDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_duckdb.go b/cmd/optional-driver-agent/provider_duckdb.go new file mode 100644 index 0000000..fe798e0 --- /dev/null +++ b/cmd/optional-driver-agent/provider_duckdb.go @@ -0,0 +1,12 @@ +//go:build gonavi_duckdb_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "duckdb" + agentDatabaseFactory = func() db.Database { + return &db.DuckDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_highgo.go b/cmd/optional-driver-agent/provider_highgo.go new file mode 100644 index 0000000..b70d3c2 --- /dev/null +++ b/cmd/optional-driver-agent/provider_highgo.go @@ -0,0 +1,12 @@ +//go:build gonavi_highgo_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "highgo" + agentDatabaseFactory = func() db.Database { + return &db.HighGoDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_kingbase.go b/cmd/optional-driver-agent/provider_kingbase.go new file mode 100644 index 0000000..2794b75 --- /dev/null +++ b/cmd/optional-driver-agent/provider_kingbase.go @@ -0,0 +1,12 @@ +//go:build gonavi_kingbase_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "kingbase" + agentDatabaseFactory = func() db.Database { + return &db.KingbaseDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_mariadb.go b/cmd/optional-driver-agent/provider_mariadb.go new file mode 100644 index 0000000..49d6e77 --- /dev/null +++ b/cmd/optional-driver-agent/provider_mariadb.go @@ -0,0 +1,12 @@ +//go:build gonavi_mariadb_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "mariadb" + agentDatabaseFactory = func() db.Database { + return &db.MariaDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_mongodb.go b/cmd/optional-driver-agent/provider_mongodb.go new file mode 100644 index 0000000..672f829 --- /dev/null +++ b/cmd/optional-driver-agent/provider_mongodb.go @@ -0,0 +1,12 @@ +//go:build gonavi_mongodb_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "mongodb" + agentDatabaseFactory = func() db.Database { + return &db.MongoDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_mysql.go b/cmd/optional-driver-agent/provider_mysql.go new file mode 100644 index 0000000..a790bef --- /dev/null +++ b/cmd/optional-driver-agent/provider_mysql.go @@ -0,0 +1,12 @@ +//go:build gonavi_mysql_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "mysql" + agentDatabaseFactory = func() db.Database { + return &db.MySQLDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_sphinx.go b/cmd/optional-driver-agent/provider_sphinx.go new file mode 100644 index 0000000..6bf042a --- /dev/null +++ b/cmd/optional-driver-agent/provider_sphinx.go @@ -0,0 +1,12 @@ +//go:build gonavi_sphinx_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "sphinx" + agentDatabaseFactory = func() db.Database { + return &db.SphinxDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_sqlite.go b/cmd/optional-driver-agent/provider_sqlite.go new file mode 100644 index 0000000..ebd734a --- /dev/null +++ b/cmd/optional-driver-agent/provider_sqlite.go @@ -0,0 +1,12 @@ +//go:build gonavi_sqlite_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "sqlite" + agentDatabaseFactory = func() db.Database { + return &db.SQLiteDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_sqlserver.go b/cmd/optional-driver-agent/provider_sqlserver.go new file mode 100644 index 0000000..c4ee88f --- /dev/null +++ b/cmd/optional-driver-agent/provider_sqlserver.go @@ -0,0 +1,12 @@ +//go:build gonavi_sqlserver_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "sqlserver" + agentDatabaseFactory = func() db.Database { + return &db.SqlServerDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_tdengine.go b/cmd/optional-driver-agent/provider_tdengine.go new file mode 100644 index 0000000..b8195a5 --- /dev/null +++ b/cmd/optional-driver-agent/provider_tdengine.go @@ -0,0 +1,12 @@ +//go:build gonavi_tdengine_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "tdengine" + agentDatabaseFactory = func() db.Database { + return &db.TDengineDB{} + } +} diff --git a/cmd/optional-driver-agent/provider_vastbase.go b/cmd/optional-driver-agent/provider_vastbase.go new file mode 100644 index 0000000..e166aae --- /dev/null +++ b/cmd/optional-driver-agent/provider_vastbase.go @@ -0,0 +1,12 @@ +//go:build gonavi_vastbase_driver + +package main + +import "GoNavi-Wails/internal/db" + +func init() { + agentDriverType = "vastbase" + agentDatabaseFactory = func() db.Database { + return &db.VastbaseDB{} + } +} diff --git a/docs/HighGo_Optional_Code_Changes.md b/docs/HighGo_Optional_Code_Changes.md deleted file mode 100644 index 2fcd44e..0000000 --- a/docs/HighGo_Optional_Code_Changes.md +++ /dev/null @@ -1,164 +0,0 @@ -# HighGo 可选代码优化建议 - -## 一、sslmode 配置优化 - -### 当前状态 - -**文件**:`internal/db/highgo_impl.go:43` - -**当前代码**: -```go -q.Set("sslmode", "disable") -``` - -### 建议修改 - -根据瀚高官方文档,sslmode 的默认值应该是 `require`。建议修改为: - -```go -q.Set("sslmode", "require") -``` - -### 修改原因 - -1. **符合官方规范**:瀚高官方文档明确指出默认 sslmode 为 `require` -2. **安全性提升**:启用 SSL 加密可以保护数据传输安全 -3. **生产环境最佳实践**:生产环境应该启用 SSL 连接 - -### 是否需要修改? - -**不一定需要修改**,取决于您的实际环境: - -#### 保持 `disable` 的场景: -- ✅ 开发/测试环境 -- ✅ HighGo 服务器未配置 SSL 证书 -- ✅ 内网环境,不需要加密传输 -- ✅ 快速测试连接功能 - -#### 修改为 `require` 的场景: -- ✅ 生产环境 -- ✅ HighGo 服务器已配置 SSL 证书 -- ✅ 跨网络连接,需要加密保护 -- ✅ 符合安全合规要求 - -### 如何修改 - -如果您决定修改,可以使用以下命令: - -**方式 1:直接修改(固定为 require)** -```go -// 文件:internal/db/highgo_impl.go 第 43 行 -q.Set("sslmode", "require") -``` - -**方式 2:可配置(推荐)** - -如果希望让用户可以选择 sslmode,可以修改为: - -```go -// 在 getDSN 方法中 -sslmode := "disable" // 默认值 -if config.SSLMode != "" { - sslmode = config.SSLMode -} -q.Set("sslmode", sslmode) -``` - -然后在 `internal/connection/connection.go` 的 `ConnectionConfig` 结构体中添加字段: - -```go -type ConnectionConfig struct { - // ... 现有字段 - SSLMode string `json:"sslMode,omitempty"` // SSL 模式:disable, require, verify-ca, verify-full -} -``` - -前端 UI 也需要相应添加 sslmode 选择控件。 - -### 测试建议 - -修改后请务必测试: - -1. **SSL 启用测试**: - - 连接配置了 SSL 的 HighGo 服务器 - - 验证连接成功 - -2. **SSL 禁用测试**: - - 连接未配置 SSL 的 HighGo 服务器 - - 验证是否会报错(如果设置为 `require` 会报错) - -3. **兼容性测试**: - - 测试现有的 HighGo 连接配置是否仍然可用 - -## 二、其他可选优化 - -### 1. 默认端口提示优化 - -**文件**:`frontend/src/components/ConnectionModal.tsx` - -**当前状态**:HighGo 的默认端口已正确设置为 5866 - -**建议**:无需修改,已符合官方规范 - -### 2. 默认数据库名称 - -**文件**:`internal/db/highgo_impl.go:33` - -**当前代码**: -```go -if dbname == "" { - dbname = "highgo" // HighGo default database -} -``` - -**建议**:无需修改,已符合官方规范(默认数据库为 `highgo`) - -### 3. 默认用户名 - -**当前状态**:未在代码中硬编码默认用户名 - -**瀚高官方默认**:`sysdba` - -**建议**: -- 可以在前端 UI 的 HighGo 连接表单中,将用户名输入框的 placeholder 设置为 `sysdba` -- 但不建议硬编码默认值,让用户自行输入更安全 - -## 三、总结 - -### 必须修改的项目 -- ✅ **无**(当前代码已基本符合规范) - -### 建议修改的项目 -1. **sslmode 配置**(根据实际环境决定) - - 开发环境:保持 `disable` - - 生产环境:修改为 `require` - -### 可选优化的项目 -1. 将 sslmode 改为可配置(需要修改前后端) -2. 前端 UI 添加 sslmode 选择控件 -3. 用户名输入框添加 `sysdba` 提示 - -## 四、修改优先级 - -**优先级 1(高)**: -- 集成瀚高 SM3 驱动(参考 `HighGo_SM3_Integration_Guide.md`) - -**优先级 2(中)**: -- 根据部署环境调整 sslmode 配置 - -**优先级 3(低)**: -- 将 sslmode 改为可配置 -- UI 优化(placeholder 提示等) - -## 五、下一步行动 - -建议按以下顺序执行: - -1. **先集成 SM3 驱动**(参考集成指南) -2. **测试基本连接功能**(使用 sslmode=disable) -3. **如果生产环境需要 SSL**,再修改 sslmode 配置 -4. **验证所有功能正常**后,考虑可选优化项 - ---- - -**注意**:所有代码修改都应该在集成 SM3 驱动并验证基本功能正常后再进行。 diff --git a/docs/HighGo_SM3_Integration_Guide.md b/docs/HighGo_SM3_Integration_Guide.md deleted file mode 100644 index 78d0842..0000000 --- a/docs/HighGo_SM3_Integration_Guide.md +++ /dev/null @@ -1,196 +0,0 @@ -# HighGo SM3 国密驱动集成指南 - -## 一、背景说明 - -HighGo(瀚高)数据库需要使用支持 SM3 国密认证的 PostgreSQL 驱动。瀚高官方提供了基于 `lib/pq` 的安全增强版本。 - -## 二、集成步骤 - -### 步骤 1:下载瀚高 pq 驱动 - -1. 访问百度网盘链接: - ``` - https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj - ``` - -2. 下载驱动源码压缩包 - -### 步骤 2:放置驱动源码 - -1. 在项目根目录创建目录(如果不存在): - ```bash - mkdir -p third_party/highgo-pq - ``` - -2. 解压下载的驱动源码到 `third_party/highgo-pq/` 目录 - -3. 确保目录结构如下: - ``` - GoNavi/ - ├── third_party/ - │ └── highgo-pq/ - │ ├── go.mod - │ ├── conn.go - │ ├── ... (其他 pq 驱动源文件) - ``` - -### 步骤 3:修改 go.mod - -在 `go.mod` 中添加独立的 HighGo 驱动依赖与本地替换: - -```go -require github.com/highgo/pq-sm3 v0.0.0 -replace github.com/highgo/pq-sm3 => ./third_party/highgo-pq -``` - -完整示例: -```go -module GoNavi-Wails - -go 1.24.3 - -require ( - // ... 现有依赖 - github.com/lib/pq v1.11.1 - github.com/highgo/pq-sm3 v0.0.0 - // ... 其他依赖 -) - -// 在文件末尾添加 -replace github.com/highgo/pq-sm3 => ./third_party/highgo-pq -``` - -并将 `third_party/highgo-pq/go.mod` 的 module 修改为: - -```go -module github.com/highgo/pq-sm3 -``` - -同时在驱动源码中把注册名改为 `highgo`,确保不覆盖 `postgres`: - -```go -sql.Register("highgo", &Driver{}) -``` - -### 步骤 4:更新 HighGo 连接配置(可选) - -根据瀚高官方文档,建议修改 `internal/db/highgo_impl.go:43` 的 sslmode: - -**当前代码**: -```go -q.Set("sslmode", "disable") -``` - -**建议修改为**(瀚高默认): -```go -q.Set("sslmode", "require") -``` - -> ⚠️ 注意:如果您的 HighGo 服务器未配置 SSL,保持 `disable` 即可。 - -### 步骤 5:验证集成 - -1. 清理依赖缓存: - ```bash - go clean -modcache - ``` - -2. 重新下载依赖: - ```bash - go mod download - ``` - -3. 编译项目: - ```bash - go build ./... - ``` - -4. 测试 HighGo 连接: - - 启动应用 - - 创建 HighGo 连接 - - 测试连接是否成功 - -## 三、重要说明 - -### ⚠️ 影响范围 - -采用独立驱动名后,影响范围如下: - -1. **PostgreSQL 继续使用原生 `github.com/lib/pq`** -2. **HighGo 使用 `github.com/highgo/pq-sm3`(本地替换到官方源码)** -3. 两条连接链路互不覆盖,降低兼容性风险 - -### 兼容性验证 - -集成后,请务必测试: - -1. ✅ HighGo 数据库连接(SM3 认证) -2. ✅ 标准 PostgreSQL 连接(确保仍然可用) - -若 PostgreSQL 或 HighGo 任一连接异常,优先检查驱动注册名与 `go.mod` replace 是否一致。 - -### 回滚方案 - -如果集成后出现问题,可以快速回滚: - -1. 删除 `go.mod` 中的 replace 指令 -2. 删除 `go.mod` 中 `github.com/highgo/pq-sm3` 的 require -3. 删除 `third_party/highgo-pq/` 目录 -4. 运行 `go mod tidy` -5. 重新编译 - -## 四、瀚高驱动特性 - -根据官方文档: - -- **项目内包路径**:`github.com/highgo/pq-sm3`(映射到本地 `third_party/highgo-pq`) -- **驱动名**:`highgo`(项目内独立注册,避免覆盖 `postgres`) -- **SM3 支持**:自动启用国密认证 -- **默认端口**:5866 -- **默认数据库**:`highgo` -- **默认用户**:`sysdba` -- **sslmode 默认**:`require` - -## 五、故障排查 - -### 问题 1:编译失败 - -**现象**:`go build` 报错找不到 `github.com/highgo/pq-sm3` - -**解决**: -1. 检查 `third_party/highgo-pq/` 目录是否存在 -2. 检查 `go.mod` 中 `github.com/highgo/pq-sm3` 的 require/replace 是否正确 -3. 运行 `go mod download` - -### 问题 2:HighGo 连接失败 - -**现象**:连接 HighGo 时报认证错误 - -**解决**: -1. 确认瀚高驱动已正确替换(检查 `go.mod`) -2. 确认项目内驱动注册名为 `highgo` -3. 确认 HighGo 服务器支持 SM3 认证 -4. 检查用户名、密码、端口是否正确 - -### 问题 3:PostgreSQL 连接失败 - -**现象**:集成后标准 PostgreSQL 无法连接 - -**解决**: -1. 检查是否误将 `github.com/lib/pq` 全局 replace 到 HighGo 驱动 -2. 确认 PostgreSQL 仍使用 `sql.Open("postgres", dsn)` -3. 确认 HighGo 使用 `sql.Open("highgo", dsn)` - -## 六、后续优化建议 - -如果后续需要增强,可考虑: - -1. 将 HighGo `sslmode` 做成可配置项(前后端联动) -2. 增加 HighGo/PG 驱动链路健康检查项 -3. 联系瀚高技术支持确认 SM3 + SSL 最佳参数组合 - -## 七、参考资料 - -- 瀚高官方文档:https://www.highgo.com/document/zh-cn/application/pq%E6%8E%A5%E5%8F%A3.html -- 瀚高驱动下载:https://pan.baidu.com/s/1xuz6uJz0utRgKWecXhpOiA?pwd=o0tj -- 标准 lib/pq:https://github.com/lib/pq diff --git a/docs/driver-manifest.json b/docs/driver-manifest.json new file mode 100644 index 0000000..ae4b5c9 --- /dev/null +++ b/docs/driver-manifest.json @@ -0,0 +1,83 @@ +{ + "engine": "go", + "drivers": { + "mariadb": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/mariadb" + }, + "diros": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/diros" + }, + "sphinx": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/sphinx" + }, + "sqlserver": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/sqlserver" + }, + "sqlite": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/sqlite" + }, + "duckdb": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/duckdb" + }, + "dameng": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/dameng" + }, + "kingbase": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/kingbase" + }, + "highgo": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/highgo" + }, + "vastbase": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/vastbase" + }, + "mongodb": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/mongodb" + }, + "tdengine": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/tdengine" + }, + "postgres": { + "engine": "go", + "version": "go-embedded", + "checksumPolicy": "off", + "downloadUrl": "builtin://activate/postgres" + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c73e0f..2125400 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; import DataSyncModal from './components/DataSyncModal'; +import DriverManagerModal from './components/DriverManagerModal'; import LogPanel from './components/LogPanel'; import { useStore } from './store'; import { SavedConnection } from './types'; @@ -19,6 +20,7 @@ const { Sider, Content } = Layout; function App() { const [isModalOpen, setIsModalOpen] = useState(false); const [isSyncModalOpen, setIsSyncModalOpen] = useState(false); + const [isDriverModalOpen, setIsDriverModalOpen] = useState(false); const [editingConnection, setEditingConnection] = useState(null); const themeMode = useStore(state => state.theme); const setTheme = useStore(state => state.setTheme); @@ -378,6 +380,12 @@ function App() { label: '数据同步', icon: , onClick: () => setIsSyncModalOpen(true) + }, + { + key: 'drivers', + label: '驱动管理', + icon: , + onClick: () => setIsDriverModalOpen(true) } ]; @@ -467,6 +475,12 @@ function App() { setEditingConnection(null); }; + const handleOpenDriverManagerFromConnection = () => { + setIsModalOpen(false); + setEditingConnection(null); + setIsDriverModalOpen(true); + }; + const handleTitleBarDoubleClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement | null; if (target?.closest('[data-no-titlebar-toggle="true"]')) { @@ -793,11 +807,16 @@ function App() { open={isModalOpen} onClose={handleCloseModal} initialValues={editingConnection} + onOpenDriverManager={handleOpenDriverManagerFromConnection} /> setIsSyncModalOpen(false)} /> + setIsDriverModalOpen(false)} + /> { const isFileDatabaseType = (type: string) => type === 'sqlite' || type === 'duckdb'; -const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialValues?: SavedConnection | null }> = ({ open, onClose, initialValues }) => { +type DriverStatusSnapshot = { + type: string; + name: string; + connectable: boolean; + message?: string; +}; + +const normalizeDriverType = (value: string): string => { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'postgresql') return 'postgres'; + if (normalized === 'doris') return 'diros'; + return normalized; +}; + +const ConnectionModal: React.FC<{ + open: boolean; + onClose: () => void; + initialValues?: SavedConnection | null; + onOpenDriverManager?: () => void; +}> = ({ open, onClose, initialValues, onOpenDriverManager }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [useSSH, setUseSSH] = useState(false); @@ -48,6 +67,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const [mongoMembers, setMongoMembers] = useState([]); const [discoveringMembers, setDiscoveringMembers] = useState(false); const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null); + const [typeSelectWarning, setTypeSelectWarning] = useState<{ driverName: string; reason: string } | null>(null); + const [driverStatusMap, setDriverStatusMap] = useState>({}); + const [driverStatusLoaded, setDriverStatusLoaded] = useState(false); const testInFlightRef = useRef(false); const testTimerRef = useRef(null); const addConnection = useStore((state) => state.addConnection); @@ -56,6 +78,70 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; + const fetchDriverStatusMap = async (): Promise> => { + const result: Record = {}; + const res = await GetDriverStatusList('', ''); + if (!res?.success) { + return result; + } + const data = (res?.data || {}) as any; + const drivers = Array.isArray(data.drivers) ? data.drivers : []; + drivers.forEach((item: any) => { + const type = normalizeDriverType(String(item.type || '').trim()); + if (!type) return; + result[type] = { + type, + name: String(item.name || item.type || type).trim(), + connectable: !!item.connectable, + message: String(item.message || '').trim() || undefined, + }; + }); + return result; + }; + + const refreshDriverStatus = async () => { + try { + const next = await fetchDriverStatusMap(); + setDriverStatusMap(next); + } catch { + setDriverStatusMap({}); + } finally { + setDriverStatusLoaded(true); + } + }; + + const resolveDriverUnavailableReason = async (type: string): Promise => { + const normalized = normalizeDriverType(type); + if (!normalized || normalized === 'custom') { + return ''; + } + let snapshot = driverStatusMap; + if (!snapshot[normalized]) { + snapshot = await fetchDriverStatusMap(); + setDriverStatusMap(snapshot); + } + const status = snapshot[normalized]; + if (!status || status.connectable) { + return ''; + } + return status.message || `${status.name || normalized} 驱动未安装启用,请先在驱动管理中安装`; + }; + + const promptInstallDriver = (driverType: string, reason: string) => { + const normalized = normalizeDriverType(driverType); + const snapshot = driverStatusMap[normalized]; + const driverName = snapshot?.name || normalized || '当前'; + Modal.confirm({ + title: `${driverName} 驱动不可用`, + content: reason || `${driverName} 驱动未安装启用,请先在驱动管理中安装`, + okText: '去驱动管理安装', + cancelText: '取消', + onOk: () => { + onOpenDriverManager?.(); + }, + }); + }; + const parseHostPort = (raw: string, defaultPort: number): { host: string; port: number } | null => { const text = String(raw || '').trim(); if (!text) { @@ -507,6 +593,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal setRedisDbList([]); setMongoMembers([]); setUriFeedback(null); + setTypeSelectWarning(null); + setDriverStatusLoaded(false); + void refreshDriverStatus(); if (initialValues) { // Edit mode: Go directly to step 2 setStep(2); @@ -588,6 +677,12 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const handleOk = async () => { try { const values = await form.validateFields(); + const unavailableReason = await resolveDriverUnavailableReason(values.type); + if (unavailableReason) { + message.warning(unavailableReason); + promptInstallDriver(values.type, unavailableReason); + return; + } setLoading(true); const config = await buildConfig(values, true); @@ -641,6 +736,13 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal testInFlightRef.current = true; try { const values = await form.validateFields(); + const unavailableReason = await resolveDriverUnavailableReason(values.type); + if (unavailableReason) { + const failMessage = buildTestFailureMessage(unavailableReason, '驱动未安装启用'); + setTestResult({ type: 'error', message: failMessage }); + promptInstallDriver(values.type, unavailableReason); + return; + } setLoading(true); setTestResult(null); const config = await buildConfig(values, false); @@ -845,7 +947,15 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal }; }; - const handleTypeSelect = (type: string) => { + const handleTypeSelect = async (type: string) => { + const unavailableReason = await resolveDriverUnavailableReason(type); + if (unavailableReason) { + const normalized = normalizeDriverType(type); + const driverName = driverStatusMap[normalized]?.name || type; + setTypeSelectWarning({ driverName, reason: unavailableReason }); + return; + } + setTypeSelectWarning(null); setDbType(type); form.setFieldsValue({ type: type }); @@ -877,6 +987,14 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const isFileDb = isFileDatabaseType(dbType); const isCustom = dbType === 'custom'; const isRedis = dbType === 'redis'; + const currentDriverType = normalizeDriverType(dbType); + const currentDriverSnapshot = driverStatusMap[currentDriverType]; + const currentDriverUnavailableReason = currentDriverType !== 'custom' + && currentDriverSnapshot + && !currentDriverSnapshot.connectable + ? (currentDriverSnapshot.message || `${currentDriverSnapshot.name || dbType} 驱动未安装启用`) + : ''; + const driverStatusChecking = currentDriverType !== 'custom' && !driverStatusLoaded && step === 2; const dbTypeGroups = [ { label: '关系型数据库', items: [ @@ -911,6 +1029,24 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal const dbTypes = dbTypeGroups.flatMap(g => g.items); const renderStep1 = () => ( +
+ {typeSelectWarning && ( + + {typeSelectWarning.reason} + + + )} + onClose={() => setTypeSelectWarning(null)} + /> + )}
{/* 左侧分类导航 */}
@@ -941,7 +1077,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal handleTypeSelect(item.key)} + onClick={() => { void handleTypeSelect(item.key); }} style={{ textAlign: 'center', cursor: 'pointer', height: 100 }} styles={{ body: { padding: '16px 8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' } }} > @@ -953,6 +1089,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
+
); const renderStep2 = () => ( @@ -1032,6 +1169,22 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal style={{ marginBottom: 12 }} /> )} + {currentDriverUnavailableReason && ( + + {currentDriverUnavailableReason} + + + )} + /> + )} {isCustom ? ( <> @@ -1342,6 +1495,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal } const isTestSuccess = testResult?.type === 'success'; const hasTestError = !!testResult && !isTestSuccess; + const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking; return (
@@ -1387,9 +1541,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal )}
- + - +
); diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx new file mode 100644 index 0000000..a36d412 --- /dev/null +++ b/frontend/src/components/DriverManagerModal.tsx @@ -0,0 +1,299 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Modal, Progress, Space, Table, Tag, Typography, message } from 'antd'; +import { DeleteOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; +import { EventsOn } from '../../wailsjs/runtime/runtime'; +import { + DownloadDriverPackage, + GetDriverStatusList, + RemoveDriverPackage, +} from '../../wailsjs/go/app/App'; + +const { Text } = Typography; + +type DriverStatusRow = { + type: string; + name: string; + builtIn: boolean; + packageSizeText?: string; + runtimeAvailable: boolean; + packageInstalled: boolean; + connectable: boolean; + defaultDownloadUrl?: string; + message?: string; +}; + +type DriverProgressEvent = { + driverType?: string; + status?: 'start' | 'downloading' | 'done' | 'error'; + message?: string; + percent?: number; +}; + +type ProgressState = { + status: 'start' | 'downloading' | 'done' | 'error'; + message: string; + percent: number; +}; + +const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { + const [loading, setLoading] = useState(false); + const [downloadDir, setDownloadDir] = useState(''); + const [rows, setRows] = useState([]); + const [actionDriver, setActionDriver] = useState(''); + const [progressMap, setProgressMap] = useState>({}); + + const refreshStatus = useCallback(async (toastOnError = true) => { + setLoading(true); + try { + const res = await GetDriverStatusList(downloadDir, ''); + if (!res?.success) { + if (toastOnError) { + message.error(res?.message || '拉取驱动状态失败'); + } + return; + } + + const data = (res?.data || {}) as any; + const resolvedDir = String(data.downloadDir || '').trim(); + const drivers = Array.isArray(data.drivers) ? data.drivers : []; + + if (resolvedDir) { + setDownloadDir(resolvedDir); + } + + const nextRows: DriverStatusRow[] = drivers.map((item: any) => ({ + type: String(item.type || '').trim(), + name: String(item.name || item.type || '').trim(), + builtIn: !!item.builtIn, + packageSizeText: String(item.packageSizeText || '').trim() || undefined, + runtimeAvailable: !!item.runtimeAvailable, + packageInstalled: !!item.packageInstalled, + connectable: !!item.connectable, + defaultDownloadUrl: String(item.defaultDownloadUrl || '').trim() || undefined, + message: String(item.message || '').trim() || undefined, + })); + setRows(nextRows); + } catch (err: any) { + if (toastOnError) { + message.error(`拉取驱动状态失败:${err?.message || String(err)}`); + } + } finally { + setLoading(false); + } + }, [downloadDir]); + + useEffect(() => { + if (!open) { + return; + } + refreshStatus(false); + }, [open, refreshStatus]); + + useEffect(() => { + if (!open) { + return; + } + const off = EventsOn('driver:download-progress', (event: DriverProgressEvent) => { + if (!event) { + return; + } + const driverType = String(event.driverType || '').trim().toLowerCase(); + const status = event.status; + if (!driverType || !status) { + return; + } + const messageText = String(event.message || '').trim(); + const percent = Math.max(0, Math.min(100, Number(event.percent || 0))); + setProgressMap((prev) => ({ + ...prev, + [driverType]: { + status, + message: messageText, + percent, + }, + })); + }); + return () => { + off(); + }; + }, [open]); + + const installDriver = useCallback(async (row: DriverStatusRow) => { + setActionDriver(row.type); + setProgressMap((prev) => ({ + ...prev, + [row.type]: { + status: 'start', + message: '开始安装', + percent: 0, + }, + })); + try { + const result = await DownloadDriverPackage(row.type, '', downloadDir); + if (!result?.success) { + message.error(result?.message || `安装 ${row.name} 失败`); + return; + } + message.success(`${row.name} 已安装启用`); + refreshStatus(false); + } finally { + setActionDriver(''); + } + }, [downloadDir, refreshStatus]); + + const removeDriver = useCallback(async (row: DriverStatusRow) => { + setActionDriver(row.type); + try { + const result = await RemoveDriverPackage(row.type, downloadDir); + if (!result?.success) { + message.error(result?.message || `移除 ${row.name} 失败`); + return; + } + message.success(`${row.name} 已移除`); + setProgressMap((prev) => { + const next = { ...prev }; + delete next[row.type]; + return next; + }); + refreshStatus(false); + } finally { + setActionDriver(''); + } + }, [downloadDir, refreshStatus]); + + const columns = useMemo(() => { + return [ + { + title: '数据源', + dataIndex: 'name', + key: 'name', + width: 150, + }, + { + title: '安装包大小', + dataIndex: 'packageSizeText', + key: 'packageSizeText', + width: 120, + render: (_: string | undefined, row: DriverStatusRow) => row.packageSizeText || '-', + }, + { + title: '状态', + key: 'status', + width: 140, + render: (_: string, row: DriverStatusRow) => { + if (row.builtIn) { + return 内置可用; + } + const progress = progressMap[row.type]; + if (progress && (progress.status === 'start' || progress.status === 'downloading')) { + return 安装中 {Math.round(progress.percent)}%; + } + if (row.connectable) { + return 已启用; + } + if (row.packageInstalled) { + return 已安装; + } + return 未启用; + }, + }, + { + title: '安装进度', + key: 'progress', + width: 170, + render: (_: string, row: DriverStatusRow) => { + if (row.builtIn) { + return -; + } + + const progress = progressMap[row.type]; + let percent = 0; + let status: 'normal' | 'exception' | 'active' | 'success' = 'normal'; + + if (progress?.status === 'error') { + percent = Math.max(0, Math.min(100, Math.round(progress.percent || 0))); + status = 'exception'; + } else if (progress && (progress.status === 'start' || progress.status === 'downloading')) { + percent = Math.max(1, Math.min(99, Math.round(progress.percent || 0))); + status = 'active'; + } else if (row.connectable || row.packageInstalled) { + percent = 100; + status = 'success'; + } + + return ; + }, + }, + { + title: '操作', + key: 'actions', + width: 190, + render: (_: string, row: DriverStatusRow) => { + if (row.builtIn) { + return -; + } + const isSlimBuildUnavailable = (row.message || '').includes('精简构建'); + const loadingAction = actionDriver === row.type; + if (isSlimBuildUnavailable && !row.packageInstalled) { + return 需 Full 版; + } + if (row.connectable) { + return ( + + ); + } + return ( + + ); + }, + }, + ]; + }, [actionDriver, installDriver, progressMap, removeDriver]); + + return ( + } onClick={() => refreshStatus(true)} loading={loading}> + 刷新 + , + , + ]} + > + + 除 MySQL / Redis / Oracle / PostgreSQL 外,其他数据源需先安装启用后再连接。 + + + + + ); +}; + +export default DriverManagerModal; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 52f5a20..f954704 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -8,6 +8,8 @@ export function ApplyChanges(arg1:connection.ConnectionConfig,arg2:string,arg3:s export function CheckForUpdates():Promise; +export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; + export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; export function DBConnect(arg1:connection.ConnectionConfig):Promise; @@ -36,6 +38,8 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise; +export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string):Promise; + export function DownloadUpdate():Promise; export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; @@ -60,12 +64,16 @@ export function ExportTablesSQL(arg1:connection.ConnectionConfig,arg2:string,arg export function GetAppInfo():Promise; +export function GetDriverStatusList(arg1:string,arg2:string):Promise; + export function ImportConfigFile():Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string):Promise; + export function InstallUpdateAndRestart():Promise; export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise; @@ -130,12 +138,24 @@ export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:A export function RedisZSetRemove(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; +export function RemoveDriverPackage(arg1:string,arg2:string):Promise; + export function RenameDatabase(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function ResolveDriverDownloadDirectory(arg1:string):Promise; + +export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise; + +export function ResolveDriverRepositoryURL(arg1:string):Promise; + +export function SelectDriverDownloadDirectory(arg1:string):Promise; + +export function SelectDriverPackageFile(arg1:string):Promise; + export function SetWindowTranslucency(arg1:number,arg2:number):Promise; export function TestConnection(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 4d33434..fee9789 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -10,6 +10,10 @@ export function CheckForUpdates() { return window['go']['app']['App']['CheckForUpdates'](); } +export function ConfigureDriverRuntimeDirectory(arg1) { + return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1); +} + export function CreateDatabase(arg1, arg2) { return window['go']['app']['App']['CreateDatabase'](arg1, arg2); } @@ -66,6 +70,10 @@ export function DataSyncPreview(arg1, arg2, arg3) { return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3); } +export function DownloadDriverPackage(arg1, arg2, arg3) { + return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3); +} + export function DownloadUpdate() { return window['go']['app']['App']['DownloadUpdate'](); } @@ -114,6 +122,10 @@ export function GetAppInfo() { return window['go']['app']['App']['GetAppInfo'](); } +export function GetDriverStatusList(arg1, arg2) { + return window['go']['app']['App']['GetDriverStatusList'](arg1, arg2); +} + export function ImportConfigFile() { return window['go']['app']['App']['ImportConfigFile'](); } @@ -126,6 +138,10 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4); } +export function InstallLocalDriverPackage(arg1, arg2, arg3) { + return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3); +} + export function InstallUpdateAndRestart() { return window['go']['app']['App']['InstallUpdateAndRestart'](); } @@ -254,6 +270,10 @@ export function RedisZSetRemove(arg1, arg2, arg3) { return window['go']['app']['App']['RedisZSetRemove'](arg1, arg2, arg3); } +export function RemoveDriverPackage(arg1, arg2) { + return window['go']['app']['App']['RemoveDriverPackage'](arg1, arg2); +} + export function RenameDatabase(arg1, arg2, arg3) { return window['go']['app']['App']['RenameDatabase'](arg1, arg2, arg3); } @@ -266,6 +286,26 @@ export function RenameView(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['RenameView'](arg1, arg2, arg3, arg4); } +export function ResolveDriverDownloadDirectory(arg1) { + return window['go']['app']['App']['ResolveDriverDownloadDirectory'](arg1); +} + +export function ResolveDriverPackageDownloadURL(arg1, arg2) { + return window['go']['app']['App']['ResolveDriverPackageDownloadURL'](arg1, arg2); +} + +export function ResolveDriverRepositoryURL(arg1) { + return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1); +} + +export function SelectDriverDownloadDirectory(arg1) { + return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1); +} + +export function SelectDriverPackageFile(arg1) { + return window['go']['app']['App']['SelectDriverPackageFile'](arg1); +} + export function SetWindowTranslucency(arg1, arg2) { return window['go']['app']['App']['SetWindowTranslucency'](arg1, arg2); } diff --git a/internal/app/app.go b/internal/app/app.go index d54ac2e..80c5541 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -198,6 +198,20 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing shortKey = shortKey[:12] } + if supported, reason := db.DriverRuntimeSupportStatus(config.Type); !supported { + if strings.TrimSpace(reason) == "" { + reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(config.Type)) + } + // Best-effort cleanup: if cached instance exists for this exact config, close it. + a.mu.Lock() + if cur, exists := a.dbCache[key]; exists && cur.inst != nil { + _ = cur.inst.Close() + delete(a.dbCache, key) + } + a.mu.Unlock() + return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()} + } + a.mu.RLock() entry, ok := a.dbCache[key] a.mu.RUnlock() diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go new file mode 100644 index 0000000..7c2cec9 --- /dev/null +++ b/internal/app/methods_driver.go @@ -0,0 +1,1657 @@ +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + stdRuntime "runtime" + "strings" + "sync" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type driverDefinition struct { + Type string `json:"type"` + Name string `json:"name"` + Engine string `json:"engine,omitempty"` + BuiltIn bool `json:"builtIn"` + PinnedVersion string `json:"pinnedVersion,omitempty"` + DefaultDownloadURL string `json:"defaultDownloadUrl,omitempty"` + DownloadSHA256 string `json:"downloadSha256,omitempty"` + ChecksumPolicy string `json:"checksumPolicy,omitempty"` +} + +type installedDriverPackage struct { + DriverType string `json:"driverType"` + FilePath string `json:"filePath"` + FileName string `json:"fileName"` + ExecutablePath string `json:"executablePath,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` + SHA256 string `json:"sha256,omitempty"` + DownloadedAt string `json:"downloadedAt"` +} + +type driverStatusItem struct { + Type string `json:"type"` + Name string `json:"name"` + Engine string `json:"engine,omitempty"` + BuiltIn bool `json:"builtIn"` + PinnedVersion string `json:"pinnedVersion,omitempty"` + PackageSizeText string `json:"packageSizeText,omitempty"` + RuntimeAvailable bool `json:"runtimeAvailable"` + PackageInstalled bool `json:"packageInstalled"` + Connectable bool `json:"connectable"` + DefaultDownloadURL string `json:"defaultDownloadUrl,omitempty"` + InstallDir string `json:"installDir,omitempty"` + PackagePath string `json:"packagePath,omitempty"` + PackageFileName string `json:"packageFileName,omitempty"` + ExecutablePath string `json:"executablePath,omitempty"` + DownloadedAt string `json:"downloadedAt,omitempty"` + Message string `json:"message,omitempty"` +} + +const driverDownloadProgressEvent = "driver:download-progress" + +type driverDownloadProgressPayload struct { + DriverType string `json:"driverType"` + Status string `json:"status"` + Percent float64 `json:"percent"` + Downloaded int64 `json:"downloaded"` + Total int64 `json:"total"` + Message string `json:"message,omitempty"` +} + +type pinnedDriverPackage struct { + Version string + DownloadURL string + SHA256 string + Policy string + Engine string +} + +type driverManifestFile struct { + Engine string `json:"engine"` + DefaultEngine string `json:"defaultEngine"` + DefaultEngine2 string `json:"default_engine"` + Drivers map[string]driverManifestItem `json:"drivers"` +} + +type driverManifestItem struct { + Version string `json:"version"` + DownloadURL string `json:"downloadUrl"` + DownloadURL2 string `json:"download_url"` + SHA256 string `json:"sha256"` + ChecksumPolicy string `json:"checksumPolicy"` + ChecksumPolicy2 string `json:"checksum_policy"` + Engine string `json:"engine"` +} + +type driverManifestCacheEntry struct { + LoadedAt time.Time + Packages map[string]pinnedDriverPackage + Err string +} + +type driverReleaseAssetSizeCacheEntry struct { + LoadedAt time.Time + SizeByKey map[string]int64 + Err string +} + +const ( + // 默认使用内置 manifest,避免依赖网络与外部仓库 404。 + defaultDriverManifestURLValue = "builtin://manifest" + driverManifestCacheTTL = 5 * time.Minute + driverReleaseAssetSizeCacheTTL = 30 * time.Minute + driverReleaseAssetSizeErrorCacheTTL = 30 * time.Second + driverReleaseAssetSizeProbeTimeout = 4 * time.Second + driverManifestMaxSize = 2 << 20 + driverChecksumPolicyStrict = "strict" + driverChecksumPolicyWarn = "warn" + driverChecksumPolicyOff = "off" + driverEngineGo = "go" + driverEngineExternal = "external" +) + +const builtinDriverManifestJSON = `{ + "engine": "go", + "drivers": { + "mysql": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off" }, + "mariadb": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mariadb" }, + "diros": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/diros" }, + "sphinx": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sphinx" }, + "sqlserver": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlserver" }, + "sqlite": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/sqlite" }, + "duckdb": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/duckdb" }, + "dameng": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/dameng" }, + "kingbase": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/kingbase" }, + "highgo": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/highgo" }, + "vastbase": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/vastbase" }, + "mongodb": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/mongodb" }, + "tdengine": { "engine": "go", "version": "go-embedded", "checksumPolicy": "off", "downloadUrl": "builtin://activate/tdengine" } + } +}` + +var ( + driverManifestCacheMu sync.RWMutex + driverManifestCache = make(map[string]driverManifestCacheEntry) + driverReleaseSizeMu sync.RWMutex + driverReleaseSizeMap = make(map[string]driverReleaseAssetSizeCacheEntry) +) + +var pinnedDriverPackageMap = map[string]pinnedDriverPackage{ + "postgres": { + Version: "go-embedded", + Policy: driverChecksumPolicyOff, + Engine: driverEngineGo, + }, +} + +func (a *App) SelectDriverDownloadDirectory(currentDir string) connection.QueryResult { + defaultDir := strings.TrimSpace(currentDir) + if defaultDir == "" { + defaultDir = defaultDriverDownloadDirectory() + } else if !filepath.IsAbs(defaultDir) { + if abs, err := filepath.Abs(defaultDir); err == nil { + defaultDir = abs + } + } + + selection, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "选择驱动下载目录", + DefaultDirectory: defaultDir, + CanCreateDirectories: true, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + + resolved, err := resolveDriverDownloadDirectory(selection) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{ + Success: true, + Data: map[string]interface{}{ + "path": resolved, + "defaultPath": defaultDriverDownloadDirectory(), + "isDefaultPath": false, + }, + } +} + +func (a *App) SelectDriverPackageFile(currentPath string) connection.QueryResult { + defaultDir := strings.TrimSpace(currentPath) + if defaultDir == "" { + defaultDir = defaultDriverDownloadDirectory() + } + if filepath.Ext(defaultDir) != "" { + defaultDir = filepath.Dir(defaultDir) + } + if !filepath.IsAbs(defaultDir) { + if abs, err := filepath.Abs(defaultDir); err == nil { + defaultDir = abs + } + } + + selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "选择驱动包文件", + DefaultDirectory: defaultDir, + Filters: []runtime.FileFilter{ + {DisplayName: "所有文件", Pattern: "*"}, + }, + }) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "Cancelled"} + } + + if abs, err := filepath.Abs(selection); err == nil { + selection = abs + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": selection}} +} + +func (a *App) ResolveDriverDownloadDirectory(directory string) connection.QueryResult { + resolved, err := resolveDriverDownloadDirectory(directory) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"path": resolved}} +} + +func (a *App) ConfigureDriverRuntimeDirectory(directory string) connection.QueryResult { + resolved, err := resolveDriverDownloadDirectory(directory) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + db.SetExternalDriverDownloadDirectory(resolved) + return connection.QueryResult{ + Success: true, + Data: map[string]interface{}{ + "path": resolved, + "defaultPath": defaultDriverDownloadDirectory(), + "isDefaultPath": strings.TrimSpace(directory) == "", + }, + Message: "驱动运行时目录已生效", + } +} + +func (a *App) ResolveDriverRepositoryURL(repositoryURL string) connection.QueryResult { + resolved, err := resolveDriverRepositoryURL(repositoryURL) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"url": resolved}} +} + +func (a *App) ResolveDriverPackageDownloadURL(driverType string, repositoryURL string) connection.QueryResult { + effectivePackages, manifestErr := resolveEffectiveDriverPackages(repositoryURL) + definition, ok := resolveDriverDefinitionWithPackages(driverType, effectivePackages) + if !ok { + return connection.QueryResult{Success: false, Message: "不支持的驱动类型"} + } + engine := effectiveDriverEngine(definition) + if definition.BuiltIn { + return connection.QueryResult{Success: false, Message: "内置驱动无需下载扩展包"} + } + if err := ensureOptionalDriverBuildAvailable(definition); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if engine == driverEngineGo && !definition.BuiltIn { + urlText := strings.TrimSpace(definition.DefaultDownloadURL) + if urlText == "" { + urlText = fmt.Sprintf("builtin://activate/%s", definition.Type) + } + data := map[string]interface{}{ + "url": urlText, + "driverType": definition.Type, + "driverName": definition.Name, + "engine": engine, + "manifestError": errorMessage(manifestErr), + } + if strings.TrimSpace(definition.DownloadSHA256) != "" { + data["sha256"] = strings.TrimSpace(definition.DownloadSHA256) + } + return connection.QueryResult{Success: true, Data: data} + } + return connection.QueryResult{Success: false, Message: "当前仅支持纯 Go 可选驱动的安装启用"} +} + +func (a *App) GetDriverStatusList(downloadDir string, manifestURL string) connection.QueryResult { + resolvedDir, err := resolveDriverDownloadDirectory(downloadDir) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + db.SetExternalDriverDownloadDirectory(resolvedDir) + + effectivePackages, manifestErr := resolveEffectiveDriverPackages(manifestURL) + definitions := allDriverDefinitionsWithPackages(effectivePackages) + packageSizeBytesMap := preloadOptionalDriverPackageSizes(definitions) + items := make([]driverStatusItem, 0, len(definitions)) + for _, definition := range definitions { + engine := effectiveDriverEngine(definition) + runtimeAvailable, runtimeReason := db.DriverRuntimeSupportStatus(definition.Type) + pkg, packageMetaExists := readInstalledDriverPackage(resolvedDir, definition.Type) + packageInstalled := definition.BuiltIn || packageMetaExists + if runtimeAvailable && db.IsOptionalGoDriver(definition.Type) { + packageInstalled = true + } + + item := driverStatusItem{ + Type: definition.Type, + Name: definition.Name, + Engine: engine, + BuiltIn: definition.BuiltIn, + PinnedVersion: definition.PinnedVersion, + PackageSizeText: resolveDriverPackageSizeText(definition, pkg, packageMetaExists, packageSizeBytesMap), + RuntimeAvailable: runtimeAvailable, + PackageInstalled: packageInstalled, + Connectable: runtimeAvailable, + DefaultDownloadURL: definition.DefaultDownloadURL, + InstallDir: driverInstallDir(resolvedDir, definition.Type), + } + if packageMetaExists { + item.PackagePath = pkg.FilePath + item.PackageFileName = pkg.FileName + item.DownloadedAt = pkg.DownloadedAt + item.ExecutablePath = pkg.ExecutablePath + } + + switch { + case definition.BuiltIn: + item.Message = "内置驱动,可直接连接" + case runtimeAvailable: + item.Message = "纯 Go 驱动已启用,可直接连接" + case packageInstalled && strings.TrimSpace(runtimeReason) != "": + item.Message = runtimeReason + case packageInstalled: + item.Message = "驱动已安装,待生效" + case strings.TrimSpace(runtimeReason) != "": + item.Message = runtimeReason + default: + if strings.TrimSpace(definition.PinnedVersion) != "" { + item.Message = fmt.Sprintf("未启用(版本:%s)", strings.TrimSpace(definition.PinnedVersion)) + } else { + item.Message = "未启用" + } + } + + items = append(items, item) + } + + return connection.QueryResult{ + Success: true, + Data: map[string]interface{}{ + "downloadDir": resolvedDir, + "drivers": items, + "manifestURL": resolveManifestURLForView(manifestURL), + "manifestError": errorMessage(manifestErr), + }, + } +} + +func (a *App) InstallLocalDriverPackage(driverType string, filePath string, downloadDir string) connection.QueryResult { + definition, ok := resolveDriverDefinition(driverType) + if !ok { + return connection.QueryResult{Success: false, Message: "不支持的驱动类型"} + } + if definition.BuiltIn { + return connection.QueryResult{Success: false, Message: "内置驱动无需安装扩展包"} + } + if err := ensureOptionalDriverBuildAvailable(definition); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + engine := effectiveDriverEngine(definition) + if !(engine == driverEngineGo && !definition.BuiltIn) { + return connection.QueryResult{Success: false, Message: "当前仅支持纯 Go 可选驱动的安装启用"} + } + + resolvedDir, err := resolveDriverDownloadDirectory(downloadDir) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + db.SetExternalDriverDownloadDirectory(resolvedDir) + + hash := "" + if pathText := strings.TrimSpace(filePath); pathText != "" { + if fileHash, hashErr := hashFileSHA256(pathText); hashErr == nil { + hash = fileHash + } + } + + a.emitDriverDownloadProgress(definition.Type, "start", 0, 0, "开始安装") + meta := installedDriverPackage{ + DriverType: definition.Type, + FilePath: "", + FileName: "embedded-go-driver", + DownloadURL: "local://activate", + SHA256: hash, + DownloadedAt: time.Now().Format(time.RFC3339), + } + if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil { + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error()) + return connection.QueryResult{Success: false, Message: err.Error()} + } + a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)") + + return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ + "driverType": definition.Type, + "driverName": definition.Name, + "engine": engine, + }} +} + +func (a *App) DownloadDriverPackage(driverType string, downloadURL string, downloadDir string) connection.QueryResult { + definition, ok := resolveDriverDefinition(driverType) + if !ok { + return connection.QueryResult{Success: false, Message: "不支持的驱动类型"} + } + engine := effectiveDriverEngine(definition) + if definition.BuiltIn { + return connection.QueryResult{Success: false, Message: "内置驱动无需下载扩展包"} + } + if err := ensureOptionalDriverBuildAvailable(definition); err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if !(engine == driverEngineGo && !definition.BuiltIn) { + return connection.QueryResult{Success: false, Message: "当前仅支持纯 Go 可选驱动的安装启用"} + } + + urlText := strings.TrimSpace(downloadURL) + if urlText == "" { + urlText = strings.TrimSpace(definition.DefaultDownloadURL) + } + if urlText == "" { + urlText = fmt.Sprintf("builtin://activate/%s", definition.Type) + } + + resolvedDir, err := resolveDriverDownloadDirectory(downloadDir) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + db.SetExternalDriverDownloadDirectory(resolvedDir) + + if db.IsOptionalGoDriver(definition.Type) { + displayName := strings.TrimSpace(definition.Name) + if displayName == "" { + displayName = strings.TrimSpace(definition.Type) + } + a.emitDriverDownloadProgress(definition.Type, "start", 0, 100, fmt.Sprintf("开始安装 %s 驱动代理", displayName)) + meta, installErr := installOptionalDriverAgentPackage(a, definition, resolvedDir, urlText) + if installErr != nil { + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, installErr.Error()) + return connection.QueryResult{Success: false, Message: installErr.Error()} + } + a.emitDriverDownloadProgress(definition.Type, "downloading", 95, 100, "写入驱动元数据") + if writeErr := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); writeErr != nil { + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, writeErr.Error()) + return connection.QueryResult{Success: false, Message: writeErr.Error()} + } + a.emitDriverDownloadProgress(definition.Type, "done", 100, 100, fmt.Sprintf("%s 驱动代理安装完成", displayName)) + return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ + "driverType": definition.Type, + "driverName": definition.Name, + "engine": engine, + }} + } + + a.emitDriverDownloadProgress(definition.Type, "start", 0, 0, "开始安装") + meta := installedDriverPackage{ + DriverType: definition.Type, + FilePath: "", + FileName: "embedded-go-driver", + DownloadURL: urlText, + SHA256: "", + DownloadedAt: time.Now().Format(time.RFC3339), + } + if err := writeInstalledDriverPackage(resolvedDir, definition.Type, meta); err != nil { + a.emitDriverDownloadProgress(definition.Type, "error", 0, 0, err.Error()) + return connection.QueryResult{Success: false, Message: err.Error()} + } + a.emitDriverDownloadProgress(definition.Type, "done", 1, 1, "安装完成(纯 Go 驱动已启用)") + + return connection.QueryResult{Success: true, Message: "驱动安装成功", Data: map[string]interface{}{ + "driverType": definition.Type, + "driverName": definition.Name, + "engine": engine, + }} +} + +func (a *App) RemoveDriverPackage(driverType string, downloadDir string) connection.QueryResult { + definition, ok := resolveDriverDefinition(driverType) + if !ok { + return connection.QueryResult{Success: false, Message: "不支持的驱动类型"} + } + if definition.BuiltIn { + return connection.QueryResult{Success: false, Message: "内置驱动不可移除"} + } + + resolvedDir, err := resolveDriverDownloadDirectory(downloadDir) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + db.SetExternalDriverDownloadDirectory(resolvedDir) + + driverDir := driverInstallDir(resolvedDir, definition.Type) + if err := os.RemoveAll(driverDir); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("移除驱动包失败:%v", err)} + } + + return connection.QueryResult{Success: true, Message: "驱动包已移除", Data: map[string]interface{}{ + "driverType": definition.Type, + "driverName": definition.Name, + }} +} + +func (a *App) emitDriverDownloadProgress(driverType string, status string, downloaded, total int64, message string) { + if a.ctx == nil { + return + } + payload := driverDownloadProgressPayload{ + DriverType: normalizeDriverType(driverType), + Status: strings.TrimSpace(status), + Percent: 0, + Downloaded: downloaded, + Total: total, + Message: strings.TrimSpace(message), + } + if payload.DriverType == "" { + payload.DriverType = "unknown" + } + if payload.Status == "" { + payload.Status = "downloading" + } + if total > 0 { + payload.Percent = (float64(downloaded) / float64(total)) * 100 + if payload.Percent < 0 { + payload.Percent = 0 + } + if payload.Percent > 100 { + payload.Percent = 100 + } + } + if payload.Status == "done" && payload.Percent < 100 { + payload.Percent = 100 + } + runtime.EventsEmit(a.ctx, driverDownloadProgressEvent, payload) +} + +func defaultDriverDownloadDirectory() string { + root, err := db.ResolveExternalDriverRoot("") + if err == nil && strings.TrimSpace(root) != "" { + return root + } + return filepath.Join(os.TempDir(), "gonavi-drivers") +} + +func resolveDriverDownloadDirectory(directory string) (string, error) { + return db.ResolveExternalDriverRoot(directory) +} + +func normalizeDriverType(driverType string) string { + normalized := strings.ToLower(strings.TrimSpace(driverType)) + switch normalized { + case "doris": + return "diros" + case "postgresql": + return "postgres" + default: + return normalized + } +} + +func normalizeDriverEngine(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case driverEngineGo: + return driverEngineGo + case "jdbc": + return driverEngineExternal + case driverEngineExternal, "exec", "binary": + return driverEngineExternal + default: + return "" + } +} + +func normalizeDriverChecksumPolicy(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case driverChecksumPolicyStrict: + return driverChecksumPolicyStrict + case driverChecksumPolicyOff: + return driverChecksumPolicyOff + case driverChecksumPolicyWarn: + return driverChecksumPolicyWarn + default: + return driverChecksumPolicyWarn + } +} + +func effectiveDriverEngine(definition driverDefinition) string { + if definition.BuiltIn { + return driverEngineGo + } + engine := normalizeDriverEngine(definition.Engine) + if engine == "" { + return driverEngineExternal + } + return engine +} + +func resolveDriverDefinition(driverType string) (driverDefinition, bool) { + return resolveDriverDefinitionWithPackages(driverType, nil) +} + +func resolveDriverDefinitionWithPackages(driverType string, packages map[string]pinnedDriverPackage) (driverDefinition, bool) { + normalized := normalizeDriverType(driverType) + for _, definition := range allDriverDefinitionsWithPackages(packages) { + if normalizeDriverType(definition.Type) == normalized { + return definition, true + } + } + return driverDefinition{}, false +} + +func allDriverDefinitionsWithPackages(packages map[string]pinnedDriverPackage) []driverDefinition { + return []driverDefinition{ + {Type: "mysql", Name: "MySQL", Engine: driverEngineGo, BuiltIn: true}, + {Type: "oracle", Name: "Oracle", Engine: driverEngineGo, BuiltIn: true}, + {Type: "redis", Name: "Redis", Engine: driverEngineGo, BuiltIn: true}, + {Type: "postgres", Name: "PostgreSQL", Engine: driverEngineGo, BuiltIn: true}, + + // 其他数据源需要先在驱动管理中“安装启用”。 + buildOptionalGoDriverDefinition("mariadb", "MariaDB", packages), + buildOptionalGoDriverDefinition("diros", "Diros", packages), + buildOptionalGoDriverDefinition("sphinx", "Sphinx", packages), + buildOptionalGoDriverDefinition("sqlserver", "SQL Server", packages), + buildOptionalGoDriverDefinition("sqlite", "SQLite", packages), + buildOptionalGoDriverDefinition("duckdb", "DuckDB", packages), + buildOptionalGoDriverDefinition("dameng", "Dameng", packages), + buildOptionalGoDriverDefinition("kingbase", "Kingbase", packages), + buildOptionalGoDriverDefinition("highgo", "HighGo", packages), + buildOptionalGoDriverDefinition("vastbase", "Vastbase", packages), + buildOptionalGoDriverDefinition("mongodb", "MongoDB", packages), + buildOptionalGoDriverDefinition("tdengine", "TDengine", packages), + } +} + +func buildOptionalGoDriverDefinition(driverType string, driverName string, packages map[string]pinnedDriverPackage) driverDefinition { + spec := resolvedPinnedPackage(driverType, packages) + return driverDefinition{ + Type: normalizeDriverType(driverType), + Name: driverName, + Engine: driverEngineGo, + BuiltIn: false, + PinnedVersion: strings.TrimSpace(spec.Version), + DefaultDownloadURL: strings.TrimSpace(spec.DownloadURL), + DownloadSHA256: strings.TrimSpace(spec.SHA256), + ChecksumPolicy: normalizeDriverChecksumPolicy(spec.Policy), + } +} + +func ensureOptionalDriverBuildAvailable(definition driverDefinition) error { + driverType := normalizeDriverType(definition.Type) + if !db.IsOptionalGoDriver(driverType) { + return nil + } + if db.IsOptionalGoDriverBuildIncluded(driverType) { + return nil + } + driverName := strings.TrimSpace(definition.Name) + if driverName == "" { + driverName = strings.TrimSpace(definition.Type) + } + return fmt.Errorf("%s 当前发行包为精简构建,未内置该驱动;如需使用请安装 Full 版", driverName) +} + +func driverPinnedPackage(driverType string) pinnedDriverPackage { + spec, ok := pinnedDriverPackageMap[normalizeDriverType(driverType)] + if !ok { + return pinnedDriverPackage{} + } + spec.Version = strings.TrimSpace(spec.Version) + spec.DownloadURL = strings.TrimSpace(spec.DownloadURL) + spec.SHA256 = strings.TrimSpace(spec.SHA256) + spec.Policy = normalizeDriverChecksumPolicy(spec.Policy) + spec.Engine = normalizeDriverEngine(spec.Engine) + return spec +} + +func resolvedPinnedPackage(driverType string, packages map[string]pinnedDriverPackage) pinnedDriverPackage { + normalizedType := normalizeDriverType(driverType) + spec := driverPinnedPackage(normalizedType) + if packages != nil { + override, ok := packages[normalizedType] + if ok { + if strings.TrimSpace(override.Version) != "" { + spec.Version = strings.TrimSpace(override.Version) + } + if strings.TrimSpace(override.DownloadURL) != "" { + spec.DownloadURL = strings.TrimSpace(override.DownloadURL) + } + if strings.TrimSpace(override.SHA256) != "" { + spec.SHA256 = strings.TrimSpace(override.SHA256) + } + if strings.TrimSpace(override.Policy) != "" { + spec.Policy = normalizeDriverChecksumPolicy(override.Policy) + } + if strings.TrimSpace(override.Engine) != "" { + spec.Engine = normalizeDriverEngine(override.Engine) + } + } + } + if normalizedType == "postgres" { + spec.Engine = driverEngineGo + if strings.TrimSpace(spec.Version) == "" { + spec.Version = "go-embedded" + } + if strings.TrimSpace(spec.Policy) == "" { + spec.Policy = driverChecksumPolicyOff + } + } + return spec +} + +func copyPinnedPackageMap(source map[string]pinnedDriverPackage) map[string]pinnedDriverPackage { + if len(source) == 0 { + return map[string]pinnedDriverPackage{} + } + result := make(map[string]pinnedDriverPackage, len(source)) + for key, value := range source { + result[key] = pinnedDriverPackage{ + Version: strings.TrimSpace(value.Version), + DownloadURL: strings.TrimSpace(value.DownloadURL), + SHA256: strings.TrimSpace(value.SHA256), + Policy: normalizeDriverChecksumPolicy(value.Policy), + Engine: normalizeDriverEngine(value.Engine), + } + } + return result +} + +func resolveEffectiveDriverPackages(manifestURL string) (map[string]pinnedDriverPackage, error) { + effective := copyPinnedPackageMap(pinnedDriverPackageMap) + manifestPackages, err := resolveManifestDriverPackages(manifestURL) + if err != nil { + return effective, err + } + for driverType, item := range manifestPackages { + normalizedType := normalizeDriverType(driverType) + base := effective[normalizedType] + if strings.TrimSpace(item.Version) != "" { + base.Version = strings.TrimSpace(item.Version) + } + if strings.TrimSpace(item.DownloadURL) != "" { + base.DownloadURL = strings.TrimSpace(item.DownloadURL) + } + if strings.TrimSpace(item.SHA256) != "" { + base.SHA256 = strings.TrimSpace(item.SHA256) + } + if strings.TrimSpace(item.Policy) != "" { + base.Policy = normalizeDriverChecksumPolicy(item.Policy) + } + if strings.TrimSpace(item.Engine) != "" { + base.Engine = normalizeDriverEngine(item.Engine) + } + effective[normalizedType] = base + } + return effective, nil +} + +func resolveDriverRepositoryURL(repositoryURL string) (string, error) { + urlText := strings.TrimSpace(repositoryURL) + if urlText == "" { + return defaultDriverManifestURLValue, nil + } + parsed, err := url.Parse(urlText) + if err == nil && parsed.Scheme != "" { + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + return parsed.String(), nil + case "file": + if parsed.Path == "" { + return "", fmt.Errorf("无效的文件清单地址") + } + return urlText, nil + case "builtin": + if isBuiltinManifestURL(parsed) { + return defaultDriverManifestURLValue, nil + } + return "", fmt.Errorf("不支持的内置清单地址:%s", parsed.String()) + default: + return "", fmt.Errorf("不支持的清单地址协议:%s", parsed.Scheme) + } + } + absPath, absErr := filepath.Abs(urlText) + if absErr != nil { + return "", absErr + } + return absPath, nil +} + +func resolveManifestURLForView(manifestURL string) string { + resolved, err := resolveDriverRepositoryURL(manifestURL) + if err != nil { + return strings.TrimSpace(manifestURL) + } + return resolved +} + +func resolveManifestDriverPackages(manifestURL string) (map[string]pinnedDriverPackage, error) { + resolvedURL, err := resolveDriverRepositoryURL(manifestURL) + if err != nil { + return nil, err + } + + driverManifestCacheMu.RLock() + cached, ok := driverManifestCache[resolvedURL] + driverManifestCacheMu.RUnlock() + if ok && time.Since(cached.LoadedAt) < driverManifestCacheTTL { + if strings.TrimSpace(cached.Err) != "" { + return nil, errors.New(cached.Err) + } + return copyPinnedPackageMap(cached.Packages), nil + } + + packages, loadErr := loadManifestPackages(resolvedURL) + entry := driverManifestCacheEntry{ + LoadedAt: time.Now(), + Packages: copyPinnedPackageMap(packages), + } + if loadErr != nil { + entry.Err = loadErr.Error() + } + driverManifestCacheMu.Lock() + driverManifestCache[resolvedURL] = entry + driverManifestCacheMu.Unlock() + + if loadErr != nil { + return nil, loadErr + } + return packages, nil +} + +func loadManifestPackages(resolvedURL string) (map[string]pinnedDriverPackage, error) { + content, err := loadManifestContent(resolvedURL) + if err != nil { + return nil, err + } + + var manifest driverManifestFile + if err := json.Unmarshal(content, &manifest); err != nil { + return nil, fmt.Errorf("解析驱动清单失败:%w", err) + } + defaultEngine := normalizeDriverEngine(manifest.Engine) + if defaultEngine == "" { + defaultEngine = normalizeDriverEngine(manifest.DefaultEngine) + } + if defaultEngine == "" { + defaultEngine = normalizeDriverEngine(manifest.DefaultEngine2) + } + + result := make(map[string]pinnedDriverPackage) + for driverType, item := range manifest.Drivers { + normalizedType := normalizeDriverType(driverType) + if normalizedType == "" { + continue + } + downloadURL := strings.TrimSpace(item.DownloadURL) + if downloadURL == "" { + downloadURL = strings.TrimSpace(item.DownloadURL2) + } + policy := strings.TrimSpace(item.ChecksumPolicy) + if policy == "" { + policy = strings.TrimSpace(item.ChecksumPolicy2) + } + engine := normalizeDriverEngine(item.Engine) + if engine == "" { + engine = defaultEngine + } + result[normalizedType] = pinnedDriverPackage{ + Version: strings.TrimSpace(item.Version), + DownloadURL: downloadURL, + SHA256: strings.TrimSpace(item.SHA256), + Policy: normalizeDriverChecksumPolicy(policy), + Engine: engine, + } + } + return result, nil +} + +func loadManifestContent(resolvedURL string) ([]byte, error) { + trimmed := strings.TrimSpace(resolvedURL) + if trimmed == "" { + return nil, fmt.Errorf("驱动清单地址为空") + } + parsed, err := url.Parse(trimmed) + if err == nil { + scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme)) + switch scheme { + case "http", "https": + client := &http.Client{Timeout: 12 * time.Second} + req, reqErr := http.NewRequest(http.MethodGet, parsed.String(), nil) + if reqErr != nil { + return nil, reqErr + } + req.Header.Set("User-Agent", "GoNavi-DriverManifest") + resp, doErr := client.Do(req) + if doErr != nil { + return nil, doErr + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("拉取驱动清单失败:HTTP %d", resp.StatusCode) + } + limited := io.LimitReader(resp.Body, driverManifestMaxSize+1) + body, readErr := io.ReadAll(limited) + if readErr != nil { + return nil, readErr + } + if int64(len(body)) > driverManifestMaxSize { + return nil, fmt.Errorf("驱动清单超过大小限制") + } + return body, nil + case "file": + pathText := strings.TrimSpace(parsed.Path) + if pathText == "" { + return nil, fmt.Errorf("无效的本地驱动清单地址") + } + body, readErr := os.ReadFile(pathText) + if readErr != nil { + return nil, readErr + } + if int64(len(body)) > driverManifestMaxSize { + return nil, fmt.Errorf("驱动清单超过大小限制") + } + return body, nil + case "builtin": + if isBuiltinManifestURL(parsed) { + return []byte(builtinDriverManifestJSON), nil + } + return nil, fmt.Errorf("不支持的内置清单地址:%s", parsed.String()) + } + } + body, readErr := os.ReadFile(trimmed) + if readErr != nil { + return nil, readErr + } + if int64(len(body)) > driverManifestMaxSize { + return nil, fmt.Errorf("驱动清单超过大小限制") + } + return body, nil +} + +func isBuiltinManifestURL(parsed *url.URL) bool { + if parsed == nil { + return false + } + if strings.ToLower(strings.TrimSpace(parsed.Scheme)) != "builtin" { + return false + } + if strings.ToLower(strings.TrimSpace(parsed.Host)) != "manifest" { + return false + } + pathText := strings.TrimSpace(parsed.Path) + return pathText == "" || pathText == "/" +} + +func errorMessage(err error) string { + if err == nil { + return "" + } + return strings.TrimSpace(err.Error()) +} + +func driverInstallDir(downloadDir string, driverType string) string { + root, err := resolveDriverDownloadDirectory(downloadDir) + if err != nil { + root = defaultDriverDownloadDirectory() + } + return filepath.Join(root, normalizeDriverType(driverType)) +} + +func installedDriverMetaPath(downloadDir string, driverType string) string { + return filepath.Join(driverInstallDir(downloadDir, driverType), "installed.json") +} + +func readInstalledDriverPackage(downloadDir string, driverType string) (installedDriverPackage, bool) { + metaPath := installedDriverMetaPath(downloadDir, driverType) + content, err := os.ReadFile(metaPath) + if err != nil { + return installedDriverPackage{}, false + } + var meta installedDriverPackage + if err := json.Unmarshal(content, &meta); err != nil { + return installedDriverPackage{}, false + } + meta.DriverType = normalizeDriverType(meta.DriverType) + if strings.TrimSpace(meta.DriverType) == "" { + meta.DriverType = normalizeDriverType(driverType) + } + return meta, true +} + +func writeInstalledDriverPackage(downloadDir string, driverType string, meta installedDriverPackage) error { + driverDir := driverInstallDir(downloadDir, driverType) + if err := os.MkdirAll(driverDir, 0o755); err != nil { + return fmt.Errorf("创建驱动目录失败:%w", err) + } + meta.DriverType = normalizeDriverType(driverType) + if meta.DownloadedAt == "" { + meta.DownloadedAt = time.Now().Format(time.RFC3339) + } + payload, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("写入驱动元数据失败:%w", err) + } + if err := os.WriteFile(installedDriverMetaPath(downloadDir, driverType), payload, 0o644); err != nil { + return fmt.Errorf("写入驱动元数据失败:%w", err) + } + return nil +} + +func hashFileSHA256(filePath string) (string, error) { + pathText := strings.TrimSpace(filePath) + if pathText == "" { + return "", fmt.Errorf("文件路径为空") + } + file, err := os.Open(pathText) + if err != nil { + return "", err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +func installOptionalDriverAgentPackage(a *App, definition driverDefinition, resolvedDir string, downloadURL string) (installedDriverPackage, error) { + driverType := normalizeDriverType(definition.Type) + executablePath, err := db.ResolveOptionalDriverAgentExecutablePath(resolvedDir, driverType) + if err != nil { + return installedDriverPackage{}, err + } + downloadSource, hash, err := ensureOptionalDriverAgentBinary(a, definition, executablePath, downloadURL) + if err != nil { + return installedDriverPackage{}, err + } + if strings.TrimSpace(hash) == "" { + hash, err = hashFileSHA256(executablePath) + if err != nil { + return installedDriverPackage{}, fmt.Errorf("计算 %s 驱动代理摘要失败:%w", resolveDriverDisplayName(definition), err) + } + } + if strings.TrimSpace(downloadSource) == "" { + downloadSource = strings.TrimSpace(downloadURL) + } + return installedDriverPackage{ + DriverType: driverType, + FilePath: executablePath, + FileName: filepath.Base(executablePath), + ExecutablePath: executablePath, + DownloadURL: strings.TrimSpace(downloadSource), + SHA256: hash, + DownloadedAt: time.Now().Format(time.RFC3339), + }, nil +} + +func ensureOptionalDriverAgentBinary(a *App, definition driverDefinition, executablePath string, downloadURL string) (string, string, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + + info, err := os.Stat(executablePath) + if err == nil && !info.IsDir() { + hash, hashErr := hashFileSHA256(executablePath) + if hashErr != nil { + return "", "", fmt.Errorf("读取已安装 %s 驱动代理摘要失败:%w", displayName, hashErr) + } + return fmt.Sprintf("local://existing/%s-driver-agent", driverType), hash, nil + } + if err == nil && info.IsDir() { + return "", "", fmt.Errorf("%s 驱动代理路径被目录占用:%s", displayName, executablePath) + } + + if mkErr := os.MkdirAll(filepath.Dir(executablePath), 0o755); mkErr != nil { + return "", "", fmt.Errorf("创建 %s 驱动目录失败:%w", displayName, mkErr) + } + if a != nil { + a.emitDriverDownloadProgress(driverType, "downloading", 10, 100, "检查本地驱动代理缓存") + } + if sourcePath, ok := findExistingOptionalDriverAgentCandidate(definition, executablePath); ok { + if copyErr := copyAgentBinary(sourcePath, executablePath); copyErr != nil { + return "", "", fmt.Errorf("复制预置 %s 驱动代理失败:%w", displayName, copyErr) + } + hash, hashErr := hashFileSHA256(executablePath) + if hashErr != nil { + return "", "", fmt.Errorf("计算预置 %s 驱动代理摘要失败:%w", displayName, hashErr) + } + return "file://" + sourcePath, hash, nil + } + + downloadURLs := resolveOptionalDriverAgentDownloadURLs(definition, downloadURL) + var downloadErrs []string + if len(downloadURLs) > 0 { + for _, candidateURL := range downloadURLs { + if a != nil { + a.emitDriverDownloadProgress(driverType, "downloading", 20, 100, fmt.Sprintf("下载预编译 %s 驱动代理", displayName)) + } + hash, dlErr := downloadOptionalDriverAgentBinary(a, definition, candidateURL, executablePath) + if dlErr == nil { + return candidateURL, hash, nil + } + downloadErrs = append(downloadErrs, fmt.Sprintf("%s: %s", candidateURL, strings.TrimSpace(dlErr.Error()))) + } + } + if a != nil { + a.emitDriverDownloadProgress(driverType, "downloading", 92, 100, "未命中预编译包,尝试开发态本地构建") + } + + hash, buildErr := buildOptionalDriverAgentFromSource(definition, executablePath) + if buildErr == nil { + return fmt.Sprintf("local://go-build/%s-driver-agent", driverType), hash, nil + } + + var parts []string + if len(downloadErrs) > 0 { + parts = append(parts, "预编译包下载失败:"+strings.Join(downloadErrs, ";")) + } + parts = append(parts, "本地构建失败:"+strings.TrimSpace(buildErr.Error())) + return "", "", errors.New(strings.Join(parts, ";")) +} + +func downloadOptionalDriverAgentBinary(a *App, definition driverDefinition, urlText string, executablePath string) (string, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + trimmedURL := strings.TrimSpace(urlText) + if trimmedURL == "" { + return "", fmt.Errorf("下载地址为空") + } + tempPath := executablePath + ".tmp" + _ = os.Remove(tempPath) + + hash, err := downloadFileWithHash(trimmedURL, tempPath, func(downloaded, total int64) { + if a == nil { + return + } + scaledDownloaded, scaledTotal := scaleProgress(downloaded, total, 20, 90) + a.emitDriverDownloadProgress(driverType, "downloading", scaledDownloaded, scaledTotal, fmt.Sprintf("下载预编译 %s 驱动代理", displayName)) + }) + if err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("下载失败:%w", err) + } + + if chmodErr := os.Chmod(tempPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + _ = os.Remove(tempPath) + return "", fmt.Errorf("设置代理权限失败:%w", chmodErr) + } + if renameErr := os.Rename(tempPath, executablePath); renameErr != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("落地代理文件失败:%w", renameErr) + } + if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + return "", fmt.Errorf("设置代理权限失败:%w", chmodErr) + } + return hash, nil +} + +func buildOptionalDriverAgentFromSource(definition driverDefinition, executablePath string) (string, error) { + driverType := normalizeDriverType(definition.Type) + displayName := resolveDriverDisplayName(definition) + goPath, lookErr := exec.LookPath("go") + if lookErr != nil { + return "", fmt.Errorf("当前环境未安装 Go,且未找到可用的 %s 预编译代理包", displayName) + } + + tagName, tagErr := optionalDriverBuildTag(driverType) + if tagErr != nil { + return "", tagErr + } + + projectRoot, rootErr := locateProjectRootForAgentBuild() + if rootErr != nil { + return "", rootErr + } + cmd := exec.Command(goPath, "build", "-tags", tagName, "-trimpath", "-ldflags", "-s -w", "-o", executablePath, "./cmd/optional-driver-agent") + cmd.Dir = projectRoot + output, buildErr := cmd.CombinedOutput() + if buildErr != nil { + return "", fmt.Errorf("构建 %s 驱动代理失败:%v,输出:%s", displayName, buildErr, strings.TrimSpace(string(output))) + } + if chmodErr := os.Chmod(executablePath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + return "", fmt.Errorf("设置 %s 驱动代理权限失败:%w", displayName, chmodErr) + } + hash, hashErr := hashFileSHA256(executablePath) + if hashErr != nil { + return "", fmt.Errorf("计算 %s 驱动代理摘要失败:%w", displayName, hashErr) + } + return hash, nil +} + +func optionalDriverBuildTag(driverType string) (string, error) { + switch normalizeDriverType(driverType) { + case "mysql": + return "gonavi_mysql_driver", nil + case "mariadb": + return "gonavi_mariadb_driver", nil + case "diros": + return "gonavi_diros_driver", nil + case "sphinx": + return "gonavi_sphinx_driver", nil + case "sqlserver": + return "gonavi_sqlserver_driver", nil + case "sqlite": + return "gonavi_sqlite_driver", nil + case "duckdb": + return "gonavi_duckdb_driver", nil + case "dameng": + return "gonavi_dameng_driver", nil + case "kingbase": + return "gonavi_kingbase_driver", nil + case "highgo": + return "gonavi_highgo_driver", nil + case "vastbase": + return "gonavi_vastbase_driver", nil + case "mongodb": + return "gonavi_mongodb_driver", nil + case "tdengine": + return "gonavi_tdengine_driver", nil + default: + return "", fmt.Errorf("未配置驱动构建标签:%s", driverType) + } +} + +func locateProjectRootForAgentBuild() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("获取当前目录失败:%w", err) + } + dir := wd + for { + if fileExists(filepath.Join(dir, "go.mod")) && fileExists(filepath.Join(dir, "cmd", "optional-driver-agent", "main.go")) { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("未找到通用驱动代理源码,无法自动构建;请使用已发布版本") +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func optionalDriverExecutableBaseName(driverType string) string { + name := fmt.Sprintf("%s-driver-agent", normalizeDriverType(driverType)) + if stdRuntime.GOOS == "windows" { + return name + ".exe" + } + return name +} + +func optionalDriverReleaseAssetName(driverType string) string { + name := fmt.Sprintf("%s-driver-agent-%s-%s", normalizeDriverType(driverType), stdRuntime.GOOS, stdRuntime.GOARCH) + if stdRuntime.GOOS == "windows" { + return name + ".exe" + } + return name +} + +func resolveOptionalDriverAgentDownloadURLs(definition driverDefinition, rawURL string) []string { + driverType := normalizeDriverType(definition.Type) + candidates := make([]string, 0, 3) + seen := make(map[string]struct{}, 3) + appendURL := func(value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + if _, ok := seen[trimmed]; ok { + return + } + seen[trimmed] = struct{}{} + candidates = append(candidates, trimmed) + } + + if parsed, err := url.Parse(strings.TrimSpace(rawURL)); err == nil { + switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { + case "http", "https": + appendURL(parsed.String()) + } + } + + assetName := optionalDriverReleaseAssetName(driverType) + currentVersion := normalizeVersion(getCurrentVersion()) + if currentVersion != "" && currentVersion != "0.0.0" { + appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/download/v%s/%s", currentVersion, assetName)) + } + appendURL(fmt.Sprintf("https://github.com/Syngnat/GoNavi/releases/latest/download/%s", assetName)) + return candidates +} + +func findExistingOptionalDriverAgentCandidate(definition driverDefinition, targetPath string) (string, bool) { + targetAbs, _ := filepath.Abs(targetPath) + candidates := resolveOptionalDriverAgentCandidatePaths(definition) + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + absPath, err := filepath.Abs(candidate) + if err != nil || absPath == "" { + continue + } + if targetAbs != "" && absPath == targetAbs { + continue + } + info, statErr := os.Stat(absPath) + if statErr == nil && !info.IsDir() { + return absPath, true + } + } + return "", false +} + +func resolveOptionalDriverAgentCandidatePaths(definition driverDefinition) []string { + driverType := normalizeDriverType(definition.Type) + name := optionalDriverExecutableBaseName(driverType) + assetName := optionalDriverReleaseAssetName(driverType) + candidates := make([]string, 0, 12) + appendPath := func(pathText string) { + trimmed := strings.TrimSpace(pathText) + if trimmed != "" { + candidates = append(candidates, trimmed) + } + } + + if exePath, err := os.Executable(); err == nil && strings.TrimSpace(exePath) != "" { + resolved := exePath + if evalPath, evalErr := filepath.EvalSymlinks(exePath); evalErr == nil && strings.TrimSpace(evalPath) != "" { + resolved = evalPath + } + exeDir := filepath.Dir(resolved) + appendPath(filepath.Join(exeDir, name)) + appendPath(filepath.Join(exeDir, assetName)) + appendPath(filepath.Join(exeDir, "drivers", driverType, name)) + appendPath(filepath.Join(exeDir, "drivers", driverType, assetName)) + + resourcesDir := filepath.Clean(filepath.Join(exeDir, "..", "Resources")) + appendPath(filepath.Join(resourcesDir, "drivers", driverType, name)) + appendPath(filepath.Join(resourcesDir, "drivers", driverType, assetName)) + } + if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" { + appendPath(filepath.Join(wd, "dist", assetName)) + appendPath(filepath.Join(wd, assetName)) + } + + unique := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, item := range candidates { + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + unique = append(unique, item) + } + return unique +} + +func resolveDriverDisplayName(definition driverDefinition) string { + if strings.TrimSpace(definition.Name) != "" { + return strings.TrimSpace(definition.Name) + } + if strings.TrimSpace(definition.Type) != "" { + return strings.TrimSpace(definition.Type) + } + return "未知" +} + +func copyAgentBinary(sourcePath, targetPath string) error { + src, err := os.Open(sourcePath) + if err != nil { + return err + } + defer src.Close() + + tempPath := targetPath + ".tmp" + _ = os.Remove(tempPath) + dst, err := os.Create(tempPath) + if err != nil { + return err + } + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + _ = os.Remove(tempPath) + return err + } + if err := dst.Sync(); err != nil { + dst.Close() + _ = os.Remove(tempPath) + return err + } + if err := dst.Close(); err != nil { + _ = os.Remove(tempPath) + return err + } + if chmodErr := os.Chmod(tempPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + _ = os.Remove(tempPath) + return chmodErr + } + if err := os.Rename(tempPath, targetPath); err != nil { + _ = os.Remove(tempPath) + return err + } + if chmodErr := os.Chmod(targetPath, 0o755); chmodErr != nil && stdRuntime.GOOS != "windows" { + return chmodErr + } + return nil +} + +func scaleProgress(downloaded, total, start, end int64) (int64, int64) { + if end <= start { + return end, 100 + } + if total <= 0 { + return start, 100 + } + if downloaded < 0 { + downloaded = 0 + } + if downloaded > total { + downloaded = total + } + span := end - start + return start + ((downloaded * span) / total), 100 +} + +func preloadOptionalDriverPackageSizes(definitions []driverDefinition) map[string]int64 { + result := make(map[string]int64) + if len(definitions) == 0 { + return result + } + + needed := make([]string, 0, len(definitions)) + for _, definition := range definitions { + normalizedType := normalizeDriverType(definition.Type) + if normalizedType == "" || definition.BuiltIn { + continue + } + if !db.IsOptionalGoDriver(normalizedType) { + continue + } + if !db.IsOptionalGoDriverBuildIncluded(normalizedType) { + continue + } + needed = append(needed, normalizedType) + } + if len(needed) == 0 { + return result + } + + currentVersion := normalizeVersion(getCurrentVersion()) + tag := "" + if currentVersion != "" && currentVersion != "0.0.0" { + tag = "v" + currentVersion + } + + fillFromSizes := func(sizeByAsset map[string]int64, driverTypes []string) []string { + missing := make([]string, 0, len(driverTypes)) + for _, driverType := range driverTypes { + assetName := optionalDriverReleaseAssetName(driverType) + sizeBytes := sizeByAsset[assetName] + if sizeBytes > 0 { + result[driverType] = sizeBytes + continue + } + missing = append(missing, driverType) + } + return missing + } + + pending := needed + if tag != "" { + if sizeByAsset, err := loadReleaseAssetSizesCached("tag:"+tag, func() (*githubRelease, error) { + return fetchReleaseByTag(tag) + }); err == nil { + pending = fillFromSizes(sizeByAsset, pending) + } + } + if len(pending) == 0 { + return result + } + if sizeByAsset, err := loadReleaseAssetSizesCached("latest", fetchLatestReleaseForDriverAssets); err == nil { + _ = fillFromSizes(sizeByAsset, pending) + } + return result +} + +func loadReleaseAssetSizesCached(cacheKey string, fetch func() (*githubRelease, error)) (map[string]int64, error) { + key := strings.TrimSpace(cacheKey) + if key == "" { + return nil, fmt.Errorf("缓存 key 为空") + } + + driverReleaseSizeMu.RLock() + cached, ok := driverReleaseSizeMap[key] + driverReleaseSizeMu.RUnlock() + if ok { + ttl := driverReleaseAssetSizeCacheTTL + if strings.TrimSpace(cached.Err) != "" { + ttl = driverReleaseAssetSizeErrorCacheTTL + } + if time.Since(cached.LoadedAt) < ttl { + if strings.TrimSpace(cached.Err) != "" { + return nil, errors.New(strings.TrimSpace(cached.Err)) + } + return cached.SizeByKey, nil + } + } + + release, err := fetch() + entry := driverReleaseAssetSizeCacheEntry{ + LoadedAt: time.Now(), + SizeByKey: map[string]int64{}, + } + if err != nil { + entry.Err = err.Error() + } else { + entry.SizeByKey = buildReleaseAssetSizeMap(release) + } + + driverReleaseSizeMu.Lock() + driverReleaseSizeMap[key] = entry + driverReleaseSizeMu.Unlock() + + if err != nil { + return nil, err + } + return entry.SizeByKey, nil +} + +func buildReleaseAssetSizeMap(release *githubRelease) map[string]int64 { + sizes := make(map[string]int64) + if release == nil { + return sizes + } + for _, asset := range release.Assets { + name := strings.TrimSpace(asset.Name) + if name == "" || asset.Size <= 0 { + continue + } + sizes[name] = asset.Size + } + return sizes +} + +func fetchLatestReleaseForDriverAssets() (*githubRelease, error) { + return fetchDriverReleaseByURL(updateAPIURL) +} + +func fetchReleaseByTag(tag string) (*githubRelease, error) { + tagName := strings.TrimSpace(tag) + if tagName == "" { + return nil, fmt.Errorf("Tag 为空") + } + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/tags/%s", updateRepo, url.PathEscape(tagName)) + return fetchDriverReleaseByURL(apiURL) +} + +func fetchDriverReleaseByURL(apiURL string) (*githubRelease, error) { + urlText := strings.TrimSpace(apiURL) + if urlText == "" { + return nil, fmt.Errorf("API 地址为空") + } + + client := &http.Client{Timeout: driverReleaseAssetSizeProbeTimeout} + req, err := http.NewRequest(http.MethodGet, urlText, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "GoNavi-DriverManager") + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("拉取 Release 信息失败:HTTP %d", resp.StatusCode) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + return &release, nil +} + +func resolveDriverPackageSizeText(definition driverDefinition, pkg installedDriverPackage, packageMetaExists bool, packageSizeBytesMap map[string]int64) string { + if definition.BuiltIn { + return "内置" + } + + normalizedType := normalizeDriverType(definition.Type) + if packageMetaExists { + sizeBytes := readInstalledPackageSizeBytes(pkg) + if sizeBytes > 0 { + return formatSizeMB(sizeBytes) + } + } + if sizeBytes, ok := packageSizeBytesMap[normalizedType]; ok && sizeBytes > 0 { + return formatSizeMB(sizeBytes) + } + + if !db.IsOptionalGoDriverBuildIncluded(normalizedType) { + return "待发布" + } + return "-" +} + +func readInstalledPackageSizeBytes(pkg installedDriverPackage) int64 { + pathText := strings.TrimSpace(pkg.ExecutablePath) + if pathText == "" { + pathText = strings.TrimSpace(pkg.FilePath) + } + if pathText == "" { + return 0 + } + info, err := os.Stat(pathText) + if err != nil || info.IsDir() { + return 0 + } + return info.Size() +} + +func formatSizeMB(sizeBytes int64) string { + if sizeBytes <= 0 { + return "-" + } + sizeMB := float64(sizeBytes) / (1024 * 1024) + return fmt.Sprintf("%.2f MB", sizeMB) +} diff --git a/internal/db/dameng_impl.go b/internal/db/dameng_impl.go index 40d04a0..6aa06b0 100644 --- a/internal/db/dameng_impl.go +++ b/internal/db/dameng_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_dameng_driver + package db import ( diff --git a/internal/db/database.go b/internal/db/database.go index 9836e4d..bf313d0 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -3,6 +3,7 @@ package db import ( "GoNavi-Wails/internal/connection" "fmt" + "strings" ) type Database interface { @@ -25,46 +26,61 @@ type BatchApplier interface { ApplyChanges(tableName string, changes connection.ChangeSet) error } -// Factory -func NewDatabase(dbType string) (Database, error) { - switch dbType { - case "mysql": - return &MySQLDB{}, nil - case "postgres": - return &PostgresDB{}, nil - case "sqlite": - return &SQLiteDB{}, nil - case "oracle": - return &OracleDB{}, nil - case "dameng": - return &DamengDB{}, nil - case "kingbase": - return &KingbaseDB{}, nil - case "mongodb": - return &MongoDB{}, nil - case "sqlserver": - return &SqlServerDB{}, nil - case "highgo": - return &HighGoDB{}, nil - case "mariadb": - return &MariaDB{}, nil - case "diros", "doris": - return &DirosDB{}, nil - case "sphinx": - return &SphinxDB{}, nil - case "vastbase": - return &VastbaseDB{}, nil - case "tdengine": - return &TDengineDB{}, nil - case "duckdb": - return &DuckDB{}, nil - case "custom": - return &CustomDB{}, nil - default: - // Default to MySQL for backward compatibility if empty - if dbType == "" { - return &MySQLDB{}, nil +type databaseFactory func() Database + +var databaseFactories = map[string]databaseFactory{ + "mysql": func() Database { + return &MySQLDB{} + }, + "postgres": func() Database { + return &PostgresDB{} + }, + "oracle": func() Database { + return &OracleDB{} + }, + "custom": func() Database { + return &CustomDB{} + }, +} + +func init() { + registerOptionalDatabaseFactories() +} + +func registerDatabaseFactory(factory databaseFactory, dbTypes ...string) { + if factory == nil || len(dbTypes) == 0 { + return + } + for _, dbType := range dbTypes { + normalized := normalizeDatabaseType(dbType) + if normalized == "" { + continue } - return nil, fmt.Errorf("unsupported database type: %s", dbType) + databaseFactories[normalized] = factory } } + +func normalizeDatabaseType(dbType string) string { + normalized := strings.ToLower(strings.TrimSpace(dbType)) + switch normalized { + case "doris": + return "diros" + case "postgresql": + return "postgres" + default: + return normalized + } +} + +// Factory +func NewDatabase(dbType string) (Database, error) { + normalized := normalizeDatabaseType(dbType) + if normalized == "" { + normalized = "mysql" + } + factory, ok := databaseFactories[normalized] + if !ok { + return nil, fmt.Errorf("unsupported database type: %s", dbType) + } + return factory(), nil +} diff --git a/internal/db/database_optional_factories_full.go b/internal/db/database_optional_factories_full.go new file mode 100644 index 0000000..2a4545c --- /dev/null +++ b/internal/db/database_optional_factories_full.go @@ -0,0 +1,18 @@ +//go:build gonavi_full_drivers + +package db + +func registerOptionalDatabaseFactories() { + registerDatabaseFactory(newOptionalDriverAgentDatabase("mariadb"), "mariadb") + registerDatabaseFactory(newOptionalDriverAgentDatabase("diros"), "diros", "doris") + registerDatabaseFactory(newOptionalDriverAgentDatabase("sphinx"), "sphinx") + registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlserver"), "sqlserver") + registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlite"), "sqlite") + registerDatabaseFactory(newOptionalDriverAgentDatabase("duckdb"), "duckdb") + registerDatabaseFactory(newOptionalDriverAgentDatabase("dameng"), "dameng") + registerDatabaseFactory(newOptionalDriverAgentDatabase("kingbase"), "kingbase") + registerDatabaseFactory(newOptionalDriverAgentDatabase("highgo"), "highgo") + registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase") + registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb") + registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine") +} diff --git a/internal/db/database_optional_factories_lite.go b/internal/db/database_optional_factories_lite.go new file mode 100644 index 0000000..df9e13c --- /dev/null +++ b/internal/db/database_optional_factories_lite.go @@ -0,0 +1,18 @@ +//go:build !gonavi_full_drivers + +package db + +func registerOptionalDatabaseFactories() { + registerDatabaseFactory(newOptionalDriverAgentDatabase("mariadb"), "mariadb") + registerDatabaseFactory(newOptionalDriverAgentDatabase("diros"), "diros", "doris") + registerDatabaseFactory(newOptionalDriverAgentDatabase("sphinx"), "sphinx") + registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlserver"), "sqlserver") + registerDatabaseFactory(newOptionalDriverAgentDatabase("sqlite"), "sqlite") + registerDatabaseFactory(newOptionalDriverAgentDatabase("duckdb"), "duckdb") + registerDatabaseFactory(newOptionalDriverAgentDatabase("dameng"), "dameng") + registerDatabaseFactory(newOptionalDriverAgentDatabase("kingbase"), "kingbase") + registerDatabaseFactory(newOptionalDriverAgentDatabase("highgo"), "highgo") + registerDatabaseFactory(newOptionalDriverAgentDatabase("vastbase"), "vastbase") + registerDatabaseFactory(newOptionalDriverAgentDatabase("mongodb"), "mongodb") + registerDatabaseFactory(newOptionalDriverAgentDatabase("tdengine"), "tdengine") +} diff --git a/internal/db/diros_impl.go b/internal/db/diros_impl.go index 44f2a15..30eb116 100644 --- a/internal/db/diros_impl.go +++ b/internal/db/diros_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_diros_driver + package db import ( diff --git a/internal/db/driver_support.go b/internal/db/driver_support.go new file mode 100644 index 0000000..1c1089f --- /dev/null +++ b/internal/db/driver_support.go @@ -0,0 +1,222 @@ +package db + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +var coreBuiltinDrivers = map[string]struct{}{ + "mysql": {}, + "redis": {}, + "oracle": {}, + "postgres": {}, +} + +// optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。 +// 注意:这是一种运行时门控(installed.json 标记),并不减少主二进制体积。 +var optionalGoDrivers = map[string]struct{}{ + "mariadb": {}, + "diros": {}, + "sphinx": {}, + "sqlserver": {}, + "sqlite": {}, + "duckdb": {}, + "dameng": {}, + "kingbase": {}, + "highgo": {}, + "vastbase": {}, + "mongodb": {}, + "tdengine": {}, +} + +var ( + externalDriverDirMu sync.RWMutex + externalDriverDir string +) + +func normalizeRuntimeDriverType(driverType string) string { + normalized := strings.ToLower(strings.TrimSpace(driverType)) + switch normalized { + case "doris": + return "diros" + case "postgresql": + return "postgres" + default: + return normalized + } +} + +func driverDisplayName(driverType string) string { + switch normalizeRuntimeDriverType(driverType) { + case "mysql": + return "MySQL" + case "oracle": + return "Oracle" + case "redis": + return "Redis" + case "mariadb": + return "MariaDB" + case "diros": + return "Diros" + case "sphinx": + return "Sphinx" + case "postgres": + return "PostgreSQL" + case "sqlserver": + return "SQL Server" + case "sqlite": + return "SQLite" + case "duckdb": + return "DuckDB" + case "dameng": + return "Dameng" + case "kingbase": + return "Kingbase" + case "highgo": + return "HighGo" + case "vastbase": + return "Vastbase" + case "mongodb": + return "MongoDB" + case "tdengine": + return "TDengine" + default: + return strings.ToUpper(strings.TrimSpace(driverType)) + } +} + +func IsOptionalGoDriver(driverType string) bool { + _, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)] + return ok +} + +func IsOptionalGoDriverBuildIncluded(driverType string) bool { + return optionalGoDriverBuildIncluded(normalizeRuntimeDriverType(driverType)) +} + +func IsBuiltinDriver(driverType string) bool { + _, ok := coreBuiltinDrivers[normalizeRuntimeDriverType(driverType)] + return ok +} + +func defaultExternalDriverDownloadDirectory() string { + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + return filepath.Join(home, ".gonavi", "drivers") + } + if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" { + return filepath.Join(wd, ".gonavi-drivers") + } + return ".gonavi-drivers" +} + +func resolveExternalDriverRoot(downloadDir string) (string, error) { + root := strings.TrimSpace(downloadDir) + if root == "" { + root = currentExternalDriverDownloadDirectory() + } + if root == "" { + root = defaultExternalDriverDownloadDirectory() + } + if !filepath.IsAbs(root) { + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + root = abs + } + if err := os.MkdirAll(root, 0o755); err != nil { + return "", fmt.Errorf("创建驱动目录失败:%w", err) + } + return root, nil +} + +func currentExternalDriverDownloadDirectory() string { + externalDriverDirMu.RLock() + current := strings.TrimSpace(externalDriverDir) + externalDriverDirMu.RUnlock() + if current != "" { + return current + } + return defaultExternalDriverDownloadDirectory() +} + +func SetExternalDriverDownloadDirectory(downloadDir string) { + root, err := resolveExternalDriverRoot(downloadDir) + if err != nil { + root = defaultExternalDriverDownloadDirectory() + } + externalDriverDirMu.Lock() + externalDriverDir = root + externalDriverDirMu.Unlock() +} + +func ResolveExternalDriverRoot(downloadDir string) (string, error) { + return resolveExternalDriverRoot(downloadDir) +} + +func ResolveOptionalGoDriverMarkerPath(downloadDir string, driverType string) (string, error) { + normalized := normalizeRuntimeDriverType(driverType) + if !IsOptionalGoDriver(normalized) { + return "", fmt.Errorf("%s 不是可选 Go 驱动", driverDisplayName(normalized)) + } + root, err := resolveExternalDriverRoot(downloadDir) + if err != nil { + return "", err + } + return filepath.Join(root, normalized, "installed.json"), nil +} + +func optionalGoDriverInstalled(driverType string) bool { + markerPath, err := ResolveOptionalGoDriverMarkerPath("", driverType) + if err != nil { + return false + } + info, statErr := os.Stat(markerPath) + return statErr == nil && !info.IsDir() +} + +func optionalGoDriverRuntimeReady(driverType string) (bool, string) { + normalized := normalizeRuntimeDriverType(driverType) + if !IsOptionalGoDriver(normalized) { + return true, "" + } + executablePath, err := ResolveOptionalDriverAgentExecutablePath("", normalized) + if err != nil { + return false, fmt.Sprintf("%s 驱动代理路径解析失败,请在驱动管理中重新安装启用", driverDisplayName(normalized)) + } + info, statErr := os.Stat(executablePath) + if statErr != nil || info.IsDir() { + return false, fmt.Sprintf("%s 驱动代理缺失,请在驱动管理中重新安装启用", driverDisplayName(normalized)) + } + return true, "" +} + +// DriverRuntimeSupportStatus 返回当前构建下驱动是否可用(可直接用于连接)。 +func DriverRuntimeSupportStatus(driverType string) (bool, string) { + normalized := normalizeRuntimeDriverType(driverType) + if normalized == "" { + return false, "未识别的数据源类型" + } + if normalized == "custom" { + return true, "" + } + if IsBuiltinDriver(normalized) { + return true, "" + } + if IsOptionalGoDriver(normalized) { + if !IsOptionalGoDriverBuildIncluded(normalized) { + return false, fmt.Sprintf("%s 当前发行包为精简构建,未内置该驱动;如需使用请安装 Full 版", driverDisplayName(normalized)) + } + if optionalGoDriverInstalled(normalized) { + if ready, reason := optionalGoDriverRuntimeReady(normalized); !ready { + return false, reason + } + return true, "" + } + return false, fmt.Sprintf("%s 纯 Go 驱动未启用,请先在驱动管理中点击“安装启用”", driverDisplayName(normalized)) + } + return true, "" +} diff --git a/internal/db/driver_support_test.go b/internal/db/driver_support_test.go new file mode 100644 index 0000000..8dc5f62 --- /dev/null +++ b/internal/db/driver_support_test.go @@ -0,0 +1,89 @@ +package db + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestPostgresRuntimeSupportRequiresInstallMarker(t *testing.T) { + tmpDir := t.TempDir() + SetExternalDriverDownloadDirectory(tmpDir) + + supported, _ := DriverRuntimeSupportStatus("postgres") + if !supported { + t.Fatalf("postgres 属于免安装内置驱动,应可用") + } + supported, reason := DriverRuntimeSupportStatus("postgres") + if !supported { + t.Fatalf("postgres 应可用,reason=%s", reason) + } +} + +func TestBuiltinLikeDriversRemainAvailable(t *testing.T) { + tmpDir := t.TempDir() + SetExternalDriverDownloadDirectory(tmpDir) + + supported, reason := DriverRuntimeSupportStatus("redis") + if !supported { + t.Fatalf("redis 应始终可用,reason=%s", reason) + } +} + +func TestManagedDriverRequiresInstallMarker(t *testing.T) { + tmpDir := t.TempDir() + SetExternalDriverDownloadDirectory(tmpDir) + + supported, _ := DriverRuntimeSupportStatus("mariadb") + if supported { + t.Fatalf("mariadb 未安装时不应可用") + } + + if !IsOptionalGoDriverBuildIncluded("mariadb") { + supported, reason := DriverRuntimeSupportStatus("mariadb") + if supported { + t.Fatalf("精简构建下 mariadb 不应可用") + } + if reason == "" { + t.Fatalf("精简构建下 mariadb 应返回不可用原因") + } + return + } + + markerPath, err := ResolveOptionalGoDriverMarkerPath(tmpDir, "mariadb") + if err != nil { + t.Fatalf("解析 marker 路径失败: %v", err) + } + if err := os.MkdirAll(filepath.Dir(markerPath), 0o755); err != nil { + t.Fatalf("创建 marker 目录失败: %v", err) + } + if err := os.WriteFile(markerPath, []byte("{}"), 0o644); err != nil { + t.Fatalf("写入 marker 失败: %v", err) + } + executablePath, err := ResolveOptionalDriverAgentExecutablePath(tmpDir, "mariadb") + if err != nil { + t.Fatalf("解析 mariadb 代理路径失败: %v", err) + } + if err := os.WriteFile(executablePath, []byte("placeholder"), 0o755); err != nil { + t.Fatalf("写入 mariadb 代理占位文件失败: %v", err) + } + if runtime.GOOS == "windows" { + _ = os.Chmod(executablePath, 0o644) + } + + supported, reason := DriverRuntimeSupportStatus("mariadb") + if !supported { + t.Fatalf("mariadb 安装后应可用,reason=%s", reason) + } +} + +func TestMySQLBuiltinRuntimeSupportAvailable(t *testing.T) { + tmpDir := t.TempDir() + SetExternalDriverDownloadDirectory(tmpDir) + + supported, reason := DriverRuntimeSupportStatus("mysql") + if !supported { + t.Fatalf("mysql 属于免安装内置驱动,应可用,reason=%s", reason) + } +} diff --git a/internal/db/dsn_test.go b/internal/db/dsn_test.go index c9feb9a..8ae4edb 100644 --- a/internal/db/dsn_test.go +++ b/internal/db/dsn_test.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers + package db import ( diff --git a/internal/db/duckdb_driver_import.go b/internal/db/duckdb_driver_import.go index b01fd0e..6ab36fa 100644 --- a/internal/db/duckdb_driver_import.go +++ b/internal/db/duckdb_driver_import.go @@ -1,4 +1,4 @@ -//go:build cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64)) +//go:build (gonavi_full_drivers || gonavi_duckdb_driver) && cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64)) package db diff --git a/internal/db/duckdb_impl.go b/internal/db/duckdb_impl.go index 1910ce9..f87ca74 100644 --- a/internal/db/duckdb_impl.go +++ b/internal/db/duckdb_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_duckdb_driver + package db import ( diff --git a/internal/db/duckdb_platform_supported.go b/internal/db/duckdb_platform_supported.go index f314e34..c9d0bb3 100644 --- a/internal/db/duckdb_platform_supported.go +++ b/internal/db/duckdb_platform_supported.go @@ -1,4 +1,4 @@ -//go:build cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64)) +//go:build (gonavi_full_drivers || gonavi_duckdb_driver) && cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64)) package db diff --git a/internal/db/duckdb_platform_unsupported.go b/internal/db/duckdb_platform_unsupported.go index 2b37b8b..54326be 100644 --- a/internal/db/duckdb_platform_unsupported.go +++ b/internal/db/duckdb_platform_unsupported.go @@ -1,4 +1,4 @@ -//go:build !(cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64))) +//go:build (gonavi_full_drivers || gonavi_duckdb_driver) && !(cgo && (duckdb_use_lib || duckdb_use_static_lib || (darwin && (amd64 || arm64)) || (linux && (amd64 || arm64)) || (windows && amd64))) package db diff --git a/internal/db/highgo_impl.go b/internal/db/highgo_impl.go index b9b7d50..8e982a6 100644 --- a/internal/db/highgo_impl.go +++ b/internal/db/highgo_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_highgo_driver + package db import ( diff --git a/internal/db/kingbase_impl.go b/internal/db/kingbase_impl.go index 4f0b481..2726665 100644 --- a/internal/db/kingbase_impl.go +++ b/internal/db/kingbase_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_kingbase_driver + package db import ( diff --git a/internal/db/mariadb_impl.go b/internal/db/mariadb_impl.go index 5559b4e..4d8457b 100644 --- a/internal/db/mariadb_impl.go +++ b/internal/db/mariadb_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_mariadb_driver + package db import ( diff --git a/internal/db/mongodb_impl.go b/internal/db/mongodb_impl.go index cdc613f..35fc9f3 100644 --- a/internal/db/mongodb_impl.go +++ b/internal/db/mongodb_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_mongodb_driver + package db import ( diff --git a/internal/db/mysql_agent_impl.go b/internal/db/mysql_agent_impl.go new file mode 100644 index 0000000..c5b53f6 --- /dev/null +++ b/internal/db/mysql_agent_impl.go @@ -0,0 +1,430 @@ +package db + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "GoNavi-Wails/internal/connection" +) + +const ( + mysqlAgentMethodConnect = "connect" + mysqlAgentMethodClose = "close" + mysqlAgentMethodPing = "ping" + mysqlAgentMethodQuery = "query" + mysqlAgentMethodExec = "exec" + mysqlAgentMethodGetDatabases = "getDatabases" + mysqlAgentMethodGetTables = "getTables" + mysqlAgentMethodGetCreateStmt = "getCreateStatement" + mysqlAgentMethodGetColumns = "getColumns" + mysqlAgentMethodGetAllColumns = "getAllColumns" + mysqlAgentMethodGetIndexes = "getIndexes" + mysqlAgentMethodGetForeignKeys = "getForeignKeys" + mysqlAgentMethodGetTriggers = "getTriggers" + mysqlAgentMethodApplyChanges = "applyChanges" + mysqlAgentDefaultScannerMaxBytes = 8 << 20 +) + +type mysqlAgentRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Config *connection.ConnectionConfig `json:"config,omitempty"` + Query string `json:"query,omitempty"` + DBName string `json:"dbName,omitempty"` + TableName string `json:"tableName,omitempty"` + Changes *connection.ChangeSet `json:"changes,omitempty"` +} + +type mysqlAgentResponse struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Fields []string `json:"fields,omitempty"` + RowsAffected int64 `json:"rowsAffected,omitempty"` +} + +type mysqlAgentClient struct { + cmd *exec.Cmd + stdin io.WriteCloser + reader *bufio.Reader + nextID int64 + mu sync.Mutex + stderrMu sync.Mutex + stderr strings.Builder +} + +func newMySQLAgentClient(executablePath string) (*mysqlAgentClient, error) { + pathText := strings.TrimSpace(executablePath) + if pathText == "" { + return nil, fmt.Errorf("MySQL 驱动代理路径为空") + } + if info, err := os.Stat(pathText); err != nil || info.IsDir() { + return nil, fmt.Errorf("MySQL 驱动代理不存在:%s", pathText) + } + + cmd := exec.Command(pathText) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("创建 MySQL 驱动代理 stdin 失败:%w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("创建 MySQL 驱动代理 stdout 失败:%w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("创建 MySQL 驱动代理 stderr 失败:%w", err) + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("启动 MySQL 驱动代理失败:%w", err) + } + + client := &mysqlAgentClient{ + cmd: cmd, + stdin: stdin, + reader: bufio.NewReader(stdout), + } + go client.captureStderr(stderr) + return client, nil +} + +func (c *mysqlAgentClient) captureStderr(stderr io.Reader) { + scanner := bufio.NewScanner(stderr) + buffer := make([]byte, 0, 8<<10) + scanner.Buffer(buffer, mysqlAgentDefaultScannerMaxBytes) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + c.stderrMu.Lock() + if c.stderr.Len() > 0 { + c.stderr.WriteString(" | ") + } + c.stderr.WriteString(line) + c.stderrMu.Unlock() + } +} + +func (c *mysqlAgentClient) stderrText() string { + c.stderrMu.Lock() + defer c.stderrMu.Unlock() + return strings.TrimSpace(c.stderr.String()) +} + +func (c *mysqlAgentClient) call(req mysqlAgentRequest, out interface{}, fields *[]string, rowsAffected *int64) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.nextID++ + req.ID = c.nextID + + payload, err := json.Marshal(req) + if err != nil { + return err + } + payload = append(payload, '\n') + if _, err := c.stdin.Write(payload); err != nil { + stderrText := c.stderrText() + if stderrText == "" { + return fmt.Errorf("调用 MySQL 驱动代理失败:%w", err) + } + return fmt.Errorf("调用 MySQL 驱动代理失败:%w(stderr: %s)", err, stderrText) + } + + line, err := c.reader.ReadBytes('\n') + if err != nil { + stderrText := c.stderrText() + if stderrText == "" { + return fmt.Errorf("读取 MySQL 驱动代理响应失败:%w", err) + } + return fmt.Errorf("读取 MySQL 驱动代理响应失败:%w(stderr: %s)", err, stderrText) + } + + var resp mysqlAgentResponse + if err := json.Unmarshal(line, &resp); err != nil { + return fmt.Errorf("解析 MySQL 驱动代理响应失败:%w", err) + } + if !resp.Success { + errText := strings.TrimSpace(resp.Error) + if errText == "" { + errText = "MySQL 驱动代理返回失败" + } + return errors.New(errText) + } + + if fields != nil { + *fields = resp.Fields + } + if rowsAffected != nil { + *rowsAffected = resp.RowsAffected + } + if out != nil && len(resp.Data) > 0 { + if err := json.Unmarshal(resp.Data, out); err != nil { + return fmt.Errorf("解析 MySQL 驱动代理数据失败:%w", err) + } + } + return nil +} + +func (c *mysqlAgentClient) close() error { + c.mu.Lock() + defer c.mu.Unlock() + var closeErr error + if c.stdin != nil { + _ = c.stdin.Close() + } + if c.cmd != nil && c.cmd.Process != nil { + if err := c.cmd.Process.Kill(); err != nil { + closeErr = err + } + } + if c.cmd != nil { + _ = c.cmd.Wait() + } + return closeErr +} + +type MySQLAgentDB struct { + client *mysqlAgentClient +} + +func (m *MySQLAgentDB) Connect(config connection.ConnectionConfig) error { + if m.client != nil { + _ = m.client.close() + m.client = nil + } + + executablePath, err := ResolveMySQLAgentExecutablePath("") + if err != nil { + return err + } + client, err := newMySQLAgentClient(executablePath) + if err != nil { + return err + } + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodConnect, + Config: &config, + }, nil, nil, nil); err != nil { + _ = client.close() + return err + } + m.client = client + return nil +} + +func (m *MySQLAgentDB) Close() error { + if m.client == nil { + return nil + } + _ = m.client.call(mysqlAgentRequest{Method: mysqlAgentMethodClose}, nil, nil, nil) + err := m.client.close() + m.client = nil + return err +} + +func (m *MySQLAgentDB) Ping() error { + client, err := m.requireClient() + if err != nil { + return err + } + return client.call(mysqlAgentRequest{Method: mysqlAgentMethodPing}, nil, nil, nil) +} + +func (m *MySQLAgentDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return m.Query(query) +} + +func (m *MySQLAgentDB) Query(query string) ([]map[string]interface{}, []string, error) { + client, err := m.requireClient() + if err != nil { + return nil, nil, err + } + var data []map[string]interface{} + var fields []string + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodQuery, + Query: query, + }, &data, &fields, nil); err != nil { + return nil, nil, err + } + return data, fields, nil +} + +func (m *MySQLAgentDB) ExecContext(ctx context.Context, query string) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + return m.Exec(query) +} + +func (m *MySQLAgentDB) Exec(query string) (int64, error) { + client, err := m.requireClient() + if err != nil { + return 0, err + } + var affected int64 + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodExec, + Query: query, + }, nil, nil, &affected); err != nil { + return 0, err + } + return affected, nil +} + +func (m *MySQLAgentDB) GetDatabases() ([]string, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var dbs []string + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetDatabases, + }, &dbs, nil, nil); err != nil { + return nil, err + } + return dbs, nil +} + +func (m *MySQLAgentDB) GetTables(dbName string) ([]string, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var tables []string + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetTables, + DBName: dbName, + }, &tables, nil, nil); err != nil { + return nil, err + } + return tables, nil +} + +func (m *MySQLAgentDB) GetCreateStatement(dbName, tableName string) (string, error) { + client, err := m.requireClient() + if err != nil { + return "", err + } + var sqlText string + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetCreateStmt, + DBName: dbName, + TableName: tableName, + }, &sqlText, nil, nil); err != nil { + return "", err + } + return sqlText, nil +} + +func (m *MySQLAgentDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var columns []connection.ColumnDefinition + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetColumns, + DBName: dbName, + TableName: tableName, + }, &columns, nil, nil); err != nil { + return nil, err + } + return columns, nil +} + +func (m *MySQLAgentDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var columns []connection.ColumnDefinitionWithTable + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetAllColumns, + DBName: dbName, + }, &columns, nil, nil); err != nil { + return nil, err + } + return columns, nil +} + +func (m *MySQLAgentDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var indexes []connection.IndexDefinition + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetIndexes, + DBName: dbName, + TableName: tableName, + }, &indexes, nil, nil); err != nil { + return nil, err + } + return indexes, nil +} + +func (m *MySQLAgentDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var keys []connection.ForeignKeyDefinition + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetForeignKeys, + DBName: dbName, + TableName: tableName, + }, &keys, nil, nil); err != nil { + return nil, err + } + return keys, nil +} + +func (m *MySQLAgentDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + client, err := m.requireClient() + if err != nil { + return nil, err + } + var triggers []connection.TriggerDefinition + if err := client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodGetTriggers, + DBName: dbName, + TableName: tableName, + }, &triggers, nil, nil); err != nil { + return nil, err + } + return triggers, nil +} + +func (m *MySQLAgentDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { + client, err := m.requireClient() + if err != nil { + return err + } + return client.call(mysqlAgentRequest{ + Method: mysqlAgentMethodApplyChanges, + TableName: tableName, + Changes: &changes, + }, nil, nil, nil) +} + +func (m *MySQLAgentDB) requireClient() (*mysqlAgentClient, error) { + if m.client == nil { + return nil, fmt.Errorf("connection not open") + } + return m.client, nil +} diff --git a/internal/db/mysql_agent_path.go b/internal/db/mysql_agent_path.go new file mode 100644 index 0000000..d5a79f7 --- /dev/null +++ b/internal/db/mysql_agent_path.go @@ -0,0 +1,40 @@ +package db + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +func mysqlAgentExecutableName() string { + return optionalDriverAgentExecutableName("mysql") +} + +func optionalDriverAgentExecutableName(driverType string) string { + normalized := normalizeRuntimeDriverType(driverType) + if normalized == "" { + normalized = "unknown" + } + name := fmt.Sprintf("%s-driver-agent", normalized) + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} + +func ResolveOptionalDriverAgentExecutablePath(downloadDir string, driverType string) (string, error) { + normalized := normalizeRuntimeDriverType(driverType) + if strings.TrimSpace(normalized) == "" { + return "", fmt.Errorf("驱动类型为空") + } + root, err := resolveExternalDriverRoot(downloadDir) + if err != nil { + return "", err + } + return filepath.Join(root, normalized, optionalDriverAgentExecutableName(normalized)), nil +} + +func ResolveMySQLAgentExecutablePath(downloadDir string) (string, error) { + return ResolveOptionalDriverAgentExecutablePath(downloadDir, "mysql") +} diff --git a/internal/db/optional_driver_agent_impl.go b/internal/db/optional_driver_agent_impl.go new file mode 100644 index 0000000..18f88ce --- /dev/null +++ b/internal/db/optional_driver_agent_impl.go @@ -0,0 +1,440 @@ +package db + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "GoNavi-Wails/internal/connection" +) + +const ( + optionalAgentMethodConnect = "connect" + optionalAgentMethodClose = "close" + optionalAgentMethodPing = "ping" + optionalAgentMethodQuery = "query" + optionalAgentMethodExec = "exec" + optionalAgentMethodGetDatabases = "getDatabases" + optionalAgentMethodGetTables = "getTables" + optionalAgentMethodGetCreateStmt = "getCreateStatement" + optionalAgentMethodGetColumns = "getColumns" + optionalAgentMethodGetAllColumns = "getAllColumns" + optionalAgentMethodGetIndexes = "getIndexes" + optionalAgentMethodGetForeignKeys = "getForeignKeys" + optionalAgentMethodGetTriggers = "getTriggers" + optionalAgentMethodApplyChanges = "applyChanges" + optionalAgentDefaultScannerMaxBytes = 8 << 20 +) + +type optionalAgentRequest struct { + ID int64 `json:"id"` + Method string `json:"method"` + Config *connection.ConnectionConfig `json:"config,omitempty"` + Query string `json:"query,omitempty"` + DBName string `json:"dbName,omitempty"` + TableName string `json:"tableName,omitempty"` + Changes *connection.ChangeSet `json:"changes,omitempty"` +} + +type optionalAgentResponse struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Fields []string `json:"fields,omitempty"` + RowsAffected int64 `json:"rowsAffected,omitempty"` +} + +type optionalDriverAgentClient struct { + cmd *exec.Cmd + stdin io.WriteCloser + reader *bufio.Reader + nextID int64 + mu sync.Mutex + stderrMu sync.Mutex + stderr strings.Builder + driver string +} + +func newOptionalDriverAgentClient(driverType string, executablePath string) (*optionalDriverAgentClient, error) { + pathText := strings.TrimSpace(executablePath) + if pathText == "" { + return nil, fmt.Errorf("%s 驱动代理路径为空", driverDisplayName(driverType)) + } + if info, err := os.Stat(pathText); err != nil || info.IsDir() { + return nil, fmt.Errorf("%s 驱动代理不存在:%s", driverDisplayName(driverType), pathText) + } + + cmd := exec.Command(pathText) + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("创建 %s 驱动代理 stdin 失败:%w", driverDisplayName(driverType), err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("创建 %s 驱动代理 stdout 失败:%w", driverDisplayName(driverType), err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("创建 %s 驱动代理 stderr 失败:%w", driverDisplayName(driverType), err) + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("启动 %s 驱动代理失败:%w", driverDisplayName(driverType), err) + } + + client := &optionalDriverAgentClient{ + cmd: cmd, + stdin: stdin, + reader: bufio.NewReader(stdout), + driver: normalizeRuntimeDriverType(driverType), + } + go client.captureStderr(stderr) + return client, nil +} + +func (c *optionalDriverAgentClient) captureStderr(stderr io.Reader) { + scanner := bufio.NewScanner(stderr) + buffer := make([]byte, 0, 8<<10) + scanner.Buffer(buffer, optionalAgentDefaultScannerMaxBytes) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + c.stderrMu.Lock() + if c.stderr.Len() > 0 { + c.stderr.WriteString(" | ") + } + c.stderr.WriteString(line) + c.stderrMu.Unlock() + } +} + +func (c *optionalDriverAgentClient) stderrText() string { + c.stderrMu.Lock() + defer c.stderrMu.Unlock() + return strings.TrimSpace(c.stderr.String()) +} + +func (c *optionalDriverAgentClient) call(req optionalAgentRequest, out interface{}, fields *[]string, rowsAffected *int64) error { + c.mu.Lock() + defer c.mu.Unlock() + + c.nextID++ + req.ID = c.nextID + + payload, err := json.Marshal(req) + if err != nil { + return err + } + payload = append(payload, '\n') + if _, err := c.stdin.Write(payload); err != nil { + stderrText := c.stderrText() + if stderrText == "" { + return fmt.Errorf("调用 %s 驱动代理失败:%w", driverDisplayName(c.driver), err) + } + return fmt.Errorf("调用 %s 驱动代理失败:%w(stderr: %s)", driverDisplayName(c.driver), err, stderrText) + } + + line, err := c.reader.ReadBytes('\n') + if err != nil { + stderrText := c.stderrText() + if stderrText == "" { + return fmt.Errorf("读取 %s 驱动代理响应失败:%w", driverDisplayName(c.driver), err) + } + return fmt.Errorf("读取 %s 驱动代理响应失败:%w(stderr: %s)", driverDisplayName(c.driver), err, stderrText) + } + + var resp optionalAgentResponse + if err := json.Unmarshal(line, &resp); err != nil { + return fmt.Errorf("解析 %s 驱动代理响应失败:%w", driverDisplayName(c.driver), err) + } + if !resp.Success { + errText := strings.TrimSpace(resp.Error) + if errText == "" { + errText = fmt.Sprintf("%s 驱动代理返回失败", driverDisplayName(c.driver)) + } + return errors.New(errText) + } + + if fields != nil { + *fields = resp.Fields + } + if rowsAffected != nil { + *rowsAffected = resp.RowsAffected + } + if out != nil && len(resp.Data) > 0 { + if err := json.Unmarshal(resp.Data, out); err != nil { + return fmt.Errorf("解析 %s 驱动代理数据失败:%w", driverDisplayName(c.driver), err) + } + } + return nil +} + +func (c *optionalDriverAgentClient) close() error { + c.mu.Lock() + defer c.mu.Unlock() + var closeErr error + if c.stdin != nil { + _ = c.stdin.Close() + } + if c.cmd != nil && c.cmd.Process != nil { + if err := c.cmd.Process.Kill(); err != nil { + closeErr = err + } + } + if c.cmd != nil { + _ = c.cmd.Wait() + } + return closeErr +} + +type OptionalDriverAgentDB struct { + driverType string + client *optionalDriverAgentClient +} + +func newOptionalDriverAgentDatabase(driverType string) databaseFactory { + normalized := normalizeRuntimeDriverType(driverType) + return func() Database { + return &OptionalDriverAgentDB{driverType: normalized} + } +} + +func (d *OptionalDriverAgentDB) Connect(config connection.ConnectionConfig) error { + if d.client != nil { + _ = d.client.close() + d.client = nil + } + + executablePath, err := ResolveOptionalDriverAgentExecutablePath("", d.driverType) + if err != nil { + return err + } + client, err := newOptionalDriverAgentClient(d.driverType, executablePath) + if err != nil { + return err + } + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodConnect, + Config: &config, + }, nil, nil, nil); err != nil { + _ = client.close() + return err + } + d.client = client + return nil +} + +func (d *OptionalDriverAgentDB) Close() error { + if d.client == nil { + return nil + } + _ = d.client.call(optionalAgentRequest{Method: optionalAgentMethodClose}, nil, nil, nil) + err := d.client.close() + d.client = nil + return err +} + +func (d *OptionalDriverAgentDB) Ping() error { + client, err := d.requireClient() + if err != nil { + return err + } + return client.call(optionalAgentRequest{Method: optionalAgentMethodPing}, nil, nil, nil) +} + +func (d *OptionalDriverAgentDB) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return d.Query(query) +} + +func (d *OptionalDriverAgentDB) Query(query string) ([]map[string]interface{}, []string, error) { + client, err := d.requireClient() + if err != nil { + return nil, nil, err + } + var data []map[string]interface{} + var fields []string + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodQuery, + Query: query, + }, &data, &fields, nil); err != nil { + return nil, nil, err + } + return data, fields, nil +} + +func (d *OptionalDriverAgentDB) ExecContext(ctx context.Context, query string) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + return d.Exec(query) +} + +func (d *OptionalDriverAgentDB) Exec(query string) (int64, error) { + client, err := d.requireClient() + if err != nil { + return 0, err + } + var affected int64 + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodExec, + Query: query, + }, nil, nil, &affected); err != nil { + return 0, err + } + return affected, nil +} + +func (d *OptionalDriverAgentDB) GetDatabases() ([]string, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var dbs []string + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetDatabases, + }, &dbs, nil, nil); err != nil { + return nil, err + } + return dbs, nil +} + +func (d *OptionalDriverAgentDB) GetTables(dbName string) ([]string, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var tables []string + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetTables, + DBName: dbName, + }, &tables, nil, nil); err != nil { + return nil, err + } + return tables, nil +} + +func (d *OptionalDriverAgentDB) GetCreateStatement(dbName, tableName string) (string, error) { + client, err := d.requireClient() + if err != nil { + return "", err + } + var sqlText string + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetCreateStmt, + DBName: dbName, + TableName: tableName, + }, &sqlText, nil, nil); err != nil { + return "", err + } + return sqlText, nil +} + +func (d *OptionalDriverAgentDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var columns []connection.ColumnDefinition + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetColumns, + DBName: dbName, + TableName: tableName, + }, &columns, nil, nil); err != nil { + return nil, err + } + return columns, nil +} + +func (d *OptionalDriverAgentDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var columns []connection.ColumnDefinitionWithTable + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetAllColumns, + DBName: dbName, + }, &columns, nil, nil); err != nil { + return nil, err + } + return columns, nil +} + +func (d *OptionalDriverAgentDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var indexes []connection.IndexDefinition + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetIndexes, + DBName: dbName, + TableName: tableName, + }, &indexes, nil, nil); err != nil { + return nil, err + } + return indexes, nil +} + +func (d *OptionalDriverAgentDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var keys []connection.ForeignKeyDefinition + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetForeignKeys, + DBName: dbName, + TableName: tableName, + }, &keys, nil, nil); err != nil { + return nil, err + } + return keys, nil +} + +func (d *OptionalDriverAgentDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + client, err := d.requireClient() + if err != nil { + return nil, err + } + var triggers []connection.TriggerDefinition + if err := client.call(optionalAgentRequest{ + Method: optionalAgentMethodGetTriggers, + DBName: dbName, + TableName: tableName, + }, &triggers, nil, nil); err != nil { + return nil, err + } + return triggers, nil +} + +func (d *OptionalDriverAgentDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { + client, err := d.requireClient() + if err != nil { + return err + } + return client.call(optionalAgentRequest{ + Method: optionalAgentMethodApplyChanges, + TableName: tableName, + Changes: &changes, + }, nil, nil, nil) +} + +func (d *OptionalDriverAgentDB) requireClient() (*optionalDriverAgentClient, error) { + if d.client == nil { + return nil, fmt.Errorf("connection not open") + } + return d.client, nil +} diff --git a/internal/db/optional_driver_build_full.go b/internal/db/optional_driver_build_full.go new file mode 100644 index 0000000..85590bc --- /dev/null +++ b/internal/db/optional_driver_build_full.go @@ -0,0 +1,9 @@ +//go:build gonavi_full_drivers + +package db + +func optionalGoDriverBuildIncluded(driverType string) bool { + _, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)] + return ok +} + diff --git a/internal/db/optional_driver_build_lite.go b/internal/db/optional_driver_build_lite.go new file mode 100644 index 0000000..b880885 --- /dev/null +++ b/internal/db/optional_driver_build_lite.go @@ -0,0 +1,8 @@ +//go:build !gonavi_full_drivers + +package db + +func optionalGoDriverBuildIncluded(driverType string) bool { + _, ok := optionalGoDrivers[normalizeRuntimeDriverType(driverType)] + return ok +} diff --git a/internal/db/postgres_impl.go b/internal/db/postgres_impl.go index 439942a..a179e91 100644 --- a/internal/db/postgres_impl.go +++ b/internal/db/postgres_impl.go @@ -18,14 +18,12 @@ import ( _ "github.com/lib/pq" ) - type PostgresDB struct { conn *sql.DB pingTimeout time.Duration forwarder *ssh.LocalForwarder // Store SSH tunnel forwarder } - func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string { // postgres://user:password@host:port/dbname?sslmode=disable dbname := config.Database @@ -48,6 +46,13 @@ func (p *PostgresDB) getDSN(config connection.ConnectionConfig) string { } func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { + if supported, reason := DriverRuntimeSupportStatus("postgres"); !supported { + if strings.TrimSpace(reason) == "" { + reason = "PostgreSQL 纯 Go 驱动未启用,请先在驱动管理中安装启用" + } + return fmt.Errorf("%s", reason) + } + var dsn string var err error @@ -98,7 +103,6 @@ func (p *PostgresDB) Connect(config connection.ConnectionConfig) error { return nil } - func (p *PostgresDB) Close() error { // Close SSH forwarder first if exists if p.forwarder != nil { diff --git a/internal/db/sphinx_impl.go b/internal/db/sphinx_impl.go index 6f8e915..7db1f8a 100644 --- a/internal/db/sphinx_impl.go +++ b/internal/db/sphinx_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_sphinx_driver + package db import ( diff --git a/internal/db/sqlite_impl.go b/internal/db/sqlite_impl.go index c0b9ebe..582937a 100644 --- a/internal/db/sqlite_impl.go +++ b/internal/db/sqlite_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_sqlite_driver + package db import ( diff --git a/internal/db/sqlserver_impl.go b/internal/db/sqlserver_impl.go index 145f6e1..bac36e9 100644 --- a/internal/db/sqlserver_impl.go +++ b/internal/db/sqlserver_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_sqlserver_driver + package db import ( diff --git a/internal/db/tdengine_impl.go b/internal/db/tdengine_impl.go index 1f6021d..640c97f 100644 --- a/internal/db/tdengine_impl.go +++ b/internal/db/tdengine_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_tdengine_driver + package db import ( diff --git a/internal/db/vastbase_impl.go b/internal/db/vastbase_impl.go index 8f7b9c6..250fe73 100644 --- a/internal/db/vastbase_impl.go +++ b/internal/db/vastbase_impl.go @@ -1,3 +1,5 @@ +//go:build gonavi_full_drivers || gonavi_vastbase_driver + package db import (