Files
MyGoNavi/internal/app/app.go
Syngnat d2dad75167 ♻️ refactor(oceanbase): 完善双协议连接链路
- 抽象 OceanBase 协议解析与运行态参数注入
- 复用 OracleDB 实现 OceanBase Oracle 租户连接能力
- 调整 DDL、schema、SQL 方言和数据源能力判断
- 补充协议优先级、缓存隔离和 RPC 参数测试
- 支持按指定 driver 自动生成 agent revision
2026-04-30 15:05:05 +08:00

904 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 时窗口标记为 opaqueGPU 不再持续计算窗口背后的模糊合成。
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)
}
// 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" {
normalized.ConnectionParams = normalizeOceanBaseConnectionParamsForCache(normalized.ConnectionParams)
}
// 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()}
}
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()}
}
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 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()
}