feat: OpsxDB 桌面数据库客户端首版入库

含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
logan committed 2026-09-19 22:43:45 +08:00
commit f8740d27f7
240 files changed
+21625

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
package db
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
const sessionTTL = 45 * time.Minute
const maxSessions = 64
type Session struct {
ID string
Conn ConnReq
mu sync.Mutex
sqlPool map[string]*sql.DB
redisCli *redis.Client
tunnel *SSHTunnel
LastUsed time.Time
}
var (
sessionsMu sync.Mutex
sessions = map[string]*Session{}
)
func init() {
go func() {
t := time.NewTicker(2 * time.Minute)
defer t.Stop()
for range t.C {
expireSessions()
}
}()
}
func newSessionID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func OpenSession(c ConnReq) (string, error) {
c.SQL = ""
c.Table = ""
c.Key = ""
c.Pattern = ""
c.SessionID = ""
dial, tun, err := ApplySSHTunnel(c)
if err != nil {
return "", err
}
if err := testDial(dial); err != nil {
if tun != nil {
tun.Close()
}
return "", fmt.Errorf("数据库: %w", err)
}
sessionsMu.Lock()
if len(sessions) >= maxSessions {
sessionsMu.Unlock()
if tun != nil {
tun.Close()
}
return "", fmt.Errorf("连接会话已满,请关闭不用的连接后重试")
}
id := newSessionID()
// Persist dialed endpoints (127.0.0.1:local) so later ops reuse the tunnel.
stored := dial
stored.SSHEnabled = false
s := &Session{
ID: id,
Conn: stored,
sqlPool: map[string]*sql.DB{},
tunnel: tun,
LastUsed: time.Now(),
}
sessions[id] = s
sessionsMu.Unlock()
return id, nil
}
func GetSession(id string) (*Session, error) {
if id == "" {
return nil, fmt.Errorf("缺少 sessionId")
}
sessionsMu.Lock()
s, ok := sessions[id]
sessionsMu.Unlock()
if !ok {
return nil, fmt.Errorf("会话已失效,请重新连接")
}
s.mu.Lock()
s.LastUsed = time.Now()
s.mu.Unlock()
return s, nil
}
func CloseSession(id string) {
sessionsMu.Lock()
s, ok := sessions[id]
if ok {
delete(sessions, id)
}
sessionsMu.Unlock()
if ok {
s.closeAll()
}
}
func expireSessions() {
cutoff := time.Now().Add(-sessionTTL)
sessionsMu.Lock()
var dead []*Session
for id, s := range sessions {
s.mu.Lock()
stale := s.LastUsed.Before(cutoff)
s.mu.Unlock()
if stale {
dead = append(dead, s)
delete(sessions, id)
}
}
sessionsMu.Unlock()
for _, s := range dead {
s.closeAll()
}
}
func (s *Session) closeAll() {
s.mu.Lock()
defer s.mu.Unlock()
for k, dbh := range s.sqlPool {
_ = dbh.Close()
delete(s.sqlPool, k)
}
if s.redisCli != nil {
_ = s.redisCli.Close()
s.redisCli = nil
}
if s.tunnel != nil {
s.tunnel.Close()
s.tunnel = nil
}
}
func (s *Session) ConnFor(req ConnReq) ConnReq {
c := s.Conn
// SQLite 的 database 是文件路径,对象树里的 "main" 不能覆盖它
if req.Database != "" && driverKey(c.Driver) != "sqlite" {
c.Database = req.Database
}
c.SQL = req.SQL
c.Table = req.Table
c.Key = req.Key
c.Pattern = req.Pattern
c.Limit = req.Limit
c.SessionID = s.ID
return c
}
func ResolveConn(req ConnReq) (ConnReq, error) {
if req.SessionID == "" {
return req, nil
}
s, err := GetSession(req.SessionID)
if err != nil {
return req, err
}
return s.ConnFor(req), nil
}