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:
logan committed 2026-05-26 20:40:45 +08:00
1 parent 9372053574
commit f2e93a09c0
5 files changed
+1577 -67

No files matched your search

+574 -29
View File
@@ -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)
}
+388 -35
View File
@@ -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,
+274
View File
@@ -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
+313 -3
View File
@@ -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 ""
}