=== 一、点表生成的语句带分号 → 查询必报 1064 ===
用户报错:
Error 1064 (42000): You have an error in your SQL syntax; ...
near 'LIMIT 100 OFFSET 0' at line 2
复现语句(点表自动生成的原文,天然带分号):
SELECT * FROM `cex_activity`.`concert_goods_language`;
根因在 instance_detail.go 的自动补 LIMIT:
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
原语句已以 ';' 结尾 → 拼完变成
SELECT * FROM ...; LIMIT 100 OFFSET 0
→ 两条语句,报错位置就是第 2 行的 LIMIT(与报错文案完全吻合)。
修复:新增 normalizeSQLForExec(),去掉首尾空白并反复剥掉结尾分号,
在两个「会拼 LIMIT」的入口统一调用:
① executeSQLQuery(查询)—— 归一化后再生效 COUNT(*) 统计与 LIMIT 注入
② exportSQLCSV(导出 CSV)—— 同样先归一化再拼 LIMIT
顺带收益:COUNT(*) 统计以前也会带上分号(虽然 MySQL 容忍),现在干净了。
安全边界未被放松:多语句仍被 isReadOnlySQL 拦住
SELECT 1; SELECT 2 → 拒绝「不允许一次执行多条语句」✅
SELECT 1; LIMIT 5 → 拒绝 ✅(这是一次性两条语句,不是尾分号)
双分号 ;; → 归一化后正常执行 ✅
=== 二、SQL / Redis / Mongo 编辑器:光标异常 + 只有 13 行且滚不动 ===
用户反馈:
「默认还是有一个很长的竖线 一闪一闪,需要回车后第 2 行才正常」
「输入框下面还有空白,最后只有 13 行可以输入,至少需要加一个滚动」
根因 1(滚不动,13 行):appTheme 的 '&' 没有 height:100%。
.cm-editor 会随内容无限增高,而外层 div 固定 200px 且裁切,
CodeMirror 内部的 .cm-scroller 永远拿不到受限高度 → 不产生滚动条。
13 行 = 200px ÷ 15px 行高,正好对上。
根因 2(光标拉长):appTheme 在 extensions 里排在 oneDark 【之前】。
CodeMirror theme 是后者覆盖前者,oneDark 的默认行高/内边距盖掉了我们的
line-height,首次测量拿到的行高不对 → 空文档时光标高度算成好几行。
修复:
① appTheme '&' 增加 height:'100%';外层容器 style 改为
height + minHeight:120px + resize:vertical + overflow:hidden
(.cm-scroller 立刻有确定高度 → 内部滚动生效;右下角还能拖动拉高)
② appTheme 移到 oneDark 之后(顺序即优先级)
③ 行高统一写成 1.55em,并在 .cm-scroller / .cm-content / .cm-line /
.cm-gutters 四处保持一致(CM 靠读 .cm-content 的 line-height 算光标高度)
④ 挂载后 requestMeasure() + 下一帧再测一次 + document.fonts.ready 后再测一次,
修掉「首帧测量错乱」这一大类问题
⑤ SqlEditorSection 默认高度 200px → 240px(约 15 行可见)
注意:仍然不要给 .cm-cursor 写死 height / marginTop ——
CM 自己按行高算,写死会溢出到相邻行(历史上已经踩过一次)。
=== 三、实测 ===
表本身为空:cex_activity.concert_goods_language COUNT(*)=0
→ SELECT * 返回 0 行是正确结果,不是故障
分号归一化:带分号 / 不带分号 / 双分号 → rows=100 total=17447 完全一致,无 1064 ✅
安全边界:两条语句、分号后接 LIMIT 均被拒 ✅
编辑器产物:SqlEditor-*.js 内含 1.55em 与 resize:"vertical" ✅
镜像:lghost/opsxsql:latest + v0.1 = sha256:72a00dda2419
1917 lines
51 KiB
Go
1917 lines
51 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"reflect"
|
||
|
||
"sql-platform/internal/handler/resp"
|
||
"sql-platform/internal/model"
|
||
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"github.com/gin-gonic/gin"
|
||
"go.mongodb.org/mongo-driver/v2/bson"
|
||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||
"regexp"
|
||
"sort"
|
||
"sql-platform/internal/middleware"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// GET /api/v1/instances/:id/detail/databases
|
||
func DetailListDatabases(c *gin.Context) {
|
||
id := c.Param("id")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if instance.DBType == "redis" {
|
||
c.JSON(http.StatusOK, []string{})
|
||
return
|
||
}
|
||
|
||
db, err := getDBConnection(&instance, "")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" {
|
||
pgRows, err := db.Query("SELECT datname FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY 1")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer pgRows.Close()
|
||
var databases []string
|
||
for pgRows.Next() {
|
||
var name string
|
||
pgRows.Scan(&name)
|
||
databases = append(databases, name)
|
||
}
|
||
if databases == nil {
|
||
databases = []string{}
|
||
}
|
||
c.JSON(http.StatusOK, databases)
|
||
return
|
||
}
|
||
|
||
rows, err := db.Query("SHOW DATABASES")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
var databases []string
|
||
for rows.Next() {
|
||
var name string
|
||
rows.Scan(&name)
|
||
// Filter out system databases
|
||
if name != "information_schema" && name != "performance_schema" && name != "mysql" && name != "sys" {
|
||
databases = append(databases, name)
|
||
}
|
||
}
|
||
if databases == nil {
|
||
databases = []string{}
|
||
}
|
||
c.JSON(http.StatusOK, databases)
|
||
}
|
||
|
||
// GET /api/v1/instances/:id/detail/tables
|
||
func DetailListTables(c *gin.Context) {
|
||
id := c.Param("id")
|
||
dbName := c.Query("database")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if instance.DBType == "redis" {
|
||
c.JSON(http.StatusOK, []interface{}{})
|
||
return
|
||
}
|
||
|
||
if dbName == "" {
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
}
|
||
if dbName == "" {
|
||
resp.BadRequest(c, "database parameter required")
|
||
return
|
||
}
|
||
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" {
|
||
pgRows, err := db.Query(`SELECT c.relname AS name, c.reltuples::bigint AS rows,
|
||
pg_total_relation_size(c.oid) AS data_length
|
||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
||
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND c.relkind = 'r'
|
||
ORDER BY c.relname`)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer pgRows.Close()
|
||
var tables []map[string]interface{}
|
||
for pgRows.Next() {
|
||
var name string
|
||
var rowCount, dataLen int64
|
||
pgRows.Scan(&name, &rowCount, &dataLen)
|
||
tables = append(tables, map[string]interface{}{
|
||
"name": name, "rows": rowCount, "data_length": dataLen, "engine": "postgres",
|
||
})
|
||
}
|
||
if tables == nil {
|
||
tables = []map[string]interface{}{}
|
||
}
|
||
c.JSON(http.StatusOK, tables)
|
||
return
|
||
}
|
||
|
||
rows, err := db.Query("SHOW TABLE STATUS")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
cols, _ := rows.Columns()
|
||
var tables []map[string]interface{}
|
||
for rows.Next() {
|
||
values := make([]interface{}, len(cols))
|
||
ptrValues := make([]interface{}, len(cols))
|
||
for i := range values {
|
||
ptrValues[i] = &values[i]
|
||
}
|
||
rows.Scan(ptrValues...)
|
||
// Normalize to lowercase keys for frontend compatibility
|
||
rowMap := make(map[string]interface{})
|
||
for i, col := range cols {
|
||
val := values[i]
|
||
b, ok := val.([]byte)
|
||
if ok {
|
||
rowMap[strings.ToLower(col)] = string(b)
|
||
} else {
|
||
rowMap[strings.ToLower(col)] = val
|
||
}
|
||
}
|
||
tables = append(tables, rowMap)
|
||
}
|
||
if tables == nil {
|
||
tables = []map[string]interface{}{}
|
||
}
|
||
c.JSON(http.StatusOK, tables)
|
||
}
|
||
|
||
// GET /api/v1/instances/:id/detail/db-schema
|
||
func DetailDbSchema(c *gin.Context) {
|
||
id := c.Param("id")
|
||
dbName := c.Query("database")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if instance.DBType == "redis" {
|
||
c.JSON(http.StatusOK, map[string]interface{}{})
|
||
return
|
||
}
|
||
|
||
if dbName == "" {
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
}
|
||
if dbName == "" {
|
||
resp.BadRequest(c, "database parameter required")
|
||
return
|
||
}
|
||
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
schema := make(map[string][]string)
|
||
|
||
if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" {
|
||
// PG:一次查询拿全部 库内表.列(information_schema)
|
||
colRows, err := db.Query(`SELECT table_name, column_name FROM information_schema.columns
|
||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
|
||
ORDER BY table_name, ordinal_position`)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer colRows.Close()
|
||
for colRows.Next() {
|
||
var tableName, columnName string
|
||
colRows.Scan(&tableName, &columnName)
|
||
schema[tableName] = append(schema[tableName], columnName)
|
||
}
|
||
c.JSON(http.StatusOK, schema)
|
||
return
|
||
}
|
||
|
||
// Get all tables and their columns
|
||
tableRows, err := db.Query("SHOW TABLES")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer tableRows.Close()
|
||
|
||
for tableRows.Next() {
|
||
var tableName string
|
||
tableRows.Scan(&tableName)
|
||
// Get columns for this table
|
||
colRows, err := db.Query("SHOW COLUMNS FROM " + "`" + tableName + "`")
|
||
if err != nil {
|
||
schema[tableName] = []string{}
|
||
continue
|
||
}
|
||
var cols []string
|
||
for colRows.Next() {
|
||
var field, typ, null, key string
|
||
var extra interface{}
|
||
var def interface{}
|
||
colRows.Scan(&field, &typ, &null, &key, &def, &extra)
|
||
cols = append(cols, field+" ("+typ+")")
|
||
}
|
||
colRows.Close()
|
||
if cols == nil {
|
||
cols = []string{}
|
||
}
|
||
schema[tableName] = cols
|
||
}
|
||
c.JSON(http.StatusOK, schema)
|
||
}
|
||
|
||
// GET /api/v1/instances/:id/detail/table-detail
|
||
func DetailTableDetail(c *gin.Context) {
|
||
id := c.Param("id")
|
||
dbName := c.Query("database")
|
||
tableName := c.Query("table")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if dbName == "" {
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
}
|
||
if dbName == "" || tableName == "" {
|
||
resp.BadRequest(c, "database and table parameters required")
|
||
return
|
||
}
|
||
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
if instance.DBType == "postgresql" || instance.DBType == "postgres" || instance.DBType == "pg" {
|
||
// PG:information_schema 拼列 + pg_indexes 拿索引(无 SHOW CREATE,DDL 以列清单形式给出)
|
||
colRows, err := db.Query(`SELECT column_name, data_type, is_nullable,
|
||
COALESCE(column_default, ''), character_maximum_length
|
||
FROM information_schema.columns
|
||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_name = $1
|
||
ORDER BY ordinal_position`, tableName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer colRows.Close()
|
||
var columns []map[string]interface{}
|
||
var ddlLines []string
|
||
for colRows.Next() {
|
||
var name, dataType, nullable, def string
|
||
var maxLen interface{}
|
||
colRows.Scan(&name, &dataType, &nullable, &def, &maxLen)
|
||
nullStr := "YES"
|
||
if nullable != "YES" {
|
||
nullStr = "NO"
|
||
}
|
||
key := ""
|
||
pkRows, _ := db.Query(`SELECT a.attname FROM pg_index i
|
||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||
WHERE i.indrelid = to_regclass($1) AND i.indisprimary`, tableName)
|
||
if pkRows != nil {
|
||
for pkRows.Next() {
|
||
var pk string
|
||
pkRows.Scan(&pk)
|
||
if pk == name {
|
||
key = "PRI"
|
||
}
|
||
}
|
||
pkRows.Close()
|
||
}
|
||
columns = append(columns, map[string]interface{}{
|
||
"Field": name, "Type": dataType, "Null": nullStr, "Key": key, "Default": def, "Extra": "",
|
||
})
|
||
ddlLines = append(ddlLines, " "+name+" "+dataType+(func() string {
|
||
if nullStr == "NO" {
|
||
return " NOT NULL"
|
||
}
|
||
return ""
|
||
})())
|
||
}
|
||
ddl := "CREATE TABLE " + tableName + " (\n" + strings.Join(ddlLines, ",\n") + "\n);"
|
||
|
||
idxRows, err := db.Query(`SELECT indexname, indexdef FROM pg_indexes
|
||
WHERE schemaname NOT IN ('pg_catalog', 'information_schema') AND tablename = $1
|
||
ORDER BY indexname`, tableName)
|
||
var indexes []map[string]interface{}
|
||
if err == nil {
|
||
defer idxRows.Close()
|
||
for idxRows.Next() {
|
||
var idxName, idxDef string
|
||
idxRows.Scan(&idxName, &idxDef)
|
||
nonUnique := !strings.Contains(idxDef, " UNIQUE INDEX ")
|
||
indexes = append(indexes, map[string]interface{}{
|
||
"Key_name": idxName, "Column_name": strings.Trim(idxDef[strings.LastIndex(idxDef, "(")+1:], ")"), "Non_unique": nonUnique, "Index_type": "btree",
|
||
})
|
||
}
|
||
}
|
||
if indexes == nil {
|
||
indexes = []map[string]interface{}{}
|
||
}
|
||
if columns == nil {
|
||
columns = []map[string]interface{}{}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ddl": ddl, "columns": columns, "indexes": indexes})
|
||
return
|
||
}
|
||
|
||
// Get DDL
|
||
var ddl string
|
||
db.QueryRow("SHOW CREATE TABLE `"+tableName+"`").Scan(&tableName, &ddl)
|
||
|
||
// Get columns
|
||
colRows, err := db.Query("SHOW FULL COLUMNS FROM `" + tableName + "`")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer colRows.Close()
|
||
|
||
colCols, _ := colRows.Columns()
|
||
var columns []map[string]interface{}
|
||
for colRows.Next() {
|
||
values := make([]interface{}, len(colCols))
|
||
ptrValues := make([]interface{}, len(colCols))
|
||
for i := range values {
|
||
ptrValues[i] = &values[i]
|
||
}
|
||
colRows.Scan(ptrValues...)
|
||
rowMap := make(map[string]interface{})
|
||
for i, col := range colCols {
|
||
val := values[i]
|
||
b, ok := val.([]byte)
|
||
if ok {
|
||
rowMap[col] = string(b)
|
||
} else {
|
||
rowMap[col] = val
|
||
}
|
||
}
|
||
columns = append(columns, rowMap)
|
||
}
|
||
|
||
// Get indexes
|
||
idxRows, err := db.Query("SHOW INDEX FROM `" + tableName + "`")
|
||
if err == nil {
|
||
defer idxRows.Close()
|
||
idxCols, _ := idxRows.Columns()
|
||
var indexes []map[string]interface{}
|
||
for idxRows.Next() {
|
||
values := make([]interface{}, len(idxCols))
|
||
ptrValues := make([]interface{}, len(idxCols))
|
||
for i := range values {
|
||
ptrValues[i] = &values[i]
|
||
}
|
||
idxRows.Scan(ptrValues...)
|
||
rowMap := make(map[string]interface{})
|
||
for i, col := range idxCols {
|
||
val := values[i]
|
||
b, ok := val.([]byte)
|
||
if ok {
|
||
rowMap[col] = string(b)
|
||
} else {
|
||
rowMap[col] = val
|
||
}
|
||
}
|
||
indexes = append(indexes, rowMap)
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"ddl": ddl,
|
||
"columns": columns,
|
||
"indexes": indexes,
|
||
})
|
||
} else {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"ddl": ddl,
|
||
"columns": columns,
|
||
"indexes": []interface{}{},
|
||
})
|
||
}
|
||
}
|
||
|
||
// GET /api/v1/instances/:id/detail/processlist
|
||
func DetailProcesslist(c *gin.Context) {
|
||
id := c.Param("id")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if instance.DBType == "redis" {
|
||
c.JSON(http.StatusOK, []interface{}{})
|
||
return
|
||
}
|
||
|
||
dbName := ""
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
rows, err := db.Query("SHOW PROCESSLIST")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
cols, _ := rows.Columns()
|
||
var processes []map[string]interface{}
|
||
for rows.Next() {
|
||
values := make([]interface{}, len(cols))
|
||
ptrValues := make([]interface{}, len(cols))
|
||
for i := range values {
|
||
ptrValues[i] = &values[i]
|
||
}
|
||
rows.Scan(ptrValues...)
|
||
rowMap := make(map[string]interface{})
|
||
for i, col := range cols {
|
||
val := values[i]
|
||
b, ok := val.([]byte)
|
||
if ok {
|
||
rowMap[col] = string(b)
|
||
} else {
|
||
rowMap[col] = val
|
||
}
|
||
}
|
||
processes = append(processes, rowMap)
|
||
}
|
||
if processes == nil {
|
||
processes = []map[string]interface{}{}
|
||
}
|
||
c.JSON(http.StatusOK, processes)
|
||
}
|
||
|
||
// GET /api/v1/instances/:id/detail/status
|
||
func DetailStatus(c *gin.Context) {
|
||
id := c.Param("id")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if instance.DBType == "redis" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"key_metrics": map[string]string{},
|
||
"all_status": []interface{}{},
|
||
})
|
||
return
|
||
}
|
||
|
||
dbName := ""
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
rows, err := db.Query("SHOW GLOBAL STATUS")
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
keyMetrics := make(map[string]string)
|
||
var allStatus []map[string]interface{}
|
||
importantKeys := map[string]bool{
|
||
"Threads_connected": true, "Threads_running": true, "Threads_cached": true,
|
||
"Queries": true, "Questions": true, "Slow_queries": true,
|
||
"Connections": true, "Uptime": true,
|
||
"Bytes_received": true, "Bytes_sent": true,
|
||
"Table_locks_waited": true, "Table_locks_immediate": true,
|
||
"Innodb_row_lock_waits": true, "Innodb_buffer_pool_reads": true,
|
||
}
|
||
|
||
for rows.Next() {
|
||
var name, val string
|
||
rows.Scan(&name, &val)
|
||
allStatus = append(allStatus, map[string]interface{}{"name": name, "value": val})
|
||
if importantKeys[name] {
|
||
keyMetrics[name] = val
|
||
}
|
||
}
|
||
if allStatus == nil {
|
||
allStatus = []map[string]interface{}{}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"key_metrics": keyMetrics,
|
||
"all_status": allStatus,
|
||
})
|
||
}
|
||
|
||
// POST /api/v1/instances/:id/detail/processlist/:pid/kill
|
||
func DetailKillProcess(c *gin.Context) {
|
||
id := c.Param("id")
|
||
pid := c.Param("pid")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
dbName := ""
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
_, err = db.Exec("KILL " + pid)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"})
|
||
}
|
||
|
||
// GET /api/v1/instances/:id/detail/users
|
||
func DetailListUsers(c *gin.Context) {
|
||
id := c.Param("id")
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
if instance.DBType == "redis" {
|
||
c.JSON(http.StatusOK, []interface{}{})
|
||
return
|
||
}
|
||
|
||
dbName := ""
|
||
if instance.Database != nil {
|
||
dbName = *instance.Database
|
||
}
|
||
db, err := getDBConnection(&instance, dbName)
|
||
if err != nil {
|
||
resp.InternalErr(c, err)
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
rows, err := db.Query("SELECT User, Host FROM mysql.user")
|
||
if err != nil {
|
||
// Might not have permission - return empty
|
||
c.JSON(http.StatusOK, []interface{}{})
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
var users []map[string]interface{}
|
||
for rows.Next() {
|
||
var user, host string
|
||
rows.Scan(&user, &host)
|
||
users = append(users, map[string]interface{}{
|
||
"user": user,
|
||
"host": host,
|
||
})
|
||
}
|
||
if users == nil {
|
||
users = []map[string]interface{}{}
|
||
}
|
||
c.JSON(http.StatusOK, users)
|
||
}
|
||
|
||
// getDBConnection is defined in connection_tester.go or instance_detail.go
|
||
// Using the one from connection_tester.go
|
||
|
||
// POST /api/v1/query/execute
|
||
func ExecuteQuery(c *gin.Context) {
|
||
userID, username, _ := middleware.GetCurrentUser(c)
|
||
var input struct {
|
||
InstanceID int `json:"instance_id"`
|
||
Database string `json:"database"`
|
||
SQL string `json:"sql"`
|
||
Limit int `json:"limit"`
|
||
Offset int `json:"offset"`
|
||
}
|
||
if err := c.ShouldBindJSON(&input); err != nil {
|
||
resp.BadRequest(c, err.Error())
|
||
return
|
||
}
|
||
if input.InstanceID == 0 || input.SQL == "" {
|
||
resp.BadRequest(c, "instance_id and sql are required")
|
||
return
|
||
}
|
||
// 查询行数上限统一由系统设置 query_limit_max 决定(默认 5000)。
|
||
// 注意:历史上这里还有一段 `if input.Limit > 1000 { input.Limit = 1000 }` 的硬编码,
|
||
// 导致管理员把上限调到 5000 也不生效(前端不传 limit 时永远被压回 1000)。
|
||
// 现已移除,唯一口径就是设置值。
|
||
maxLimit := getSettingInt("query_limit_max", 5000)
|
||
if maxLimit <= 0 {
|
||
maxLimit = 5000
|
||
}
|
||
if input.Limit <= 0 {
|
||
input.Limit = maxLimit
|
||
}
|
||
if input.Limit > maxLimit {
|
||
input.Limit = maxLimit
|
||
}
|
||
if input.Offset < 0 {
|
||
input.Offset = 0
|
||
}
|
||
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
// Dangerous pattern check
|
||
if getSetting("query_dangerous_pattern", "true") == "true" {
|
||
upper := strings.ToUpper(input.SQL)
|
||
for _, pat := range []string{"DELETE FROM", "UPDATE ", "DROP ", "TRUNCATE", "ALTER ", "RENAME "} {
|
||
if strings.Contains(upper, pat) && !strings.Contains(upper, " WHERE ") && !strings.Contains(upper, " LIMIT ") {
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, 0, 0, "failed", "危险操作检测: "+pat+" 缺少 WHERE/LIMIT 条件,请在工单中提交", true)
|
||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": 0,
|
||
"error": "危险操作检测: " + pat + " 缺少 WHERE/LIMIT 条件,请在工单中提交"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
start := time.Now()
|
||
|
||
switch instance.DBType {
|
||
case "mysql", "postgresql", "postgres", "pg":
|
||
result, err := executeSQLQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||
costMs := time.Since(start).Milliseconds()
|
||
if err != nil {
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false)
|
||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||
return
|
||
}
|
||
result["cost_ms"] = costMs
|
||
rowsInterface, _ := result["rows"].([]interface{})
|
||
applyDataMasking(result, &instance)
|
||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, len(rowsInterface), "success", "", false)
|
||
c.JSON(http.StatusOK, result)
|
||
|
||
case "redis":
|
||
result, err := executeRedisQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||
costMs := time.Since(start).Milliseconds()
|
||
if err != nil {
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false)
|
||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||
return
|
||
}
|
||
result["cost_ms"] = costMs
|
||
rowCount := 1
|
||
if rowsArr, ok := result["rows"].([]interface{}); ok {
|
||
rowCount = len(rowsArr)
|
||
}
|
||
applyDataMasking(result, &instance)
|
||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, rowCount)
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, rowCount, "success", "", false)
|
||
c.JSON(http.StatusOK, result)
|
||
|
||
case "mongodb", "mongo":
|
||
result, err := executeMongoQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset)
|
||
costMs := time.Since(start).Milliseconds()
|
||
if err != nil {
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false)
|
||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||
return
|
||
}
|
||
result["cost_ms"] = costMs
|
||
rowsInterface, _ := result["rows"].([]interface{})
|
||
applyDataMasking(result, &instance)
|
||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
||
saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, len(rowsInterface), "success", "", false)
|
||
c.JSON(http.StatusOK, result)
|
||
|
||
default:
|
||
resp.BadRequest(c, "Unsupported database type: "+instance.DBType)
|
||
}
|
||
}
|
||
|
||
func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, limit int, offset int) (gin.H, error) {
|
||
db, err := getDBConnection(instance, dbName)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("connection failed: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
// Apply query timeout from platform settings
|
||
timeoutSec := getSettingInt("query_timeout", 30)
|
||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
|
||
defer cancel()
|
||
_ = ctx
|
||
|
||
// 先剥掉结尾分号:否则后面拼 LIMIT/OFFSET 会变成两条语句(MySQL 1064)
|
||
sqlStr = normalizeSQLForExec(sqlStr)
|
||
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
|
||
if ok, reason := isReadOnlySQL(sqlStr); !ok {
|
||
return nil, fmt.Errorf("查询入口仅允许只读语句:%s。数据变更请提交工单", reason)
|
||
}
|
||
|
||
// Count total (only when no user LIMIT and offset=0)
|
||
total := 0
|
||
hasLimit := strings.Contains(upperSQL, " LIMIT ")
|
||
|
||
if !hasLimit && offset == 0 {
|
||
if strings.HasPrefix(upperSQL, "SELECT") {
|
||
fromIdx := strings.Index(upperSQL, " FROM ")
|
||
if fromIdx > 0 {
|
||
countSQL := "SELECT COUNT(*) " + sqlStr[fromIdx:]
|
||
upperCount := strings.ToUpper(countSQL)
|
||
if idx := strings.Index(upperCount, " ORDER BY "); idx > 0 {
|
||
countSQL = countSQL[:idx]
|
||
}
|
||
if idx := strings.Index(upperCount, " GROUP BY "); idx > 0 {
|
||
countSQL = countSQL[:idx]
|
||
}
|
||
db.QueryRowContext(ctx, countSQL).Scan(&total)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Inject LIMIT/OFFSET if not already present — only for SELECT statements
|
||
// SHOW/DESC/EXPLAIN/CREATE/ALTER/DROP/etc do not support LIMIT syntax
|
||
autoLimit := getSetting("query_auto_limit", "true") == "true"
|
||
needsLimit := autoLimit && !hasLimit && isLimitableReadSQL(upperSQL)
|
||
if needsLimit {
|
||
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
|
||
}
|
||
|
||
rows, err := db.QueryContext(ctx, sqlStr)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query failed: %v", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
cols, _ := rows.Columns()
|
||
colNames := make([]string, len(cols))
|
||
for i, col := range cols {
|
||
colNames[i] = col
|
||
}
|
||
|
||
var resultRows []interface{}
|
||
for rows.Next() {
|
||
values := make([]interface{}, len(cols))
|
||
ptrValues := make([]interface{}, len(cols))
|
||
for i := range values {
|
||
ptrValues[i] = &values[i]
|
||
}
|
||
rows.Scan(ptrValues...)
|
||
rowMap := make(map[string]interface{})
|
||
for i, col := range colNames {
|
||
val := values[i]
|
||
b, ok := val.([]byte)
|
||
if ok {
|
||
rowMap[col] = string(b)
|
||
} else {
|
||
rowMap[col] = val
|
||
}
|
||
}
|
||
resultRows = append(resultRows, rowMap)
|
||
}
|
||
|
||
return gin.H{
|
||
"columns": colNames,
|
||
"rows": resultRows,
|
||
"total": total,
|
||
"count": len(resultRows),
|
||
}, nil
|
||
}
|
||
|
||
// normalizeSQLForExec 去掉首尾空白与结尾分号。
|
||
//
|
||
// 必须做这一步的原因:前端「点表」生成的语句天然带分号,例如
|
||
//
|
||
// SELECT * FROM `cex_activity`.`concert_goods_language`;
|
||
//
|
||
// 而 executeSQLQuery 在没有 LIMIT 时会在末尾拼 " LIMIT %d OFFSET %d",
|
||
// 拼完就成了两条语句:
|
||
//
|
||
// SELECT * FROM ...; LIMIT 100 OFFSET 0
|
||
//
|
||
// MySQL 直接报:
|
||
//
|
||
// Error 1064 (42000): ... near 'LIMIT 100 OFFSET 0' at line 2
|
||
//
|
||
// 所以拼 LIMIT / 生成 COUNT(*) 之前必须先剥掉结尾分号。
|
||
func normalizeSQLForExec(sqlStr string) string {
|
||
s := strings.TrimSpace(sqlStr)
|
||
for {
|
||
trimmed := strings.TrimSpace(strings.TrimSuffix(s, ";"))
|
||
if trimmed == s {
|
||
break
|
||
}
|
||
s = trimmed
|
||
}
|
||
return s
|
||
}
|
||
|
||
func isReadOnlySQL(sqlStr string) (bool, string) {
|
||
cleaned := strings.TrimSpace(stripLeadingSQLComments(sqlStr))
|
||
if cleaned == "" {
|
||
return false, "空语句"
|
||
}
|
||
if hasMultipleSQLStatements(cleaned) {
|
||
return false, "不允许一次执行多条语句"
|
||
}
|
||
|
||
upper := strings.ToUpper(cleaned)
|
||
readPrefixes := []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH"}
|
||
allowed := false
|
||
for _, prefix := range readPrefixes {
|
||
if strings.HasPrefix(upper, prefix+" ") || upper == prefix {
|
||
allowed = true
|
||
break
|
||
}
|
||
}
|
||
if !allowed {
|
||
return false, "只允许 SELECT / SHOW / DESCRIBE / EXPLAIN"
|
||
}
|
||
|
||
blockedPatterns := []string{
|
||
" FOR UPDATE", " LOCK IN SHARE MODE", " INTO OUTFILE", " INTO DUMPFILE",
|
||
"EXPLAIN ANALYZE", "DELETE ", "UPDATE ", "INSERT ", "REPLACE ",
|
||
"CREATE ", "ALTER ", "DROP ", "TRUNCATE ", "RENAME ", "GRANT ", "REVOKE ",
|
||
"CALL ", "DO ", "LOAD DATA", "COPY ",
|
||
}
|
||
for _, pattern := range blockedPatterns {
|
||
if strings.Contains(upper, pattern) {
|
||
return false, strings.TrimSpace(pattern)
|
||
}
|
||
}
|
||
return true, ""
|
||
}
|
||
|
||
func isLimitableReadSQL(upperSQL string) bool {
|
||
return strings.HasPrefix(upperSQL, "SELECT") || strings.HasPrefix(upperSQL, "WITH")
|
||
}
|
||
|
||
func hasMultipleSQLStatements(sqlStr string) bool {
|
||
trimmed := strings.TrimSpace(sqlStr)
|
||
if strings.HasSuffix(trimmed, ";") {
|
||
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
|
||
}
|
||
return strings.Contains(trimmed, ";")
|
||
}
|
||
|
||
func stripLeadingSQLComments(sqlStr string) string {
|
||
s := strings.TrimSpace(sqlStr)
|
||
for {
|
||
switch {
|
||
case strings.HasPrefix(s, "--"):
|
||
if idx := strings.Index(s, "\n"); idx >= 0 {
|
||
s = strings.TrimSpace(s[idx+1:])
|
||
continue
|
||
}
|
||
return ""
|
||
case strings.HasPrefix(s, "#"):
|
||
if idx := strings.Index(s, "\n"); idx >= 0 {
|
||
s = strings.TrimSpace(s[idx+1:])
|
||
continue
|
||
}
|
||
return ""
|
||
case strings.HasPrefix(s, "/*"):
|
||
if idx := strings.Index(s, "*/"); idx >= 0 {
|
||
s = strings.TrimSpace(s[idx+2:])
|
||
continue
|
||
}
|
||
return s
|
||
default:
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
|
||
// parseCommandParts splits a command string into parts, respecting quoted strings.
|
||
// e.g. HGET "my key" "my field" => ["HGET", "my key", "my field"]
|
||
func parseCommandParts(input string) []string {
|
||
var parts []string
|
||
var current strings.Builder
|
||
inQuote := false
|
||
quoteChar := byte(0)
|
||
|
||
for i := 0; i < len(input); i++ {
|
||
ch := input[i]
|
||
if inQuote {
|
||
if ch == quoteChar {
|
||
inQuote = false
|
||
quoteChar = 0
|
||
} else {
|
||
current.WriteByte(ch)
|
||
}
|
||
} else {
|
||
switch ch {
|
||
case '"', '\'':
|
||
if current.Len() > 0 {
|
||
parts = append(parts, current.String())
|
||
current.Reset()
|
||
}
|
||
inQuote = true
|
||
quoteChar = ch
|
||
case ' ', '\t', '\n', '\r':
|
||
if current.Len() > 0 {
|
||
parts = append(parts, current.String())
|
||
current.Reset()
|
||
}
|
||
default:
|
||
current.WriteByte(ch)
|
||
}
|
||
}
|
||
}
|
||
if current.Len() > 0 {
|
||
parts = append(parts, current.String())
|
||
}
|
||
return parts
|
||
}
|
||
|
||
// rowsOrDefault ensures rows is never nil (JSON null), always an empty slice
|
||
func rowsOrDefault(rows []interface{}) []interface{} {
|
||
if rows == nil {
|
||
return []interface{}{}
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func executeRedisQuery(instance *model.Instance, dbSelect string, command string, limit int, offset int) (gin.H, error) {
|
||
// Parse Redis DB number from dbSelect (e.g. "0", "1")
|
||
redisDB := 0
|
||
if dbSelect != "" {
|
||
if n, err := strconv.Atoi(dbSelect); err == nil && n >= 0 {
|
||
redisDB = n
|
||
}
|
||
}
|
||
|
||
client, err := getRedisClient(instance, redisDB)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer client.Close()
|
||
|
||
ctx := context.Background()
|
||
|
||
parts := parseCommandParts(command)
|
||
if len(parts) == 0 {
|
||
return nil, fmt.Errorf("empty command")
|
||
}
|
||
|
||
cmd := strings.ToUpper(parts[0])
|
||
keyName := ""
|
||
if len(parts) > 1 {
|
||
keyName = parts[1]
|
||
}
|
||
readOnly := map[string]bool{
|
||
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
|
||
"LRANGE": true, "SMEMBERS": true, "ZRANGE": true,
|
||
"TYPE": true, "TTL": true, "PTTL": true, "EXISTS": true, "KEYS": true,
|
||
"INFO": true, "DBSIZE": true, "SCAN": true, "STRLEN": true,
|
||
"LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true,
|
||
"HKEYS": true, "HVALS": true, "HSCAN": true, "SSCAN": true,
|
||
"ZSCAN": true, "ZRANGEBYSCORE": true,
|
||
"GETSET": true, "GETRANGE": true, "SUBSTR": true,
|
||
"PING": true, "CONFIG": true, "CLIENT": true, "SLOWLOG": true,
|
||
"CLUSTER": true, "MEMORY": true, "OBJECT": true, "DEBUG": true,
|
||
"SRANDMEMBER": true, "RANDOMKEY": true, "LASTSAVE": true,
|
||
"ROLE": true, "REPLCONF": true, "SENTINEL": true,
|
||
"LATENCY": true, "MODULE": true, "COMMAND": true,
|
||
}
|
||
if !readOnly[cmd] {
|
||
return nil, fmt.Errorf("only read-only commands allowed, got: %s", cmd)
|
||
}
|
||
|
||
// Build args: command name + all arguments
|
||
args := make([]interface{}, 0, len(parts))
|
||
args = append(args, cmd)
|
||
for _, p := range parts[1:] {
|
||
args = append(args, p)
|
||
}
|
||
|
||
// For KEYS command, use ScanAllKeys (cluster-aware) with pagination
|
||
if cmd == "KEYS" {
|
||
pattern := "*"
|
||
if len(parts) > 1 {
|
||
pattern = parts[1]
|
||
}
|
||
// Auto-append * if pattern has no wildcard
|
||
if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") {
|
||
pattern = pattern + "*"
|
||
}
|
||
allKeys, err := client.ScanAllKeys(ctx, pattern, limit+offset)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("SCAN failed: %v", err)
|
||
}
|
||
total := len(allKeys)
|
||
end := offset + limit
|
||
if end > total {
|
||
end = total
|
||
}
|
||
if offset > total {
|
||
offset = total
|
||
}
|
||
pagedKeys := allKeys[offset:end]
|
||
rows := make([]interface{}, len(pagedKeys))
|
||
for i, k := range pagedKeys {
|
||
rows[i] = map[string]interface{}{"index": offset + i, "key": k}
|
||
}
|
||
return gin.H{
|
||
"columns": []string{"index", "key"},
|
||
"rows": rows,
|
||
"total": total,
|
||
"count": len(rows),
|
||
}, nil
|
||
}
|
||
|
||
// For LRANGE, inject pagination if user didn't specify start/stop
|
||
if cmd == "LRANGE" && len(parts) < 4 {
|
||
key := ""
|
||
if len(parts) > 1 {
|
||
key = parts[1]
|
||
}
|
||
result, err := client.LRange(ctx, key, int64(offset), int64(offset+limit-1)).Result()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("command failed: %v", err)
|
||
}
|
||
// Get total via LLEN
|
||
total, _ := client.LLen(ctx, key).Result()
|
||
rows := make([]interface{}, len(result))
|
||
for i, val := range result {
|
||
rows[i] = map[string]interface{}{"index": offset + i, "value": val}
|
||
}
|
||
return gin.H{
|
||
"columns": []string{"index", "value"},
|
||
"rows": rows,
|
||
"total": int(total),
|
||
"count": len(rows),
|
||
}, nil
|
||
}
|
||
|
||
// For SMEMBERS with limit, use SSCAN
|
||
if cmd == "SMEMBERS" && limit > 0 {
|
||
key := ""
|
||
if len(parts) > 1 {
|
||
key = parts[1]
|
||
}
|
||
total, _ := client.SCard(ctx, key).Result()
|
||
var members []string
|
||
cursor := uint64(0)
|
||
for len(members) < offset+limit {
|
||
batch, nextCursor, err := client.SScan(ctx, key, cursor, "", int64(offset+limit-len(members))).Result()
|
||
if err != nil {
|
||
break
|
||
}
|
||
members = append(members, batch...)
|
||
cursor = nextCursor
|
||
if cursor == 0 {
|
||
break
|
||
}
|
||
}
|
||
end := offset + limit
|
||
if end > len(members) {
|
||
end = len(members)
|
||
}
|
||
paged := members[offset:end]
|
||
rows := make([]interface{}, len(paged))
|
||
for i, m := range paged {
|
||
rows[i] = map[string]interface{}{"index": offset + i, "member": m}
|
||
}
|
||
return gin.H{
|
||
"columns": []string{"index", "member"},
|
||
"rows": rows,
|
||
"total": int(total),
|
||
"count": len(rows),
|
||
}, nil
|
||
}
|
||
|
||
result, err := client.Do(ctx, args...).Result()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("command failed: %v", err)
|
||
}
|
||
|
||
keyMeta := gin.H{}
|
||
if keyName != "" && cmd != "TYPE" && cmd != "TTL" && cmd != "PTTL" && cmd != "EXISTS" && cmd != "DBSIZE" && cmd != "INFO" {
|
||
keyMeta["raw_key"] = keyName
|
||
if t, err := client.Type(ctx, keyName).Result(); err == nil {
|
||
keyMeta["raw_type"] = t
|
||
}
|
||
if ttl, err := client.TTL(ctx, keyName).Result(); err == nil {
|
||
if ttl < 0 {
|
||
keyMeta["raw_ttl"] = "-1"
|
||
} else {
|
||
keyMeta["raw_ttl"] = fmt.Sprintf("%d", int(ttl.Seconds()))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Format result into rows/columns for frontend table display
|
||
columns := []string{"key", "value"}
|
||
var rows []interface{}
|
||
|
||
switch v := result.(type) {
|
||
case []interface{}:
|
||
// ZRANGE WITHSCORES: Do() returns [[member1, score1], [member2, score2], ...]
|
||
// Each element is a []interface{} pair
|
||
if (cmd == "ZRANGE" || cmd == "ZRANGEBYSCORE") && len(v) >= 1 {
|
||
withScores := false
|
||
if pair, ok := v[0].([]interface{}); ok && len(pair) == 2 {
|
||
// Check if second element is numeric (score)
|
||
switch pair[1].(type) {
|
||
case float64, int64, string:
|
||
withScores = true
|
||
}
|
||
}
|
||
if withScores {
|
||
type jsonEntry struct {
|
||
Member string
|
||
Score float64
|
||
Fields map[string]interface{}
|
||
}
|
||
entries := make([]jsonEntry, 0, len(v))
|
||
allKeys := map[string]bool{}
|
||
|
||
for _, item := range v {
|
||
pair, ok := item.([]interface{})
|
||
if !ok || len(pair) < 2 {
|
||
continue
|
||
}
|
||
member := fmt.Sprintf("%v", pair[0])
|
||
var score float64
|
||
switch s := pair[1].(type) {
|
||
case float64:
|
||
score = s
|
||
case int64:
|
||
score = float64(s)
|
||
case string:
|
||
score, _ = strconv.ParseFloat(s, 64)
|
||
}
|
||
entry := jsonEntry{Member: member, Score: score}
|
||
|
||
var parsed interface{}
|
||
if json.Unmarshal([]byte(member), &parsed) == nil {
|
||
if m, ok := parsed.(map[string]interface{}); ok {
|
||
entry.Fields = m
|
||
for k := range m {
|
||
allKeys[k] = true
|
||
}
|
||
}
|
||
}
|
||
entries = append(entries, entry)
|
||
}
|
||
|
||
if len(allKeys) > 0 {
|
||
// JSON members → expand into table columns
|
||
sortedKeys := make([]string, 0, len(allKeys))
|
||
for k := range allKeys {
|
||
sortedKeys = append(sortedKeys, k)
|
||
}
|
||
sort.Strings(sortedKeys)
|
||
|
||
cols := []string{"#", "timestamp"}
|
||
cols = append(cols, sortedKeys...)
|
||
|
||
for idx, entry := range entries {
|
||
row := map[string]interface{}{
|
||
"#": idx + 1,
|
||
"timestamp": int64(entry.Score),
|
||
}
|
||
for _, k := range sortedKeys {
|
||
if entry.Fields != nil {
|
||
if fv, ok := entry.Fields[k]; ok {
|
||
row[k] = fv
|
||
} else {
|
||
row[k] = nil
|
||
}
|
||
} else {
|
||
row[k] = nil
|
||
}
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
columns = cols
|
||
} else {
|
||
// Plain members with scores
|
||
for idx, item := range v {
|
||
pair, ok := item.([]interface{})
|
||
if !ok || len(pair) < 2 {
|
||
continue
|
||
}
|
||
member := fmt.Sprintf("%v", pair[0])
|
||
var score float64
|
||
switch s := pair[1].(type) {
|
||
case float64:
|
||
score = s
|
||
case int64:
|
||
score = float64(s)
|
||
case string:
|
||
score, _ = strconv.ParseFloat(s, 64)
|
||
}
|
||
rows = append(rows, map[string]interface{}{
|
||
"#": idx + 1,
|
||
"member": member,
|
||
"timestamp": int64(score),
|
||
})
|
||
}
|
||
columns = []string{"#", "member", "timestamp"}
|
||
}
|
||
|
||
total := len(rows)
|
||
return gin.H{
|
||
"columns": columns,
|
||
"rows": rows,
|
||
"total": total,
|
||
"count": total,
|
||
}, nil
|
||
}
|
||
}
|
||
|
||
// Array result (MGET, LRANGE, ZRANGE without WITHSCORES, etc.)
|
||
if cmd == "MGET" {
|
||
for i, val := range v {
|
||
rows = append(rows, map[string]interface{}{
|
||
"index": i,
|
||
"value": fmt.Sprintf("%v", val),
|
||
})
|
||
}
|
||
columns = []string{"index", "value"}
|
||
} else {
|
||
for i, val := range v {
|
||
rows = append(rows, map[string]interface{}{
|
||
"index": i,
|
||
"value": fmt.Sprintf("%v", val),
|
||
})
|
||
}
|
||
}
|
||
case map[string]string:
|
||
// HGETALL returns map[string]string via some redis clients
|
||
for k, val := range v {
|
||
rows = append(rows, map[string]interface{}{
|
||
"field": k,
|
||
"value": val,
|
||
})
|
||
}
|
||
columns = []string{"field", "value"}
|
||
default:
|
||
// Use reflect to handle any map type (map[interface{}]interface{}, etc.)
|
||
rv := reflect.ValueOf(result)
|
||
if rv.Kind() == reflect.Map {
|
||
iter := rv.MapRange()
|
||
for iter.Next() {
|
||
rows = append(rows, map[string]interface{}{
|
||
"field": fmt.Sprintf("%v", iter.Key().Interface()),
|
||
"value": fmt.Sprintf("%v", iter.Value().Interface()),
|
||
})
|
||
}
|
||
columns = []string{"field", "value"}
|
||
} else {
|
||
// Single value result (GET, HGET, TYPE, TTL, etc.)
|
||
rawValue := fmt.Sprintf("%v", v)
|
||
// Try to pretty-format JSON with Chinese characters preserved
|
||
var prettyJSON string
|
||
var parsed interface{}
|
||
if json.Unmarshal([]byte(rawValue), &parsed) == nil {
|
||
switch parsed.(type) {
|
||
case map[string]interface{}, []interface{}:
|
||
var buf bytes.Buffer
|
||
enc := json.NewEncoder(&buf)
|
||
enc.SetEscapeHTML(false)
|
||
enc.SetIndent("", " ")
|
||
enc.Encode(parsed)
|
||
prettyJSON = strings.TrimRight(buf.String(), "\n")
|
||
}
|
||
}
|
||
result := gin.H{
|
||
"columns": []string{},
|
||
"rows": []interface{}{},
|
||
"total": 0,
|
||
"raw_value": prettyJSON != "",
|
||
"raw_key": keyName,
|
||
"raw_text": rawValue,
|
||
}
|
||
for k, v := range keyMeta {
|
||
result[k] = v
|
||
}
|
||
return result, nil
|
||
}
|
||
}
|
||
|
||
response := gin.H{
|
||
"columns": columns,
|
||
"rows": rowsOrDefault(rows),
|
||
"total": len(rows),
|
||
"count": len(rows),
|
||
}
|
||
for k, v := range keyMeta {
|
||
response[k] = v
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
// executeMongoQuery — MongoDB query with pagination
|
||
// Supports: db.collection.find({...}), db.collection.aggregate([...])
|
||
// Input format: db.collection.find({query}) or JSON array of pipeline stages
|
||
func executeMongoQuery(instance *model.Instance, dbName string, queryStr string, limit int, offset int) (gin.H, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
// Build connection URI
|
||
uri := fmt.Sprintf("mongodb://%s:%d", instance.Host, instance.Port)
|
||
if instance.Password != "" {
|
||
pwd := instance.Password
|
||
if strings.HasPrefix(pwd, "enc:") {
|
||
if dec, err := decryptPassword(strings.TrimPrefix(pwd, "enc:")); err == nil {
|
||
pwd = dec
|
||
}
|
||
}
|
||
user := instance.Username
|
||
if user == "" {
|
||
user = "root"
|
||
}
|
||
uri = fmt.Sprintf("mongodb://%s:%s@%s:%d", user, pwd, instance.Host, instance.Port)
|
||
}
|
||
|
||
client, err := mongo.Connect(options.Client().ApplyURI(uri))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("MongoDB connection failed: %v", err)
|
||
}
|
||
defer client.Disconnect(ctx)
|
||
|
||
if err := client.Ping(ctx, readpref.Primary()); err != nil {
|
||
return nil, fmt.Errorf("MongoDB ping failed: %v", err)
|
||
}
|
||
|
||
// Parse query: "db.collection.find({...})" or raw JSON
|
||
collectionName, filter, pipeline, err := parseMongoQuery(queryStr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if dbName == "" {
|
||
dbName = "test"
|
||
}
|
||
coll := client.Database(dbName).Collection(collectionName)
|
||
|
||
var total int64
|
||
var docs []bson.M
|
||
|
||
if pipeline != nil {
|
||
// Aggregation — add $facet for count + data
|
||
facetStage := bson.D{
|
||
{Key: "$facet", Value: bson.D{
|
||
{Key: "data", Value: bson.A{
|
||
bson.D{{Key: "$skip", Value: offset}},
|
||
bson.D{{Key: "$limit", Value: limit}},
|
||
}},
|
||
{Key: "count", Value: bson.A{
|
||
bson.D{{Key: "$count", Value: "total"}},
|
||
}},
|
||
}},
|
||
}
|
||
fullPipeline := append(pipeline, facetStage)
|
||
var results []bson.M
|
||
cursor, err := coll.Aggregate(ctx, fullPipeline)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("aggregation failed: %v", err)
|
||
}
|
||
defer cursor.Close(ctx)
|
||
cursor.All(ctx, &results)
|
||
|
||
if len(results) > 0 {
|
||
if dataArr, ok := results[0]["data"].(bson.A); ok {
|
||
for _, d := range dataArr {
|
||
if m, ok := d.(bson.M); ok {
|
||
docs = append(docs, m)
|
||
}
|
||
}
|
||
}
|
||
if countArr, ok := results[0]["count"].(bson.A); ok && len(countArr) > 0 {
|
||
if m, ok := countArr[0].(bson.M); ok {
|
||
if t, ok := m["total"].(int32); ok {
|
||
total = int64(t)
|
||
} else if t, ok := m["total"].(int64); ok {
|
||
total = t
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Find
|
||
total, _ = coll.CountDocuments(ctx, filter)
|
||
opts := options.Find().SetSkip(int64(offset)).SetLimit(int64(limit))
|
||
cursor, err := coll.Find(ctx, filter, opts)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("find failed: %v", err)
|
||
}
|
||
defer cursor.Close(ctx)
|
||
cursor.All(ctx, &docs)
|
||
}
|
||
|
||
// Convert bson.M to flat maps for table display
|
||
columnSet := make(map[string]bool)
|
||
rowMaps := make([]interface{}, 0, len(docs))
|
||
for _, doc := range docs {
|
||
flat := flattenBSON(doc, "")
|
||
for k := range flat {
|
||
columnSet[k] = true
|
||
}
|
||
rowMaps = append(rowMaps, flat)
|
||
}
|
||
|
||
// Stable column order
|
||
columns := make([]string, 0, len(columnSet))
|
||
if _, hasID := columnSet["_id"]; hasID {
|
||
columns = append(columns, "_id")
|
||
}
|
||
for k := range columnSet {
|
||
if k != "_id" {
|
||
columns = append(columns, k)
|
||
}
|
||
}
|
||
|
||
return gin.H{
|
||
"columns": columns,
|
||
"rows": rowMaps,
|
||
"total": int(total),
|
||
"count": len(rowMaps),
|
||
}, nil
|
||
}
|
||
|
||
// parseMongoQuery parses "db.collection.find({...})" or "db.collection.aggregate([...])" or raw JSON
|
||
func parseMongoQuery(queryStr string) (collection string, filter bson.M, pipeline bson.A, err error) {
|
||
queryStr = strings.TrimSpace(queryStr)
|
||
|
||
// Pattern: db.collection.find({...})
|
||
if re := regexpFromPattern(`db\.(\w+)\.find\((.+)?\)`); re != nil {
|
||
matches := re.FindStringSubmatch(queryStr)
|
||
if len(matches) >= 2 {
|
||
collection = matches[1]
|
||
if len(matches) >= 3 && strings.TrimSpace(matches[2]) != "" {
|
||
if e := json.Unmarshal([]byte(matches[2]), &filter); e != nil {
|
||
filter = bson.M{}
|
||
}
|
||
} else {
|
||
filter = bson.M{}
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Pattern: db.collection.aggregate([...])
|
||
if re := regexpFromPattern(`db\.(\w+)\.aggregate\((.+)\)`); re != nil {
|
||
matches := re.FindStringSubmatch(queryStr)
|
||
if len(matches) >= 3 {
|
||
collection = matches[1]
|
||
if e := json.Unmarshal([]byte(matches[2]), &pipeline); e != nil {
|
||
err = fmt.Errorf("invalid aggregation pipeline: %v", e)
|
||
return
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
// Fallback: raw JSON object treated as find on all collections? No — require db.xxx format
|
||
err = fmt.Errorf("query format not supported. Use: db.collection.find({}) or db.collection.aggregate([...])")
|
||
return
|
||
}
|
||
|
||
func regexpFromPattern(pattern string) *regexp.Regexp {
|
||
// Import regexp lazily — actually let's just import at top
|
||
// This is a simple helper
|
||
return regexp.MustCompile(pattern)
|
||
}
|
||
|
||
func flattenBSON(doc bson.M, prefix string) map[string]interface{} {
|
||
result := make(map[string]interface{})
|
||
for k, v := range doc {
|
||
fullKey := k
|
||
if prefix != "" {
|
||
fullKey = prefix + "." + k
|
||
}
|
||
switch child := v.(type) {
|
||
case bson.M:
|
||
for ck, cv := range flattenBSON(child, fullKey) {
|
||
result[ck] = cv
|
||
}
|
||
case bson.A:
|
||
// Serialize arrays as JSON string
|
||
b, _ := json.Marshal(child)
|
||
result[fullKey] = string(b)
|
||
default:
|
||
// Convert ObjectID to string
|
||
s := fmt.Sprintf("%v", v)
|
||
result[fullKey] = s
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// POST /api/v1/query/export — Streaming CSV export for large datasets
|
||
func ExportQueryCSV(c *gin.Context) {
|
||
var input struct {
|
||
InstanceID int `json:"instance_id"`
|
||
Database string `json:"database"`
|
||
SQL string `json:"sql"`
|
||
Limit int `json:"limit"`
|
||
}
|
||
if err := c.ShouldBindJSON(&input); err != nil {
|
||
resp.BadRequest(c, err.Error())
|
||
return
|
||
}
|
||
if input.InstanceID == 0 || input.SQL == "" {
|
||
resp.BadRequest(c, "instance_id and sql are required")
|
||
return
|
||
}
|
||
if input.Limit <= 0 {
|
||
input.Limit = 10000
|
||
}
|
||
if input.Limit > 50000 {
|
||
input.Limit = 50000
|
||
}
|
||
|
||
var instance model.Instance
|
||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||
resp.NotFound(c, "Instance not found")
|
||
return
|
||
}
|
||
|
||
// Stream CSV response headers
|
||
filename := fmt.Sprintf("query_export_%s.csv", time.Now().Format("20060102_150405"))
|
||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||
c.Writer.Write([]byte("\xEF\xBB\xBF")) // BOM for Excel
|
||
|
||
switch instance.DBType {
|
||
case "mysql", "postgresql", "postgres", "pg":
|
||
exportSQLCSV(c, &instance, input.Database, input.SQL, input.Limit)
|
||
case "redis":
|
||
exportRedisCSV(c, &instance, input.Database, input.SQL, input.Limit)
|
||
case "mongodb", "mongo":
|
||
exportMongoCSV(c, &instance, input.Database, input.SQL, input.Limit)
|
||
default:
|
||
resp.BadRequest(c, "CSV export not supported for "+instance.DBType)
|
||
}
|
||
}
|
||
|
||
func exportSQLCSV(c *gin.Context, instance *model.Instance, dbName string, sqlStr string, limit int) {
|
||
db, err := getDBConnection(instance, dbName)
|
||
if err != nil {
|
||
c.Writer.Write([]byte("Error: connection failed\n"))
|
||
return
|
||
}
|
||
defer db.Close()
|
||
|
||
// 同 executeSQLQuery:结尾分号会让 " LIMIT n" 变成第二条语句
|
||
sqlStr = normalizeSQLForExec(sqlStr)
|
||
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
|
||
if !strings.Contains(upperSQL, " LIMIT ") {
|
||
sqlStr = fmt.Sprintf("%s LIMIT %d", sqlStr, limit)
|
||
}
|
||
|
||
rows, err := db.Query(sqlStr)
|
||
if err != nil {
|
||
c.Writer.Write([]byte("Error: query failed\n"))
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
cols, _ := rows.Columns()
|
||
c.Writer.Write([]byte(strings.Join(cols, ",") + "\n"))
|
||
|
||
batchSize := 500
|
||
batch := make([]string, 0, batchSize)
|
||
rowCount := 0
|
||
|
||
for rows.Next() {
|
||
values := make([]interface{}, len(cols))
|
||
ptrValues := make([]interface{}, len(cols))
|
||
for i := range values {
|
||
ptrValues[i] = &values[i]
|
||
}
|
||
rows.Scan(ptrValues...)
|
||
|
||
cells := make([]string, len(cols))
|
||
for i, val := range values {
|
||
s := ""
|
||
if b, ok := val.([]byte); ok {
|
||
s = string(b)
|
||
} else if val != nil {
|
||
s = fmt.Sprintf("%v", val)
|
||
}
|
||
if strings.ContainsAny(s, ",\"\n\r") {
|
||
s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||
}
|
||
cells[i] = s
|
||
}
|
||
batch = append(batch, strings.Join(cells, ","))
|
||
rowCount++
|
||
|
||
if len(batch) >= batchSize {
|
||
c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n"))
|
||
c.Writer.(http.Flusher).Flush()
|
||
batch = batch[:0]
|
||
}
|
||
}
|
||
if len(batch) > 0 {
|
||
c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n"))
|
||
}
|
||
c.Writer.(http.Flusher).Flush()
|
||
saveQueryHistory(0, int(instance.ID), dbName, sqlStr, 0, rowCount)
|
||
}
|
||
|
||
func exportRedisCSV(c *gin.Context, instance *model.Instance, dbSelect string, command string, limit int) {
|
||
result, err := executeRedisQuery(instance, dbSelect, command, limit, 0)
|
||
if err != nil {
|
||
c.Writer.Write([]byte("Error: " + err.Error() + "\n"))
|
||
return
|
||
}
|
||
|
||
columns, _ := result["columns"].([]string)
|
||
rows, _ := result["rows"].([]interface{})
|
||
|
||
c.Writer.Write([]byte(strings.Join(columns, ",") + "\n"))
|
||
|
||
for _, row := range rows {
|
||
if m, ok := row.(map[string]interface{}); ok {
|
||
cells := make([]string, len(columns))
|
||
for i, col := range columns {
|
||
s := fmt.Sprintf("%v", m[col])
|
||
if strings.ContainsAny(s, ",\"\n\r") {
|
||
s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||
}
|
||
cells[i] = s
|
||
}
|
||
c.Writer.Write([]byte(strings.Join(cells, ",") + "\n"))
|
||
}
|
||
}
|
||
c.Writer.(http.Flusher).Flush()
|
||
rowCount := len(rows)
|
||
saveQueryHistory(0, int(instance.ID), dbSelect, command, 0, rowCount)
|
||
}
|
||
|
||
func exportMongoCSV(c *gin.Context, instance *model.Instance, dbName string, queryStr string, limit int) {
|
||
result, err := executeMongoQuery(instance, dbName, queryStr, limit, 0)
|
||
if err != nil {
|
||
c.Writer.Write([]byte("Error: " + err.Error() + "\n"))
|
||
return
|
||
}
|
||
|
||
columns, _ := result["columns"].([]string)
|
||
rows, _ := result["rows"].([]interface{})
|
||
|
||
c.Writer.Write([]byte(strings.Join(columns, ",") + "\n"))
|
||
|
||
for _, row := range rows {
|
||
if m, ok := row.(map[string]interface{}); ok {
|
||
cells := make([]string, len(columns))
|
||
for i, col := range columns {
|
||
s := fmt.Sprintf("%v", m[col])
|
||
if strings.ContainsAny(s, ",\"\n\r") {
|
||
s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||
}
|
||
cells[i] = s
|
||
}
|
||
c.Writer.Write([]byte(strings.Join(cells, ",") + "\n"))
|
||
}
|
||
}
|
||
c.Writer.(http.Flusher).Flush()
|
||
rowCount := len(rows)
|
||
saveQueryHistory(0, int(instance.ID), dbName, queryStr, 0, rowCount)
|
||
}
|
||
|
||
func saveQueryHistory(userID int, instanceID int, database string, sqlStr string, costMs int64, rowCount int) {
|
||
var user model.User
|
||
var inst model.Instance
|
||
username := ""
|
||
instName := ""
|
||
dbType := ""
|
||
if model.DB.First(&user, userID).Error == nil {
|
||
username = user.Username
|
||
}
|
||
if model.DB.First(&inst, instanceID).Error == nil {
|
||
instName = inst.Name
|
||
dbType = inst.DBType
|
||
}
|
||
|
||
queryType := "SELECT"
|
||
upper := strings.ToUpper(strings.TrimSpace(sqlStr))
|
||
if strings.HasPrefix(upper, "SHOW") || strings.HasPrefix(upper, "DESCRIBE") || strings.HasPrefix(upper, "EXPLAIN") {
|
||
queryType = strings.SplitN(upper, " ", 2)[0]
|
||
}
|
||
|
||
history := model.QueryHistory{
|
||
UserID: userID,
|
||
Username: username,
|
||
InstanceID: instanceID,
|
||
InstanceName: instName,
|
||
DBType: dbType,
|
||
DatabaseName: database,
|
||
QueryContent: sqlStr,
|
||
QueryType: queryType,
|
||
ExecutionTimeMs: int(costMs),
|
||
ResultRows: rowCount,
|
||
IsSuccess: true,
|
||
}
|
||
model.DB.Create(&history)
|
||
}
|
||
|
||
func saveQueryAuditLog(c *gin.Context, userID int, username string, instance *model.Instance, database string, sqlStr string, costMs int64, rowCount int, status string, errMsg string, isRisky bool) {
|
||
targetID := instance.ID
|
||
cost := int(costMs)
|
||
rows := rowCount
|
||
action := "query"
|
||
if status != "success" {
|
||
action = "query_failed"
|
||
}
|
||
|
||
detail := fmt.Sprintf("数据查询: %s/%s, 返回 %d 行, 耗时 %dms", instance.Name, database, rowCount, costMs)
|
||
if errMsg != "" {
|
||
detail = fmt.Sprintf("数据查询失败: %s/%s, %s", instance.Name, database, errMsg)
|
||
}
|
||
|
||
auditLog := model.AuditLog{
|
||
UserID: userID,
|
||
Username: &username,
|
||
Action: action,
|
||
TargetType: "query",
|
||
TargetID: &targetID,
|
||
Detail: &detail,
|
||
IPAddress: auditStringPtr(c.ClientIP()),
|
||
DBType: &instance.DBType,
|
||
DatabaseName: &database,
|
||
InstanceName: &instance.Name,
|
||
ExecutionTimeMs: &cost,
|
||
RowsAffected: &rows,
|
||
ResultStatus: &status,
|
||
QueryContent: &sqlStr,
|
||
UserAgent: auditStringPtr(c.GetHeader("User-Agent")),
|
||
IsRisky: &isRisky,
|
||
}
|
||
model.DB.Create(&auditLog)
|
||
}
|
||
|
||
func auditStringPtr(value string) *string {
|
||
return &value
|
||
}
|
||
|
||
// applyDataMasking masks sensitive fields in query results with "*".
|
||
// It reads global settings (data_masking_enabled + sensitive fields) and
|
||
// merges with instance-level mask_fields.
|
||
func applyDataMasking(result gin.H, instance *model.Instance) {
|
||
// Check global enable flag
|
||
var enabledSetting model.SystemSetting
|
||
if model.DB.Where("`key` = ?", "data_masking_enabled").First(&enabledSetting).Error != nil {
|
||
return // setting not found, skip masking
|
||
}
|
||
if enabledSetting.Value != "true" {
|
||
return // masking disabled
|
||
}
|
||
|
||
// Collect global sensitive fields
|
||
var fieldsSetting model.SystemSetting
|
||
globalFields := map[string]bool{}
|
||
if model.DB.Where("`key` = ?", "data_masking_sensitive_fields").First(&fieldsSetting).Error == nil {
|
||
for _, f := range strings.Split(fieldsSetting.Value, ",") {
|
||
f = strings.TrimSpace(f)
|
||
if f != "" {
|
||
globalFields[strings.ToLower(f)] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Merge instance-level mask_fields
|
||
instanceFields := map[string]bool{}
|
||
if instance.MaskFields != nil && *instance.MaskFields != "" {
|
||
for _, f := range strings.Split(*instance.MaskFields, ",") {
|
||
f = strings.TrimSpace(f)
|
||
if f != "" {
|
||
instanceFields[strings.ToLower(f)] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Combine all mask fields
|
||
allMaskFields := make(map[string]bool)
|
||
for k := range globalFields {
|
||
allMaskFields[k] = true
|
||
}
|
||
for k := range instanceFields {
|
||
allMaskFields[k] = true
|
||
}
|
||
|
||
if len(allMaskFields) == 0 {
|
||
return
|
||
}
|
||
|
||
// Apply masking to rows and collect masked column names
|
||
rows, ok := result["rows"].([]interface{})
|
||
if !ok {
|
||
return
|
||
}
|
||
maskedCols := []string{}
|
||
for _, rowIface := range rows {
|
||
row, ok := rowIface.(map[string]interface{})
|
||
if !ok {
|
||
continue
|
||
}
|
||
for colName, val := range row {
|
||
if allMaskFields[strings.ToLower(colName)] {
|
||
row[colName] = maskValue(val)
|
||
// Track masked column (deduplicate)
|
||
found := false
|
||
for _, mc := range maskedCols {
|
||
if mc == colName {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
maskedCols = append(maskedCols, colName)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if len(maskedCols) > 0 {
|
||
result["masked_columns"] = maskedCols
|
||
}
|
||
}
|
||
|
||
// maskValue replaces a value with asterisks, preserving length info for strings
|
||
func maskValue(val interface{}) string {
|
||
if val == nil {
|
||
return "***"
|
||
}
|
||
s := fmt.Sprintf("%v", val)
|
||
if len(s) == 0 {
|
||
return "*"
|
||
}
|
||
// Replace all chars with *, keep reasonable length
|
||
if len(s) > 20 {
|
||
return "********************"
|
||
}
|
||
return strings.Repeat("*", len(s))
|
||
}
|