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,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,
|
||||
|
||||
Reference in new issue
Block a user