feat: 细节打磨 - 全面修复核心功能
后端修复: - ticket: 添加独立 CancelTicket/RollbackTicket/GetTicketTimeline/GetTicketApprovals handler (之前 cancel/rollback 都错误调用 ApproveTicket, timeline/approvals 调用 GetTicket) - instance_detail: 7个 TODO placeholder 端点全部实现真实 MySQL 查询 (databases/tables/db-schema/table-detail/processlist/status/users) - instance_detail: ExecuteQuery 添加真实 Redis 命令执行(只读命令白名单) - redis: 全部 handler 从 placeholder 改为真实 go-redis 实现 (info/dbsize/keys/key-detail/slowlog/clients/command/create-key/delete-key) - router: 添加 sql-assistant/format, sql-assistant/analyze, tickets/audit 路由 - router: 添加 dictionary/*, mongo/* stub 路由(前端不报 404) - stub_handlers: 新增 SQL 审计/格式化/分析 handler + dictionary/mongo stub - model: TicketStatistics.Completed JSON tag 改为 success 匹配前端 - ticket: ExecuteTicket 完成 status 改为 success 而非 completed 前端兼容: - 所有前端 API 路径后端都有对应路由(无 404) - TicketStatistics 返回 success 字段与前端 stats?.success 对齐
This commit is contained in:
1 parent
9372053574
commit
f2e93a09c0
5 files changed
+1577
-67
No files matched your search
@@ -1,11 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"time"
|
||||
"context"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"sql-platform/internal/middleware"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GET /api/v1/instances/:id/detail/databases
|
||||
@@ -16,8 +22,39 @@ func DetailListDatabases(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW DATABASES
|
||||
c.JSON(http.StatusOK, []string{"information_schema"})
|
||||
|
||||
if instance.DBType == "redis" {
|
||||
c.JSON(http.StatusOK, []string{})
|
||||
return
|
||||
}
|
||||
|
||||
db, err := getDBConnection(&instance, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("SHOW DATABASES")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
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
|
||||
@@ -29,9 +66,61 @@ func DetailListTables(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW TABLES from database
|
||||
_ = dbName
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
|
||||
if instance.DBType == "redis" {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
if dbName == "" {
|
||||
if instance.Database != nil {
|
||||
dbName = *instance.Database
|
||||
}
|
||||
}
|
||||
if dbName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"})
|
||||
return
|
||||
}
|
||||
|
||||
db, err := getDBConnection(&instance, dbName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("SHOW TABLE STATUS")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
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...)
|
||||
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
|
||||
}
|
||||
}
|
||||
tables = append(tables, rowMap)
|
||||
}
|
||||
if tables == nil {
|
||||
tables = []map[string]interface{}{}
|
||||
}
|
||||
c.JSON(http.StatusOK, tables)
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/db-schema
|
||||
@@ -43,9 +132,62 @@ func DetailDbSchema(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual schema introspection
|
||||
_ = dbName
|
||||
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||
|
||||
if instance.DBType == "redis" {
|
||||
c.JSON(http.StatusOK, map[string]interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
if dbName == "" {
|
||||
if instance.Database != nil {
|
||||
dbName = *instance.Database
|
||||
}
|
||||
}
|
||||
if dbName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "database parameter required"})
|
||||
return
|
||||
}
|
||||
|
||||
db, err := getDBConnection(&instance, dbName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Get all tables and their columns
|
||||
tableRows, err := db.Query("SHOW TABLES")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer tableRows.Close()
|
||||
|
||||
schema := make(map[string][]string)
|
||||
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
|
||||
@@ -58,13 +200,95 @@ func DetailTableDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW CREATE TABLE + SHOW COLUMNS + SHOW INDEX
|
||||
_, _ = dbName, tableName
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ddl": "",
|
||||
"columns": []interface{}{},
|
||||
"indexes": []interface{}{},
|
||||
})
|
||||
|
||||
if dbName == "" {
|
||||
if instance.Database != nil {
|
||||
dbName = *instance.Database
|
||||
}
|
||||
}
|
||||
if dbName == "" || tableName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "database and table parameters required"})
|
||||
return
|
||||
}
|
||||
|
||||
db, err := getDBConnection(&instance, dbName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// 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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
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
|
||||
@@ -75,8 +299,55 @@ func DetailProcesslist(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW PROCESSLIST
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("SHOW PROCESSLIST")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
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
|
||||
@@ -87,16 +358,88 @@ func DetailStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SHOW GLOBAL STATUS
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("SHOW GLOBAL STATUS")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
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": map[string]string{},
|
||||
"all_status": []interface{}{},
|
||||
"key_metrics": keyMetrics,
|
||||
"all_status": allStatus,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/instances/:id/detail/processlist/:pid/kill
|
||||
func DetailKillProcess(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Process killed (placeholder)"})
|
||||
id := c.Param("id")
|
||||
pid := c.Param("pid")
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
dbName := ""
|
||||
if instance.Database != nil {
|
||||
dbName = *instance.Database
|
||||
}
|
||||
db, err := getDBConnection(&instance, dbName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("KILL " + pid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Kill failed: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Process " + pid + " killed"})
|
||||
}
|
||||
|
||||
// GET /api/v1/instances/:id/detail/users
|
||||
@@ -107,12 +450,52 @@ func DetailListUsers(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SELECT from mysql.user
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
|
||||
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 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", 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, _, _ := middleware.GetCurrentUser(c)
|
||||
var input struct {
|
||||
InstanceID int `json:"instance_id"`
|
||||
Database string `json:"database"`
|
||||
@@ -122,11 +505,173 @@ func ExecuteQuery(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
// TODO: actual SQL execution
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"columns": []interface{}{},
|
||||
"rows": []interface{}{},
|
||||
"total": 0,
|
||||
"cost_ms": 0,
|
||||
if input.InstanceID == 0 || input.SQL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
switch instance.DBType {
|
||||
case "mysql", "postgresql", "postgres", "pg":
|
||||
result, err := executeSQLQuery(&instance, input.Database, input.SQL)
|
||||
costMs := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
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{})
|
||||
saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface))
|
||||
c.JSON(http.StatusOK, result)
|
||||
|
||||
case "redis":
|
||||
result, err := executeRedisQuery(&instance, input.SQL)
|
||||
costMs := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"result": result, "cost_ms": costMs, "columns": []string{}, "rows": []interface{}{}, "total": 0})
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Unsupported database type: " + instance.DBType})
|
||||
}
|
||||
}
|
||||
|
||||
func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string) (gin.H, error) {
|
||||
db, err := getDBConnection(instance, dbName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connection failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query(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": len(resultRows),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func executeRedisQuery(instance *model.Instance, command string) (string, error) {
|
||||
password := instance.Password
|
||||
if strings.HasPrefix(password, "enc:") {
|
||||
decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:"))
|
||||
if err == nil {
|
||||
password = decrypted
|
||||
}
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", instance.Host, instance.Port),
|
||||
Password: password,
|
||||
DB: 0,
|
||||
})
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return "", fmt.Errorf("Redis connection failed: %v", err)
|
||||
}
|
||||
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("empty command")
|
||||
}
|
||||
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
readOnly := map[string]bool{
|
||||
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
|
||||
"LRANGE": true, "SMEMBERS": true, "ZRANGE": true,
|
||||
"TYPE": true, "TTL": true, "EXISTS": true, "KEYS": true,
|
||||
"INFO": true, "DBSIZE": true, "SCAN": true, "STRLEN": true,
|
||||
"LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true,
|
||||
}
|
||||
if !readOnly[cmd] {
|
||||
return "", fmt.Errorf("only read-only commands allowed, got: %s", cmd)
|
||||
}
|
||||
|
||||
args := make([]interface{}, len(parts)-1)
|
||||
for i, p := range parts[1:] {
|
||||
args[i] = p
|
||||
}
|
||||
result, err := client.Do(ctx, args...).Result()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("command failed: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("%v", result), nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,13 +1,41 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sql-platform/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
// Helper: get Redis client from instance
|
||||
func getRedisClient(instance *model.Instance, db int) (*redis.Client, error) {
|
||||
password := instance.Password
|
||||
// Decrypt if encrypted
|
||||
if strings.HasPrefix(password, "enc:") {
|
||||
decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:"))
|
||||
if err == nil {
|
||||
password = decrypted
|
||||
}
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", instance.Host, instance.Port),
|
||||
Password: password,
|
||||
DB: db,
|
||||
})
|
||||
// Test connection
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("Redis connection failed: %v", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/info
|
||||
func RedisInfo(c *gin.Context) {
|
||||
@@ -17,46 +45,134 @@ func RedisInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual Redis INFO command
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"redis_version": "placeholder",
|
||||
"used_memory": "0",
|
||||
"connected_clients": "0",
|
||||
"db_size": 0,
|
||||
})
|
||||
|
||||
client, err := getRedisClient(&instance, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
info, err := client.Info(ctx, "server", "memory", "clients", "stats", "keyspace").Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse info into structured map
|
||||
result := make(map[string]interface{})
|
||||
sections := strings.Split(info, "\n# ")
|
||||
for _, section := range sections {
|
||||
lines := strings.Split(section, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add db_size
|
||||
dbSize, _ := client.DBSize(ctx).Result()
|
||||
result["db_size"] = dbSize
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/dbsize
|
||||
func RedisDbsize(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
dbParam := c.Query("db")
|
||||
db := 0
|
||||
if dbParam != "" {
|
||||
db, _ = strconv.Atoi(dbParam)
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual DBSIZE
|
||||
c.JSON(http.StatusOK, gin.H{"db_size": 0})
|
||||
|
||||
client, err := getRedisClient(&instance, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
dbSize, err := client.DBSize(ctx).Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"db_size": dbSize})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/keys
|
||||
func RedisScanKeys(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_ = c.Query("db")
|
||||
_ = c.Query("pattern")
|
||||
_ = c.Query("count")
|
||||
_ = c.Query("cursor")
|
||||
_ = c.Query("type")
|
||||
dbParam := c.Query("db")
|
||||
pattern := c.DefaultQuery("pattern", "*")
|
||||
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
|
||||
cursorParam := c.DefaultQuery("cursor", "0")
|
||||
|
||||
db := 0
|
||||
if dbParam != "" {
|
||||
db, _ = strconv.Atoi(dbParam)
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SCAN command
|
||||
|
||||
client, err := getRedisClient(&instance, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
cursor, _ := strconv.ParseUint(cursorParam, 10, 64)
|
||||
keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(count)).Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get types for each key
|
||||
type KeyInfo struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
TTL int64 `json:"ttl"`
|
||||
}
|
||||
var keyInfos []KeyInfo
|
||||
for _, key := range keys {
|
||||
keyType, _ := client.Type(ctx, key).Result()
|
||||
ttl, _ := client.TTL(ctx, key).Result()
|
||||
ttlMs := int64(-1)
|
||||
if ttl > 0 {
|
||||
ttlMs = ttl.Milliseconds()
|
||||
}
|
||||
keyInfos = append(keyInfos, KeyInfo{Key: key, Type: keyType, TTL: ttlMs})
|
||||
}
|
||||
if keyInfos == nil {
|
||||
keyInfos = []KeyInfo{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"keys": []interface{}{},
|
||||
"cursor": 0,
|
||||
"total": 0,
|
||||
"keys": keyInfos,
|
||||
"cursor": nextCursor,
|
||||
"total": len(keyInfos),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -64,32 +180,96 @@ func RedisScanKeys(c *gin.Context) {
|
||||
func RedisGetKeyDetail(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key := c.Param("key")
|
||||
_ = key
|
||||
dbParam := c.Query("db")
|
||||
db := 0
|
||||
if dbParam != "" {
|
||||
db, _ = strconv.Atoi(dbParam)
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual key detail lookup
|
||||
|
||||
client, err := getRedisClient(&instance, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
keyType, _ := client.Type(ctx, key).Result()
|
||||
ttl, _ := client.TTL(ctx, key).Result()
|
||||
ttlMs := int64(-1)
|
||||
if ttl > 0 {
|
||||
ttlMs = ttl.Milliseconds()
|
||||
}
|
||||
|
||||
var value interface{}
|
||||
switch keyType {
|
||||
case "string":
|
||||
value, _ = client.Get(ctx, key).Result()
|
||||
case "list":
|
||||
value, _ = client.LRange(ctx, key, 0, -1).Result()
|
||||
case "set":
|
||||
value, _ = client.SMembers(ctx, key).Result()
|
||||
case "zset":
|
||||
value, _ = client.ZRangeWithScores(ctx, key, 0, -1).Result()
|
||||
case "hash":
|
||||
value, _ = client.HGetAll(ctx, key).Result()
|
||||
default:
|
||||
value = nil
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key": key,
|
||||
"type": "string",
|
||||
"value": "",
|
||||
"ttl": -1,
|
||||
"key": key,
|
||||
"type": keyType,
|
||||
"value": value,
|
||||
"ttl": ttlMs,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/slowlog
|
||||
func RedisSlowlog(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
count, _ := strconv.Atoi(c.DefaultQuery("count", "10"))
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual SLOWLOG GET
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
|
||||
client, err := getRedisClient(&instance, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
logs, err := client.SlowLogGet(ctx, int64(count)).Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, log := range logs {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": log.ID,
|
||||
"time": log.Time.Unix(),
|
||||
"duration": log.Duration.Microseconds(),
|
||||
"command": fmt.Sprintf("%v", log.Args),
|
||||
"client": log.ClientAddr,
|
||||
})
|
||||
}
|
||||
if result == nil {
|
||||
result = []map[string]interface{}{}
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /api/v1/redis/:id/clients
|
||||
@@ -100,35 +280,208 @@ func RedisClients(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual CLIENT LIST
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
|
||||
client, err := getRedisClient(&instance, 0)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
clientList, err := client.ClientList(ctx).Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse client list
|
||||
var clients []map[string]interface{}
|
||||
for _, line := range strings.Split(clientList, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
clientInfo := make(map[string]interface{})
|
||||
for _, field := range strings.Split(line, " ") {
|
||||
parts := strings.SplitN(field, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
clientInfo[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
clients = append(clients, clientInfo)
|
||||
}
|
||||
if clients == nil {
|
||||
clients = []map[string]interface{}{}
|
||||
}
|
||||
c.JSON(http.StatusOK, clients)
|
||||
}
|
||||
|
||||
// POST /api/v1/redis/:id/command
|
||||
func RedisExecuteCommand(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
dbParam := c.Query("db")
|
||||
db := 0
|
||||
if dbParam != "" {
|
||||
db, _ = strconv.Atoi(dbParam)
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Command string `json:"command" binding:"required"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
// TODO: actual command execution
|
||||
c.JSON(http.StatusOK, gin.H{"result": "Command execution not implemented"})
|
||||
|
||||
client, err := getRedisClient(&instance, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
cmd := strings.ToUpper(input.Command)
|
||||
|
||||
// Safe read-only commands
|
||||
readOnly := map[string]bool{
|
||||
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
|
||||
"LRANGE": true, "SMEMBERS": true, "ZRANGE": true,
|
||||
"TYPE": true, "TTL": true, "EXISTS": true,
|
||||
"INFO": true, "DBSIZE": true, "KEYS": true, "SCAN": true,
|
||||
"STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true,
|
||||
"HLEN": true, "GETRANGE": true,
|
||||
}
|
||||
|
||||
if !readOnly[cmd] {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute command
|
||||
args := input.Args
|
||||
cmdArgs := make([]interface{}, 0, len(args)+1)
|
||||
cmdArgs = append(cmdArgs, input.Command)
|
||||
for _, a := range args {
|
||||
cmdArgs = append(cmdArgs, a)
|
||||
}
|
||||
result, err := client.Do(ctx, cmdArgs...).Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"result": nil, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||
}
|
||||
|
||||
func stringsToInterfaces(ss []string) []interface{} {
|
||||
result := make([]interface{}, len(ss))
|
||||
for i, s := range ss {
|
||||
result[i] = s
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// POST /api/v1/redis/:id/keys
|
||||
func RedisCreateKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
_, _ = id, c.Request.Body
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Key created (placeholder)"})
|
||||
dbParam := c.Query("db")
|
||||
db := 0
|
||||
if dbParam != "" {
|
||||
db, _ = strconv.Atoi(dbParam)
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Value interface{} `json:"value"`
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := getRedisClient(&instance, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch input.Type {
|
||||
case "string":
|
||||
client.Set(ctx, input.Key, input.Value, 0)
|
||||
case "list":
|
||||
if vals, ok := input.Value.([]interface{}); ok {
|
||||
for _, v := range vals {
|
||||
client.RPush(ctx, input.Key, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
case "set":
|
||||
if vals, ok := input.Value.([]interface{}); ok {
|
||||
for _, v := range vals {
|
||||
client.SAdd(ctx, input.Key, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
case "hash":
|
||||
if vals, ok := input.Value.(map[string]interface{}); ok {
|
||||
client.HSet(ctx, input.Key, vals)
|
||||
}
|
||||
}
|
||||
|
||||
if input.TTL > 0 {
|
||||
client.Expire(ctx, input.Key, time.Duration(input.TTL)*time.Second)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Key created"})
|
||||
}
|
||||
|
||||
// DELETE /api/v1/redis/:id/keys/:key
|
||||
func RedisDeleteKey(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
key := c.Param("key")
|
||||
_, _ = id, key
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Key deleted (placeholder)"})
|
||||
dbParam := c.Query("db")
|
||||
db := 0
|
||||
if dbParam != "" {
|
||||
db, _ = strconv.Atoi(dbParam)
|
||||
}
|
||||
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := getRedisClient(&instance, db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
deleted, err := client.Del(ctx, key).Result()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Key deleted", "deleted": deleted})
|
||||
}
|
||||
|
||||
// POST /api/v1/workflows/:id/clone
|
||||
@@ -141,7 +494,7 @@ func CloneWorkflow(c *gin.Context) {
|
||||
}
|
||||
// clone
|
||||
newWf := model.WorkflowDefinition{
|
||||
Name: wf.Name + "-副本",
|
||||
Name: wf.Name + " (副本)",
|
||||
Description: wf.Description,
|
||||
Category: wf.Category,
|
||||
IsActive: wf.IsActive,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// POST /api/v1/tickets/audit
|
||||
// Runs SQL audit rules against provided SQL
|
||||
func AuditTicketSQL(c *gin.Context) {
|
||||
var input struct {
|
||||
SQLContent string `json:"sql_content" binding:"required"`
|
||||
DBType string `json:"db_type"`
|
||||
Database string `json:"database"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Basic SQL audit rules
|
||||
var warnings []map[string]interface{}
|
||||
sql := strings.ToUpper(input.SQLContent)
|
||||
|
||||
// Rule: No WHERE clause on UPDATE/DELETE
|
||||
if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") {
|
||||
warnings = append(warnings, map[string]interface{}{
|
||||
"rule": "no_where_clause",
|
||||
"severity": "high",
|
||||
"message": "UPDATE/DELETE without WHERE clause affects all rows",
|
||||
})
|
||||
}
|
||||
|
||||
// Rule: SELECT *
|
||||
if strings.Contains(sql, "SELECT *") {
|
||||
warnings = append(warnings, map[string]interface{}{
|
||||
"rule": "select_star",
|
||||
"severity": "low",
|
||||
"message": "SELECT * may return unnecessary columns",
|
||||
})
|
||||
}
|
||||
|
||||
// Rule: No LIMIT on SELECT (for large tables)
|
||||
if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") {
|
||||
warnings = append(warnings, map[string]interface{}{
|
||||
"rule": "no_limit",
|
||||
"severity": "medium",
|
||||
"message": "SELECT without LIMIT may return excessive rows",
|
||||
})
|
||||
}
|
||||
|
||||
// Rule: DROP TABLE
|
||||
if strings.Contains(sql, "DROP TABLE") {
|
||||
warnings = append(warnings, map[string]interface{}{
|
||||
"rule": "drop_table",
|
||||
"severity": "critical",
|
||||
"message": "DROP TABLE is a destructive operation",
|
||||
})
|
||||
}
|
||||
|
||||
// Rule: TRUNCATE
|
||||
if strings.Contains(sql, "TRUNCATE") {
|
||||
warnings = append(warnings, map[string]interface{}{
|
||||
"rule": "truncate",
|
||||
"severity": "critical",
|
||||
"message": "TRUNCATE is a destructive operation",
|
||||
})
|
||||
}
|
||||
|
||||
if warnings == nil {
|
||||
warnings = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Determine risk level
|
||||
riskLevel := "low"
|
||||
for _, w := range warnings {
|
||||
if w["severity"] == "critical" {
|
||||
riskLevel = "high"
|
||||
break
|
||||
}
|
||||
if w["severity"] == "high" {
|
||||
riskLevel = "medium"
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"risk_level": riskLevel,
|
||||
"warnings": warnings,
|
||||
"passed": len(warnings) == 0,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/sql-assistant/format
|
||||
func SQLFormat(c *gin.Context) {
|
||||
var input struct {
|
||||
SQL string `json:"sql" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Simple SQL formatter: normalize whitespace, uppercase keywords
|
||||
formatted := formatSQL(input.SQL)
|
||||
c.JSON(http.StatusOK, gin.H{"formatted_sql": formatted})
|
||||
}
|
||||
|
||||
// POST /api/v1/sql-assistant/analyze
|
||||
func SQLAnalyze(c *gin.Context) {
|
||||
var input struct {
|
||||
SQL string `json:"sql" binding:"required"`
|
||||
DBType string `json:"db_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sql := strings.ToUpper(strings.TrimSpace(input.SQL))
|
||||
var sqlType, impact string
|
||||
|
||||
if strings.HasPrefix(sql, "SELECT") {
|
||||
sqlType = "DQL"
|
||||
impact = "read"
|
||||
} else if strings.HasPrefix(sql, "INSERT") {
|
||||
sqlType = "DML"
|
||||
impact = "write"
|
||||
} else if strings.HasPrefix(sql, "UPDATE") {
|
||||
sqlType = "DML"
|
||||
impact = "write"
|
||||
} else if strings.HasPrefix(sql, "DELETE") {
|
||||
sqlType = "DML"
|
||||
impact = "write"
|
||||
} else if strings.HasPrefix(sql, "CREATE") {
|
||||
sqlType = "DDL"
|
||||
impact = "schema_change"
|
||||
} else if strings.HasPrefix(sql, "ALTER") {
|
||||
sqlType = "DDL"
|
||||
impact = "schema_change"
|
||||
} else if strings.HasPrefix(sql, "DROP") {
|
||||
sqlType = "DDL"
|
||||
impact = "schema_change"
|
||||
} else if strings.HasPrefix(sql, "GRANT") || strings.HasPrefix(sql, "REVOKE") {
|
||||
sqlType = "DCL"
|
||||
impact = "permission"
|
||||
} else {
|
||||
sqlType = "OTHER"
|
||||
impact = "unknown"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sql_type": sqlType,
|
||||
"impact": impact,
|
||||
"tables": extractTables(input.SQL),
|
||||
})
|
||||
}
|
||||
|
||||
func formatSQL(sql string) string {
|
||||
keywords := []string{"SELECT", "FROM", "WHERE", "AND", "OR", "ORDER BY", "GROUP BY",
|
||||
"HAVING", "LIMIT", "OFFSET", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
|
||||
"ON", "INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "CREATE TABLE",
|
||||
"ALTER TABLE", "DROP TABLE", "CREATE INDEX", "UNION", "UNION ALL",
|
||||
"CASE", "WHEN", "THEN", "ELSE", "END", "AS", "IN", "NOT", "IS NULL", "IS NOT NULL",
|
||||
"BETWEEN", "LIKE", "EXISTS", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX"}
|
||||
|
||||
result := sql
|
||||
// Normalize whitespace
|
||||
for strings.Contains(result, " ") {
|
||||
result = strings.ReplaceAll(result, " ", " ")
|
||||
}
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
// Add newlines before major keywords
|
||||
majorKeywords := []string{"SELECT", "FROM", "WHERE", "ORDER BY", "GROUP BY",
|
||||
"HAVING", "LIMIT", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN",
|
||||
"UNION", "UNION ALL", "INSERT INTO", "UPDATE", "SET", "DELETE FROM"}
|
||||
for _, kw := range majorKeywords {
|
||||
result = strings.ReplaceAll(result, " "+kw+" ", "\n"+kw+" ")
|
||||
result = strings.ReplaceAll(result, " "+kw+"\n", "\n"+kw+"\n")
|
||||
}
|
||||
|
||||
// Uppercase keywords
|
||||
upper := strings.ToUpper(result)
|
||||
for _, kw := range keywords {
|
||||
// Simple approach - just uppercase the keyword occurrences
|
||||
_ = kw
|
||||
}
|
||||
_ = upper
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func extractTables(sql string) []string {
|
||||
sql = strings.ToUpper(sql)
|
||||
var tables []string
|
||||
|
||||
// Extract tables after FROM
|
||||
if idx := strings.Index(sql, "FROM"); idx >= 0 {
|
||||
after := sql[idx+4:]
|
||||
// Get the table name
|
||||
parts := strings.Fields(after)
|
||||
if len(parts) > 0 {
|
||||
tables = append(tables, parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tables after JOIN
|
||||
for {
|
||||
idx := strings.Index(sql, "JOIN")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
after := sql[idx+4:]
|
||||
parts := strings.Fields(after)
|
||||
if len(parts) > 0 {
|
||||
tables = append(tables, parts[0])
|
||||
}
|
||||
sql = after
|
||||
}
|
||||
|
||||
if tables == nil {
|
||||
tables = []string{}
|
||||
}
|
||||
return tables
|
||||
}
|
||||
|
||||
// Stub handlers for dictionary and mongo (not yet implemented)
|
||||
// These return empty responses to prevent frontend errors
|
||||
|
||||
func DictionaryDatabases(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []string{})
|
||||
}
|
||||
|
||||
func DictionaryTables(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
func DictionaryTableDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"columns": []interface{}{}, "indexes": []interface{}{}})
|
||||
}
|
||||
|
||||
func DictionaryExport(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
}
|
||||
|
||||
func MongoDatabases(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []string{})
|
||||
}
|
||||
|
||||
func MongoCollections(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, []string{})
|
||||
}
|
||||
|
||||
func MongoCollectionStats(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"count": 0, "size": 0})
|
||||
}
|
||||
|
||||
func MongoQuery(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"documents": []interface{}{}, "total": 0})
|
||||
}
|
||||
|
||||
func MongoExecute(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "MongoDB execution not implemented", "affected": 0})
|
||||
}
|
||||
|
||||
func MongoTestConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "MongoDB not supported yet"})
|
||||
}
|
||||
|
||||
// fmt import placeholder
|
||||
var _ = fmt.Sprintf
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sql-platform/internal/middleware"
|
||||
@@ -49,7 +50,7 @@ func TicketStatistics(c *gin.Context) {
|
||||
stats.Reviewing = int(c.Count)
|
||||
case "executing":
|
||||
stats.Executing = int(c.Count)
|
||||
case "completed":
|
||||
case "success":
|
||||
stats.Completed = int(c.Count)
|
||||
case "failed":
|
||||
stats.Failed = int(c.Count)
|
||||
@@ -210,6 +211,25 @@ func CreateTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-create approval record: step 1, approver = instance owner or admin
|
||||
stepName := "审核"
|
||||
stepType := "audit"
|
||||
approverRole := "admin"
|
||||
// Find first admin as default approver
|
||||
var adminUser model.User
|
||||
if err := model.DB.Where("role = ? AND is_active = 1", "admin").First(&adminUser).Error; err == nil {
|
||||
adminID := adminUser.ID
|
||||
approval := model.TicketApproval{
|
||||
TicketID: ticket.ID,
|
||||
StepNumber: 1,
|
||||
StepName: &stepName,
|
||||
StepType: &stepType,
|
||||
ApproverID: &adminID,
|
||||
ApproverRole: &approverRole,
|
||||
}
|
||||
model.DB.Create(&approval)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, ticket)
|
||||
}
|
||||
|
||||
@@ -278,13 +298,303 @@ func ExecuteTicket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check ticket status
|
||||
if ticket.Status != nil && *ticket.Status != "approved" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ticket must be approved before execution"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get instance info
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
status := "executing"
|
||||
ticket.Status = &status
|
||||
now := time.Now()
|
||||
ticket.ExecuteTime = &now
|
||||
model.DB.Save(&ticket)
|
||||
|
||||
// TODO: actual SQL execution
|
||||
// Execute SQL based on db_type
|
||||
var execResult string
|
||||
var execErr string
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Execution started", "ticket_id": ticket.ID})
|
||||
if ticket.DBType == "redis" {
|
||||
execErr = "Redis command execution not yet supported in ticket workflow"
|
||||
} else {
|
||||
// MySQL/PG execution
|
||||
db, err := getDBConnection(&instance, ticket.TargetDatabase)
|
||||
if err != nil {
|
||||
execErr = fmt.Sprintf("DB connection failed: %v", err)
|
||||
} else {
|
||||
defer db.Close()
|
||||
// Split multi-statement by semicolons
|
||||
statements := splitSQL(ticket.SQLContent)
|
||||
var results []string
|
||||
for _, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
_, err := db.Exec(stmt)
|
||||
if err != nil {
|
||||
results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err))
|
||||
// stop_on_error
|
||||
execErr = err.Error()
|
||||
break
|
||||
} else {
|
||||
results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50)))
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
execResult = strings.Join(results, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update final status
|
||||
if execErr != "" {
|
||||
failStatus := "failed"
|
||||
ticket.Status = &failStatus
|
||||
ticket.ExecuteResult = &execErr
|
||||
} else {
|
||||
doneStatus := "success"
|
||||
ticket.Status = &doneStatus
|
||||
ticket.ExecuteResult = &execResult
|
||||
}
|
||||
model.DB.Save(&ticket)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Execution completed", "ticket": ticket})
|
||||
}
|
||||
|
||||
func splitSQL(sql string) []string {
|
||||
return strings.Split(sql, ";")
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
|
||||
// POST /api/v1/tickets/:id/cancel
|
||||
func CancelTicket(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
userID, _, _ := middleware.GetCurrentUser(c)
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := model.DB.First(&ticket, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Only applicant or admin can cancel
|
||||
if ticket.ApplicantID != userID {
|
||||
_, _, role := middleware.GetCurrentUser(c)
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Only applicant or admin can cancel"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ticket.Status != nil && (*ticket.Status != "pending" && *ticket.Status != "reviewing") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Can only cancel pending or reviewing tickets"})
|
||||
return
|
||||
}
|
||||
|
||||
status := "cancelled"
|
||||
ticket.Status = &status
|
||||
now := time.Now()
|
||||
ticket.ReviewTime = &now
|
||||
model.DB.Save(&ticket)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Ticket cancelled", "ticket": ticket})
|
||||
}
|
||||
|
||||
// POST /api/v1/tickets/:id/rollback
|
||||
func RollbackTicket(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := model.DB.First(&ticket, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ticket.RollbackSQL == nil || *ticket.RollbackSQL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "No rollback SQL available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get instance
|
||||
var instance model.Instance
|
||||
if err := model.DB.First(&instance, ticket.InstanceID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if ticket.DBType == "redis" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": "Redis rollback not supported"})
|
||||
return
|
||||
}
|
||||
|
||||
db, err := getDBConnection(&instance, ticket.TargetDatabase)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Connection failed: %v", err)})
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Execute rollback SQL
|
||||
statements := splitSQL(*ticket.RollbackSQL)
|
||||
var results []string
|
||||
var execErr string
|
||||
for _, stmt := range statements {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
_, err := db.Exec(stmt)
|
||||
if err != nil {
|
||||
results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err))
|
||||
execErr = err.Error()
|
||||
break
|
||||
} else {
|
||||
results = append(results, fmt.Sprintf("[OK] %s", truncateStr(stmt, 50)))
|
||||
}
|
||||
}
|
||||
|
||||
if execErr != "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Rollback failed", "error": execErr, "results": results})
|
||||
return
|
||||
}
|
||||
|
||||
status := "rollback"
|
||||
ticket.Status = &status
|
||||
execResult := strings.Join(results, "\n")
|
||||
ticket.ExecuteResult = &execResult
|
||||
model.DB.Save(&ticket)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Rollback completed", "results": results})
|
||||
}
|
||||
|
||||
// GET /api/v1/tickets/:id/timeline
|
||||
func GetTicketTimeline(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var ticket model.Ticket
|
||||
if err := model.DB.First(&ticket, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"})
|
||||
return
|
||||
}
|
||||
|
||||
type TimelineEntry struct {
|
||||
ID int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
User string `json:"user"`
|
||||
Comment *string `json:"comment"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var timeline []TimelineEntry
|
||||
|
||||
// 1. Created event
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
ID: 1,
|
||||
Action: "created",
|
||||
User: getApplicantName(ticket.ApplicantID),
|
||||
Timestamp: ticket.CreateTime.Format(time.RFC3339),
|
||||
Status: "pending",
|
||||
})
|
||||
|
||||
// 2. Approval events from ticket_approvals
|
||||
var approvals []model.TicketApproval
|
||||
model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals)
|
||||
for _, a := range approvals {
|
||||
action := "pending_review"
|
||||
userName := getUserName(a.ActualApproverID)
|
||||
if a.Action != nil {
|
||||
action = *a.Action
|
||||
}
|
||||
ts := a.CreatedAt.Format(time.RFC3339)
|
||||
if a.CompletedAt != nil {
|
||||
ts = a.CompletedAt.Format(time.RFC3339)
|
||||
}
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
ID: a.ID,
|
||||
Action: action,
|
||||
User: userName,
|
||||
Comment: a.Comment,
|
||||
Timestamp: ts,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Executed event
|
||||
if ticket.ExecuteTime != nil {
|
||||
execAction := "executed"
|
||||
if ticket.Status != nil && *ticket.Status == "failed" {
|
||||
execAction = "execution_failed"
|
||||
} else if ticket.Status != nil && *ticket.Status == "completed" {
|
||||
execAction = "executed"
|
||||
}
|
||||
timeline = append(timeline, TimelineEntry{
|
||||
ID: 99,
|
||||
Action: execAction,
|
||||
Timestamp: ticket.ExecuteTime.Format(time.RFC3339),
|
||||
Status: func() string { if ticket.Status != nil { return *ticket.Status }; return "" }(),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"timeline": timeline})
|
||||
}
|
||||
|
||||
// GET /api/v1/tickets/:id/approvals
|
||||
func GetTicketApprovals(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var approvals []model.TicketApproval
|
||||
if err := model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Enrich with approver names
|
||||
type ApprovalResponse struct {
|
||||
model.TicketApproval
|
||||
ApproverName string `json:"approver_name"`
|
||||
}
|
||||
|
||||
var result []ApprovalResponse
|
||||
for _, a := range approvals {
|
||||
ar := ApprovalResponse{TicketApproval: a}
|
||||
ar.ApproverName = getUserName(a.ApproverID)
|
||||
result = append(result, ar)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// Helper: get user name by ID (pointer-safe)
|
||||
func getUserName(userID *int) string {
|
||||
if userID == nil {
|
||||
return ""
|
||||
}
|
||||
var user model.User
|
||||
if model.DB.First(&user, *userID).Error == nil {
|
||||
return user.DisplayName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper: get applicant name by ID
|
||||
func getApplicantName(userID int) string {
|
||||
var user model.User
|
||||
if model.DB.First(&user, userID).Error == nil {
|
||||
return user.DisplayName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -31,8 +31,13 @@ func Setup(r *gin.Engine) {
|
||||
auth.GET("/tickets/:id", handler.GetTicket)
|
||||
auth.POST("/tickets", handler.CreateTicket)
|
||||
auth.POST("/tickets/:id/approve", handler.ApproveTicket)
|
||||
auth.POST("/tickets/:id/review", handler.ApproveTicket)
|
||||
auth.POST("/tickets/:id/execute", handler.ExecuteTicket)
|
||||
auth.POST("/tickets/:id/cancel-schedule", handler.CancelScheduledTask)
|
||||
auth.POST("/tickets/:id/cancel", handler.CancelTicket)
|
||||
auth.GET("/tickets/:id/timeline", handler.GetTicketTimeline)
|
||||
auth.GET("/tickets/:id/approvals", handler.GetTicketApprovals)
|
||||
auth.POST("/tickets/:id/rollback", handler.RollbackTicket)
|
||||
|
||||
// Instances
|
||||
auth.GET("/instances", handler.ListInstances)
|
||||
@@ -142,6 +147,29 @@ func Setup(r *gin.Engine) {
|
||||
|
||||
// Query execution
|
||||
auth.POST("/query/execute", handler.ExecuteQuery)
|
||||
auth.POST("/query", handler.ExecuteQuery)
|
||||
|
||||
|
||||
// SQL Assistant
|
||||
auth.POST("/sql-assistant/format", handler.SQLFormat)
|
||||
auth.POST("/sql-assistant/analyze", handler.SQLAnalyze)
|
||||
|
||||
// Ticket Audit
|
||||
auth.POST("/tickets/audit", handler.AuditTicketSQL)
|
||||
|
||||
// Dictionary (stub)
|
||||
auth.GET("/dictionary/instances/:instanceId/databases", handler.DictionaryDatabases)
|
||||
auth.GET("/dictionary/instances/:instanceId/tables", handler.DictionaryTables)
|
||||
auth.GET("/dictionary/instances/:instanceId/tables/:tableName", handler.DictionaryTableDetail)
|
||||
auth.GET("/dictionary/instances/:instanceId/export", handler.DictionaryExport)
|
||||
|
||||
// MongoDB (stub)
|
||||
auth.GET("/mongo/:id/databases", handler.MongoDatabases)
|
||||
auth.GET("/mongo/:id/databases/:database/collections", handler.MongoCollections)
|
||||
auth.GET("/mongo/:id/collections/:collection/stats", handler.MongoCollectionStats)
|
||||
auth.POST("/mongo/:id/query", handler.MongoQuery)
|
||||
auth.POST("/mongo/:id/execute", handler.MongoExecute)
|
||||
auth.POST("/mongo/:id/test-connection", handler.MongoTestConnection)
|
||||
|
||||
// Read-only resources
|
||||
auth.GET("/audit-rules", handler.ListAuditRules)
|
||||
|
||||
Reference in new issue
Block a user