Files
opsxsql/backend/internal/handler/stub_handlers.go
T
logan f2e93a09c0 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 对齐
2026-05-26 20:40:45 +08:00

275 lines
7.0 KiB
Go

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