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

+97
View File
@@ -0,0 +1,97 @@
package ai
import (
"fmt"
"strings"
)
type ChatReq struct {
Action string `json:"action"`
Prompt string `json:"prompt"`
SQL string `json:"sql"`
Driver string `json:"driver"`
Database string `json:"database"`
Error string `json:"error"`
}
type ChatRes struct {
Content string `json:"content"`
SQL string `json:"sql,omitempty"`
}
func Chat(req ChatReq) ChatRes {
driver := req.Driver
if driver == "" {
driver = "mysql"
}
db := req.Database
if db == "" {
db = "your_db"
}
sql := strings.TrimSpace(req.SQL)
prompt := strings.TrimSpace(req.Prompt)
switch strings.ToLower(req.Action) {
case "generate", "ask":
q := prompt
if q == "" {
q = "示例查询"
}
gen := fmt.Sprintf("-- 根据描述生成(%s / %s)\n-- 需求: %s\nSELECT *\nFROM `%s`.some_table\nWHERE 1=1\nLIMIT 100;", driver, db, q, db)
return ChatRes{
Content: fmt.Sprintf("已根据「%s」生成 SQL(stub,可替换为真实模型)。\n\n```sql\n%s\n```", q, gen),
SQL: gen,
}
case "explain":
if sql == "" {
sql = "SELECT 1"
}
return ChatRes{Content: fmt.Sprintf("## 解释 SQL\n\n```sql\n%s\n```\n\n- 这是一条在 **%s** 上执行的查询。\n- 建议检查 WHERE 条件是否走索引,避免 `SELECT *` 拉取过大结果集。\n- (stub 回复)", sql, driver)}
case "optimize":
opt := sql
if opt == "" {
opt = "SELECT id, name FROM users WHERE status = 1 LIMIT 100;"
} else if strings.Contains(strings.ToUpper(opt), "SELECT *") {
opt = strings.Replace(opt, "*", "id /* TODO: 显式列 */", 1)
}
opt = strings.TrimSpace(opt)
if !strings.Contains(strings.ToUpper(opt), "LIMIT") {
opt = opt + "\nLIMIT 100;"
}
return ChatRes{
Content: fmt.Sprintf("## 优化建议\n\n1. 避免不必要的全表扫描\n2. 显式列出列名\n3. 加合理 LIMIT\n\n```sql\n%s\n```", opt),
SQL: opt,
}
case "fix":
fixed := sql
if fixed == "" {
fixed = "SELECT 1 AS ok;"
}
msg := req.Error
if msg == "" {
msg = "未知错误"
}
return ChatRes{
Content: fmt.Sprintf("## 修复错误\n\n原错误:`%s`\n\n尝试修正后的 SQL:\n\n```sql\n%s\n```\n\n(stub:请结合真实报错继续调整)", msg, fixed),
SQL: fixed,
}
case "dialect":
out := sql
if out == "" {
out = "SELECT * FROM dual"
}
out = strings.ReplaceAll(out, "`", "\"")
return ChatRes{
Content: fmt.Sprintf("## 方言转换 → PostgreSQL 风格(示例)\n\n```sql\n%s\n```", out),
SQL: out,
}
case "sample":
sample := fmt.Sprintf("SELECT id, name, created_at\nFROM `%s`.users\nORDER BY id DESC\nLIMIT 20;", db)
return ChatRes{
Content: fmt.Sprintf("## 样例 SQL(%s)\n\n```sql\n%s\n```", driver, sample),
SQL: sample,
}
default:
return ChatRes{Content: "未知 AI 动作。可用:ask / generate / explain / optimize / fix / dialect / sample"}
}
}
+50
View File
@@ -0,0 +1,50 @@
package db
import "strings"
// IsDangerousSQL 识别可能破坏数据的语句,供前端二次确认。
func IsDangerousSQL(sql string) bool {
s := stripSQLLead(sql)
if s == "" {
return false
}
up := strings.ToUpper(s)
for _, p := range []string{"DROP ", "DROP\t", "TRUNCATE ", "ALTER ", "GRANT ", "REVOKE "} {
if strings.HasPrefix(up, strings.TrimSpace(p)) || strings.HasPrefix(up, p) {
return true
}
}
if strings.HasPrefix(up, "DELETE") {
return !containsWhere(up)
}
if strings.HasPrefix(up, "UPDATE") {
return !containsWhere(up)
}
return false
}
func containsWhere(up string) bool {
return strings.Contains(up, " WHERE ") || strings.Contains(up, "\nWHERE ") || strings.Contains(up, "\tWHERE ")
}
func stripSQLLead(sql string) string {
s := strings.TrimSpace(sql)
for {
if strings.HasPrefix(s, "--") {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = strings.TrimSpace(s[i+1:])
continue
}
return ""
}
if strings.HasPrefix(s, "/*") {
if i := strings.Index(s, "*/"); i >= 0 {
s = strings.TrimSpace(s[i+2:])
continue
}
return ""
}
break
}
return s
}
+19
View File
@@ -0,0 +1,19 @@
package db
import "strings"
func quoteMySQLIdent(s string) string {
return "`" + strings.ReplaceAll(s, "`", "") + "`"
}
func quotePGIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
func splitSchemaTable(name string) (schema, table string) {
name = strings.TrimSpace(name)
if i := strings.LastIndex(name, "."); i > 0 {
return name[:i], name[i+1:]
}
return "", name
}
+139
View File
@@ -0,0 +1,139 @@
package db
import (
"database/sql"
"fmt"
"net/url"
"strings"
_ "github.com/microsoft/go-mssqldb"
)
func openMSSQL(c ConnReq) (*sql.DB, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 1433
}
user := c.User
if user == "" {
user = "sa"
}
dbName := c.Database
if dbName == "" {
dbName = "master"
}
mode := EffectiveSSLMode(c)
q := url.Values{}
q.Set("database", dbName)
q.Set("connection timeout", "5")
switch mode {
case "disable":
q.Set("encrypt", "disable")
case "skip-verify":
q.Set("encrypt", "true")
q.Set("TrustServerCertificate", "true")
if ca := strings.TrimSpace(c.SSLCA); ca != "" {
q.Set("certificate", ca)
}
case "require", "verify-ca":
q.Set("encrypt", "true")
q.Set("TrustServerCertificate", "false")
ca := strings.TrimSpace(c.SSLCA)
if mode == "verify-ca" && ca == "" {
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
}
if ca != "" {
// go-mssqldb msdsn certificate= builds RootCAs from PEM/DER
q.Set("certificate", ca)
}
if host != "" {
q.Set("hostnameincertificate", host)
}
default:
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
}
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?%s",
url.QueryEscape(user), url.QueryEscape(c.Password), host, port, q.Encode())
return sql.Open("sqlserver", dsn)
}
func listDatabasesMSSQL(db *sql.DB) ([]string, error) {
rows, err := db.Query(`SELECT name FROM sys.databases WHERE name NOT IN ('master','tempdb','model','msdb') ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listTablesMSSQL(db *sql.DB, database string) ([]string, error) {
_ = database
rows, err := db.Query(`
SELECT TABLE_SCHEMA + '.' + TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_TYPE = 'BASE TABLE'
ORDER BY 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listColumnsMSSQL(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
schema, t := splitSchemaTable(table)
if schema == "" {
schema = "dbo"
}
rows, err := dbh.Query(`
SELECT COLUMN_NAME, DATA_TYPE,
ISNULL(CAST(CHARACTER_MAXIMUM_LENGTH AS varchar), ''),
IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @p1 AND TABLE_NAME = @p2
ORDER BY ORDINAL_POSITION`, schema, t)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var name, typ, extra, nullable string
var def sql.NullString
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
return nil, err
}
if extra != "" && extra != "-1" {
typ = typ + "(" + extra + ")"
}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
if def.Valid {
col.Default = def.String
}
out = append(out, col)
}
if out == nil {
out = []ColumnInfo{}
}
return out, rows.Err()
}
+152
View File
@@ -0,0 +1,152 @@
package db
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"net"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
)
func openMySQL(c ConnReq) (*sql.DB, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 3306
}
user := c.User
if user == "" {
user = "root"
}
cfg := mysqldriver.NewConfig()
cfg.User = user
cfg.Passwd = c.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(host, fmt.Sprintf("%d", port))
cfg.DBName = c.Database
cfg.ParseTime = true
cfg.Timeout = 5 * time.Second
cfg.ReadTimeout = 30 * time.Second
cfg.WriteTimeout = 30 * time.Second
cfg.Loc = time.Local
cfg.Params = map[string]string{"charset": "utf8mb4"}
mode := EffectiveSSLMode(c)
switch mode {
case "disable":
// no TLS
case "skip-verify":
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" {
cfg.TLSConfig = "skip-verify"
} else {
name, err := registerMySQLTLS(c)
if err != nil {
return nil, err
}
cfg.TLSConfig = name
}
case "require", "verify-ca":
if c.SSLCA == "" && c.SSLCert == "" && c.SSLKey == "" && mode == "require" {
cfg.TLSConfig = "true"
} else {
name, err := registerMySQLTLS(c)
if err != nil {
return nil, err
}
cfg.TLSConfig = name
}
default:
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
}
return sql.Open("mysql", cfg.FormatDSN())
}
func registerMySQLTLS(c ConnReq) (string, error) {
tlsCfg, err := tlsConfigFrom(c)
if err != nil {
return "", err
}
if tlsCfg == nil {
return "", fmt.Errorf("SSL 已启用但未能构建 TLS 配置")
}
sum := sha256.Sum256([]byte(EffectiveSSLMode(c) + "|" + c.SSLCA + "|" + c.SSLCert + "|" + c.SSLKey + "|" + c.Host))
name := "opsxdb-" + hex.EncodeToString(sum[:8])
mysqldriver.DeregisterTLSConfig(name)
if err := mysqldriver.RegisterTLSConfig(name, tlsCfg); err != nil {
return "", fmt.Errorf("注册 MySQL TLS 配置失败: %w", err)
}
return name, nil
}
func listDatabasesMySQL(db *sql.DB) ([]string, error) {
rows, err := db.Query("SHOW DATABASES")
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listTablesMySQL(db *sql.DB, database string) ([]string, error) {
q := "SHOW TABLES"
if database != "" {
q = "SHOW TABLES FROM " + quoteMySQLIdent(database)
}
rows, err := db.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listColumnsMySQL(db *sql.DB, database, table string) ([]ColumnInfo, error) {
ident := quoteMySQLIdent(table)
if database != "" {
ident = quoteMySQLIdent(database) + "." + ident
}
rows, err := db.Query("SHOW FULL COLUMNS FROM " + ident)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var field, typ, null, key string
var collation, extra, priv, comment sql.NullString
var def sql.NullString
if err := rows.Scan(&field, &typ, &collation, &null, &key, &def, &extra, &priv, &comment); err != nil {
return nil, err
}
col := ColumnInfo{Name: field, Type: typ, Nullable: null == "YES", Key: key, Comment: comment.String}
if def.Valid {
col.Default = def.String
}
out = append(out, col)
}
if out == nil {
out = []ColumnInfo{}
}
return out, rows.Err()
}
+85
View File
@@ -0,0 +1,85 @@
package db
import (
"database/sql"
"fmt"
"strings"
"time"
)
func driverKey(d string) string {
return strings.ToLower(strings.TrimSpace(d))
}
func isMySQLFamily(d string) bool {
switch driverKey(d) {
case "mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql":
return true
default:
return false
}
}
func isPostgresFamily(d string) bool {
switch driverKey(d) {
case "postgresql", "postgres", "gaussdb", "opengauss", "kingbase":
return true
default:
return false
}
}
// Open 按驱动打开 *sql.DB
func Open(c ConnReq) (*sql.DB, error) {
var (
db *sql.DB
err error
)
switch {
case isMySQLFamily(c.Driver):
db, err = openMySQL(c)
case isPostgresFamily(c.Driver):
db, err = openPostgres(c)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
db, err = openMSSQL(c)
case driverKey(c.Driver) == "oracle":
db, err = openOracle(c)
case driverKey(c.Driver) == "sqlite":
db, err = openSQLite(c)
default:
return nil, fmt.Errorf("驱动「%s」尚未实现(当前支持: mysql/mariadb/tidb、postgresql、sqlserver、oracle、sqlite、redis)", c.Driver)
}
if err != nil {
return nil, err
}
db.SetConnMaxLifetime(2 * time.Minute)
db.SetMaxOpenConns(5)
db.SetMaxIdleConns(2)
return db, nil
}
func TestConnection(c ConnReq) error {
dial, tun, err := ApplySSHTunnel(c)
if err != nil {
return err
}
if tun != nil {
defer tun.Close()
}
if err := testDial(dial); err != nil {
return fmt.Errorf("数据库: %w", err)
}
return nil
}
func testDial(c ConnReq) error {
if isRedis(c.Driver) {
return testRedis(c)
}
db, err := Open(c)
if err != nil {
return err
}
defer db.Close()
return db.Ping()
}
+238
View File
@@ -0,0 +1,238 @@
package db
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
func ListDatabases(c ConnReq) ([]string, error) {
if isRedis(c.Driver) {
return listDatabasesRedis(c)
}
cc := c
switch {
case isMySQLFamily(c.Driver):
cc.Database = ""
case isPostgresFamily(c.Driver):
if cc.Database == "" {
cc.Database = "postgres"
}
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
if cc.Database == "" {
cc.Database = "master"
}
}
db, err := Open(cc)
if err != nil {
return nil, err
}
defer db.Close()
switch {
case isMySQLFamily(c.Driver):
return listDatabasesMySQL(db)
case isPostgresFamily(c.Driver):
return listDatabasesPostgres(db)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
return listDatabasesMSSQL(db)
case driverKey(c.Driver) == "oracle":
return listDatabasesOracle(db)
case driverKey(c.Driver) == "sqlite":
return listDatabasesSQLite(db)
default:
return nil, fmt.Errorf("unsupported driver")
}
}
func ListTables(c ConnReq) ([]string, error) {
if isRedis(c.Driver) {
return listTablesRedis(c)
}
db, err := Open(c)
if err != nil {
return nil, err
}
defer db.Close()
switch {
case isMySQLFamily(c.Driver):
return listTablesMySQL(db, c.Database)
case isPostgresFamily(c.Driver):
return listTablesPostgres(db, c.Database)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
return listTablesMSSQL(db, c.Database)
case driverKey(c.Driver) == "oracle":
return listTablesOracle(db, c.Database)
case driverKey(c.Driver) == "sqlite":
return listTablesSQLite(db, c.Database)
default:
return nil, fmt.Errorf("unsupported driver")
}
}
func ListColumns(c ConnReq) ([]ColumnInfo, error) {
if c.Table == "" {
return nil, fmt.Errorf("缺少表名")
}
if isRedis(c.Driver) {
return listColumnsRedis(c, c.Table)
}
db, err := Open(c)
if err != nil {
return nil, err
}
defer db.Close()
switch {
case isMySQLFamily(c.Driver):
return listColumnsMySQL(db, c.Database, c.Table)
case isPostgresFamily(c.Driver):
return listColumnsPostgres(db, c.Database, c.Table)
case driverKey(c.Driver) == "sqlserver" || driverKey(c.Driver) == "mssql":
return listColumnsMSSQL(db, c.Database, c.Table)
case driverKey(c.Driver) == "oracle":
return listColumnsOracle(db, c.Database, c.Table)
case driverKey(c.Driver) == "sqlite":
return listColumnsSQLite(db, c.Database, c.Table)
default:
return nil, fmt.Errorf("unsupported driver")
}
}
func rowLimit(c ConnReq) int {
n := c.Limit
if n <= 0 {
n = DefaultRowLimit
}
if n > MaxRowLimit {
n = MaxRowLimit
}
return n
}
func errMsg(err error) string {
if err == nil {
return ""
}
if errors.Is(err, context.Canceled) {
return "已取消"
}
// client gone / deadline tied to request cancel
if errors.Is(err, context.DeadlineExceeded) {
return err.Error()
}
msg := err.Error()
if strings.Contains(msg, "context canceled") {
return "已取消"
}
return msg
}
func execFail(start time.Time, err error) ExecResult {
return ExecResult{
Columns: []string{},
Rows: []map[string]interface{}{},
TimeMs: time.Since(start).Milliseconds(),
Error: errMsg(err),
}
}
// ExecSQL 执行查询 / 非查询语句。ctx 来自 HTTP 请求,客户端断开/Abort 时可取消 DB 调用。
func ExecSQL(ctx context.Context, c ConnReq) ExecResult {
start := time.Now()
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return execFail(start, err)
}
if strings.TrimSpace(c.SQL) == "" {
return ExecResult{Columns: []string{}, Rows: []map[string]interface{}{}, Error: "empty sql"}
}
if isRedis(c.Driver) {
return execRedis(ctx, c)
}
db, err := Open(c)
if err != nil {
return execFail(start, err)
}
defer db.Close()
if !isQuerySQL(c.SQL) {
res, err := db.ExecContext(ctx, c.SQL)
if err != nil {
return execFail(start, err)
}
affected, _ := res.RowsAffected()
return ExecResult{
Columns: []string{"affected_rows"},
Rows: []map[string]interface{}{{"affected_rows": affected}},
RowCount: 1,
TimeMs: time.Since(start).Milliseconds(),
}
}
rows, err := db.QueryContext(ctx, c.SQL)
if err != nil {
return execFail(start, err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return execFail(start, err)
}
limit := rowLimit(c)
var resultRows []map[string]interface{}
truncated := false
for rows.Next() {
if err := ctx.Err(); err != nil {
return execFail(start, err)
}
raw := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range raw {
ptrs[i] = &raw[i]
}
if err := rows.Scan(ptrs...); err != nil {
return execFail(start, err)
}
m := make(map[string]interface{}, len(cols))
for i, col := range cols {
m[col] = normalize(raw[i])
}
resultRows = append(resultRows, m)
if len(resultRows) >= limit {
truncated = true
break
}
}
if err := rows.Err(); err != nil {
return execFail(start, err)
}
if resultRows == nil {
resultRows = []map[string]interface{}{}
}
return ExecResult{
Columns: cols,
Rows: resultRows,
RowCount: len(resultRows),
TimeMs: time.Since(start).Milliseconds(),
Truncated: truncated,
Limit: limit,
}
}
func isQuerySQL(sql string) bool {
s := stripSQLLead(sql)
if s == "" {
return true
}
up := strings.ToUpper(s)
for _, p := range []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH", "PRAGMA"} {
if strings.HasPrefix(up, p+" ") || up == p || strings.HasPrefix(up, p+"\t") || strings.HasPrefix(up, p+"\n") {
return true
}
}
return false
}
+39
View File
@@ -0,0 +1,39 @@
package db
import (
"context"
"testing"
)
func TestIsQuerySQL(t *testing.T) {
if !isQuerySQL("SELECT 1") {
t.Fatal("select")
}
if isQuerySQL("DELETE FROM t") {
t.Fatal("delete is not query")
}
if !isQuerySQL("-- hi\nSELECT 1") {
t.Fatal("comment then select")
}
}
func TestIsDangerousSQL(t *testing.T) {
if !IsDangerousSQL("DROP TABLE users") {
t.Fatal("drop")
}
if !IsDangerousSQL("DELETE FROM users") {
t.Fatal("delete no where")
}
if IsDangerousSQL("DELETE FROM users WHERE id=1") {
t.Fatal("delete with where is ok")
}
if IsDangerousSQL("SELECT * FROM users") {
t.Fatal("select")
}
}
func TestErrMsgCanceled(t *testing.T) {
if got := errMsg(context.Canceled); got != "已取消" {
t.Fatalf("got %q", got)
}
}
+145
View File
@@ -0,0 +1,145 @@
package db
import (
"database/sql"
"fmt"
"net/url"
ora "github.com/sijms/go-ora/v2"
)
func openOracle(c ConnReq) (*sql.DB, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 1521
}
user := c.User
if user == "" {
user = "system"
}
service := c.Service
if service == "" {
if c.Database != "" {
service = c.Database
} else {
service = "XEPDB1"
}
}
dsn := fmt.Sprintf("oracle://%s:%s@%s:%d/%s", url.PathEscape(user), url.PathEscape(c.Password), host, port, service)
mode := EffectiveSSLMode(c)
if mode != "disable" {
q := url.Values{}
q.Set("SSL", "true")
if mode == "skip-verify" {
q.Set("SSL VERIFY", "false")
} else {
q.Set("SSL VERIFY", "true")
}
dsn = dsn + "?" + q.Encode()
}
tlsCfg, err := tlsConfigFrom(c)
if err != nil {
return nil, err
}
if tlsCfg != nil {
connector := ora.NewConnector(dsn)
oc, ok := connector.(*ora.OracleConnector)
if !ok {
return nil, fmt.Errorf("oracle: connector 类型异常")
}
oc.WithTLSConfig(tlsCfg)
return sql.OpenDB(oc), nil
}
return sql.Open("oracle", dsn)
}
func listDatabasesOracle(db *sql.DB) ([]string, error) {
// 返回当前用户可见 schema
rows, err := db.Query(`SELECT username FROM all_users ORDER BY username`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listTablesOracle(db *sql.DB, schema string) ([]string, error) {
var (
rows *sql.Rows
err error
)
if schema == "" {
rows, err = db.Query(`SELECT table_name FROM user_tables ORDER BY table_name`)
} else {
rows, err = db.Query(`SELECT table_name FROM all_tables WHERE owner = UPPER(:1) ORDER BY table_name`, schema)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listColumnsOracle(dbh *sql.DB, schema, table string) ([]ColumnInfo, error) {
owner, t := splitSchemaTable(table)
if owner == "" {
owner = schema
}
var (
rows *sql.Rows
err error
)
if owner == "" {
rows, err = dbh.Query(`
SELECT column_name, data_type, nullable, data_default
FROM user_tab_columns WHERE table_name = UPPER(:1)
ORDER BY column_id`, t)
} else {
rows, err = dbh.Query(`
SELECT column_name, data_type, nullable, data_default
FROM all_tab_columns WHERE owner = UPPER(:1) AND table_name = UPPER(:2)
ORDER BY column_id`, owner, t)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var name, typ, nullable string
var def sql.NullString
if err := rows.Scan(&name, &typ, &nullable, &def); err != nil {
return nil, err
}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "Y"}
if def.Valid {
col.Default = def.String
}
out = append(out, col)
}
if out == nil {
out = []ColumnInfo{}
}
return out, rows.Err()
}
+147
View File
@@ -0,0 +1,147 @@
package db
import (
"database/sql"
"fmt"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
)
func openPostgres(c ConnReq) (*sql.DB, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 5432
}
user := c.User
if user == "" {
user = "postgres"
}
dbName := c.Database
if dbName == "" {
dbName = "postgres"
}
mode := EffectiveSSLMode(c)
sslmode := "disable"
switch mode {
case "disable":
sslmode = "disable"
case "require", "skip-verify":
// libpq require ≈ encrypt without CA/hostname verify
sslmode = "require"
case "verify-ca":
sslmode = "verify-ca"
if strings.TrimSpace(c.SSLCA) == "" {
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
}
default:
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
}
var b strings.Builder
fmt.Fprintf(&b, "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=5",
pqEscape(host), port, pqEscape(user), pqEscape(c.Password), pqEscape(dbName), sslmode)
if ca := strings.TrimSpace(c.SSLCA); ca != "" {
fmt.Fprintf(&b, " sslrootcert=%s", pqEscape(ca))
}
if cert := strings.TrimSpace(c.SSLCert); cert != "" {
fmt.Fprintf(&b, " sslcert=%s", pqEscape(cert))
}
if key := strings.TrimSpace(c.SSLKey); key != "" {
fmt.Fprintf(&b, " sslkey=%s", pqEscape(key))
}
return sql.Open("pgx", b.String())
}
func pqEscape(v string) string {
if v == "" {
return "''"
}
needs := strings.ContainsAny(v, " '\\")
if !needs {
return v
}
return "'" + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `'`, `\'`) + "'"
}
func listDatabasesPostgres(db *sql.DB) ([]string, error) {
rows, err := db.Query(`SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listTablesPostgres(db *sql.DB, database string) ([]string, error) {
// 当前连接库下的 public 表;切库需重新 Open
_ = database
rows, err := db.Query(`
SELECT CASE WHEN schemaname = 'public' THEN tablename ELSE schemaname || '.' || tablename END
FROM pg_catalog.pg_tables
WHERE schemaname NOT IN ('pg_catalog','information_schema')
ORDER BY 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listColumnsPostgres(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
schema, t := splitSchemaTable(table)
if schema == "" {
schema = "public"
}
rows, err := dbh.Query(`
SELECT column_name, data_type,
COALESCE(character_maximum_length::text, numeric_precision::text, ''),
is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2
ORDER BY ordinal_position`, schema, t)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var name, typ, extra, nullable string
var def sql.NullString
if err := rows.Scan(&name, &typ, &extra, &nullable, &def); err != nil {
return nil, err
}
if extra != "" {
typ = typ + "(" + extra + ")"
}
col := ColumnInfo{Name: name, Type: typ, Nullable: nullable == "YES"}
if def.Valid {
col.Default = def.String
}
out = append(out, col)
}
if out == nil {
out = []ColumnInfo{}
}
return out, rows.Err()
}
+597
View File
@@ -0,0 +1,597 @@
package db
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
func redisOpts(c ConnReq) (*redis.Options, error) {
host := c.Host
if host == "" {
host = "127.0.0.1"
}
port := c.Port
if port == 0 {
port = 6379
}
db := 0
if c.Database != "" {
if n, err := strconv.Atoi(c.Database); err == nil {
db = n
}
}
opts := &redis.Options{
Addr: fmt.Sprintf("%s:%d", host, port),
Password: c.Password,
Username: c.User,
DB: db,
DialTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
if SSLEnabled(c) {
tlsCfg, err := tlsConfigFrom(c)
if err != nil {
return nil, err
}
opts.TLSConfig = tlsCfg
}
return opts, nil
}
func openRedis(c ConnReq) (*redis.Client, error) {
opts, err := redisOpts(c)
if err != nil {
return nil, err
}
return redis.NewClient(opts), nil
}
func testRedis(c ConnReq) error {
cli, err := openRedis(c)
if err != nil {
return err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return cli.Ping(ctx).Err()
}
func listDatabasesRedis(c ConnReq) ([]string, error) {
cli, err := openRedis(c)
if err != nil {
return nil, err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// INFO keyspace 可拿已用 DB;统一暴露 0-15 便于切换
_ = ctx
out := make([]string, 16)
for i := 0; i < 16; i++ {
out[i] = strconv.Itoa(i)
}
return out, nil
}
func listTablesRedis(c ConnReq) ([]string, error) {
cli, err := openRedis(c)
if err != nil {
return nil, err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
var keys []string
var cursor uint64
for {
batch, next, err := cli.Scan(ctx, cursor, "*", 200).Result()
if err != nil {
return nil, err
}
keys = append(keys, batch...)
cursor = next
if cursor == 0 || len(keys) >= 500 {
break
}
}
if keys == nil {
keys = []string{}
}
return keys, nil
}
func execRedis(parent context.Context, c ConnReq) ExecResult {
start := time.Now()
sql := strings.TrimSpace(c.SQL)
if sql == "" {
return ExecResult{Error: "empty sql", TimeMs: 0}
}
cli, err := openRedis(c)
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
fields := splitRedisCmd(sql)
if len(fields) == 0 {
return ExecResult{Error: "empty command", TimeMs: time.Since(start).Milliseconds()}
}
cmd := strings.ToUpper(fields[0])
args := fields[1:]
switch cmd {
case "PING":
val, err := cli.Ping(ctx).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"result"}, Rows: []map[string]interface{}{{"result": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "GET":
if len(args) < 1 {
return ExecResult{Error: "GET key", TimeMs: time.Since(start).Milliseconds()}
}
val, err := cli.Get(ctx, args[0]).Result()
if err == redis.Nil {
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{}, RowCount: 0, TimeMs: time.Since(start).Milliseconds()}
}
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"key", "value"}, Rows: []map[string]interface{}{{"key": args[0], "value": val}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "SET":
if len(args) < 2 {
return ExecResult{Error: "SET key value", TimeMs: time.Since(start).Milliseconds()}
}
if err := cli.Set(ctx, args[0], strings.Join(args[1:], " "), 0).Err(); err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": 1}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "DEL":
if len(args) < 1 {
return ExecResult{Error: "DEL key [key...]", TimeMs: time.Since(start).Milliseconds()}
}
n, err := cli.Del(ctx, args...).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"affected_rows"}, Rows: []map[string]interface{}{{"affected_rows": n}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "KEYS":
pat := "*"
if len(args) > 0 {
pat = args[0]
}
keys, err := cli.Keys(ctx, pat).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
rows := make([]map[string]interface{}, 0, len(keys))
for _, k := range keys {
rows = append(rows, map[string]interface{}{"key": k})
}
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
case "SCAN":
keys, err := listTablesRedis(c)
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
rows := make([]map[string]interface{}, 0, len(keys))
for _, k := range keys {
rows = append(rows, map[string]interface{}{"key": k})
}
return ExecResult{Columns: []string{"key"}, Rows: rows, RowCount: len(rows), TimeMs: time.Since(start).Milliseconds()}
case "HGETALL":
if len(args) < 1 {
return ExecResult{Error: "HGETALL key", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
c2.Limit = redisValueRowCap
v := GetRedisKey(c2)
if v.Error != "" && v.Type == "" {
return ExecResult{Error: v.Error, TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "LRANGE":
if len(args) < 1 {
return ExecResult{Error: "LRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
c2.Limit = redisValueRowCap
v := GetRedisKey(c2)
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "SMEMBERS":
if len(args) < 1 {
return ExecResult{Error: "SMEMBERS key", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
v := GetRedisKey(c2)
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "ZRANGE":
if len(args) < 1 {
return ExecResult{Error: "ZRANGE key [start stop]", TimeMs: time.Since(start).Milliseconds()}
}
c2 := c
c2.Key = args[0]
v := GetRedisKey(c2)
return ExecResult{Columns: v.Columns, Rows: v.Rows, RowCount: v.RowCount, TimeMs: time.Since(start).Milliseconds(), Truncated: v.Truncated, Limit: v.Limit, Error: v.Error}
case "TYPE":
if len(args) < 1 {
return ExecResult{Error: "TYPE key", TimeMs: time.Since(start).Milliseconds()}
}
t, err := cli.Type(ctx, args[0]).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"key", "type"}, Rows: []map[string]interface{}{{"key": args[0], "type": t}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
case "TTL":
if len(args) < 1 {
return ExecResult{Error: "TTL key", TimeMs: time.Since(start).Milliseconds()}
}
n, err := cli.TTL(ctx, args[0]).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{Columns: []string{"key", "ttl_sec"}, Rows: []map[string]interface{}{{"key": args[0], "ttl_sec": int64(n.Seconds())}}, RowCount: 1, TimeMs: time.Since(start).Milliseconds()}
default:
return ExecResult{Error: fmt.Sprintf("暂不支持命令 %s(可用: PING/GET/SET/DEL/KEYS/SCAN/TYPE/TTL/HGETALL/LRANGE/SMEMBERS/ZRANGE)", cmd), TimeMs: time.Since(start).Milliseconds()}
}
}
func splitRedisCmd(s string) []string {
var out []string
var cur strings.Builder
inQ := false
esc := false
for _, r := range s {
switch {
case esc:
cur.WriteRune(r)
esc = false
case r == '\\':
esc = true
case r == '"':
inQ = !inQ
case !inQ && (r == ' ' || r == '\t' || r == '\n'):
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(r)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
func isRedis(d string) bool {
return driverKey(d) == "redis"
}
// IsRedisDriver exported for router guards.
func IsRedisDriver(d string) bool {
return isRedis(d)
}
func listColumnsRedis(c ConnReq, key string) ([]ColumnInfo, error) {
cli, err := openRedis(c)
if err != nil {
return nil, err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
typ, err := cli.Type(ctx, key).Result()
if err != nil {
return nil, err
}
ttl, _ := cli.TTL(ctx, key).Result()
return []ColumnInfo{
{Name: key, Type: typ, Comment: fmt.Sprintf("ttl=%ds", int64(ttl.Seconds()))},
}, nil
}
const redisKeyListCap = 500
const redisValueRowCap = 500
func redisKeyName(c ConnReq) string {
if c.Key != "" {
return c.Key
}
return c.Table
}
func redisScanPattern(c ConnReq) string {
p := strings.TrimSpace(c.Pattern)
if p == "" {
return "*"
}
return p
}
func redisValueLimit(c ConnReq) int {
n := c.Limit
if n <= 0 {
n = redisValueRowCap
}
if n > MaxRowLimit {
n = MaxRowLimit
}
return n
}
func ttlSeconds(d time.Duration) int64 {
// go-redis maps Redis TTL -1 (no expire) / -2 (missing) to -1ns/-2ns
// (some versions use -1s/-2s). Truncating with d/time.Second turns those into 0.
switch d {
case time.Duration(-1), -1 * time.Second:
return -1
case time.Duration(-2), -2 * time.Second:
return -2
}
return int64(d / time.Second)
}
// ListRedisKeys SCAN + pipeline TYPE/TTL. Cap ~500.
func ListRedisKeys(c ConnReq) RedisKeysResult {
start := time.Now()
cli, err := openRedis(c)
if err != nil {
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
pat := redisScanPattern(c)
var names []string
var cursor uint64
truncated := false
for {
batch, next, err := cli.Scan(ctx, cursor, pat, 200).Result()
if err != nil {
return RedisKeysResult{Keys: []RedisKeyInfo{}, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
names = append(names, batch...)
cursor = next
if len(names) >= redisKeyListCap {
truncated = true
names = names[:redisKeyListCap]
break
}
if cursor == 0 {
break
}
}
if names == nil {
names = []string{}
}
out := make([]RedisKeyInfo, len(names))
if len(names) == 0 {
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
}
// Pipeline TYPE + TTL (avoid N+1)
pipe := cli.Pipeline()
typeCmds := make([]*redis.StatusCmd, len(names))
ttlCmds := make([]*redis.DurationCmd, len(names))
for i, k := range names {
typeCmds[i] = pipe.Type(ctx, k)
ttlCmds[i] = pipe.TTL(ctx, k)
}
_, _ = pipe.Exec(ctx)
for i, k := range names {
typ := "none"
if t, err := typeCmds[i].Result(); err == nil {
typ = t
}
ttl := int64(-2)
if d, err := ttlCmds[i].Result(); err == nil {
ttl = ttlSeconds(d)
}
out[i] = RedisKeyInfo{Name: k, Type: typ, TTL: ttl}
}
return RedisKeysResult{Keys: out, Truncated: truncated, TimeMs: time.Since(start).Milliseconds()}
}
// GetRedisKey typed value → ResultPane columns/rows.
func GetRedisKey(c ConnReq) RedisKeyValue {
start := time.Now()
key := redisKeyName(c)
if key == "" {
return RedisKeyValue{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
}
cli, err := openRedis(c)
if err != nil {
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
typ, err := cli.Type(ctx, key).Result()
if err != nil {
return RedisKeyValue{Name: key, Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
ttl := int64(-2)
if d, err := cli.TTL(ctx, key).Result(); err == nil {
ttl = ttlSeconds(d)
}
limit := redisValueLimit(c)
base := RedisKeyValue{Name: key, Type: typ, TTL: ttl, Limit: limit}
switch typ {
case "none":
base.Columns = []string{"value"}
base.Rows = []map[string]interface{}{}
base.RowCount = 0
base.Error = "key 不存在"
base.TimeMs = time.Since(start).Milliseconds()
return base
case "string":
val, err := cli.Get(ctx, key).Result()
if err == redis.Nil {
base.Columns = []string{"value"}
base.Rows = []map[string]interface{}{}
base.RowCount = 0
base.TimeMs = time.Since(start).Milliseconds()
return base
}
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
base.Columns = []string{"value"}
base.Rows = []map[string]interface{}{{"value": val}}
base.RowCount = 1
base.TimeMs = time.Since(start).Milliseconds()
return base
case "hash":
fields, err := cli.HGetAll(ctx, key).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
base.Columns = []string{"field", "value"}
rows := make([]map[string]interface{}, 0, len(fields))
for f, v := range fields {
rows = append(rows, map[string]interface{}{"field": f, "value": v})
if len(rows) >= limit {
base.Truncated = true
break
}
}
base.Rows = rows
base.RowCount = len(rows)
base.TimeMs = time.Since(start).Milliseconds()
return base
case "list":
vals, err := cli.LRange(ctx, key, 0, int64(limit)-1).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
truncated := false
n, _ := cli.LLen(ctx, key).Result()
if n > int64(len(vals)) {
truncated = true
}
base.Columns = []string{"index", "value"}
rows := make([]map[string]interface{}, 0, len(vals))
for i, v := range vals {
rows = append(rows, map[string]interface{}{"index": i, "value": v})
}
base.Rows = rows
base.RowCount = len(rows)
base.Truncated = truncated
base.TimeMs = time.Since(start).Milliseconds()
return base
case "set":
base.Columns = []string{"member"}
var rows []map[string]interface{}
var scursor uint64
truncated := false
for {
batch, next, err := cli.SScan(ctx, key, scursor, "*", 200).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
for _, m := range batch {
rows = append(rows, map[string]interface{}{"member": m})
if len(rows) >= limit {
truncated = true
break
}
}
scursor = next
if truncated || scursor == 0 {
break
}
}
if rows == nil {
rows = []map[string]interface{}{}
}
base.Rows = rows
base.RowCount = len(rows)
base.Truncated = truncated
base.TimeMs = time.Since(start).Milliseconds()
return base
case "zset":
vals, err := cli.ZRangeWithScores(ctx, key, 0, int64(limit-1)).Result()
if err != nil {
base.Error = errMsg(err)
base.TimeMs = time.Since(start).Milliseconds()
return base
}
truncated := false
n, _ := cli.ZCard(ctx, key).Result()
if n > int64(len(vals)) {
truncated = true
}
base.Columns = []string{"score", "member"}
rows := make([]map[string]interface{}, 0, len(vals))
for _, z := range vals {
rows = append(rows, map[string]interface{}{"score": z.Score, "member": z.Member})
}
base.Rows = rows
base.RowCount = len(rows)
base.Truncated = truncated
base.TimeMs = time.Since(start).Milliseconds()
return base
default:
// stream / recon / module types: meta only
base.Columns = []string{"info"}
base.Rows = []map[string]interface{}{{"info": fmt.Sprintf("类型 %s 暂不展开,可用命令查看", typ)}}
base.RowCount = 1
base.TimeMs = time.Since(start).Milliseconds()
return base
}
}
// DeleteRedisKey DEL one key.
func DeleteRedisKey(c ConnReq) ExecResult {
start := time.Now()
key := redisKeyName(c)
if key == "" {
return ExecResult{Error: "缺少 key", TimeMs: time.Since(start).Milliseconds()}
}
cli, err := openRedis(c)
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
n, err := cli.Del(ctx, key).Result()
if err != nil {
return ExecResult{Error: errMsg(err), TimeMs: time.Since(start).Milliseconds()}
}
return ExecResult{
Columns: []string{"affected_rows"},
Rows: []map[string]interface{}{{"affected_rows": n}},
RowCount: 1,
TimeMs: time.Since(start).Milliseconds(),
}
}
@@ -0,0 +1,86 @@
package db
import (
"net"
"strconv"
"testing"
"github.com/alicebob/miniredis/v2"
)
func TestListGetDeleteRedisKeys(t *testing.T) {
mr, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer mr.Close()
_ = mr.Set("str:a", "hello")
mr.HSet("hash:b", "f1", "v1")
mr.Push("list:c", "x", "y")
mr.SAdd("set:d", "m1", "m2")
_, _ = mr.ZAdd("zset:e", 1.5, "zm")
host, portStr, err := net.SplitHostPort(mr.Addr())
if err != nil {
t.Fatal(err)
}
port, _ := strconv.Atoi(portStr)
base := ConnReq{Driver: "redis", Host: host, Port: port, Database: "0"}
list := ListRedisKeys(base)
if list.Error != "" {
t.Fatal(list.Error)
}
if len(list.Keys) < 5 {
t.Fatalf("want >=5 keys, got %d %#v", len(list.Keys), list.Keys)
}
byName := map[string]RedisKeyInfo{}
for _, k := range list.Keys {
byName[k.Name] = k
}
if byName["str:a"].Type != "string" {
t.Fatalf("str type %s", byName["str:a"].Type)
}
if byName["hash:b"].Type != "hash" {
t.Fatalf("hash type %s", byName["hash:b"].Type)
}
// Real Redis returns -1 (no expire); miniredis may report 0.
if byName["str:a"].TTL < -1 {
t.Fatalf("unexpected ttl %d", byName["str:a"].TTL)
}
gv := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
if gv.Error != "" || gv.RowCount != 1 || gv.Rows[0]["value"] != "hello" {
t.Fatalf("get string %#v", gv)
}
gh := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "hash:b"})
if gh.Error != "" || gh.RowCount < 1 || gh.Columns[0] != "field" {
t.Fatalf("get hash %#v", gh)
}
gl := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "list:c"})
if gl.Error != "" || gl.RowCount < 2 {
t.Fatalf("get list %#v", gl)
}
gs := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "set:d"})
if gs.Error != "" || gs.RowCount < 2 {
t.Fatalf("get set %#v", gs)
}
gz := GetRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "zset:e"})
if gz.Error != "" || gz.RowCount != 1 {
t.Fatalf("get zset %#v", gz)
}
pat := ListRedisKeys(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Pattern: "str:*"})
if pat.Error != "" || len(pat.Keys) != 1 || pat.Keys[0].Name != "str:a" {
t.Fatalf("pattern %#v", pat)
}
del := DeleteRedisKey(ConnReq{Driver: "redis", Host: host, Port: port, Database: "0", Key: "str:a"})
if del.Error != "" {
t.Fatal(del.Error)
}
if mr.Exists("str:a") {
t.Fatal("key should be deleted")
}
}
+29
View File
@@ -0,0 +1,29 @@
package db
import (
"testing"
"time"
)
func TestTTLSecondsSentinels(t *testing.T) {
cases := []struct {
in time.Duration
want int64
}{
{time.Duration(-1), -1},
{-1 * time.Nanosecond, -1},
{-1 * time.Second, -1},
{time.Duration(-2), -2},
{-2 * time.Nanosecond, -2},
{-2 * time.Second, -2},
{0, 0},
{5 * time.Second, 5},
{1500 * time.Millisecond, 1},
}
for _, c := range cases {
got := ttlSeconds(c.in)
if got != c.want {
t.Fatalf("ttlSeconds(%v)=%d want %d", c.in, got, c.want)
}
}
}
+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
}
+70
View File
@@ -0,0 +1,70 @@
package db
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
func openSQLite(c ConnReq) (*sql.DB, error) {
path := c.Database
if path == "" {
path = c.Host
}
if path == "" {
return nil, fmt.Errorf("SQLite 需要 database 填文件路径")
}
return sql.Open("sqlite", path)
}
func listDatabasesSQLite(_ *sql.DB) ([]string, error) {
return []string{"main"}, nil
}
func listTablesSQLite(db *sql.DB, _ string) ([]string, error) {
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY 1`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out = append(out, name)
}
return out, rows.Err()
}
func listColumnsSQLite(dbh *sql.DB, _, table string) ([]ColumnInfo, error) {
rows, err := dbh.Query(`PRAGMA table_info(` + quotePGIdent(table) + `)`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ColumnInfo
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var def sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notnull, &def, &pk); err != nil {
return nil, err
}
col := ColumnInfo{Name: name, Type: typ, Nullable: notnull == 0}
if pk > 0 {
col.Key = "PRI"
}
if def.Valid {
col.Default = def.String
}
out = append(out, col)
}
if out == nil {
out = []ColumnInfo{}
}
return out, rows.Err()
}
+216
View File
@@ -0,0 +1,216 @@
package db
import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// SSHTunnel holds a local TCP listener that forwards through an SSH session
// to the remote database host:port (local port forward semantics).
type SSHTunnel struct {
LocalPort int
listener net.Listener
client *ssh.Client
cancel chan struct{}
wg sync.WaitGroup
once sync.Once
}
func (t *SSHTunnel) Close() {
if t == nil {
return
}
t.once.Do(func() {
close(t.cancel)
if t.listener != nil {
_ = t.listener.Close()
}
if t.client != nil {
_ = t.client.Close()
}
t.wg.Wait()
})
}
// StartLocalForward dials SSH and listens on 127.0.0.1:0, forwarding to c.Host:c.Port.
func StartLocalForward(c ConnReq) (*SSHTunnel, error) {
sshHost := strings.TrimSpace(c.SSHHost)
if sshHost == "" {
return nil, fmt.Errorf("请填写 SSH 主机")
}
sshPort := c.SSHPort
if sshPort <= 0 {
sshPort = 22
}
sshUser := strings.TrimSpace(c.SSHUser)
if sshUser == "" {
return nil, fmt.Errorf("请填写 SSH 用户")
}
auth, err := sshAuthMethods(c)
if err != nil {
return nil, err
}
cfg := &ssh.ClientConfig{
User: sshUser,
Auth: auth,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // product tunnel; host-key pinning later
Timeout: 12 * time.Second,
}
addr := net.JoinHostPort(sshHost, fmt.Sprintf("%d", sshPort))
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return nil, fmt.Errorf("无法连接跳板机 %s: %w", addr, err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
_ = client.Close()
return nil, fmt.Errorf("本地监听失败: %w", err)
}
localPort := ln.Addr().(*net.TCPAddr).Port
remoteHost := c.Host
if remoteHost == "" {
remoteHost = "127.0.0.1"
}
remotePort := c.Port
if remotePort <= 0 {
remotePort = 3306
}
remoteAddr := net.JoinHostPort(remoteHost, fmt.Sprintf("%d", remotePort))
tun := &SSHTunnel{
LocalPort: localPort,
listener: ln,
client: client,
cancel: make(chan struct{}),
}
tun.wg.Add(1)
go func() {
defer tun.wg.Done()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-tun.cancel:
return
default:
return
}
}
tun.wg.Add(1)
go func(local net.Conn) {
defer tun.wg.Done()
defer local.Close()
remote, err := client.Dial("tcp", remoteAddr)
if err != nil {
return
}
defer remote.Close()
pipe(local, remote)
}(conn)
}
}()
return tun, nil
}
func sshAuthMethods(c ConnReq) ([]ssh.AuthMethod, error) {
authMode := strings.ToLower(strings.TrimSpace(c.SSHAuth))
if authMode == "" {
authMode = "password"
}
switch authMode {
case "password":
if c.SSHPassword == "" {
return nil, fmt.Errorf("请填写 SSH 密码")
}
return []ssh.AuthMethod{ssh.Password(c.SSHPassword)}, nil
case "privatekey", "key", "private_key":
keyMaterial, err := loadSSHPrivateKey(c.SSHPrivateKey)
if err != nil {
return nil, err
}
var signer ssh.Signer
if strings.TrimSpace(c.SSHPassphrase) != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(keyMaterial, []byte(c.SSHPassphrase))
} else {
signer, err = ssh.ParsePrivateKey(keyMaterial)
}
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
default:
return nil, fmt.Errorf("不支持的 SSH 认证方式: %s", c.SSHAuth)
}
}
func loadSSHPrivateKey(raw string) ([]byte, error) {
s := strings.TrimSpace(raw)
if s == "" {
return nil, fmt.Errorf("请填写私钥 PEM 或路径")
}
if strings.Contains(s, "BEGIN") && strings.Contains(s, "PRIVATE KEY") {
return []byte(s), nil
}
path := s
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("展开 ~ 失败: %w", err)
}
path = filepath.Join(home, path[2:])
}
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
}
return b, nil
}
func pipe(a, b net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(a, b)
_ = a.Close()
}()
go func() {
defer wg.Done()
_, _ = io.Copy(b, a)
_ = b.Close()
}()
wg.Wait()
}
// ApplySSHTunnel starts a local forward when sshEnabled and rewrites Host/Port to 127.0.0.1:<local>.
// Caller must Close the returned tunnel (may be nil).
func ApplySSHTunnel(c ConnReq) (ConnReq, *SSHTunnel, error) {
if !c.SSHEnabled {
return c, nil, nil
}
tun, err := StartLocalForward(c)
if err != nil {
return c, nil, fmt.Errorf("SSH: %w", err)
}
out := c
out.Host = "127.0.0.1"
out.Port = tun.LocalPort
// Avoid nested tunnel if helpers re-enter
out.SSHEnabled = false
return out, tun, nil
}
+115
View File
@@ -0,0 +1,115 @@
package db
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"strings"
)
// EffectiveSSLMode resolves ssl / sslMode for drivers.
// Checkbox off or mode disable → disable. Legacy ssl:true with empty mode → require.
func EffectiveSSLMode(c ConnReq) string {
mode := strings.ToLower(strings.TrimSpace(c.SSLMode))
switch mode {
case "disable", "require", "skip-verify", "verify-ca":
if mode == "disable" {
return "disable"
}
return mode
}
if c.SSL {
return "require"
}
return "disable"
}
// SSLEnabled is true when TLS should be negotiated.
func SSLEnabled(c ConnReq) bool {
return EffectiveSSLMode(c) != "disable"
}
// tlsConfigFrom builds a *tls.Config from ConnReq paths/mode.
// Returns (nil, nil) when SSL is disabled.
func tlsConfigFrom(c ConnReq) (*tls.Config, error) {
mode := EffectiveSSLMode(c)
if mode == "disable" {
return nil, nil
}
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if host := strings.TrimSpace(c.Host); host != "" {
cfg.ServerName = host
}
switch mode {
case "skip-verify":
cfg.InsecureSkipVerify = true
case "require":
// Encrypt; verify with system / provided CAs when present.
case "verify-ca":
// Prefer explicit CA; still verify chain (hostname via ServerName when set).
default:
return nil, fmt.Errorf("未知 SSL 模式: %s", mode)
}
if path := strings.TrimSpace(c.SSLCA); path != "" {
pem, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取 CA 证书失败 (%s): %w", path, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("CA 证书不是有效 PEM: %s", path)
}
cfg.RootCAs = pool
if mode == "verify-ca" {
// verify-ca: trust given CA but allow hostname mismatch (common for IP hosts)
cfg.InsecureSkipVerify = true
roots := pool
cfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return fmt.Errorf("服务端未提供证书")
}
certs := make([]*x509.Certificate, 0, len(rawCerts))
for i, raw := range rawCerts {
cert, err := x509.ParseCertificate(raw)
if err != nil {
return fmt.Errorf("解析服务端证书失败: %w", err)
}
if i == 0 {
// keep leaf for Intermediates below
}
certs = append(certs, cert)
}
leaf := certs[0]
inter := x509.NewCertPool()
for _, cert := range certs[1:] {
inter.AddCert(cert)
}
_, err := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter})
return err
}
}
} else if mode == "verify-ca" {
return nil, fmt.Errorf("verify-ca 模式需要填写 CA 证书路径")
}
certPath := strings.TrimSpace(c.SSLCert)
keyPath := strings.TrimSpace(c.SSLKey)
if certPath != "" || keyPath != "" {
if certPath == "" || keyPath == "" {
return nil, fmt.Errorf("客户端证书与密钥需同时填写")
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, fmt.Errorf("加载客户端证书失败: %w", err)
}
cfg.Certificates = []tls.Certificate{cert}
}
return cfg, nil
}
+111
View File
@@ -0,0 +1,111 @@
package db
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func TestEffectiveSSLMode(t *testing.T) {
if got := EffectiveSSLMode(ConnReq{}); got != "disable" {
t.Fatalf("empty -> disable, got %s", got)
}
if got := EffectiveSSLMode(ConnReq{SSL: true}); got != "require" {
t.Fatalf("ssl true -> require, got %s", got)
}
if got := EffectiveSSLMode(ConnReq{SSL: true, SSLMode: "skip-verify"}); got != "skip-verify" {
t.Fatalf("mode wins, got %s", got)
}
if got := EffectiveSSLMode(ConnReq{SSL: true, SSLMode: "disable"}); got != "disable" {
t.Fatalf("explicit disable, got %s", got)
}
if !SSLEnabled(ConnReq{SSLMode: "require"}) {
t.Fatal("require should enable")
}
}
func TestTLSConfigFromDisable(t *testing.T) {
cfg, err := tlsConfigFrom(ConnReq{})
if err != nil || cfg != nil {
t.Fatalf("disable => nil,nil got %#v %v", cfg, err)
}
}
func TestTLSConfigFromSkipVerify(t *testing.T) {
cfg, err := tlsConfigFrom(ConnReq{SSL: true, SSLMode: "skip-verify", Host: "db.example"})
if err != nil {
t.Fatal(err)
}
if cfg == nil || !cfg.InsecureSkipVerify {
t.Fatalf("expected InsecureSkipVerify, got %#v", cfg)
}
if cfg.ServerName != "db.example" {
t.Fatalf("ServerName %q", cfg.ServerName)
}
}
func TestTLSConfigFromVerifyCARequiresPath(t *testing.T) {
_, err := tlsConfigFrom(ConnReq{SSLMode: "verify-ca"})
if err == nil {
t.Fatal("expected error without CA")
}
}
func TestTLSConfigFromVerifyCAWithPEM(t *testing.T) {
dir := t.TempDir()
caPath := filepath.Join(dir, "ca.pem")
if err := writeTestCA(caPath); err != nil {
t.Fatal(err)
}
cfg, err := tlsConfigFrom(ConnReq{SSLMode: "verify-ca", SSLCA: caPath, Host: "127.0.0.1"})
if err != nil {
t.Fatal(err)
}
if cfg == nil || cfg.RootCAs == nil {
t.Fatal("expected RootCAs")
}
if cfg.VerifyPeerCertificate == nil {
t.Fatal("expected custom verify for verify-ca")
}
}
func TestTLSConfigFromBadCAPath(t *testing.T) {
_, err := tlsConfigFrom(ConnReq{SSLMode: "require", SSLCA: "/no/such/ca.pem"})
if err == nil {
t.Fatal("expected read error")
}
}
func writeTestCA(path string) error {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "opsxdb-test-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: der})
}
+98
View File
@@ -0,0 +1,98 @@
package db
import "time"
const DefaultRowLimit = 1000
const MaxRowLimit = 10000
// ConnReq 连接 / 执行请求。sessionId 有值时服务端用会话里的账密,请求里的 password 可空。
type ConnReq struct {
Driver string `json:"driver"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
SQL string `json:"sql"`
Service string `json:"service"`
SSL bool `json:"ssl"`
SSLMode string `json:"sslMode"` // disable|require|skip-verify|verify-ca
SSLCA string `json:"sslCA"`
SSLCert string `json:"sslCert"`
SSLKey string `json:"sslKey"`
SessionID string `json:"sessionId"`
Table string `json:"table"`
Limit int `json:"limit"`
Key string `json:"key"` // Redis key (get/delete)
Pattern string `json:"pattern"` // Redis SCAN match
SSHEnabled bool `json:"sshEnabled"`
SSHHost string `json:"sshHost"`
SSHPort int `json:"sshPort"`
SSHUser string `json:"sshUser"`
SSHAuth string `json:"sshAuth"` // password | privateKey
SSHPassword string `json:"sshPassword"`
SSHPrivateKey string `json:"sshPrivateKey"`
SSHPassphrase string `json:"sshPassphrase"`
}
type ColumnInfo struct {
Name string `json:"name"`
Type string `json:"type"`
Nullable bool `json:"nullable"`
Key string `json:"key,omitempty"`
Default any `json:"default,omitempty"`
Comment string `json:"comment,omitempty"`
}
// ExecResult 与前端 QueryResult 字段对齐
type ExecResult struct {
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
RowCount int `json:"rowCount"`
TimeMs int64 `json:"timeMs"`
Error string `json:"error,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Limit int `json:"limit,omitempty"`
}
func normalize(v interface{}) interface{} {
switch t := v.(type) {
case nil:
return nil
case []byte:
return string(t)
case time.Time:
return t.Format(time.RFC3339Nano)
default:
return t
}
}
// Redis key browse (dedicated endpoints; do not reshape SQL tables []).
type RedisKeyInfo struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int64 `json:"ttl"` // seconds; -1 no expire; -2 missing
}
type RedisKeysResult struct {
Keys []RedisKeyInfo `json:"keys"`
Truncated bool `json:"truncated,omitempty"`
TimeMs int64 `json:"timeMs"`
Error string `json:"error,omitempty"`
}
// RedisKeyValue feeds ResultPane (columns/rows) plus type/ttl meta.
type RedisKeyValue struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int64 `json:"ttl"`
Columns []string `json:"columns"`
Rows []map[string]interface{} `json:"rows"`
RowCount int `json:"rowCount"`
TimeMs int64 `json:"timeMs"`
Truncated bool `json:"truncated,omitempty"`
Limit int `json:"limit,omitempty"`
Error string `json:"error,omitempty"`
}
+39
View File
@@ -0,0 +1,39 @@
:root {
--bg: #0e1116; --panel: #151a21; --border: #243044;
--text: #e6edf7; --muted: #8b9bb4; --accent: #3b82f6;
}
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.55 ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
.top {
height: 44px; border-bottom: 1px solid var(--border); background: var(--panel);
display: flex; align-items: center; justify-content: space-between; padding: 0 16px;
}
.brand { display: flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; font-weight: 600; }
.brand em { color: var(--muted); font-style: normal; font-weight: 500; }
.logo {
width: 24px; height: 24px; border-radius: 6px; background: var(--accent);
display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff;
}
.top a { color: var(--muted); text-decoration: none; font-size: 12.5px; }
.layout { display: flex; min-height: calc(100vh - 44px); }
.side {
width: 200px; border-right: 1px solid var(--border); padding: 12px 10px;
background: var(--panel); display: flex; flex-direction: column; gap: 2px;
}
.nav-link {
display: block; padding: 6px 8px; border-radius: 6px; color: var(--muted);
text-decoration: none; font-size: 12.5px;
}
.nav-link:hover { color: var(--text); background: #1a2230; }
.nav-link.active { color: var(--text); background: color-mix(in srgb, var(--accent) 18%, transparent); }
.content { flex: 1; padding: 24px 28px; max-width: 720px; }
.content h1 { font-size: 20px; margin: 0 0 14px; }
.content h2 { font-size: 15px; margin: 22px 0 8px; }
.content p, .content li { color: #c5d0e0; }
.content code, .content pre {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
}
.content pre {
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
}
.content code { background: #1a2230; padding: 1px 5px; border-radius: 4px; }
@@ -0,0 +1,7 @@
<h1>连库入门</h1>
<p>在连库页选择驱动,填写主机 / 端口 / 账号,测试通过后保存并打开。</p>
<ul>
<li>密码仅存本机(本地加密存储)。</li>
<li>会话由本地后端维护;后端未启动时顶部会提示,不会白屏。</li>
<li>断线后可在底栏重连,恢复当前库上下文。</li>
</ul>
@@ -0,0 +1,7 @@
<h1>常见问题</h1>
<h2>打开后提示本地后端未就绪?</h2>
<p>确认应用已拉起 sidecar;或手动 <code>./run.sh server</code> 后重试。默认只监听 <code>127.0.0.1:8788</code>。</p>
<h2>浏览器也能用吗?</h2>
<p>可以调试,但<strong>产品说明与对外沟通应以桌面版为准</strong>,不要暗示 Web 即产品。</p>
<h2>官网 / 文档在哪?</h2>
<p>本地后端提供:<code>http://127.0.0.1:8788/</code>(官网)、<code>/docs</code>(文档)。</p>
@@ -0,0 +1,13 @@
<h1>安装与启动</h1>
<p>OpsxDB <strong>正式形态是桌面 GUI</strong>(Tauri)。开发态浏览器 <code>:5174</code> 只用于调试,不是产品交付。</p>
<h2>桌面版</h2>
<ol>
<li>安装本机构建产物(macOS <code>.dmg</code> / <code>.app</code>,或 <code>npm run build:desktop</code> 产出)。</li>
<li>双击启动:应用会拉起本地后端(默认 <code>127.0.0.1:8788</code>),进入工作台。</li>
<li>无需手动执行 <code>./run.sh web</code>。</li>
</ol>
<h2>开发调试</h2>
<pre><code>./run.sh gui # 或 ./run.sh web
</code></pre>
<p>开发壳地址:<code>http://127.0.0.1:5174</code>。</p>
<p>改后端(<code>apps/server</code>)后请执行 <code>./run.sh restart</code>(或等价重启 Gin),避免 <code>:8788</code> 仍跑旧二进制。</p>
@@ -0,0 +1,7 @@
<h1>工作台简介</h1>
<p>深色密度布局:左侧连接与对象树,中间编辑器与多结果 Tab,可选 AI 侧栏。</p>
<ul>
<li>多结果集:成功结果保留;失败不盖掉已有成功集。</li>
<li>执行计划:有则展示,无则禁用。</li>
<li>书签:紧凑列表,回填 SQL,本地持久化。</li>
</ul>
+89
View File
@@ -0,0 +1,89 @@
// Package docs:用户文档骨架(嵌入 HTML,侧栏+正文密度对齐工作台)。
package docs
import (
"embed"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed content assets template.html
var files embed.FS
type page struct {
Slug string
Title string
Body string
}
var order = []page{
{Slug: "install", Title: "安装与启动"},
{Slug: "connect", Title: "连库入门"},
{Slug: "workbench", Title: "工作台简介"},
{Slug: "faq", Title: "常见问题"},
}
type Handler struct {
tpl string
}
func New() *Handler {
raw, err := files.ReadFile("template.html")
if err != nil {
panic("docs: template.html 缺失: " + err.Error())
}
return &Handler{tpl: string(raw)}
}
func (h *Handler) Register(r *gin.Engine) {
r.GET("/docs", func(c *gin.Context) { c.Redirect(http.StatusFound, "/docs/install") })
r.GET("/docs/:slug", h.Page)
r.GET("/docs/assets/*file", h.Asset)
}
func (h *Handler) Page(c *gin.Context) {
slug := c.Param("slug")
bodyBytes, err := files.ReadFile("content/" + slug + ".html")
if err != nil {
c.Status(http.StatusNotFound)
return
}
title := slug
nav := strings.Builder{}
for _, p := range order {
cls := "nav-link"
if p.Slug == slug {
cls += " active"
title = p.Title
}
nav.WriteString(`<a class="` + cls + `" href="/docs/` + p.Slug + `">` + p.Title + `</a>`)
}
html := h.tpl
html = strings.ReplaceAll(html, "{{TITLE}}", title)
html = strings.ReplaceAll(html, "{{NAV}}", nav.String())
html = strings.ReplaceAll(html, "{{BODY}}", string(bodyBytes))
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-cache")
_, _ = c.Writer.WriteString(html)
}
func (h *Handler) Asset(c *gin.Context) {
name := strings.TrimPrefix(c.Param("file"), "/")
if strings.Contains(name, "..") {
c.Status(http.StatusBadRequest)
return
}
data, err := files.ReadFile("assets/" + name)
if err != nil {
c.Status(http.StatusNotFound)
return
}
ct := "application/octet-stream"
if strings.HasSuffix(name, ".css") {
ct = "text/css; charset=utf-8"
}
c.Header("Content-Type", ct)
c.Data(http.StatusOK, ct, data)
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}} · OpsxDB 文档</title>
<link rel="stylesheet" href="/docs/assets/docs.css">
</head>
<body>
<header class="top">
<a class="brand" href="/docs"><span class="logo">DB</span> OpsxDB <em>文档</em></a>
<nav><a href="/home">官网</a></nav>
</header>
<div class="layout">
<aside class="side">{{NAV}}</aside>
<main class="content">{{BODY}}</main>
</div>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
package proxy
import (
"fmt"
"net"
"net/url"
"strings"
)
var blockedHosts = map[string]struct{}{
"169.254.169.254": {},
"metadata.google.internal": {},
}
// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR
// deny-lists and DNS-rebinding protection before exposing this beyond localhost.
func ValidateURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("only http/https schemes are allowed")
}
if u.Hostname() == "" {
return nil, fmt.Errorf("url host is required")
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if _, blocked := blockedHosts[host]; blocked {
return nil, fmt.Errorf("blocked metadata host")
}
if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) {
return nil, fmt.Errorf("blocked metadata address")
}
return u, nil
}
+23
View File
@@ -0,0 +1,23 @@
package proxy
import "testing"
func TestValidateURL(t *testing.T) {
for _, tc := range []struct {
raw string
ok bool
}{
{"https://example.com/a", true},
{"file:///etc/passwd", false},
{"http://169.254.169.254/latest/meta-data", false},
{"http://metadata.google.internal/computeMetadata/v1", false},
} {
_, err := ValidateURL(tc.raw)
if tc.ok && err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
if !tc.ok && err == nil {
t.Fatalf("expected rejection: %s", tc.raw)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package proxy
import (
"bytes"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const maxResponseBytes = 10 << 20 // 10 MiB MVP safety cap
type Handler struct {
BaseClient *http.Client
}
func NewHandler() *Handler {
return &Handler{BaseClient: &http.Client{Timeout: 30 * time.Second}}
}
func (h *Handler) ProxyHTTP(c *gin.Context) {
var spec HttpSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
return
}
method := strings.ToUpper(spec.Method)
switch method {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported method"})
return
}
u, err := ValidateURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := u.Query()
for key, value := range spec.Query {
query.Set(key, value)
}
u.RawQuery = query.Encode()
var body io.Reader
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet {
body = bytes.NewBufferString(spec.Body.Raw)
}
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
return
}
for key, value := range spec.Headers {
// Hop-by-hop headers are managed by net/http.
if !isHopByHop(key) {
req.Header.Set(key, value)
}
}
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := h.BaseClient
if spec.TimeoutMS > 0 {
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
}
started := time.Now()
res, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
return
}
defer res.Body.Close()
payload, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "cannot read upstream response", "detail": err.Error()})
return
}
if len(payload) > maxResponseBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream response exceeds 10 MiB limit"})
return
}
headers := make(map[string]string, len(res.Header))
for key, values := range res.Header {
headers[strings.ToLower(key)] = strings.Join(values, ", ")
}
c.JSON(http.StatusOK, HttpResult{
Status: res.StatusCode,
Headers: headers,
Body: string(payload),
TimeMS: time.Since(started).Milliseconds(),
Size: len(payload),
})
}
func isHopByHop(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
return true
default:
return false
}
}
+23
View File
@@ -0,0 +1,23 @@
package proxy
type BodySpec struct {
Mode string `json:"mode"`
Raw string `json:"raw,omitempty"`
}
type HttpSpec struct {
Method string `json:"method" binding:"required"`
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers"`
Query map[string]string `json:"query,omitempty"`
Body *BodySpec `json:"body,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
}
type HttpResult struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
TimeMS int64 `json:"timeMs"`
Size int `json:"size"`
}
+229
View File
@@ -0,0 +1,229 @@
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"opsxdb/server/internal/ai"
"opsxdb/server/internal/db"
"opsxdb/server/internal/docs"
"opsxdb/server/internal/site"
)
func NewRouter() *gin.Engine {
r := gin.New()
_ = r.SetTrustedProxies(nil)
r.Use(gin.Logger(), gin.Recovery(), localhostCORS())
r.GET("/health", health)
r.GET("/api/health", health)
docs.New().Register(r)
site.New().Register(r)
api := r.Group("/api")
{
api.POST("/db/test-connection", func(c *gin.Context) {
req, err := bindConn(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
return
}
if err := db.TestConnection(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "connected"})
})
api.POST("/db/open", func(c *gin.Context) {
req, err := bindConn(c, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
id, err := db.OpenSession(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "sessionId": id, "message": "connected"})
})
api.POST("/db/close", func(c *gin.Context) {
var req db.ConnReq
_ = c.ShouldBindJSON(&req)
if req.SessionID != "" {
db.CloseSession(req.SessionID)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
})
api.POST("/db/databases", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListDatabases(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"databases": list})
})
api.POST("/db/tables", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListTables(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tables": list})
})
api.POST("/db/columns", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
list, err := db.ListColumns(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"columns": list})
})
api.POST("/db/exec", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
res := db.ExecSQL(c.Request.Context(), req)
c.JSON(http.StatusOK, res)
})
api.POST("/db/redis/keys", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.ListRedisKeys(req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
}
c.JSON(http.StatusOK, res)
})
api.POST("/db/redis/key", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.GetRedisKey(req)
if res.Error != "" && res.Type == "" {
c.JSON(http.StatusBadRequest, res)
return
}
c.JSON(http.StatusOK, res)
})
api.POST("/db/redis/key/delete", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !db.IsRedisDriver(req.Driver) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 Redis 可用"})
return
}
res := db.DeleteRedisKey(req)
if res.Error != "" {
c.JSON(http.StatusBadRequest, res)
return
}
c.JSON(http.StatusOK, res)
})
api.POST("/ai/chat", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ai.Chat(req))
})
api.POST("/ai/generate", func(c *gin.Context) {
var req ai.ChatReq
_ = c.ShouldBindJSON(&req)
req.Action = "generate"
c.JSON(http.StatusOK, ai.Chat(req))
})
}
return r
}
func bindConn(c *gin.Context, allowSession bool) (db.ConnReq, error) {
var req db.ConnReq
if err := c.ShouldBindJSON(&req); err != nil {
return req, err
}
if allowSession && req.SessionID != "" {
return db.ResolveConn(req)
}
return req, nil
}
func health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb"})
}
func localhostCORS() gin.HandlerFunc {
allowed := map[string]struct{}{
"http://localhost:5174": {},
"http://127.0.0.1:5174": {},
"http://localhost:5173": {},
"http://127.0.0.1:5173": {},
"http://localhost:1420": {},
"http://127.0.0.1:1420": {},
"tauri://localhost": {},
"http://tauri.localhost": {},
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if _, ok := allowed[strings.TrimSuffix(origin, "/")]; ok {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+50
View File
@@ -0,0 +1,50 @@
:root {
--bg: #0e1116;
--panel: #151a21;
--border: #243044;
--text: #e6edf7;
--muted: #8b9bb4;
--accent: #3b82f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.nav { border-bottom: 1px solid var(--border); background: var(--panel); }
.nav .inner {
max-width: 880px; margin: 0 auto; padding: 0 20px; height: 48px;
display: flex; align-items: center; justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 10px; color: var(--text); text-decoration: none; font-weight: 600; }
.logo {
width: 28px; height: 28px; border-radius: 7px; background: var(--accent);
display: grid; place-items: center; font-size: 11px; font-weight: 700; color: #fff;
}
.nav a { color: var(--muted); text-decoration: none; margin-left: 16px; font-size: 13px; }
.nav a:hover { color: var(--text); }
.hero { max-width: 880px; margin: 0 auto; padding: 56px 20px 72px; }
.eyebrow { color: var(--accent); font-size: 12px; letter-spacing: .04em; margin: 0 0 12px; }
h1 { font-size: 28px; line-height: 1.25; margin: 0 0 14px; font-weight: 650; }
.lead { color: var(--muted); max-width: 52ch; margin: 0 0 24px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 36px; }
.btn {
display: inline-flex; align-items: center; height: 36px; padding: 0 14px;
border-radius: 8px; text-decoration: none; font-size: 13px; font-weight: 550;
border: 1px solid var(--border);
}
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.ghost { color: var(--text); background: transparent; }
.caps { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.caps li {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 14px; display: grid; gap: 4px;
}
.caps strong { font-size: 13px; }
.caps span { color: var(--muted); font-size: 12.5px; }
.foot {
border-top: 1px solid var(--border); color: var(--muted);
text-align: center; padding: 18px; font-size: 12px;
}
+58
View File
@@ -0,0 +1,58 @@
// Package site:产品官网落地页(嵌入二进制,深色密度跟工作台)。
package site
import (
"embed"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed template.html assets
var files embed.FS
type Handler struct {
html string
}
func New() *Handler {
raw, err := files.ReadFile("template.html")
if err != nil {
panic("site: template.html 缺失: " + err.Error())
}
return &Handler{html: string(raw)}
}
func (h *Handler) Register(r *gin.Engine) {
r.GET("/", h.Page)
r.GET("/home", h.Page)
r.GET("/index.html", h.Page)
r.GET("/site/assets/*file", h.Asset)
}
func (h *Handler) Page(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-cache")
_, _ = c.Writer.WriteString(h.html)
}
func (h *Handler) Asset(c *gin.Context) {
name := strings.TrimPrefix(c.Param("file"), "/")
if strings.Contains(name, "..") {
c.Status(http.StatusBadRequest)
return
}
data, err := files.ReadFile("assets/" + name)
if err != nil {
c.Status(http.StatusNotFound)
return
}
ct := "application/octet-stream"
if strings.HasSuffix(name, ".css") {
ct = "text/css; charset=utf-8"
}
c.Header("Content-Type", ct)
c.Header("Cache-Control", "public, max-age=3600")
c.Data(http.StatusOK, ct, data)
}
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpsxDB — 本地优先的桌面数据库工作台</title>
<meta name="description" content="OpsxDB 主交付是桌面 GUI:连库、写 SQL、看结果。开发态 Vite 仅调试用。">
<link rel="stylesheet" href="/site/assets/site.css">
</head>
<body>
<header class="nav">
<div class="inner">
<a class="brand" href="/home"><span class="logo">DB</span> OpsxDB</a>
<nav>
<a href="/docs">文档</a>
<a href="#download">下载</a>
</nav>
</div>
</header>
<main class="hero">
<p class="eyebrow">桌面 GUI · 本地优先</p>
<h1>连库、写 SQL、看结果——都在本机完成</h1>
<p class="lead">正式形态是可安装的桌面应用,本地后端随应用启动。浏览器里的 :5174 只是开发壳,不是产品。</p>
<div class="actions">
<a class="btn primary" id="download" href="#download">下载桌面版(占位)</a>
<a class="btn ghost" href="/docs">阅读文档</a>
</div>
<ul class="caps">
<li><strong>多驱动连库</strong><span>MySQL / Postgres / SQLite 等,会话保活</span></li>
<li><strong>工作台密度</strong><span>结果集、对象树、书签,深色紧凑布局</span></li>
<li><strong>本机后端</strong><span>双击启动即带本地服务,不依赖手动起 Vite</span></li>
</ul>
</main>
<footer class="foot">© 2026 OpsxDB · 产品形态为桌面 GUI</footer>
</body>
</html>