package app import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net" "net/url" "os" "path/filepath" "strings" "sync" "time" "GoNavi-Wails/internal/appdata" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/logger" proxytunnel "GoNavi-Wails/internal/proxy" "GoNavi-Wails/internal/secretstore" "github.com/google/uuid" ) const dbCachePingInterval = 30 * time.Second const dbConnectFailureCooldown = 30 * time.Second const ( startupConnectRetryWindow = 20 * time.Second startupConnectRetryDelay = 800 * time.Millisecond startupConnectRetryAttempts = 4 ) var ( newDatabaseFunc = db.NewDatabase resolveDialConfigWithProxyFunc = resolveDialConfigWithProxy ) type cachedDatabase struct { inst db.Database lastPing time.Time } type cachedConnectFailure struct { occurredAt time.Time err error } type queryContext struct { cancel context.CancelFunc started time.Time } // App struct type App struct { ctx context.Context startedAt time.Time dbCache map[string]cachedDatabase // Cache for DB connections connectFailures map[string]cachedConnectFailure mu sync.RWMutex // Mutex for cache access updateMu sync.Mutex updateState updateState queryMu sync.RWMutex configDir string secretStore secretstore.SecretStore runningQueries map[string]queryContext // queryID -> cancelFunc and start time jvmPreviewTokenMu sync.Mutex jvmPreviewTokens map[string]jvmPreviewConfirmationToken jvmPreviewTokenTTL time.Duration } // NewApp creates a new App application struct func NewApp() *App { return NewAppWithSecretStore(secretstore.NewKeyringStore()) } func NewAppWithSecretStore(store secretstore.SecretStore) *App { if store == nil { store = secretstore.NewUnavailableStore("secret store unavailable") } return &App{ dbCache: make(map[string]cachedDatabase), connectFailures: make(map[string]cachedConnectFailure), runningQueries: make(map[string]queryContext), configDir: resolveAppConfigDir(), secretStore: store, jvmPreviewTokens: make(map[string]jvmPreviewConfirmationToken), jvmPreviewTokenTTL: defaultJVMPreviewConfirmationTokenTTL, } } // InitializeLifecycle attaches runtime context without exposing lifecycle internals to Wails bindings. func InitializeLifecycle(a *App, ctx context.Context) { a.startup(ctx) } // startup is called when the app starts. The context is saved // so we can call the runtime methods. func (a *App) startup(ctx context.Context) { a.ctx = ctx a.startedAt = time.Now() if strings.TrimSpace(a.configDir) == "" { a.configDir = resolveAppConfigDir() } db.SetExternalDriverDownloadDirectory(appdata.DriverRoot(a.configDir)) logger.Init() if err := migrateDailySecretsIfNeeded(a); err != nil { logger.Warnf("迁移日常密文失败:%v", err) } a.loadPersistedGlobalProxy() if err := migrateLegacyWebKitStorageIfNeeded(a); err != nil { logger.Warnf("迁移旧 WebKit 连接存储失败:%v", err) } if shouldInstallMacNativeWindowDiagnostics() { installMacNativeWindowDiagnostics(logger.Path()) } applyMacWindowTranslucencyFix() logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts) } // SetWindowTranslucency 动态调整 macOS 窗口透明度。 // 前端在加载用户外观设置后、以及用户修改外观时调用此方法。 // opacity=1.0 且 blur=0 时窗口标记为 opaque,GPU 不再持续计算窗口背后的模糊合成。 func (a *App) SetWindowTranslucency(opacity float64, blur float64) { setMacWindowTranslucency(opacity, blur) } // SetMacNativeWindowControls toggles macOS native traffic-light window controls. // On non-macOS platforms this is a no-op. func (a *App) SetMacNativeWindowControls(enabled bool) { setMacNativeWindowControls(enabled) } // ResetWebViewZoom 把 WebView2 zoom factor 强制重置为 1.0,让 WebView2 重算字体度量。 // 用于 Windows 任务栏恢复后字体异常变大的"零感知"修复:不动窗口、零动画。 // 仅 Windows 上生效,其他平台返回错误(前端按需忽略)。 func (a *App) ResetWebViewZoom() (result connection.QueryResult) { defer func() { if recovered := recover(); recovered != nil { logger.Errorf("重置 WebView2 zoom 失败:%v", recovered) result = connection.QueryResult{ Success: false, Message: fmt.Sprintf("重置 WebView2 zoom 失败:%v", recovered), } } }() if err := resetWebViewZoomFactor(a.ctx, 1.0); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } return connection.QueryResult{Success: true, Message: "WebView2 zoom factor reset to 1.0"} } // LogWindowDiagnostic 记录前端采集到的窗口诊断信息,便于排查 macOS 原生全屏异常。 func (a *App) LogWindowDiagnostic(stage string, payload string) { stage = strings.TrimSpace(stage) payload = strings.TrimSpace(payload) if stage == "" { stage = "unknown" } logger.Warnf("窗口诊断:stage=%s payload=%s", stage, payload) } // Shutdown is called when the app terminates func (a *App) Shutdown(ctx context.Context) { logger.Infof("应用开始关闭,准备释放资源") a.mu.Lock() defer a.mu.Unlock() for _, dbInst := range a.dbCache { if err := dbInst.inst.Close(); err != nil { logger.Error(err, "关闭数据库连接失败") } } proxytunnel.CloseAllForwarders() // Close all Redis connections CloseAllRedisClients() logger.Infof("资源释放完成,应用已关闭") logger.Close() } func dataRootInfoPayload(activeRoot string) map[string]interface{} { defaultRoot := appdata.DefaultRoot() currentRoot := strings.TrimSpace(activeRoot) if currentRoot == "" { currentRoot = appdata.MustResolveActiveRoot() } return map[string]interface{}{ "path": currentRoot, "defaultPath": defaultRoot, "driverPath": appdata.DriverRoot(currentRoot), "isDefaultPath": filepath.Clean(currentRoot) == filepath.Clean(defaultRoot), "bootstrapPath": appdata.BootstrapPath(), } } func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig { normalized := config normalized.ID = "" normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type)) if normalized.Type == "oceanbase" { protocol := resolveOceanBaseProtocolForApp(normalized) normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCacheWithProtocol(normalized.ConnectionParams, protocol) normalized.OceanBaseProtocol = "" } // timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。 normalized.Timeout = 0 normalized.SavePassword = false if !normalized.UseSSH { normalized.SSH = connection.SSHConfig{} } if !normalized.UseProxy { normalized.Proxy = connection.ProxyConfig{} } if !normalized.UseHTTPTunnel { normalized.HTTPTunnel = connection.HTTPTunnelConfig{} } if isFileDatabaseType(normalized.Type) { dsn := strings.TrimSpace(normalized.Host) if dsn == "" { dsn = strings.TrimSpace(normalized.Database) } if dsn == "" { dsn = ":memory:" } // DuckDB/SQLite 仅基于文件来源识别连接,其他网络字段不参与键计算。 normalized.Host = dsn normalized.Database = "" normalized.Port = 0 normalized.User = "" normalized.Password = "" normalized.URI = "" normalized.ConnectionParams = "" normalized.Hosts = nil normalized.Topology = "" normalized.MySQLReplicaUser = "" normalized.MySQLReplicaPassword = "" normalized.ReplicaSet = "" normalized.AuthSource = "" normalized.ReadPreference = "" normalized.MongoSRV = false normalized.MongoAuthMechanism = "" normalized.MongoReplicaUser = "" normalized.MongoReplicaPassword = "" normalized.UseHTTPTunnel = false normalized.HTTPTunnel = connection.HTTPTunnelConfig{} } return normalized } func resolveFileDatabaseDSN(config connection.ConnectionConfig) string { dsn := strings.TrimSpace(config.Host) if dsn == "" { dsn = strings.TrimSpace(config.Database) } if dsn == "" { dsn = ":memory:" } return dsn } // Helper: Generate a unique key for the connection config func getCacheKey(config connection.ConnectionConfig) string { normalized := normalizeCacheKeyConfig(config) b, _ := json.Marshal(normalized) sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } func shortCacheKey(cacheKey string) string { shortKey := cacheKey if len(shortKey) > 12 { shortKey = shortKey[:12] } return shortKey } func shouldRefreshCachedConnection(err error) bool { if err == nil { return false } normalized := strings.ToLower(normalizeErrorMessage(err)) if normalized == "" { return false } patterns := []string{ "invalid connection", "bad connection", "database is closed", "connection is already closed", "use of closed network connection", "broken pipe", "connection reset by peer", "server has gone away", "eof", } for _, pattern := range patterns { if strings.Contains(normalized, pattern) { return true } } return false } func (a *App) invalidateCachedDatabase(config connection.ConnectionConfig, reason error) bool { if resolvedConfig, err := a.resolveConnectionSecrets(config); err == nil { config = resolvedConfig } effectiveConfig := applyGlobalProxyToConnection(config) key := getCacheKey(effectiveConfig) shortKey := shortCacheKey(key) a.mu.Lock() defer a.mu.Unlock() entry, exists := a.dbCache[key] if !exists || entry.inst == nil { return false } if closeErr := entry.inst.Close(); closeErr != nil { logger.Error(closeErr, "关闭失效缓存连接失败:缓存Key=%s", shortKey) } delete(a.dbCache, key) if reason != nil { logger.Errorf("检测到连接失效,已清理缓存连接:%s 缓存Key=%s 原因=%s", formatConnSummary(effectiveConfig), shortKey, normalizeErrorMessage(reason)) } else { logger.Infof("已清理缓存连接:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) } return true } func wrapConnectError(config connection.ConnectionConfig, err error) error { if err == nil { return nil } err = sanitizeMongoConnectErrorLabel(config, err) var netErr net.Error if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { dbName := config.Database if dbName == "" { dbName = "(default)" } err = fmt.Errorf("数据库连接超时:%s %s:%d/%s:%w", config.Type, config.Host, config.Port, dbName, err) } return withLogHint{err: err, logPath: logger.Path()} } type errorMessageOverride struct { message string cause error } func (e errorMessageOverride) Error() string { return e.message } func (e errorMessageOverride) Unwrap() error { return e.cause } func sanitizeMongoConnectErrorLabel(config connection.ConnectionConfig, err error) error { if err == nil { return nil } if strings.ToLower(strings.TrimSpace(config.Type)) != "mongodb" { return err } if mongoConnectUsesTLS(config) { return err } original := err.Error() rewritten := strings.ReplaceAll(original, "SSL 主库凭据", "主库凭据") rewritten = strings.ReplaceAll(rewritten, "SSL 从库凭据", "从库凭据") if rewritten == original { return err } return errorMessageOverride{ message: rewritten, cause: err, } } func mongoConnectUsesTLS(config connection.ConnectionConfig) bool { if config.UseSSL { return true } uriText := strings.TrimSpace(config.URI) if uriText == "" { return false } parsed, err := url.Parse(uriText) if err != nil { return false } for _, key := range []string{"tls", "ssl"} { if enabled, known := parseMongoBool(parsed.Query().Get(key)); known { return enabled } } return strings.EqualFold(strings.TrimSpace(parsed.Scheme), "mongodb+srv") } func parseMongoBool(raw string) (enabled bool, known bool) { value := strings.ToLower(strings.TrimSpace(raw)) switch value { case "1", "true", "t", "yes", "y", "on", "required": return true, true case "0", "false", "f", "no", "n", "off", "disable", "disabled": return false, true default: return false, false } } type withLogHint struct { err error logPath string } func (e withLogHint) Error() string { message := normalizeErrorMessage(e.err) path := strings.TrimSpace(e.logPath) if path == "" { return message } info, statErr := os.Stat(path) if statErr != nil || info.IsDir() || info.Size() <= 0 { return message } return fmt.Sprintf("%s(详细日志:%s)", message, path) } func (e withLogHint) Unwrap() error { return e.err } func formatConnSummary(config connection.ConnectionConfig) string { timeoutSeconds := config.Timeout if timeoutSeconds <= 0 { timeoutSeconds = 30 } dbName := config.Database if strings.TrimSpace(dbName) == "" { dbName = "(default)" } var b strings.Builder normalizedType := strings.ToLower(strings.TrimSpace(config.Type)) if normalizedType == "sqlite" || normalizedType == "duckdb" { path := strings.TrimSpace(config.Host) if path == "" { path = "(未配置)" } b.WriteString(fmt.Sprintf("类型=%s 路径=%s 超时=%ds", config.Type, path, timeoutSeconds)) } else { b.WriteString(fmt.Sprintf("类型=%s 地址=%s:%d 数据库=%s 用户=%s 超时=%ds", config.Type, config.Host, config.Port, dbName, config.User, timeoutSeconds)) } if len(config.Hosts) > 0 { b.WriteString(fmt.Sprintf(" 节点数=%d", len(config.Hosts))) } if strings.TrimSpace(config.Topology) != "" { b.WriteString(fmt.Sprintf(" 拓扑=%s", strings.TrimSpace(config.Topology))) } if strings.TrimSpace(config.URI) != "" { b.WriteString(fmt.Sprintf(" URI=已配置(长度=%d)", len(config.URI))) } if strings.TrimSpace(config.ConnectionParams) != "" { b.WriteString(fmt.Sprintf(" 连接参数=已配置(长度=%d)", len(config.ConnectionParams))) } if strings.TrimSpace(config.MySQLReplicaUser) != "" { b.WriteString(" MySQL从库凭据=已配置") } if strings.EqualFold(strings.TrimSpace(config.Type), "mongodb") { if strings.TrimSpace(config.MongoReplicaUser) != "" { b.WriteString(" Mongo从库凭据=已配置") } if strings.TrimSpace(config.ReplicaSet) != "" { b.WriteString(fmt.Sprintf(" 副本集=%s", strings.TrimSpace(config.ReplicaSet))) } if strings.TrimSpace(config.ReadPreference) != "" { b.WriteString(fmt.Sprintf(" 读偏好=%s", strings.TrimSpace(config.ReadPreference))) } if strings.TrimSpace(config.AuthSource) != "" { b.WriteString(fmt.Sprintf(" 认证库=%s", strings.TrimSpace(config.AuthSource))) } } if strings.EqualFold(strings.TrimSpace(config.Type), "clickhouse") { protocol := strings.ToLower(strings.TrimSpace(config.ClickHouseProtocol)) if protocol == "" { protocol = "auto" } b.WriteString(fmt.Sprintf(" ClickHouse协议=%s", protocol)) } if strings.EqualFold(strings.TrimSpace(config.Type), "oceanbase") { protocol := "mysql" if isOceanBaseOracleProtocol(config) { protocol = "oracle" } b.WriteString(fmt.Sprintf(" OceanBase协议=%s", protocol)) } if config.UseSSH { b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User)) } if config.UseProxy { b.WriteString(fmt.Sprintf(" 代理=%s://%s:%d", strings.ToLower(strings.TrimSpace(config.Proxy.Type)), config.Proxy.Host, config.Proxy.Port)) if strings.TrimSpace(config.Proxy.User) != "" { b.WriteString(" 代理认证=已配置") } } if config.UseHTTPTunnel { b.WriteString(fmt.Sprintf(" HTTP隧道=%s:%d", strings.TrimSpace(config.HTTPTunnel.Host), config.HTTPTunnel.Port)) if strings.TrimSpace(config.HTTPTunnel.User) != "" { b.WriteString(" HTTP隧道认证=已配置") } } if config.Type == "custom" { driver := strings.TrimSpace(config.Driver) if driver == "" { driver = "(未配置)" } dsnState := "未配置" if strings.TrimSpace(config.DSN) != "" { dsnState = fmt.Sprintf("已配置(长度=%d)", len(config.DSN)) } b.WriteString(fmt.Sprintf(" 驱动=%s DSN=%s", driver, dsnState)) } return b.String() } func (a *App) getDatabaseForcePing(config connection.ConnectionConfig) (db.Database, error) { return a.getDatabaseWithPing(config, true) } // Helper: Get or create a database connection func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, error) { return a.getDatabaseWithPing(config, false) } func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) { resolvedConfig, err := a.resolveConnectionSecrets(config) if err != nil { return nil, wrapConnectError(config, err) } effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported { if strings.TrimSpace(reason) == "" { reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type)) } return nil, withLogHint{err: fmt.Errorf("%s", reason), logPath: logger.Path()} } if revisionErr := verifyRuntimeOptionalDriverAgentRevision(effectiveConfig); revisionErr != nil { return nil, withLogHint{err: revisionErr, logPath: logger.Path()} } dbInst, err := newDatabaseFunc(effectiveConfig.Type) if err != nil { return nil, err } connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) if proxyErr != nil { _ = dbInst.Close() return nil, wrapConnectError(effectiveConfig, proxyErr) } if err := dbInst.Connect(connectConfig); err != nil { _ = dbInst.Close() return nil, wrapConnectError(effectiveConfig, err) } return dbInst, nil } func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) { resolvedConfig, err := a.resolveConnectionSecrets(config) if err != nil { return nil, wrapConnectError(config, err) } effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) isFileDB := isFileDatabaseType(effectiveConfig.Type) key := getCacheKey(effectiveConfig) shortKey := shortenCacheKey(key) if isFileDB { rawDSN := resolveFileDatabaseDSN(effectiveConfig) normalizedDSN := resolveFileDatabaseDSN(normalizeCacheKeyConfig(effectiveConfig)) logger.Infof("文件库连接缓存探测:类型=%s 原始DSN=%s 归一化DSN=%s timeout=%ds forcePing=%t 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), rawDSN, normalizedDSN, effectiveConfig.Timeout, forcePing, shortKey) } if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported { if strings.TrimSpace(reason) == "" { reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.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() if ok { if isFileDB { logger.Infof("命中文件库连接缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } needPing := forcePing if !needPing { lastPing := entry.lastPing if lastPing.IsZero() || time.Since(lastPing) >= dbCachePingInterval { needPing = true } } if !needPing { if isFileDB { logger.Infof("复用文件库连接缓存(免 Ping):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } return entry.inst, nil } if err := entry.inst.Ping(); err == nil { // Update lastPing (best effort) a.mu.Lock() if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst { cur.lastPing = time.Now() a.dbCache[key] = cur } a.mu.Unlock() if isFileDB { logger.Infof("复用文件库连接缓存(Ping 成功):类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } return entry.inst, nil } else { logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) } // Ping failed: remove cached instance (best effort) a.mu.Lock() if cur, exists := a.dbCache[key]; exists && cur.inst == entry.inst { if err := cur.inst.Close(); err != nil { logger.Error(err, "关闭失效缓存连接失败:缓存Key=%s", shortKey) } delete(a.dbCache, key) } a.mu.Unlock() if isFileDB { logger.Infof("文件库缓存连接已剔除,准备新建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } } if isFileDB { logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } if failure, remaining, ok := a.getCachedConnectFailureByKey(key); ok { message := fmt.Sprintf("连接最近失败,正在冷却中,请 %s 后重试;上次错误:%s", formatConnectFailureCooldown(remaining), normalizeErrorMessage(failure.err), ) logger.Warnf("命中数据库连接失败冷却:%s 缓存Key=%s 剩余=%s 原因=%s", formatConnSummary(effectiveConfig), shortKey, formatConnectFailureCooldown(remaining), normalizeErrorMessage(failure.err)) return nil, withLogHint{err: fmt.Errorf("%s", message), logPath: logger.Path()} } if revisionErr := verifyRuntimeOptionalDriverAgentRevision(effectiveConfig); revisionErr != nil { return nil, withLogHint{err: revisionErr, logPath: logger.Path()} } initialKey := key dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(resolvedConfig) if err != nil { failedKey := getCacheKey(connectedConfig) a.recordConnectFailureByKey(failedKey, err) return nil, err } a.clearConnectFailureByKey(initialKey) effectiveConfig = connectedConfig key = getCacheKey(effectiveConfig) shortKey = shortenCacheKey(key) a.clearConnectFailureByKey(key) now := time.Now() a.mu.Lock() if existing, exists := a.dbCache[key]; exists && existing.inst != nil { a.mu.Unlock() // Prefer existing cached connection to avoid cache racing duplicates. _ = dbInst.Close() if isFileDB { logger.Infof("并发创建命中已存在文件库连接,关闭新建连接并复用缓存:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } return existing.inst, nil } a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now} a.mu.Unlock() logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey) return dbInst, nil } func (a *App) getCachedConnectFailureByKey(key string) (cachedConnectFailure, time.Duration, bool) { if a == nil || strings.TrimSpace(key) == "" { return cachedConnectFailure{}, 0, false } a.mu.RLock() entry, exists := a.connectFailures[key] a.mu.RUnlock() if !exists || entry.err == nil || entry.occurredAt.IsZero() { return cachedConnectFailure{}, 0, false } remaining := dbConnectFailureCooldown - time.Since(entry.occurredAt) if remaining <= 0 { a.clearConnectFailureByKey(key) return cachedConnectFailure{}, 0, false } return entry, remaining, true } func (a *App) recordConnectFailureByKey(key string, err error) { if a == nil || strings.TrimSpace(key) == "" || err == nil { return } a.mu.Lock() if a.connectFailures == nil { a.connectFailures = make(map[string]cachedConnectFailure) } a.connectFailures[key] = cachedConnectFailure{ occurredAt: time.Now(), err: err, } a.mu.Unlock() } func (a *App) clearConnectFailureByKey(key string) { if a == nil || strings.TrimSpace(key) == "" { return } a.mu.Lock() if a.connectFailures != nil { delete(a.connectFailures, key) } a.mu.Unlock() } func formatConnectFailureCooldown(remaining time.Duration) time.Duration { if remaining <= time.Second { return time.Second } return remaining.Truncate(time.Second) } func verifyRuntimeOptionalDriverAgentRevision(config connection.ConnectionConfig) error { driverType := normalizeDriverType(config.Type) if !db.IsOptionalGoDriver(driverType) { return nil } executablePath, err := db.ResolveOptionalDriverAgentExecutablePath("", driverType) if err != nil { return err } pkg, packageMetaExists := readInstalledDriverPackage("", driverType) selectedVersion := "" if packageMetaExists { selectedVersion = strings.TrimSpace(pkg.Version) } agentRevision, err := verifyInstalledOptionalDriverAgentRevision(driverType, executablePath, selectedVersion) if err != nil { return err } if expectedRevision := strings.TrimSpace(db.OptionalDriverAgentRevision(driverType)); expectedRevision != "" { displayName := resolveDriverDisplayName(driverDefinition{Type: driverType}) logger.Infof("%s driver-agent revision 校验通过:已安装=%s 当前需要=%s version=%s path=%s", displayName, strings.TrimSpace(agentRevision), expectedRevision, selectedVersion, executablePath) } return nil } func shortenCacheKey(key string) string { if len(key) > 12 { return key[:12] } return key } func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionConfig) (db.Database, connection.ConnectionConfig, error) { resolvedConfig, err := a.resolveConnectionSecrets(rawConfig) if err != nil { return nil, rawConfig, wrapConnectError(rawConfig, err) } rawConfig = resolvedConfig var lastErr error var lastEffectiveConfig connection.ConnectionConfig for attempt := 1; attempt <= startupConnectRetryAttempts; attempt++ { effectiveConfig := applyGlobalProxyToConnection(rawConfig) lastEffectiveConfig = effectiveConfig cacheKey := shortenCacheKey(getCacheKey(effectiveConfig)) logger.Infof("获取数据库连接:%s 缓存Key=%s 启动阶段=%s", formatConnSummary(effectiveConfig), cacheKey, a.startupPhaseLabel()) logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s 尝试=%d/%d", effectiveConfig.Type, cacheKey, attempt, startupConnectRetryAttempts) dbInst, err := newDatabaseFunc(effectiveConfig.Type) if err != nil { logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", effectiveConfig.Type, cacheKey) return nil, effectiveConfig, err } connectConfig, proxyErr := resolveDialConfigWithProxyFunc(effectiveConfig) if proxyErr != nil { _ = dbInst.Close() wrapped := wrapConnectError(effectiveConfig, proxyErr) logger.Error(wrapped, "连接代理准备失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) return nil, effectiveConfig, wrapped } if err := dbInst.Connect(connectConfig); err == nil { if attempt > 1 { logger.Warnf("数据库连接在重试后成功:%s 缓存Key=%s 尝试=%d/%d", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts) } return dbInst, effectiveConfig, nil } else { _ = dbInst.Close() wrapped := wrapConnectError(effectiveConfig, err) lastErr = wrapped logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(effectiveConfig), cacheKey) if !a.shouldRetryConnect(err, attempt) { return nil, effectiveConfig, wrapped } logger.Warnf("检测到瞬时网络失败,准备重试连接:%s 缓存Key=%s 尝试=%d/%d 延迟=%s 原因=%s", formatConnSummary(effectiveConfig), cacheKey, attempt, startupConnectRetryAttempts, startupConnectRetryDelay, normalizeErrorMessage(err)) time.Sleep(startupConnectRetryDelay) } } if lastErr == nil { lastErr = fmt.Errorf("建立数据库连接失败") } return nil, lastEffectiveConfig, lastErr } func (a *App) startupPhaseLabel() string { if a == nil || a.startedAt.IsZero() { return "未知" } age := time.Since(a.startedAt).Round(time.Millisecond) if age < 0 { age = 0 } if age <= startupConnectRetryWindow { snapshot := currentGlobalProxyConfig() state := "关闭" if snapshot.Enabled { state = fmt.Sprintf("启用(%s://%s:%d)", strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)), strings.TrimSpace(snapshot.Proxy.Host), snapshot.Proxy.Port) } return fmt.Sprintf("启动期(age=%s,全局代理=%s)", age, state) } return fmt.Sprintf("稳定期(age=%s)", age) } func (a *App) shouldRetryConnect(err error, attempt int) bool { if attempt >= startupConnectRetryAttempts { return false } if !isTransientStartupConnectError(err) { return false } if a != nil && !a.startedAt.IsZero() { age := time.Since(a.startedAt) if age >= 0 && age <= startupConnectRetryWindow { return true } } return false } func isTransientStartupConnectError(err error) bool { if err == nil { return false } message := strings.ToLower(normalizeErrorMessage(err)) transientHints := []string{ "no route to host", "network is unreachable", "connection refused", "connection timed out", "i/o timeout", "context deadline exceeded", } for _, hint := range transientHints { if strings.Contains(message, hint) { return true } } return false } // generateQueryID generates a unique ID for a query using UUID v4 func generateQueryID() string { return "query-" + uuid.New().String() } // CancelQuery cancels a running query by its ID func (a *App) CancelQuery(queryID string) connection.QueryResult { a.queryMu.Lock() defer a.queryMu.Unlock() if ctx, exists := a.runningQueries[queryID]; exists { ctx.cancel() delete(a.runningQueries, queryID) logger.Infof("查询已取消:queryID=%s", queryID) return connection.QueryResult{Success: true, Message: "查询已取消"} } logger.Warnf("取消查询失败:queryID=%s 不存在或已完成", queryID) return connection.QueryResult{Success: false, Message: "查询不存在或已完成"} } // cleanupStaleQueries removes queries older than maxAge. func (a *App) cleanupStaleQueries(maxAge time.Duration) { a.queryMu.Lock() defer a.queryMu.Unlock() now := time.Now() for id, ctx := range a.runningQueries { if now.Sub(ctx.started) > maxAge { // Query likely finished or stuck, remove from tracking delete(a.runningQueries, id) // Query expired, silently remove } } } // GenerateQueryID generates a unique query ID for cancellation tracking func (a *App) GenerateQueryID() string { return generateQueryID() }