Files
cyi-box/backend/internal/dbx/dbx.go
T
chiyiandDevin 834ecacdb9 cyi-box: Memphis-style toolbox admin platform
Go(fun)+SQLite backend, Vue 3 frontend. Modules: dashboard, pay gateway
(Epay-compatible + CDK stock), license/card system (per-app appid, domain
binding), AI memory bank with built-in MCP server (/mcp, FTS5 search,
signed read links), server SSH monitor + terminal + SFTP, proxy pool with
protocol-level alive checks, DB manager (mysql/pg/sqlite + SQL
autocomplete + import/export), disk share, vault (AES-GCM + TOTP),
bookmarks, dev tools.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 01:58:47 +08:00

401 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package dbx 通用数据库连接层:按 engine 生成 DSN、库/表/列 introspection、
// 受限执行 SQL(超时 + 行数上限)并把结果序列化为可 JSON 化结构。
// 支持 mysql / postgres / sqlite。
package dbx
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"unicode/utf8"
_ "github.com/go-sql-driver/mysql"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
// Conn 已解密的连接信息(service 层组装)
type Conn struct {
Engine string
Host string
Port int64
Username string
Password string
Database string // mysql/pg:默认库;sqlite:文件路径
Params string // 附加 DSN 参数,形如 "charset=utf8mb4&sslmode=disable"
}
func (c Conn) driverName() (string, error) {
switch c.Engine {
case "mysql":
return "mysql", nil
case "postgres", "postgresql", "pg":
return "pgx", nil
case "sqlite", "sqlite3":
return "sqlite", nil
}
return "", fmt.Errorf("dbx: 不支持的引擎 %q", c.Engine)
}
// dsn 按引擎拼连接串;db 为空用连接默认库
func (c Conn) dsn(db string) string {
if db == "" {
db = c.Database
}
switch c.Engine {
case "mysql":
params := "parseTime=true&timeout=6s"
if c.Params != "" {
params = c.Params
if !strings.Contains(params, "parseTime") {
params += "&parseTime=true"
}
if !strings.Contains(params, "timeout") {
params += "&timeout=6s"
}
}
host := c.Host
if c.Port > 0 {
host += ":" + strconv.FormatInt(c.Port, 10)
}
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.Username, c.Password, host, db, params)
case "postgres", "postgresql", "pg":
u := &url.URL{Scheme: "postgres", Host: c.Host, User: url.UserPassword(c.Username, c.Password), Path: "/" + db}
if c.Port > 0 {
u.Host += ":" + strconv.FormatInt(c.Port, 10)
}
q := u.Query()
if c.Params != "" {
if extra, err := url.ParseQuery(c.Params); err == nil {
for k, vs := range extra {
for _, v := range vs {
q.Add(k, v)
}
}
}
}
if q.Get("sslmode") == "" {
q.Set("sslmode", "disable")
}
u.RawQuery = q.Encode()
return u.String()
default: // sqlite:host/port/user 不用,database 是文件路径
path := db
if path == "" {
path = c.Database
}
return "file:" + path
}
}
// Open 建连并 ping 验证
func Open(c Conn, db string) (*sql.DB, error) {
driver, err := c.driverName()
if err != nil {
return nil, err
}
d, err := sql.Open(driver, c.dsn(db))
if err != nil {
return nil, err
}
d.SetMaxOpenConns(2)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
if err := d.PingContext(ctx); err != nil {
d.Close()
return nil, fmt.Errorf("dbx: 连接失败: %w", err)
}
return d, nil
}
// ---- introspection ----
// Databases 列出可访问的库;sqlite 恒为 main
func Databases(d *sql.DB, engine string) ([]string, error) {
var q string
switch engine {
case "mysql":
q = `SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('mysql','sys','information_schema','performance_schema') ORDER BY schema_name`
case "postgres", "postgresql", "pg":
q = `SELECT datname FROM pg_database WHERE NOT datistemplate ORDER BY datname`
default: // sqlite
return []string{"main"}, nil
}
return queryStrings(d, q)
}
type Table struct {
Name string
Type string // table | view
}
type Column struct {
Table string
Name string
Type string
Pk bool
}
// TablesAndColumns 某库下的表/视图与列(sqlite 用 sqlite_master+pragma)
func TablesAndColumns(d *sql.DB, engine, database string) ([]Table, []Column, error) {
if engine == "sqlite" || engine == "sqlite3" {
return sqliteSchema(d)
}
var tq, cq string
if engine == "mysql" {
tq = `SELECT table_name, table_type FROM information_schema.tables
WHERE table_schema = ? ORDER BY table_name`
cq = `SELECT table_name, column_name, column_type,
(column_key = 'PRI') AS pk FROM information_schema.columns
WHERE table_schema = ? ORDER BY table_name, ordinal_position`
} else { // postgres:只取 public 及其余非系统 schema 的用户表
tq = `SELECT table_name, table_type FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog','information_schema')
ORDER BY table_name`
cq = `SELECT c.table_name, c.column_name, c.data_type,
EXISTS (
SELECT 1 FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_namespace n ON n.oid = rel.relnamespace
JOIN pg_attribute att ON att.attrelid = con.conrelid
AND att.attnum = ANY(con.conkey)
WHERE con.contype = 'p'
AND n.nspname = c.table_schema
AND rel.relname = c.table_name
AND att.attname = c.column_name
) AS pk
FROM information_schema.columns c
WHERE c.table_schema NOT IN ('pg_catalog','information_schema')
ORDER BY c.table_name, c.ordinal_position`
}
tables := []Table{}
rows, err := d.Query(tq)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var t Table
var ty string
if err := rows.Scan(&t.Name, &ty); err != nil {
rows.Close()
return nil, nil, err
}
t.Type = "table"
if strings.Contains(strings.ToLower(ty), "view") {
t.Type = "view"
}
tables = append(tables, t)
}
rows.Close()
cols := []Column{}
rows2, err := d.Query(cq)
if err != nil {
return tables, cols, nil // 列拿不到不致命:补全降级
}
defer rows2.Close()
for rows2.Next() {
var c Column
var pk any
if err := rows2.Scan(&c.Table, &c.Name, &c.Type, &pk); err == nil {
if b, ok := pk.(int64); ok {
c.Pk = b != 0
} else if b, ok := pk.(bool); ok {
c.Pk = b
}
cols = append(cols, c)
}
}
return tables, cols, nil
}
func sqliteSchema(d *sql.DB) ([]Table, []Column, error) {
tables := []Table{}
rows, err := d.Query(
`SELECT name, type FROM sqlite_master WHERE type IN ('table','view')
AND name NOT LIKE 'sqlite_%' ORDER BY name`)
if err != nil {
return nil, nil, err
}
defer rows.Close()
for rows.Next() {
var t Table
if err := rows.Scan(&t.Name, &t.Type); err != nil {
return nil, nil, err
}
tables = append(tables, t)
}
rows.Close()
cols := []Column{}
for _, t := range tables {
if t.Type != "table" {
continue
}
pr, err := d.Query(`SELECT name, type, pk FROM pragma_table_info(?)`, t.Name)
if err != nil {
continue
}
for pr.Next() {
var c Column
var pk int64
if err := pr.Scan(&c.Name, &c.Type, &pk); err == nil {
c.Table = t.Name
c.Pk = pk != 0
cols = append(cols, c)
}
}
pr.Close()
}
return tables, cols, nil
}
func queryStrings(d *sql.DB, q string, args ...any) ([]string, error) {
rows, err := d.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// ---- 查询执行 ----
type Result struct {
IsSelect bool
Columns []string
Rows [][]any
RowCount int64
AffectedRows int64
Truncated bool
ElapsedMs int64
}
const maxRowsCap = 10000
// returnsRows 按首个关键字预判语句是否产生结果集;
// 不能只靠 QueryContext 试探——sqlite 会把 INSERT 当空结果集执行掉,拿不到 affected
func returnsRows(sqlText string) bool {
s := strings.TrimLeft(sqlText, " \t\r\n(")
for strings.HasPrefix(s, "--") {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = strings.TrimLeft(s[i+1:], " \t\r\n(")
} else {
return true
}
}
if i := strings.IndexAny(s, " \t\r\n("); i > 0 {
s = s[:i]
}
switch strings.ToUpper(s) {
case "SELECT", "WITH", "SHOW", "PRAGMA", "EXPLAIN", "VALUES", "TABLE", "DESC", "DESCRIBE":
return true
}
return false
}
// Query 执行单条 SQL:有结果集走行收集(cap 截断),否则回 affected rows
func Query(ctx context.Context, d *sql.DB, sqlText string, maxRows int64) (*Result, error) {
if maxRows <= 0 || maxRows > maxRowsCap {
maxRows = 500
}
start := time.Now()
if !returnsRows(sqlText) {
res, err := d.ExecContext(ctx, sqlText)
if err != nil {
return nil, err
}
aff, _ := res.RowsAffected()
return &Result{IsSelect: false, AffectedRows: aff,
ElapsedMs: time.Since(start).Milliseconds(),
Columns: []string{}, Rows: [][]any{}}, nil
}
r := &Result{Columns: []string{}, Rows: [][]any{}}
rows, err := d.QueryContext(ctx, sqlText)
if err != nil {
return nil, err
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return nil, err
}
r.IsSelect = true
r.Columns = cols
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
return nil, err
}
row := make([]any, len(cols))
for i, v := range vals {
row[i] = cellJSON(v)
}
r.Rows = append(r.Rows, row)
if int64(len(r.Rows)) >= maxRows {
r.Truncated = true
break
}
}
r.RowCount = int64(len(r.Rows))
r.ElapsedMs = time.Since(start).Milliseconds()
return r, nil
}
// cellJSON 把驱动返回值规整为 JSON 友好类型
func cellJSON(v any) any {
switch t := v.(type) {
case nil:
return nil
case []byte:
if utf8.Valid(t) {
return string(t)
}
return "0x" + base64.StdEncoding.EncodeToString(t)
case time.Time:
return t.Format("2006-01-02 15:04:05")
case driver.Valuer: // 防御:部分驱动包一层
vv, _ := t.Value()
return cellJSON(vv)
default:
return v
}
}
// Exec 明确写路径(删改/DDL),回受影响行数
func Exec(ctx context.Context, d *sql.DB, sqlText string) (*Result, error) {
start := time.Now()
res, err := d.ExecContext(ctx, sqlText)
if err != nil {
return nil, err
}
aff, _ := res.RowsAffected()
return &Result{IsSelect: false, AffectedRows: aff,
ElapsedMs: time.Since(start).Milliseconds(),
Columns: []string{}, Rows: [][]any{}}, nil
}
var ErrNoPK = errors.New("dbx: no primary key")