Files
opsxsql/backend/internal/handler/stub_handlers.go
T
logan 24c633d9f0 feat: ZRANGE WITHSCORES JSON自动解析为结构化表格 + Redis审计规则 + 前端安全检查
- ZRANGE WITHSCORES 自动检测JSON member,展开为 open/close/high/low/volume 等独立列
- Redis 审计: 只读命令安全,写命令warning,FLUSHALL等危险命令error
- TicketCreatePage: auditResult.findings 添加 undefined 安全检查
- 审计API支持 instance_id 自动解析 db_type
2026-05-31 02:07:10 +08:00

360 lines
9.5 KiB
Go

package handler
import (
"fmt"
"net/http"
"strings"
"sql-platform/internal/model"
"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"`
InstanceID int `json:"instance_id"`
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
}
// 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
}
}
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)
}
if findings == nil {
findings = []map[string]interface{}{}
}
// Determine risk level
riskLevel := "low"
for _, f := range findings {
sev, _ := f["level"].(string)
if sev == "error" {
riskLevel = "high"
break
}
if sev == "warning" {
riskLevel = "medium"
}
}
c.JSON(http.StatusOK, gin.H{
"risk_level": riskLevel,
"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 {
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