Files
MyGoNavi/internal/app/saved_query_fingerprint.go
Syngnat eca9601ab0 feat(saved-query): 支持已存查询后端持久化与连接重绑
- 后端新增 saved_queries.json 仓库,保存、导入、删除和重绑统一走 Wails 方法
- 启动时导入旧 lite-db-storage 中的 savedQueries 和连接快照,成功后清理旧字段
- 新增连接指纹匹配,唯一强匹配自动重绑,歧义场景保留为未匹配
- 侧边栏新增未匹配已存查询分组,并支持手动绑定到目标连接
- 前端保存、重命名、删除查询改为后端持久化,并补充浏览器 mock
- 补充后端与前端迁移回归测试
2026-06-15 12:20:57 +08:00

272 lines
9.7 KiB
Go

package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strings"
"GoNavi-Wails/internal/connection"
)
const (
savedQueryFingerprintVersion = "connection-v1"
savedQueryBindingActive = "active"
savedQueryBindingRebound = "rebound"
savedQueryBindingOrphan = "orphan"
)
type savedQueryConnectionFingerprintPayload struct {
Version string `json:"version"`
Type string `json:"type"`
Driver string `json:"driver,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Hosts []string `json:"hosts,omitempty"`
User string `json:"user,omitempty"`
Database string `json:"database,omitempty"`
UseSSL bool `json:"useSSL,omitempty"`
SSLMode string `json:"sslMode,omitempty"`
UseSSH bool `json:"useSSH,omitempty"`
SSHHost string `json:"sshHost,omitempty"`
SSHPort int `json:"sshPort,omitempty"`
SSHUser string `json:"sshUser,omitempty"`
UseHTTPTunnel bool `json:"useHttpTunnel,omitempty"`
HTTPTunnelHost string `json:"httpTunnelHost,omitempty"`
HTTPTunnelPort int `json:"httpTunnelPort,omitempty"`
HTTPTunnelUser string `json:"httpTunnelUser,omitempty"`
ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"`
OceanBaseProtocol string `json:"oceanBaseProtocol,omitempty"`
Topology string `json:"topology,omitempty"`
ReplicaSet string `json:"replicaSet,omitempty"`
AuthSource string `json:"authSource,omitempty"`
ReadPreference string `json:"readPreference,omitempty"`
MongoSRV bool `json:"mongoSrv,omitempty"`
MongoAuthMechanism string `json:"mongoAuthMechanism,omitempty"`
RedisMaster string `json:"redisMaster,omitempty"`
RedisUser string `json:"redisUser,omitempty"`
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"`
MongoReplicaUser string `json:"mongoReplicaUser,omitempty"`
}
func normalizeFingerprintText(value string) string {
return strings.TrimSpace(value)
}
func normalizeFingerprintLower(value string) string {
return strings.ToLower(normalizeFingerprintText(value))
}
func normalizeFingerprintHosts(values []string) []string {
result := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
host := normalizeFingerprintLower(value)
if host == "" {
continue
}
if _, exists := seen[host]; exists {
continue
}
seen[host] = struct{}{}
result = append(result, host)
}
sort.Strings(result)
return result
}
func buildConnectionFingerprint(config connection.ConnectionConfig) (string, bool) {
payload := savedQueryConnectionFingerprintPayload{
Version: savedQueryFingerprintVersion,
Type: normalizeFingerprintLower(config.Type),
Driver: normalizeFingerprintLower(config.Driver),
Host: normalizeFingerprintLower(config.Host),
Port: config.Port,
Hosts: normalizeFingerprintHosts(config.Hosts),
User: normalizeFingerprintText(config.User),
Database: normalizeFingerprintText(config.Database),
UseSSL: config.UseSSL,
SSLMode: normalizeFingerprintLower(config.SSLMode),
UseSSH: config.UseSSH,
SSHHost: normalizeFingerprintLower(config.SSH.Host),
SSHPort: config.SSH.Port,
SSHUser: normalizeFingerprintText(config.SSH.User),
UseHTTPTunnel: config.UseHTTPTunnel,
HTTPTunnelHost: normalizeFingerprintLower(config.HTTPTunnel.Host),
HTTPTunnelPort: config.HTTPTunnel.Port,
HTTPTunnelUser: normalizeFingerprintText(config.HTTPTunnel.User),
ClickHouseProtocol: normalizeFingerprintLower(config.ClickHouseProtocol),
OceanBaseProtocol: normalizeFingerprintLower(config.OceanBaseProtocol),
Topology: normalizeFingerprintLower(config.Topology),
ReplicaSet: normalizeFingerprintText(config.ReplicaSet),
AuthSource: normalizeFingerprintText(config.AuthSource),
ReadPreference: normalizeFingerprintLower(config.ReadPreference),
MongoSRV: config.MongoSRV,
MongoAuthMechanism: normalizeFingerprintLower(config.MongoAuthMechanism),
RedisMaster: normalizeFingerprintText(config.RedisSentinelMaster),
RedisUser: normalizeFingerprintText(config.RedisSentinelUser),
MySQLReplicaUser: normalizeFingerprintText(config.MySQLReplicaUser),
MongoReplicaUser: normalizeFingerprintText(config.MongoReplicaUser),
}
if payload.Type == "" {
return "", false
}
if payload.Host == "" && len(payload.Hosts) == 0 && payload.Database == "" {
return "", false
}
data, err := json.Marshal(payload)
if err != nil {
return "", false
}
sum := sha256.Sum256(data)
return savedQueryFingerprintVersion + ":" + hex.EncodeToString(sum[:]), true
}
func buildSavedConnectionFingerprint(view connection.SavedConnectionView) (string, bool) {
return buildConnectionFingerprint(view.Config)
}
func buildLegacyConnectionFingerprint(input connection.SavedConnectionInput) (string, bool) {
config := input.Config
if strings.TrimSpace(config.ID) == "" {
config.ID = strings.TrimSpace(input.ID)
}
return buildConnectionFingerprint(config)
}
func indexSavedConnectionsByID(connections []connection.SavedConnectionView) map[string]connection.SavedConnectionView {
result := make(map[string]connection.SavedConnectionView, len(connections))
for _, item := range connections {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
result[id] = item
}
return result
}
func indexLegacyConnectionsByID(connections []connection.SavedConnectionInput) map[string]connection.SavedConnectionInput {
result := make(map[string]connection.SavedConnectionInput, len(connections))
for _, item := range connections {
id := strings.TrimSpace(item.ID)
if id == "" {
id = strings.TrimSpace(item.Config.ID)
}
if id == "" {
continue
}
result[id] = item
}
return result
}
func indexSavedConnectionsByFingerprint(connections []connection.SavedConnectionView) map[string][]connection.SavedConnectionView {
result := make(map[string][]connection.SavedConnectionView)
for _, item := range connections {
fingerprint, ok := buildSavedConnectionFingerprint(item)
if !ok {
continue
}
result[fingerprint] = append(result[fingerprint], item)
}
return result
}
func applySavedQueryActiveBinding(query connection.SavedQuery, current connection.SavedConnectionView) connection.SavedQuery {
if fingerprint, ok := buildSavedConnectionFingerprint(current); ok {
query.ConnectionFingerprint = fingerprint
query.FingerprintVersion = savedQueryFingerprintVersion
}
query.BindingStatus = savedQueryBindingActive
if strings.TrimSpace(query.OriginalConnectionID) == strings.TrimSpace(query.ConnectionID) {
query.OriginalConnectionID = ""
}
return query
}
func applySavedQueryOrphanBinding(query connection.SavedQuery, originalConnectionID string, fingerprint string) connection.SavedQuery {
if strings.TrimSpace(query.OriginalConnectionID) == "" {
query.OriginalConnectionID = strings.TrimSpace(originalConnectionID)
}
if strings.TrimSpace(fingerprint) != "" {
query.ConnectionFingerprint = fingerprint
query.FingerprintVersion = savedQueryFingerprintVersion
}
query.BindingStatus = savedQueryBindingOrphan
return query
}
func rebindSavedQueryByFingerprint(
query connection.SavedQuery,
connectionID string,
fingerprint string,
currentByFingerprint map[string][]connection.SavedConnectionView,
) (connection.SavedQuery, bool) {
fingerprint = strings.TrimSpace(fingerprint)
if fingerprint == "" {
return query, false
}
matches := currentByFingerprint[fingerprint]
if len(matches) != 1 {
return applySavedQueryOrphanBinding(query, connectionID, fingerprint), true
}
if strings.TrimSpace(query.OriginalConnectionID) == "" {
query.OriginalConnectionID = connectionID
}
query.ConnectionID = matches[0].ID
query.ConnectionFingerprint = fingerprint
query.FingerprintVersion = savedQueryFingerprintVersion
query.BindingStatus = savedQueryBindingRebound
return query, true
}
func resolveSavedQueryBinding(
query connection.SavedQuery,
currentByID map[string]connection.SavedConnectionView,
currentByFingerprint map[string][]connection.SavedConnectionView,
legacyByID map[string]connection.SavedConnectionInput,
) connection.SavedQuery {
connectionID := strings.TrimSpace(query.ConnectionID)
if current, found := currentByID[connectionID]; found {
return applySavedQueryActiveBinding(query, current)
}
if rebound, resolved := rebindSavedQueryByFingerprint(query, connectionID, query.ConnectionFingerprint, currentByFingerprint); resolved {
return rebound
}
legacy, found := legacyByID[connectionID]
if !found {
return applySavedQueryOrphanBinding(query, connectionID, query.ConnectionFingerprint)
}
fingerprint, ok := buildLegacyConnectionFingerprint(legacy)
if !ok {
return applySavedQueryOrphanBinding(query, connectionID, query.ConnectionFingerprint)
}
rebound, _ := rebindSavedQueryByFingerprint(query, connectionID, fingerprint, currentByFingerprint)
return rebound
}
func resolveSavedQueryBindings(
queries []connection.SavedQuery,
currentConnections []connection.SavedConnectionView,
legacyConnections []connection.SavedConnectionInput,
) []connection.SavedQuery {
currentByID := indexSavedConnectionsByID(currentConnections)
currentByFingerprint := indexSavedConnectionsByFingerprint(currentConnections)
legacyByID := indexLegacyConnectionsByID(legacyConnections)
result := make([]connection.SavedQuery, 0, len(queries))
for _, query := range queries {
result = append(result, resolveSavedQueryBinding(query, currentByID, currentByFingerprint, legacyByID))
}
return result
}