1. 审核规则改为从audit_rules表动态匹配(去硬编码)
- CreateTicket提交时查audit_rules WHERE db_type=? AND is_enabled=true
- 正则匹配SQL,value=reject拦截,warn/review放行
- UPDATE/DELETE无WHERE: Go RE2不支持(?!...)负向前瞻
→ 用value=where_check特殊标记+strings.Contains WHERE检测
- 修复UPDATE/DELETE无WHERE正则(Go兼容)
2. ListAuditRules按db_type过滤
- ghost.go: 添加c.Query('db_type') WHERE过滤
- MySQL不显示KEYS规则,Redis不显示DROP规则
3. Redis只读命令拦截(工单提交)
- 新增Redis只读命令白名单: GET/HGET/MGET/HGETALL/KEYS/SCAN等28个
- 只读命令走工单→直接拦截,引导用户用查询功能
4. 补充audit_rules规则
- Redis新增7条: DEBUG/SHUTDOWN/SCRIPT/SAVE/BGSAVE/BGREWRITEAOF/SLAVEOF/CLUSTER
- MySQL新增1条: DROP SCHEMA
- 总计34条规则(MySQL12/Redis11/PG7/MongoDB4)
5. docs/CONFIGURATION.md 配置系统文档
- 三套配置体系: system_settings/audit_rules/ldap_configs
- 每项配置: key/默认值/作用/代码引用位置
- 未引用配置标注: executor_batch_size/executor_backup/query_max_rows_examined(待实现)
- LDAP冗余配置: system_settings中7项ldap_*无代码引用(LDAP用独立表)
验证: 5/5审批流通过, 36/36安全测试通过, 27/27审核规则匹配正确
748 lines
24 KiB
Go
748 lines
24 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"sql-platform/internal/ghost"
|
|
"sql-platform/internal/model"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ==================== Ghost Settings Helpers ====================
|
|
|
|
// getSetting reads a system_setting value by key
|
|
func getSetting(key, fallback string) string {
|
|
var s model.SystemSetting
|
|
if model.DB.Where("`key` = ?", key).Take(&s).Error == nil && s.Value != "" {
|
|
return s.Value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// isGhostEnabled checks if gh-ost is enabled in system settings
|
|
func isGhostEnabled() bool {
|
|
return getSetting("ghost_enabled", "false") == "true"
|
|
}
|
|
|
|
// getGhostPath returns the gh-ost binary path from settings
|
|
func getGhostPath() string {
|
|
return getSetting("ghost_path", "gh-ost")
|
|
}
|
|
|
|
// getGhostConfig returns all ghost-related settings as a map for API responses
|
|
func getGhostConfig() gin.H {
|
|
return gin.H{
|
|
"enabled": isGhostEnabled(),
|
|
"default_mode": getSetting("ghost_default_mode", "dry_run"),
|
|
"osc_priority": getSetting("ghost_osc_priority", "native_first"),
|
|
"env_mode": getSetting("ghost_env_mode", "development"),
|
|
"cloud_type": getSetting("ghost_cloud_type", "self_hosted"),
|
|
"auto_trigger_rows": getSettingInt("ghost_auto_trigger_rows", 10),
|
|
"auto_trigger_ddl": getSetting("ghost_auto_trigger_ddl", "ADD COLUMN,MODIFY COLUMN,ADD INDEX"),
|
|
"chunk_size": getSettingInt("ghost_default_chunk_size", 1000),
|
|
"max_load": getSetting("ghost_max_load", "Threads_running=25"),
|
|
"critical_load": getSetting("ghost_critical_load", "Threads_running=50"),
|
|
"allow_on_master": getSetting("ghost_allow_on_master", "true") == "true",
|
|
"max_lag_millis": getSettingInt("ghost_max_lag_millis", 1500),
|
|
}
|
|
}
|
|
|
|
// getSettingInt reads a system_setting as integer
|
|
func getSettingInt(key string, fallback int) int {
|
|
if v, err := strconv.Atoi(getSetting(key, "")); err == nil {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// ==================== Ghost OSC Strategy Engine ====================
|
|
|
|
// DDLAnalysis contains the analysis result of an ALTER statement
|
|
type DDLAnalysis struct {
|
|
DDLTypes []string // extracted DDL operation types
|
|
NeedsOSC bool // whether OSC tool is recommended
|
|
Reason string // explanation for the recommendation
|
|
RecommendedTool string // "native", "gh-ost", "pt-osc"
|
|
}
|
|
|
|
// analyzeDDL inspects the ALTER statement to determine DDL types and OSC need
|
|
func analyzeDDL(alterStmt string) DDLAnalysis {
|
|
upper := strings.ToUpper(alterStmt)
|
|
var types []string
|
|
|
|
ddls := map[string]string{
|
|
"ADD COLUMN": "ADD COLUMN", "DROP COLUMN": "DROP COLUMN",
|
|
"MODIFY COLUMN": "MODIFY COLUMN", "CHANGE COLUMN": "CHANGE COLUMN",
|
|
"ADD INDEX": "ADD INDEX", "DROP INDEX": "DROP INDEX",
|
|
"ADD UNIQUE": "ADD INDEX", "ADD PRIMARY KEY": "ADD INDEX",
|
|
"DROP PRIMARY KEY": "DROP INDEX",
|
|
"RENAME COLUMN": "CHANGE COLUMN", "ALGORITHM": "ALGORITHM",
|
|
}
|
|
for pattern, ddlType := range ddls {
|
|
if strings.Contains(upper, pattern) {
|
|
types = appendIfMissing(types, ddlType)
|
|
}
|
|
}
|
|
|
|
// Determine recommendation based on config
|
|
oscPriority := getSetting("ghost_osc_priority", "native_first")
|
|
envMode := getSetting("ghost_env_mode", "development")
|
|
|
|
analysis := DDLAnalysis{DDLTypes: types, RecommendedTool: "native"}
|
|
|
|
switch oscPriority {
|
|
case "ghost_only":
|
|
analysis.NeedsOSC = true
|
|
analysis.RecommendedTool = "gh-ost"
|
|
analysis.Reason = "平台配置为仅使用 gh-ost 模式"
|
|
case "ghost_first":
|
|
analysis.NeedsOSC = true
|
|
analysis.RecommendedTool = "gh-ost"
|
|
analysis.Reason = "平台配置为优先使用 gh-ost"
|
|
default: // native_first
|
|
if envMode == "production" {
|
|
// Production: check auto-trigger conditions
|
|
triggerDDLs := strings.Split(getSetting("ghost_auto_trigger_ddl", ""), ",")
|
|
for _, t := range types {
|
|
for _, trigger := range triggerDDLs {
|
|
if strings.TrimSpace(trigger) == t {
|
|
analysis.NeedsOSC = true
|
|
analysis.RecommendedTool = "gh-ost"
|
|
analysis.Reason = fmt.Sprintf("生产环境 + DDL类型[%s]匹配自动触发规则", t)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
analysis.Reason = "开发/测试环境,建议使用 Native DDL"
|
|
}
|
|
}
|
|
|
|
return analysis
|
|
}
|
|
|
|
// appendIfMissing adds a string to slice only if not present
|
|
func appendIfMissing(slice []string, s string) []string {
|
|
for _, el := range slice {
|
|
if el == s {
|
|
return slice
|
|
}
|
|
}
|
|
return append(slice, s)
|
|
}
|
|
|
|
// ==================== Ghost API Handlers ====================
|
|
|
|
// GET /api/v1/ghost/config — return current ghost configuration
|
|
func GhOstGetConfig(c *gin.Context) {
|
|
c.JSON(http.StatusOK, getGhostConfig())
|
|
}
|
|
|
|
// getMySQLVersion returns the MySQL version string for an instance
|
|
func getMySQLVersion(instanceID int) string {
|
|
var inst model.Instance
|
|
if model.DB.First(&inst, instanceID).Error != nil {
|
|
return ""
|
|
}
|
|
if inst.DBType != "mysql" {
|
|
return ""
|
|
}
|
|
db, err := getDBConnection(&inst, "")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer db.Close()
|
|
var version string
|
|
db.QueryRow("SELECT VERSION()").Scan(&version)
|
|
return version
|
|
}
|
|
|
|
// supportsInstantDDL checks if MySQL version supports INSTANT DDL (8.0.12+)
|
|
func supportsInstantDDL(version string) bool {
|
|
// Parse "8.0.32" or "5.7.40-log" etc
|
|
parts := strings.SplitN(version, "-", 2)
|
|
v := parts[0]
|
|
segments := strings.Split(v, ".")
|
|
if len(segments) < 3 {
|
|
return false
|
|
}
|
|
major, _ := strconv.Atoi(segments[0])
|
|
minor, _ := strconv.Atoi(segments[1])
|
|
patch, _ := strconv.Atoi(segments[2])
|
|
if major > 8 {
|
|
return true
|
|
}
|
|
if major == 8 && minor > 0 {
|
|
return true
|
|
}
|
|
if major == 8 && minor == 0 && patch >= 12 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// POST /api/v1/ghost/analyze — analyze DDL and return OSC recommendation
|
|
func GhOstAnalyzeDDL(c *gin.Context) {
|
|
var req struct {
|
|
InstanceID int `json:"instance_id"`
|
|
Database string `json:"database"`
|
|
Table string `json:"table"`
|
|
Alter string `json:"alter"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
analysis := analyzeDDL(req.Alter)
|
|
|
|
// Get MySQL version for INSTANT DDL check
|
|
mysqlVersion := ""
|
|
instantDDL := false
|
|
if req.InstanceID > 0 {
|
|
mysqlVersion = getMySQLVersion(req.InstanceID)
|
|
instantDDL = supportsInstantDDL(mysqlVersion)
|
|
}
|
|
|
|
// Cloud databases: prefer Native DDL
|
|
cloudType := getSetting("ghost_cloud_type", "self_hosted")
|
|
if cloudType != "self_hosted" && analysis.NeedsOSC {
|
|
analysis.NeedsOSC = false
|
|
analysis.RecommendedTool = "native"
|
|
analysis.Reason = fmt.Sprintf("云数据库(%s)建议使用原生DDL,无需gh-ost", cloudType)
|
|
}
|
|
|
|
// Also check table row count if instance_id provided
|
|
rowCount := -1
|
|
if req.InstanceID > 0 {
|
|
rowCount = getTableRowCount(req.InstanceID, req.Database, req.Table)
|
|
}
|
|
|
|
// Check auto-trigger by row count (only for self-hosted)
|
|
if cloudType == "self_hosted" {
|
|
triggerRows := getSettingInt("ghost_auto_trigger_rows", 10) * 10000
|
|
if rowCount >= 0 && rowCount > triggerRows && !analysis.NeedsOSC {
|
|
analysis.NeedsOSC = true
|
|
analysis.RecommendedTool = "gh-ost"
|
|
analysis.Reason = fmt.Sprintf("表行数(%d)超过阈值(%d),推荐使用 gh-ost", rowCount, triggerRows)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"enabled": isGhostEnabled(),
|
|
"ddl_types": analysis.DDLTypes,
|
|
"needs_osc": analysis.NeedsOSC,
|
|
"recommended_tool": analysis.RecommendedTool,
|
|
"reason": analysis.Reason,
|
|
"table_rows": rowCount,
|
|
"mysql_version": mysqlVersion,
|
|
"supports_instant": instantDDL,
|
|
"cloud_type": cloudType,
|
|
"config": getGhostConfig(),
|
|
})
|
|
}
|
|
|
|
// getTableRowCount estimates row count via INFORMATION_SCHEMA
|
|
func getTableRowCount(instanceID int, database, table string) int {
|
|
var inst model.Instance
|
|
if model.DB.First(&inst, instanceID).Error != nil {
|
|
return -1
|
|
}
|
|
if inst.DBType != "mysql" {
|
|
return -1
|
|
}
|
|
|
|
db, err := getDBConnection(&inst, database)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
defer db.Close()
|
|
|
|
var count int
|
|
row := db.QueryRow("SELECT TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", database, table)
|
|
if err := row.Scan(&count); err != nil {
|
|
return -1
|
|
}
|
|
return count
|
|
}
|
|
|
|
// POST /api/v1/ghost/dry-run
|
|
func GhOstDryRun(c *gin.Context) {
|
|
if !isGhostEnabled() {
|
|
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
InstanceID int `json:"instance_id"`
|
|
Database string `json:"database"`
|
|
Table string `json:"table"`
|
|
Alter string `json:"alter"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
task.DryRun = true
|
|
task.Execute = false
|
|
applyGhostSettings(task)
|
|
|
|
result, err := ghost.RunGhOst(c.Request.Context(), *task, getGhostPath())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, result)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// POST /api/v1/ghost/execute
|
|
func GhOstExecute(c *gin.Context) {
|
|
if !isGhostEnabled() {
|
|
c.JSON(http.StatusForbidden, gin.H{"detail": "gh-ost 功能已禁用,请在系统设置中开启"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
InstanceID int `json:"instance_id"`
|
|
Database string `json:"database"`
|
|
Table string `json:"table"`
|
|
Alter string `json:"alter"`
|
|
Force bool `json:"force"` // skip auto-trigger check
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
task, err := buildGhOstTask(req.InstanceID, req.Database, req.Table, req.Alter)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
task.DryRun = false
|
|
task.Execute = true
|
|
applyGhostSettings(task)
|
|
|
|
taskID := ghost.RunGhOstAsync(*task, getGhostPath(), nil)
|
|
c.JSON(http.StatusAccepted, gin.H{
|
|
"task_id": taskID,
|
|
"message": "gh-ost execution started",
|
|
})
|
|
}
|
|
|
|
// ==================== Ghost Helpers ====================
|
|
|
|
// buildGhOstTask resolves instance_id to connection params
|
|
func buildGhOstTask(instanceID int, database, table, alter string) (*ghost.GhOstTask, error) {
|
|
var inst model.Instance
|
|
if err := model.DB.First(&inst, instanceID).Error; err != nil {
|
|
return nil, fmt.Errorf("instance %d not found", instanceID)
|
|
}
|
|
if inst.DBType != "mysql" {
|
|
return nil, fmt.Errorf("gh-ost only supports MySQL instances, got %s", inst.DBType)
|
|
}
|
|
|
|
// Decrypt password for gh-ost command line
|
|
password := inst.Password
|
|
if dec, err := decryptPassword(strings.TrimPrefix(inst.Password, "enc:")); err == nil {
|
|
password = dec
|
|
}
|
|
|
|
task := &ghost.GhOstTask{
|
|
Host: inst.Host,
|
|
Port: inst.Port,
|
|
User: inst.Username,
|
|
Password: password,
|
|
Database: database,
|
|
Table: table,
|
|
Alter: alter,
|
|
}
|
|
|
|
// Apply cloud-specific flags
|
|
cloudType := getSetting("ghost_cloud_type", "self_hosted")
|
|
switch cloudType {
|
|
case "aliyun_rds":
|
|
task.AliyunRDS = true
|
|
case "aws_aurora":
|
|
task.AWSAurora = true
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
// applyGhostSettings applies all system_settings to the task
|
|
func applyGhostSettings(task *ghost.GhOstTask) {
|
|
// Chunk size
|
|
if cs := getSettingInt("ghost_default_chunk_size", 1000); cs > 0 {
|
|
task.ChunkSize = cs
|
|
}
|
|
// Max load
|
|
if ml := getSetting("ghost_max_load", ""); ml != "" {
|
|
task.MaxLoad = ml
|
|
}
|
|
// Critical load
|
|
if cl := getSetting("ghost_critical_load", ""); cl != "" {
|
|
task.CriticalLoad = cl
|
|
}
|
|
// Max lag millis
|
|
if ml := getSettingInt("ghost_max_lag_millis", 1500); ml > 0 {
|
|
task.MaxLagMillis = ml
|
|
}
|
|
// Allow on master
|
|
task.AllowOnMaster = getSetting("ghost_allow_on_master", "true") == "true"
|
|
}
|
|
|
|
// GET /api/v1/ghost/status/:taskId
|
|
func GhOstStatus(c *gin.Context) {
|
|
taskID := c.Param("taskId")
|
|
status, result := ghost.Manager.GetTaskStatus(taskID)
|
|
if status == "" {
|
|
c.JSON(http.StatusNotFound, gin.H{"detail": "Task not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"task_id": taskID,
|
|
"status": status,
|
|
"result": result,
|
|
})
|
|
}
|
|
|
|
// GET /api/v1/ghost/tasks
|
|
func GhOstListTasks(c *gin.Context) {
|
|
tasks := ghost.Manager.ListTasks()
|
|
c.JSON(http.StatusOK, tasks)
|
|
}
|
|
|
|
// GET /api/v1/audit-rules
|
|
func ListAuditRules(c *gin.Context) {
|
|
var rules []model.AuditRule
|
|
query := model.DB.Order("id")
|
|
if dbType := c.Query("db_type"); dbType != "" {
|
|
query = query.Where("db_type = ?", dbType)
|
|
}
|
|
query.Find(&rules)
|
|
c.JSON(http.StatusOK, rules)
|
|
}
|
|
|
|
// GET /api/v1/announcements
|
|
func ListAnnouncements(c *gin.Context) {
|
|
var announcements []model.Announcement
|
|
model.DB.Where("is_published = ?", true).Order("id DESC").Find(&announcements)
|
|
c.JSON(http.StatusOK, announcements)
|
|
}
|
|
|
|
// GET /api/v1/query-histories
|
|
func ListQueryHistories(c *gin.Context) {
|
|
_, _, _ = c.GetInt("user_id"), c.GetString("username"), c.GetString("role")
|
|
|
|
var histories []model.QueryHistory
|
|
model.DB.Order("id DESC").Limit(100).Find(&histories)
|
|
c.JSON(http.StatusOK, histories)
|
|
}
|
|
|
|
// GET /api/v1/settings — returns settings grouped by `group` field
|
|
func ListSettings(c *gin.Context) {
|
|
var settings []model.SystemSetting
|
|
model.DB.Order("sort_order").Find(&settings)
|
|
|
|
// group by `group` field
|
|
grouped := make(map[string][]model.SystemSetting)
|
|
for _, s := range settings {
|
|
grouped[s.Group] = append(grouped[s.Group], s)
|
|
}
|
|
c.JSON(http.StatusOK, grouped)
|
|
}
|
|
|
|
// PUT /api/v1/settings — batch update settings
|
|
func UpdateSettings(c *gin.Context) {
|
|
var items []struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
if err := c.ShouldBindJSON(&items); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
for _, item := range items {
|
|
model.DB.Model(&model.SystemSetting{}).Where("`key` = ?", item.Key).Update("value", item.Value)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "Settings updated"})
|
|
}
|
|
|
|
// GET /api/v1/settings/cleanup/preview
|
|
func SettingsCleanupPreview(c *gin.Context) {
|
|
now := time.Now()
|
|
stats := gin.H{}
|
|
|
|
// Tickets
|
|
ticketDays := getSettingInt("ticket_retention_days", 180)
|
|
var ticketCount int64
|
|
model.DB.Model(&model.Ticket{}).
|
|
Where("status IN ? AND create_time < ?", []string{"success", "failed", "closed", "rejected"}, now.AddDate(0, 0, -ticketDays)).
|
|
Count(&ticketCount)
|
|
stats["tickets"] = gin.H{"days": ticketDays, "count": ticketCount}
|
|
|
|
// Query history
|
|
queryDays := getSettingInt("query_history_retention_days", 90)
|
|
var queryCount int64
|
|
model.DB.Model(&model.QueryHistory{}).
|
|
Where("created_at < ?", now.AddDate(0, 0, -queryDays)).
|
|
Count(&queryCount)
|
|
stats["query_history"] = gin.H{"days": queryDays, "count": queryCount}
|
|
|
|
// Slow logs
|
|
slowDays := getSettingInt("slow_log_retention_days", 180)
|
|
var slowCount int64
|
|
model.DB.Model(&model.SlowLog{}).
|
|
Where("created_at < ?", now.AddDate(0, 0, -slowDays)).
|
|
Count(&slowCount)
|
|
stats["slow_logs"] = gin.H{"days": slowDays, "count": slowCount}
|
|
|
|
// Audit logs — skip if no AuditLog model; use raw SQL
|
|
auditDays := getSettingInt("audit_log_retention_days", 365)
|
|
var auditCount int64
|
|
model.DB.Raw("SELECT COUNT(*) FROM audit_logs WHERE created_at < ?", now.AddDate(0, 0, -auditDays)).Scan(&auditCount)
|
|
stats["audit_logs"] = gin.H{"days": auditDays, "count": auditCount}
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
// POST /api/v1/settings/cleanup/run
|
|
func SettingsCleanupRun(c *gin.Context) {
|
|
now := time.Now()
|
|
stats := gin.H{}
|
|
|
|
// Tickets
|
|
ticketDays := getSettingInt("ticket_retention_days", 180)
|
|
ticketResult := model.DB.Where("status IN ? AND create_time < ?",
|
|
[]string{"success", "failed", "closed", "rejected"}, now.AddDate(0, 0, -ticketDays)).
|
|
Delete(&model.Ticket{})
|
|
stats["tickets_deleted"] = ticketResult.RowsAffected
|
|
|
|
// Query history
|
|
queryDays := getSettingInt("query_history_retention_days", 90)
|
|
queryResult := model.DB.Where("created_at < ?", now.AddDate(0, 0, -queryDays)).Delete(&model.QueryHistory{})
|
|
stats["query_history_deleted"] = queryResult.RowsAffected
|
|
|
|
// Slow logs
|
|
slowDays := getSettingInt("slow_log_retention_days", 180)
|
|
slowResult := model.DB.Where("created_at < ?", now.AddDate(0, 0, -slowDays)).Delete(&model.SlowLog{})
|
|
stats["slow_logs_deleted"] = slowResult.RowsAffected
|
|
|
|
// Audit logs
|
|
auditDays := getSettingInt("audit_log_retention_days", 365)
|
|
auditResult := model.DB.Exec("DELETE FROM audit_logs WHERE created_at < ?", now.AddDate(0, 0, -auditDays))
|
|
stats["audit_logs_deleted"] = auditResult.RowsAffected
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Cleanup completed", "stats": stats})
|
|
}
|
|
|
|
// GET /api/v1/notifications/channels
|
|
func ListNotificationChannels(c *gin.Context) {
|
|
var channels []model.NotificationChannel
|
|
model.DB.Order("id").Find(&channels)
|
|
c.JSON(http.StatusOK, channels)
|
|
}
|
|
|
|
// GET /api/v1/notifications/templates
|
|
func ListNotificationTemplates(c *gin.Context) {
|
|
var templates []model.NotificationTemplate
|
|
model.DB.Order("id").Find(&templates)
|
|
c.JSON(http.StatusOK, templates)
|
|
}
|
|
|
|
// GET /api/v1/workflows
|
|
func ListWorkflows(c *gin.Context) {
|
|
var workflows []model.WorkflowDefinition
|
|
model.DB.Order("id").Find(&workflows)
|
|
|
|
type WorkflowWithSteps struct {
|
|
model.WorkflowDefinition
|
|
Steps []model.WorkflowStep `json:"steps"`
|
|
}
|
|
|
|
result := make([]WorkflowWithSteps, len(workflows))
|
|
for i, wf := range workflows {
|
|
result[i] = WorkflowWithSteps{WorkflowDefinition: wf, Steps: []model.WorkflowStep{}}
|
|
model.DB.Where("workflow_id = ?", wf.ID).Order("step_number").Find(&result[i].Steps)
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// GET /api/v1/instance-groups
|
|
func ListInstanceGroups(c *gin.Context) {
|
|
var groups []model.InstanceGroup
|
|
model.DB.Order("id").Find(&groups)
|
|
result := make([]gin.H, len(groups))
|
|
for i, g := range groups {
|
|
result[i] = enrichGroup(g)
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// POST /api/v1/notifications/test
|
|
func TestNotification(c *gin.Context) {
|
|
_ = time.Now()
|
|
c.JSON(http.StatusOK, gin.H{"message": "Notification sent (placeholder)"})
|
|
}
|
|
|
|
// ==================== Audit Rules ====================
|
|
|
|
// defaultAuditRules defines seed rules per db_type
|
|
var defaultAuditRules = map[string][]struct {
|
|
Name string
|
|
Desc string
|
|
RuleType string
|
|
Severity string
|
|
Pattern string
|
|
Message string
|
|
Value string
|
|
}{
|
|
"mysql": {
|
|
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作,可能导致数据永久丢失", "reject"},
|
|
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
|
|
{"禁止 TRUNCATE TABLE", "清空表数据需走审批", "DDL", "error", `(?i)^\s*TRUNCATE\s+TABLE?`, "禁止执行 TRUNCATE 操作,请使用 DELETE 逐批删除", "reject"},
|
|
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
|
|
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
|
|
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
|
|
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段,避免使用 SELECT *", "warn"},
|
|
{"INSERT 需指定列", "INSERT 需指定列名", "DML", "warning", `(?i)^\s*INSERT\s+INTO\s+\S+\s+VALUES`, "INSERT 语句应明确指定列名", "review"},
|
|
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL PRIVILEGES", "reject"},
|
|
{"大表变更警告", "影响行数超过阈值需注意", "performance", "warning", "", "操作影响行数可能较大,建议在低峰期执行", "warn"},
|
|
{"禁止不带 LIMIT 的 DELETE", "批量删除建议带 LIMIT", "DML", "warning", `(?i)^\s*DELETE\s+FROM\s+\S+\s+WHERE`, "建议 DELETE 语句添加 LIMIT 子句分批删除", "warn"},
|
|
},
|
|
"postgresql": {
|
|
{"禁止 DROP TABLE", "生产环境不允许删除表", "DDL", "error", `(?i)^\s*DROP\s+TABLE`, "禁止执行 DROP TABLE 操作", "reject"},
|
|
{"禁止 DROP DATABASE", "不允许删除数据库", "DDL", "error", `(?i)^\s*DROP\s+DATABASE`, "禁止执行 DROP DATABASE 操作", "reject"},
|
|
{"禁止 DELETE 无 WHERE", "删除操作必须带条件", "DML", "error", `(?i)^\s*DELETE\s+FROM?\s+\S+\s*$`, "DELETE 语句必须包含 WHERE 条件", "reject"},
|
|
{"禁止 UPDATE 无 WHERE", "更新操作必须带条件", "DML", "error", `(?i)^\s*UPDATE\s+\S+\s+SET\s+.*\s*$`, "UPDATE 语句必须包含 WHERE 条件", "reject"},
|
|
{"ALTER TABLE 需审核", "表结构变更需审批", "DDL", "warning", `(?i)^\s*ALTER\s+TABLE`, "ALTER TABLE 操作需要审批后执行", "review"},
|
|
{"SELECT * 建议", "避免使用 SELECT *", "DQL", "info", `(?i)^\s*SELECT\s+\*\s+FROM`, "建议明确指定查询字段", "warn"},
|
|
{"禁止 GRANT ALL", "不允许授予全部权限", "DCL", "error", `(?i)^\s*GRANT\s+ALL`, "禁止使用 GRANT ALL", "reject"},
|
|
},
|
|
"redis": {
|
|
{"禁止 FLUSHALL", "不允许清空所有数据", "DDL", "error", `(?i)^FLUSHALL`, "禁止执行 FLUSHALL 操作", "reject"},
|
|
{"禁止 FLUSHDB", "清空数据库需审批", "DDL", "error", `(?i)^FLUSHDB`, "禁止执行 FLUSHDB 操作", "reject"},
|
|
{"禁止 CONFIG", "配置修改需走审批", "security", "error", `(?i)^CONFIG\s`, "CONFIG 命令需要审批后执行", "reject"},
|
|
{"KEYS 命令警告", "生产环境禁止 KEYS", "performance", "error", `(?i)^KEYS\s`, "生产环境禁止使用 KEYS 命令,请使用 SCAN", "reject"},
|
|
},
|
|
"mongodb": {
|
|
{"禁止 dropDatabase", "不允许删除数据库", "DDL", "error", `dropDatabase`, "禁止执行 dropDatabase 操作", "reject"},
|
|
{"禁止 drop 集合", "不允许删除集合", "DDL", "error", `(?i)\.drop\(\)`, "禁止执行集合 drop 操作", "reject"},
|
|
{"remove 需带条件", "删除操作必须带条件", "DML", "error", `(?i)remove\(\s*\)`, "remove 操作必须带查询条件", "reject"},
|
|
{"updateMany 需审核", "批量更新需审批", "DML", "warning", `(?i)updateMany`, "updateMany 操作需要审批后执行", "review"},
|
|
},
|
|
}
|
|
|
|
// POST /api/v1/audit-rules/seed/:dbType
|
|
func SeedAuditRules(c *gin.Context) {
|
|
dbType := c.Param("dbType")
|
|
rules, ok := defaultAuditRules[dbType]
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的数据库类型: %s", dbType)})
|
|
return
|
|
}
|
|
|
|
enabled := true
|
|
count := 0
|
|
for _, r := range rules {
|
|
// Idempotent: skip if rule with same name+db_type exists
|
|
var existing int64
|
|
model.DB.Model(&model.AuditRule{}).Where("name = ? AND db_type = ?", r.Name, dbType).Count(&existing)
|
|
if existing > 0 {
|
|
continue
|
|
}
|
|
desc := r.Desc
|
|
pattern := r.Pattern
|
|
severity := r.Severity
|
|
value := r.Value
|
|
rule := model.AuditRule{
|
|
Name: r.Name,
|
|
Desc: &desc,
|
|
RuleType: r.RuleType,
|
|
Severity: &severity,
|
|
DBType: &dbType,
|
|
Pattern: &pattern,
|
|
IsEnabled: &enabled,
|
|
Message: r.Message,
|
|
Value: &value,
|
|
}
|
|
if err := model.DB.Create(&rule).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("创建规则失败: %s", err.Error())})
|
|
return
|
|
}
|
|
count++
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("成功初始化 %d 条规则", count), "count": count})
|
|
}
|
|
|
|
// POST /api/v1/audit-rules/:id/toggle
|
|
func ToggleAuditRule(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
|
|
return
|
|
}
|
|
var rule model.AuditRule
|
|
if err := model.DB.First(&rule, id).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
|
|
return
|
|
}
|
|
newEnabled := !*rule.IsEnabled
|
|
model.DB.Model(&rule).Update("is_enabled", newEnabled)
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "is_enabled": newEnabled})
|
|
}
|
|
|
|
// PUT /api/v1/audit-rules/:id
|
|
func UpdateAuditRule(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的规则 ID"})
|
|
return
|
|
}
|
|
var rule model.AuditRule
|
|
if err := model.DB.First(&rule, id).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"detail": "规则不存在"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Value *string `json:"value"`
|
|
Pattern *string `json:"pattern"`
|
|
Message *string `json:"message"`
|
|
Severity *string `json:"severity"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
|
return
|
|
}
|
|
updates := map[string]interface{}{}
|
|
if body.Value != nil {
|
|
updates["value"] = *body.Value
|
|
}
|
|
if body.Pattern != nil {
|
|
updates["pattern"] = *body.Pattern
|
|
}
|
|
if body.Message != nil {
|
|
updates["message"] = *body.Message
|
|
}
|
|
if body.Severity != nil {
|
|
updates["severity"] = *body.Severity
|
|
}
|
|
if len(updates) > 0 {
|
|
model.DB.Model(&rule).Updates(updates)
|
|
}
|
|
c.JSON(http.StatusOK, rule)
|
|
}
|