Files
MyGoNavi/internal/app/app.go
杨国锋 78e35a5be8 ️ perf(data-grid): 重构批量编辑链路并优化表格渲染性能
- 重构批量改单元格的状态流,减少高频交互时的无效重渲染
- 优化大数据量场景下的表格交互流畅度与响应延迟
- 调整单元格编辑细节,增强与 Navicat 编辑习惯的一致性

🔧 fix(sidebar-connection): 修复多数据源切换后旧连接节点无响应问题

- 修复新建并连接新数据源后,旧数据源点击无响应的问题

 feat(tab-manager): 表与设计标签支持环境前缀显示

- 基于连接名识别 DEV/UAT/PROD/SIT/STG/TEST 环境标记
- 仅对 table/design 标签添加环境前缀,查询等标签保持原样
- 无法识别标准环境时回退显示连接名,提升多环境可辨识性

 feat(connection-config): 新增连接URI复制解析并支持MySQL/Mongo主从配置

- 连接弹窗新增 URI 生成、解析、复制能力,支持参数回填
- MySQL 支持多地址主从拓扑、从库地址列表与从库独立凭据
- Mongo 支持多节点配置、replicaSet、authSource、readPreference
- 扩展前后端连接配置模型并同步 Wails 生成类型文件
- 后端接入主从凭据回退策略,保持旧配置兼容

 feat(mongodb-replica): 对齐Navicat主从配置并补齐成员发现能力

- 新增 mongoSrv、mongoAuthMechanism、savePassword 配置项
- 支持 mongodb+srv URI 构建与解析,并透传 authMechanism
- 新增 MongoDiscoverMembers 接口,返回成员与状态信息
- 驱动侧实现 replSetGetStatus -> hello/isMaster 回退发现链路
- 前端弹窗新增 SRV 开关、验证方式、成员发现按钮与状态表
- 增加 SRV+SSH 冲突提示与后端保护,避免无效连接路径

🔧 fix(app-error-text): 修复连接测试错误信息乱码并完善日志提示

- 新增错误文本编码纠正能力,处理混合编码导致的中文乱码
- 连接错误提示统一走 normalizeErrorMessage 输出
- 增加 GB18030 纠正相关单元测试覆盖 PostgreSQL 认证失败场景
- go.mod 显式引入 golang.org/x/text 依赖

 feat(filter-panel): 筛选条件支持启用停用与批量开关

- 筛选条件新增 enabled 状态,支持按条件勾选启用/停用
- 筛选面板新增“全启用”“全停用”快捷操作
- SQL 组装时自动跳过已停用条件,保留条件内容便于复用
- 同步 DataViewer 与 SQL 工具层类型,确保筛选链路一致性

🔧 fix(connection-modal-scroll): 修复连接弹窗滚动行为并去除外层滚动条

- 连接配置步骤设置弹窗 body 最大高度与内部滚动
- 为连接弹窗增加专用 wrapClassName 并禁用外层滚动
- 修复出现双滚动条的问题,确保仅保留弹窗内部滚动条
2026-02-09 21:54:11 +08:00

263 lines
7.1 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"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/logger"
)
const dbCachePingInterval = 30 * time.Second
type cachedDatabase struct {
inst db.Database
lastPing time.Time
}
// App struct
type App struct {
ctx context.Context
dbCache map[string]cachedDatabase // Cache for DB connections
mu sync.RWMutex // Mutex for cache access
updateMu sync.Mutex
updateState updateState
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{
dbCache: make(map[string]cachedDatabase),
}
}
// 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
logger.Init()
applyMacWindowTranslucencyFix()
logger.Infof("应用启动完成")
}
// 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, "关闭数据库连接失败")
}
}
// Close all Redis connections
CloseAllRedisClients()
logger.Infof("资源释放完成,应用已关闭")
logger.Close()
}
// Helper: Generate a unique key for the connection config
func getCacheKey(config connection.ConnectionConfig) string {
if !config.UseSSH {
config.SSH = connection.SSHConfig{}
}
// 保持与驱动默认一致,避免同一连接被重复缓存
if config.Type == "postgres" && config.Database == "" {
config.Database = "postgres"
}
b, _ := json.Marshal(config)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func wrapConnectError(config connection.ConnectionConfig, err error) error {
if err == nil {
return nil
}
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 withLogHint struct {
err error
logPath string
}
func (e withLogHint) Error() string {
message := normalizeErrorMessage(e.err)
if strings.TrimSpace(e.logPath) == "" {
return message
}
return fmt.Sprintf("%s详细日志%s", message, e.logPath)
}
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
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.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 config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))
}
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) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) {
key := getCacheKey(config)
shortKey := key
if len(shortKey) > 12 {
shortKey = shortKey[:12]
}
a.mu.RLock()
entry, ok := a.dbCache[key]
a.mu.RUnlock()
if ok {
needPing := forcePing
if !needPing {
lastPing := entry.lastPing
if lastPing.IsZero() || time.Since(lastPing) >= dbCachePingInterval {
needPing = true
}
}
if !needPing {
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()
return entry.inst, nil
} else {
logger.Error(err, "缓存连接不可用,准备重建:%s 缓存Key=%s", formatConnSummary(config), 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()
}
logger.Infof("获取数据库连接:%s 缓存Key=%s", formatConnSummary(config), shortKey)
logger.Infof("创建数据库驱动实例:类型=%s 缓存Key=%s", config.Type, shortKey)
dbInst, err := db.NewDatabase(config.Type)
if err != nil {
logger.Error(err, "创建数据库驱动实例失败:类型=%s 缓存Key=%s", config.Type, shortKey)
return nil, err
}
if err := dbInst.Connect(config); err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "建立数据库连接失败:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return nil, wrapped
}
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()
return existing.inst, nil
}
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
a.mu.Unlock()
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(config), shortKey)
return dbInst, nil
}