diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go
index ac757c1..5d2020a 100644
--- a/backend/internal/handler/instance_detail.go
+++ b/backend/internal/handler/instance_detail.go
@@ -13,6 +13,7 @@ import (
"sql-platform/internal/middleware"
"strings"
"strconv"
+ "sort"
"encoding/json"
"bytes"
"regexp"
@@ -869,7 +870,122 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
switch v := result.(type) {
case []interface{}:
- // Array result (MGET, LRANGE, etc.)
+ // ZRANGE WITHSCORES: Do() returns [[member1, score1], [member2, score2], ...]
+ // Each element is a []interface{} pair
+ if (cmd == "ZRANGE" || cmd == "ZRANGEBYSCORE") && len(v) >= 1 {
+ withScores := false
+ if pair, ok := v[0].([]interface{}); ok && len(pair) == 2 {
+ // Check if second element is numeric (score)
+ switch pair[1].(type) {
+ case float64, int64, string:
+ withScores = true
+ }
+ }
+ if withScores {
+ type jsonEntry struct {
+ Member string
+ Score float64
+ Fields map[string]interface{}
+ }
+ entries := make([]jsonEntry, 0, len(v))
+ allKeys := map[string]bool{}
+
+ for _, item := range v {
+ pair, ok := item.([]interface{})
+ if !ok || len(pair) < 2 {
+ continue
+ }
+ member := fmt.Sprintf("%v", pair[0])
+ var score float64
+ switch s := pair[1].(type) {
+ case float64:
+ score = s
+ case int64:
+ score = float64(s)
+ case string:
+ score, _ = strconv.ParseFloat(s, 64)
+ }
+ entry := jsonEntry{Member: member, Score: score}
+
+ var parsed interface{}
+ if json.Unmarshal([]byte(member), &parsed) == nil {
+ if m, ok := parsed.(map[string]interface{}); ok {
+ entry.Fields = m
+ for k := range m {
+ allKeys[k] = true
+ }
+ }
+ }
+ entries = append(entries, entry)
+ }
+
+ if len(allKeys) > 0 {
+ // JSON members β expand into table columns
+ sortedKeys := make([]string, 0, len(allKeys))
+ for k := range allKeys {
+ sortedKeys = append(sortedKeys, k)
+ }
+ sort.Strings(sortedKeys)
+
+ cols := []string{"#", "timestamp"}
+ cols = append(cols, sortedKeys...)
+
+ for idx, entry := range entries {
+ row := map[string]interface{}{
+ "#": idx + 1,
+ "timestamp": int64(entry.Score),
+ }
+ for _, k := range sortedKeys {
+ if entry.Fields != nil {
+ if fv, ok := entry.Fields[k]; ok {
+ row[k] = fv
+ } else {
+ row[k] = nil
+ }
+ } else {
+ row[k] = nil
+ }
+ }
+ rows = append(rows, row)
+ }
+ columns = cols
+ } else {
+ // Plain members with scores
+ for idx, item := range v {
+ pair, ok := item.([]interface{})
+ if !ok || len(pair) < 2 {
+ continue
+ }
+ member := fmt.Sprintf("%v", pair[0])
+ var score float64
+ switch s := pair[1].(type) {
+ case float64:
+ score = s
+ case int64:
+ score = float64(s)
+ case string:
+ score, _ = strconv.ParseFloat(s, 64)
+ }
+ rows = append(rows, map[string]interface{}{
+ "#": idx + 1,
+ "member": member,
+ "timestamp": int64(score),
+ })
+ }
+ columns = []string{"#", "member", "timestamp"}
+ }
+
+ total := len(rows)
+ return gin.H{
+ "columns": columns,
+ "rows": rows,
+ "total": total,
+ "count": total,
+ }, nil
+ }
+ }
+
+ // Array result (MGET, LRANGE, ZRANGE without WITHSCORES, etc.)
if cmd == "MGET" {
for i, val := range v {
rows = append(rows, map[string]interface{}{
diff --git a/backend/internal/handler/stub_handlers.go b/backend/internal/handler/stub_handlers.go
index 853b5e2..946d75d 100644
--- a/backend/internal/handler/stub_handlers.go
+++ b/backend/internal/handler/stub_handlers.go
@@ -5,6 +5,8 @@ import (
"net/http"
"strings"
+ "sql-platform/internal/model"
+
"github.com/gin-gonic/gin"
)
@@ -13,6 +15,7 @@ import (
func AuditTicketSQL(c *gin.Context) {
var input struct {
SQLContent string `json:"sql_content" binding:"required"`
+ InstanceID int `json:"instance_id"`
DBType string `json:"db_type"`
Database string `json:"database"`
}
@@ -21,78 +24,160 @@ func AuditTicketSQL(c *gin.Context) {
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",
- })
+ // Resolve db_type from instance_id if not provided
+ dbType := input.DBType
+ if dbType == "" && input.InstanceID > 0 {
+ var instance model.Instance
+ if err := model.DB.Select("db_type").First(&instance, input.InstanceID).Error; err == nil {
+ dbType = instance.DBType
+ }
}
- // Rule: SELECT *
- if strings.Contains(sql, "SELECT *") {
- warnings = append(warnings, map[string]interface{}{
- "rule": "select_star",
- "severity": "low",
- "message": "SELECT * may return unnecessary columns",
- })
+ var findings []map[string]interface{}
+ sql := strings.ToUpper(strings.TrimSpace(input.SQLContent))
+
+ if dbType == "redis" {
+ // Redis command audit
+ findings = auditRedisCommand(sql)
+ } else {
+ // SQL audit rules
+ findings = auditSQL(sql)
}
- // 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{}{}
+ if findings == nil {
+ findings = []map[string]interface{}{}
}
// Determine risk level
riskLevel := "low"
- for _, w := range warnings {
- if w["severity"] == "critical" {
+ for _, f := range findings {
+ sev, _ := f["level"].(string)
+ if sev == "error" {
riskLevel = "high"
break
}
- if w["severity"] == "high" {
+ if sev == "warning" {
riskLevel = "medium"
}
}
c.JSON(http.StatusOK, gin.H{
"risk_level": riskLevel,
- "warnings": warnings,
- "passed": len(warnings) == 0,
+ "findings": findings,
+ "passed": len(findings) == 0,
})
}
+func auditSQL(sql string) []map[string]interface{} {
+ var findings []map[string]interface{}
+
+ // Rule: No WHERE clause on UPDATE/DELETE
+ if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") {
+ findings = append(findings, map[string]interface{}{
+ "rule": "no_where_clause", "level": "error",
+ "message": "UPDATE/DELETE without WHERE clause affects all rows",
+ })
+ }
+
+ // Rule: SELECT *
+ if strings.Contains(sql, "SELECT *") {
+ findings = append(findings, map[string]interface{}{
+ "rule": "select_star", "level": "info",
+ "message": "SELECT * may return unnecessary columns",
+ })
+ }
+
+ // Rule: No LIMIT on SELECT (for large tables)
+ if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") {
+ findings = append(findings, map[string]interface{}{
+ "rule": "no_limit", "level": "warning",
+ "message": "SELECT without LIMIT may return excessive rows",
+ })
+ }
+
+ // Rule: DROP TABLE
+ if strings.Contains(sql, "DROP TABLE") {
+ findings = append(findings, map[string]interface{}{
+ "rule": "drop_table", "level": "error",
+ "message": "DROP TABLE is a destructive operation",
+ })
+ }
+
+ // Rule: TRUNCATE
+ if strings.Contains(sql, "TRUNCATE") {
+ findings = append(findings, map[string]interface{}{
+ "rule": "truncate", "level": "error",
+ "message": "TRUNCATE is a destructive operation",
+ })
+ }
+
+ return findings
+}
+
+func auditRedisCommand(sql string) []map[string]interface{} {
+ var findings []map[string]interface{}
+
+ // Extract command name
+ parts := strings.Fields(sql)
+ if len(parts) == 0 {
+ return findings
+ }
+ cmd := parts[0]
+
+ // Read-only commands are safe
+ readOnly := map[string]bool{
+ "GET": true, "MGET": true, "HGET": true, "HGETALL": true,
+ "LRANGE": true, "SMEMBERS": true, "ZRANGE": true, "ZSCORE": true,
+ "TYPE": true, "TTL": true, "PTTL": true, "EXISTS": true, "KEYS": true,
+ "INFO": true, "DBSIZE": true, "SCAN": true, "STRLEN": true,
+ "LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true,
+ "HKEYS": true, "HVALS": true, "PING": true, "CONFIG": true,
+ "CLIENT": true, "SLOWLOG": true, "CLUSTER": true, "MEMORY": true,
+ "COMMAND": true, "SRANDMEMBER": true, "RANDOMKEY": true,
+ }
+
+ if readOnly[cmd] {
+ // Read-only: always safe
+ return findings
+ }
+
+ // Write commands β always need approval
+ writeDangerous := map[string]bool{
+ "FLUSHALL": true, "FLUSHDB": true, "DEBUG": true,
+ }
+ if writeDangerous[cmd] {
+ findings = append(findings, map[string]interface{}{
+ "rule": "redis_dangerous_command", "level": "error",
+ "message": cmd + " is a dangerous operation",
+ })
+ return findings
+ }
+
+ // General write commands
+ writeCmds := map[string]bool{
+ "SET": true, "DEL": true, "HSET": true, "HDEL": true,
+ "LPUSH": true, "RPUSH": true, "LPOP": true, "RPOP": true,
+ "SADD": true, "SREM": true, "ZADD": true, "ZREM": true,
+ "EXPIRE": true, "TTL": true, "RENAME": true, "MOVE": true,
+ "MSET": true, "SETNX": true, "SETEX": true, "INCR": true,
+ "INCRBY": true, "DECR": true, "DECRBY": true, "APPEND": true,
+ "PERSIST": true, "PEXPIRE": true, "EXPIREAT": true,
+ }
+ if writeCmds[cmd] {
+ findings = append(findings, map[string]interface{}{
+ "rule": "redis_write_command", "level": "warning",
+ "message": cmd + " is a write operation, requires approval",
+ })
+ } else {
+ findings = append(findings, map[string]interface{}{
+ "rule": "redis_unknown_command", "level": "info",
+ "message": cmd + " is not in known command list",
+ })
+ }
+
+ return findings
+}
+
// POST /api/v1/sql-assistant/format
func SQLFormat(c *gin.Context) {
var input struct {
diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx
index 836de7f..9e8487a 100644
--- a/frontend/src/pages/TicketCreatePage.tsx
+++ b/frontend/src/pages/TicketCreatePage.tsx
@@ -399,7 +399,7 @@ export default function TicketCreatePage() {