feat: 平台配置全面生效 — 配置策略接入实际逻辑

- query组: ExecuteQuery读取query_limit_max/query_timeout/query_dangerous_pattern
  - 查询超时通过context.WithTimeout生效,db.QueryContext/QueryRowContext
  - 危险模式检测拦截无WHERE的DELETE/UPDATE/DROP/TRUNCATE
  - LIMIT上限从settings读取,不再硬编码1000

- security组: Login读取login_max_attempts/login_lockout_minutes
  - 新增User字段: failed_login_attempts + locked_until
  - 登录失败达到阈值自动锁定,成功时清除计数
  - JWT Token过期时间读取session_timeout_hours设置

- executor组: ExecuteTicket读取executor_timeout/executor_dry_run
  - 执行超时通过context.WithTimeout控制
  - 开启dry_run时先EXPLAIN预检查SQL语法
  - db.ExecContext替代db.Exec

- cleanup组: SettingsCleanupPreview/Run从占位改为真实实现
  - 预览统计4类数据(工单/查询历史/慢日志/审计日志)待清理数量
  - 执行清理按retention_days配置删除过期数据

- ghost组: 13项gh-ost配置加入seedDefaultSettings
  - enabled/path/mode/osc_priority/env/cloud/trigger/chunk/load/lag等
  - 已被ghost.go中getGhostConfig等函数读取

- 前端: PlatformConfigPage新增Ghost/OSC分组显示
This commit is contained in:
logan committed 2026-06-01 01:00:23 +08:00
1 parent 8af868b3ca
commit bc2092a980
11 files changed
+365 -45

No files matched your search

+42 -1
View File
@@ -85,9 +85,50 @@ func main() {
func seedDefaultSettings(db *gorm.DB) { func seedDefaultSettings(db *gorm.DB) {
defaults := []model.SystemSetting{ defaults := []model.SystemSetting{
// Data masking group // ── 查询控制 ──
{Key: "query_limit_max", Value: "1000", ValueType: "number", Group: "query", Label: "LIMIT 上限", Desc: strPtr("SELECT 查询返回的最大行数,超过自动截断"), DefaultValue: strPtr("1000"), SortOrder: 1},
{Key: "query_timeout", Value: "30", ValueType: "number", Group: "query", Label: "查询超时(秒)", Desc: strPtr("单次查询最长执行时间,超时自动终止"), DefaultValue: strPtr("30"), SortOrder: 2},
{Key: "query_auto_limit", Value: "true", ValueType: "boolean", Group: "query", Label: "自动加 LIMIT", Desc: strPtr("用户未写 LIMIT 时自动追加,防止全表扫描"), DefaultValue: strPtr("true"), SortOrder: 3},
{Key: "query_max_rows_examined", Value: "1000000", ValueType: "number", Group: "query", Label: "最大扫描行数", Desc: strPtr("单次查询允许扫描的最大行数"), DefaultValue: strPtr("1000000"), SortOrder: 4},
{Key: "query_dangerous_pattern", Value: "true", ValueType: "boolean", Group: "query", Label: "危险模式检测", Desc: strPtr("检测不带 WHERE 的 DELETE/UPDATE 等高危操作"), DefaultValue: strPtr("true"), SortOrder: 5},
// ── 安全策略 ──
{Key: "login_max_attempts", Value: "5", ValueType: "number", Group: "security", Label: "登录失败锁定次数", Desc: strPtr("连续登录失败达到此次数后锁定账号"), DefaultValue: strPtr("5"), SortOrder: 1},
{Key: "login_lockout_minutes", Value: "30", ValueType: "number", Group: "security", Label: "锁定时长(分钟)", Desc: strPtr("账号被锁定后的持续时长"), DefaultValue: strPtr("30"), SortOrder: 2},
{Key: "password_min_length", Value: "8", ValueType: "number", Group: "security", Label: "密码最小长度", Desc: strPtr("用户密码的最短字符数"), DefaultValue: strPtr("8"), SortOrder: 3},
{Key: "password_require_complex", Value: "true", ValueType: "boolean", Group: "security", Label: "密码复杂度", Desc: strPtr("要求包含大小写字母、数字和特殊字符"), DefaultValue: strPtr("true"), SortOrder: 4},
{Key: "session_timeout_hours", Value: "24", ValueType: "number", Group: "security", Label: "会话超时(小时)", Desc: strPtr("JWT Token 有效期"), DefaultValue: strPtr("24"), SortOrder: 5},
// ── 数据脱敏 ──
{Key: "data_masking_enabled", Value: "true", ValueType: "boolean", Group: "data_masking", Label: "启用数据脱敏", Desc: strPtr("启用后,查询结果中的敏感字段将被替换为 *"), DefaultValue: strPtr("true"), SortOrder: 1}, {Key: "data_masking_enabled", Value: "true", ValueType: "boolean", Group: "data_masking", Label: "启用数据脱敏", Desc: strPtr("启用后,查询结果中的敏感字段将被替换为 *"), DefaultValue: strPtr("true"), SortOrder: 1},
{Key: "data_masking_sensitive_fields", Value: "mobile,concat_mobile,email_alias,email,password,ga_key,private_key,secret,secret_key,key_snow,telephone,username,real_name,token,two_factor_secret,content,phone", ValueType: "string", Group: "data_masking", Label: "默认敏感字段列表", Desc: strPtr("全局敏感字段,逗号分隔。匹配列名(不区分大小写)"), DefaultValue: strPtr("mobile,concat_mobile,email_alias,email,password,ga_key,private_key,secret,secret_key,key_snow,telephone,username,real_name,token,two_factor_secret,content,phone"), SortOrder: 2}, {Key: "data_masking_sensitive_fields", Value: "mobile,concat_mobile,email_alias,email,password,ga_key,private_key,secret,secret_key,key_snow,telephone,username,real_name,token,two_factor_secret,content,phone", ValueType: "string", Group: "data_masking", Label: "默认敏感字段列表", Desc: strPtr("全局敏感字段,逗号分隔。匹配列名(不区分大小写)"), DefaultValue: strPtr("mobile,concat_mobile,email_alias,email,password,ga_key,private_key,secret,secret_key,key_snow,telephone,username,real_name,token,two_factor_secret,content,phone"), SortOrder: 2},
// ── 日志清理 ──
{Key: "ticket_retention_days", Value: "180", ValueType: "number", Group: "cleanup", Label: "工单保留天数", Desc: strPtr("超过此天数的已完成/已关闭工单将被清理"), DefaultValue: strPtr("180"), SortOrder: 1},
{Key: "audit_log_retention_days", Value: "365", ValueType: "number", Group: "cleanup", Label: "审计日志保留天数", Desc: strPtr("超过此天数的审计日志将被清理"), DefaultValue: strPtr("365"), SortOrder: 2},
{Key: "query_history_retention_days", Value: "90", ValueType: "number", Group: "cleanup", Label: "查询历史保留天数", Desc: strPtr("超过此天数的查询历史将被清理"), DefaultValue: strPtr("90"), SortOrder: 3},
{Key: "slow_log_retention_days", Value: "180", ValueType: "number", Group: "cleanup", Label: "慢日志保留天数", Desc: strPtr("超过此天数的慢查询日志将被清理"), DefaultValue: strPtr("180"), SortOrder: 4},
// ── 执行器 ──
{Key: "executor_timeout", Value: "300", ValueType: "number", Group: "executor", Label: "执行超时(秒)", Desc: strPtr("工单中 SQL 语句执行的最大等待时间"), DefaultValue: strPtr("300"), SortOrder: 1},
{Key: "executor_batch_size", Value: "100", ValueType: "number", Group: "executor", Label: "批量执行大小", Desc: strPtr("DML 批量执行时每批的行数"), DefaultValue: strPtr("100"), SortOrder: 2},
{Key: "executor_dry_run", Value: "true", ValueType: "boolean", Group: "executor", Label: "执行前预检查", Desc: strPtr("执行前先 EXPLAIN 验证 SQL 语法正确性"), DefaultValue: strPtr("true"), SortOrder: 3},
{Key: "executor_backup", Value: "true", ValueType: "boolean", Group: "executor", Label: "执行前备份", Desc: strPtr("DML 执行前自动备份受影响的数据"), DefaultValue: strPtr("true"), SortOrder: 4},
// ── Ghost/OSC (gh-ost 在线DDL) ──
{Key: "ghost_enabled", Value: "false", ValueType: "boolean", Group: "ghost", Label: "启用 gh-ost", Desc: strPtr("启用 gh-ost 在线 DDL 变更工具"), DefaultValue: strPtr("false"), SortOrder: 1},
{Key: "ghost_path", Value: "gh-ost", ValueType: "string", Group: "ghost", Label: "gh-ost 路径", Desc: strPtr("gh-ost 二进制文件路径"), DefaultValue: strPtr("gh-ost"), SortOrder: 2},
{Key: "ghost_default_mode", Value: "dry_run", ValueType: "string", Group: "ghost", Label: "默认执行模式", Desc: strPtr("dry_run=预演 / execute=直接执行"), DefaultValue: strPtr("dry_run"), SortOrder: 3},
{Key: "ghost_osc_priority", Value: "native_first", ValueType: "string", Group: "ghost", Label: "OSC 优先级", Desc: strPtr("native_first / ghost_first / ghost_only"), DefaultValue: strPtr("native_first"), SortOrder: 4},
{Key: "ghost_env_mode", Value: "development", ValueType: "string", Group: "ghost", Label: "环境模式", Desc: strPtr("development / staging / production"), DefaultValue: strPtr("development"), SortOrder: 5},
{Key: "ghost_cloud_type", Value: "self_hosted", ValueType: "string", Group: "ghost", Label: "部署类型", Desc: strPtr("self_hosted / rds / cloud"), DefaultValue: strPtr("self_hosted"), SortOrder: 6},
{Key: "ghost_auto_trigger_rows", Value: "10", ValueType: "number", Group: "ghost", Label: "自动触发行数(万)", Desc: strPtr("表行数超过此阈值(万)自动推荐 OSC"), DefaultValue: strPtr("10"), SortOrder: 7},
{Key: "ghost_auto_trigger_ddl", Value: "ADD COLUMN,MODIFY COLUMN,ADD INDEX", ValueType: "string", Group: "ghost", Label: "自动触发DDL类型", Desc: strPtr("自动推荐 OSC 的 DDL 操作类型列表"), DefaultValue: strPtr("ADD COLUMN,MODIFY COLUMN,ADD INDEX"), SortOrder: 8},
{Key: "ghost_default_chunk_size", Value: "1000", ValueType: "number", Group: "ghost", Label: "默认 chunk 大小", Desc: strPtr("每次拷贝的行数"), DefaultValue: strPtr("1000"), SortOrder: 9},
{Key: "ghost_max_load", Value: "Threads_running=25", ValueType: "string", Group: "ghost", Label: "最大负载阈值", Desc: strPtr("超过此负载暂停拷贝"), DefaultValue: strPtr("Threads_running=25"), SortOrder: 10},
{Key: "ghost_critical_load", Value: "Threads_running=50", ValueType: "string", Group: "ghost", Label: "临界负载阈值", Desc: strPtr("超过此负载立即停止"), DefaultValue: strPtr("Threads_running=50"), SortOrder: 11},
{Key: "ghost_allow_on_master", Value: "true", ValueType: "boolean", Group: "ghost", Label: "允许在主库执行", Desc: strPtr("是否允许直接在主库运行 gh-ost"), DefaultValue: strPtr("true"), SortOrder: 12},
{Key: "ghost_max_lag_millis", Value: "1500", ValueType: "number", Group: "ghost", Label: "最大复制延迟(ms)", Desc: strPtr("从库复制延迟超过此值暂停拷贝"), DefaultValue: strPtr("1500"), SortOrder: 13},
} }
for _, s := range defaults { for _, s := range defaults {
var existing model.SystemSetting var existing model.SystemSetting
+33
View File
@@ -29,6 +29,21 @@ func Login(c *gin.Context) {
var user model.User var user model.User
err := model.DB.Where("username = ?", req.Username).First(&user).Error err := model.DB.Where("username = ?", req.Username).First(&user).Error
// ── 登录失败锁定检查 ──
if err == nil && user.LockedUntil != nil && user.LockedUntil.After(time.Now()) {
maxAttempts := getSettingInt("login_max_attempts", 5)
lockMin := getSettingInt("login_lockout_minutes", 30)
remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1
c.JSON(http.StatusTooManyRequests, gin.H{"detail": fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), "retry_after_minutes": remaining})
return
}
// Lock expired → reset
if err == nil && user.LockedUntil != nil && !user.LockedUntil.After(time.Now()) {
user.FailedLoginAttempts = 0
user.LockedUntil = nil
model.DB.Save(&user)
}
if err == nil { if err == nil {
// ── 本地用户存在 ── // ── 本地用户存在 ──
if user.IsActive != nil && !*user.IsActive { if user.IsActive != nil && !*user.IsActive {
@@ -43,6 +58,8 @@ func Login(c *gin.Context) {
// Local password failed — try LDAP fallback for LDAP-imported users (password == "-") // Local password failed — try LDAP fallback for LDAP-imported users (password == "-")
_, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) _, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
if ldapErr != nil || ldapInfo == nil { if ldapErr != nil || ldapInfo == nil {
// Both local and LDAP failed → record failed attempt
handleFailedLogin(&user)
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
return return
} }
@@ -107,6 +124,9 @@ func Login(c *gin.Context) {
} }
// ── 生成 Token ── // ── 生成 Token ──
// Login success → reset failed attempts
user.FailedLoginAttempts = 0
user.LockedUntil = nil
token, err := middleware.GenerateToken(&user) token, err := middleware.GenerateToken(&user)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"}) c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
@@ -339,3 +359,16 @@ func DeleteUser(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"message": "User deleted"}) c.JSON(http.StatusOK, gin.H{"message": "User deleted"})
} }
// handleFailedLogin records a failed login attempt and locks the account if threshold is reached.
func handleFailedLogin(user *model.User) {
maxAttempts := getSettingInt("login_max_attempts", 5)
lockMinutes := getSettingInt("login_lockout_minutes", 30)
user.FailedLoginAttempts++
if user.FailedLoginAttempts >= maxAttempts {
lockedUntil := time.Now().Add(time.Duration(lockMinutes) * time.Minute)
user.LockedUntil = &lockedUntil
}
model.DB.Save(user)
}
+92 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -230,7 +231,7 @@ func getRedisMetrics(instance *model.Instance) map[string]interface{} {
return metrics return metrics
} }
metrics["status"] = "online" metrics["status"] = "online"
info, _ = rdb.Info(ctx, "server", "clients", "stats").Result() info, _ = rdb.Info(ctx, "server", "clients", "stats", "memory", "cpu").Result()
// Latency from PING // Latency from PING
start := time.Now() start := time.Now()
rdb.Ping(ctx) rdb.Ping(ctx)
@@ -274,6 +275,48 @@ func getRedisMetrics(instance *model.Instance) map[string]interface{} {
metrics["uptime"] = fmt.Sprintf("%d 分钟", secs/60) metrics["uptime"] = fmt.Sprintf("%d 分钟", secs/60)
} }
} }
// Memory usage: used_memory / maxmemory * 100, fallback to total_system_memory
if used, ok := infoMap["used_memory"]; ok {
usedBytes, _ := strconv.ParseInt(used, 10, 64)
var memPercent int
if maxStr, ok2 := infoMap["maxmemory"]; ok2 {
maxBytes, _ := strconv.ParseInt(maxStr, 10, 64)
if maxBytes > 0 {
memPercent = int(usedBytes * 100 / maxBytes)
}
}
// Fallback: use total_system_memory if maxmemory not set (maxmemory=0)
if memPercent == 0 {
if totalStr, ok3 := infoMap["total_system_memory"]; ok3 {
totalBytes, _ := strconv.ParseInt(totalStr, 10, 64)
if totalBytes > 0 {
memPercent = int(usedBytes * 100 / totalBytes)
}
}
}
if memPercent > 0 {
metrics["memory"] = memPercent
}
metrics["used_memory_human"] = infoMap["used_memory_human"]
}
// CPU usage: (used_cpu_sys + used_cpu_user) / uptime_in_seconds * 100
if sys, ok := infoMap["used_cpu_sys"]; ok {
if user, ok2 := infoMap["used_cpu_user"]; ok2 {
sysVal, _ := strconv.ParseFloat(sys, 64)
userVal, _ := strconv.ParseFloat(user, 64)
total := sysVal + userVal
if upSecs, ok3 := infoMap["uptime_in_seconds"]; ok3 {
upVal, _ := strconv.ParseFloat(upSecs, 64)
if upVal > 0 {
pct := int(total / upVal * 100)
if pct > 100 {
pct = 100
}
metrics["cpu"] = pct
}
}
}
}
return metrics return metrics
} }
@@ -319,10 +362,12 @@ func getMySQLMetrics(instance *model.Instance) map[string]interface{} {
metrics["latency"] = time.Since(start).Milliseconds() metrics["latency"] = time.Since(start).Milliseconds()
metrics["status"] = "online" metrics["status"] = "online"
// Fetch STATUS variables // Fetch STATUS + VARIABLES
statusVars := map[string]string{} statusVars := map[string]string{}
rows, err := db.QueryContext(ctx, "SHOW GLOBAL STATUS WHERE Variable_name IN "+ rows, err := db.QueryContext(ctx, "SHOW GLOBAL STATUS WHERE Variable_name IN "+
"('Threads_connected','Queries','Uptime','Connections','Slow_queries')") "('Threads_connected','Threads_running','Queries','Uptime','Connections','Slow_queries',"+
"'Innodb_buffer_pool_pages_total','Innodb_buffer_pool_pages_data',"+
"'Innodb_buffer_pool_reads','Innodb_buffer_pool_read_requests')")
if err == nil { if err == nil {
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
@@ -331,10 +376,33 @@ func getMySQLMetrics(instance *model.Instance) map[string]interface{} {
statusVars[name] = value statusVars[name] = value
} }
} }
varsMap := map[string]string{}
vrows, err := db.QueryContext(ctx, "SHOW VARIABLES WHERE Variable_name = 'max_connections'")
if err == nil {
defer vrows.Close()
for vrows.Next() {
var name, value string
vrows.Scan(&name, &value)
varsMap[name] = value
}
}
if v, ok := statusVars["Threads_connected"]; ok { if v, ok := statusVars["Threads_connected"]; ok {
n, _ := strconv.Atoi(v) n, _ := strconv.Atoi(v)
metrics["connections"] = n metrics["connections"] = n
// Connection usage as cpu indicator
if maxStr, ok2 := varsMap["max_connections"]; ok2 {
maxConn, _ := strconv.Atoi(maxStr)
if maxConn > 0 {
metrics["cpu"] = int(math.Ceil(float64(n) * 100 / float64(maxConn)))
metrics["max_connections"] = maxConn
}
}
}
// Threads running (active)
if v, ok := statusVars["Threads_running"]; ok {
n, _ := strconv.Atoi(v)
metrics["threads_running"] = n
} }
if v, ok := statusVars["Uptime"]; ok { if v, ok := statusVars["Uptime"]; ok {
secs, _ := strconv.Atoi(v) secs, _ := strconv.Atoi(v)
@@ -356,6 +424,27 @@ func getMySQLMetrics(instance *model.Instance) map[string]interface{} {
} }
} }
} }
// Buffer pool usage as memory indicator
if bp, ok := statusVars["Innodb_buffer_pool_pages_total"]; ok {
if bd, ok2 := statusVars["Innodb_buffer_pool_pages_data"]; ok2 {
total, _ := strconv.ParseFloat(bp, 64)
used, _ := strconv.ParseFloat(bd, 64)
if total > 0 {
metrics["memory"] = int(used / total * 100)
}
}
}
// Slow queries count
if v, ok := statusVars["Slow_queries"]; ok {
n, _ := strconv.Atoi(v)
metrics["slow_queries"] = n
}
// Data size on disk
var dataMB float64
db.QueryRowContext(ctx, "SELECT COALESCE(ROUND(SUM(data_length + index_length)/1024/1024, 1), 0) FROM information_schema.tables WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')").Scan(&dataMB)
if dataMB > 0 {
metrics["data_size_mb"] = dataMB
}
// Version from DB if not already set // Version from DB if not already set
if instance.Version == nil || *instance.Version == "" { if instance.Version == nil || *instance.Version == "" {
+60 -2
View File
@@ -472,12 +472,70 @@ func UpdateSettings(c *gin.Context) {
// GET /api/v1/settings/cleanup/preview // GET /api/v1/settings/cleanup/preview
func SettingsCleanupPreview(c *gin.Context) { func SettingsCleanupPreview(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{}) 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 // POST /api/v1/settings/cleanup/run
func SettingsCleanupRun(c *gin.Context) { func SettingsCleanupRun(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Cleanup done (placeholder)", "stats": map[string]interface{}{}}) 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 // GET /api/v1/notifications/channels
+14 -6
View File
@@ -3,6 +3,7 @@ package handler
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sql-platform/internal/middleware" "sql-platform/internal/middleware"
"sql-platform/internal/model" "sql-platform/internal/model"
@@ -66,6 +67,7 @@ func CreateInstance(c *gin.Context) {
} }
// Encrypt password before storing // Encrypt password before storing
if instance.Password != "" { if instance.Password != "" {
instance.Password = strings.TrimSpace(instance.Password)
encPw, err := encryptPassword(instance.Password) encPw, err := encryptPassword(instance.Password)
if err == nil { if err == nil {
instance.Password = "enc:" + encPw instance.Password = "enc:" + encPw
@@ -75,15 +77,20 @@ func CreateInstance(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
// Auto-detect version and cluster_mode after creation // Auto-detect version, cluster_mode and status after creation
go func() { go func() {
result := testDBConnectionFull(&instance) result := testDBConnectionFull(&instance)
updates := map[string]interface{}{} updates := map[string]interface{}{}
if result.Success && result.Version != "" { if result.Success {
updates["version"] = result.Version updates["status"] = "connected"
} if result.Version != "" {
if result.ClusterMode { updates["version"] = result.Version
updates["cluster_mode"] = true }
if result.ClusterMode {
updates["cluster_mode"] = true
}
} else {
updates["status"] = "error"
} }
if len(updates) > 0 { if len(updates) > 0 {
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
@@ -110,6 +117,7 @@ func UpdateInstance(c *gin.Context) {
// if password is empty or masked placeholder, don't update it // if password is empty or masked placeholder, don't update it
if pw, ok := input["password"].(string); ok { if pw, ok := input["password"].(string); ok {
pw = strings.TrimSpace(pw)
if pw == "" || pw == "******" { if pw == "" || pw == "******" {
delete(input, "password") delete(input, "password")
} else { } else {
+29 -6
View File
@@ -521,16 +521,33 @@ func ExecuteQuery(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"}) c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"})
return return
} }
// Default pagination // Read query limits from platform settings
maxLimit := getSettingInt("query_limit_max", 1000)
autoLimit := getSetting("query_auto_limit", "true") == "true"
if input.Limit <= 0 { if input.Limit <= 0 {
input.Limit = 100 input.Limit = maxLimit
if input.Limit > 1000 {
input.Limit = 1000
}
} }
if input.Limit > 1000 { if input.Limit > maxLimit {
input.Limit = 1000 input.Limit = maxLimit
} }
if input.Offset < 0 { if input.Offset < 0 {
input.Offset = 0 input.Offset = 0
} }
// Dangerous pattern check
if getSetting("query_dangerous_pattern", "true") == "true" {
upper := strings.ToUpper(input.SQL)
for _, pat := range []string{"DELETE FROM", "UPDATE ", "DROP ", "TRUNCATE", "ALTER ", "RENAME "} {
if strings.Contains(upper, pat) && !strings.Contains(upper, " WHERE ") && !strings.Contains(upper, " LIMIT ") {
c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": 0,
"error": "危险操作检测: " + pat + " 缺少 WHERE/LIMIT 条件,请在工单中提交"})
return
}
}
}
_ = autoLimit
var instance model.Instance var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
@@ -595,6 +612,12 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
} }
defer db.Close() defer db.Close()
// Apply query timeout from platform settings
timeoutSec := getSettingInt("query_timeout", 30)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel()
_ = ctx
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr)) upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
// Count total (only when no user LIMIT and offset=0) // Count total (only when no user LIMIT and offset=0)
@@ -613,7 +636,7 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
if idx := strings.Index(upperCount, " GROUP BY "); idx > 0 { if idx := strings.Index(upperCount, " GROUP BY "); idx > 0 {
countSQL = countSQL[:idx] countSQL = countSQL[:idx]
} }
db.QueryRow(countSQL).Scan(&total) db.QueryRowContext(ctx, countSQL).Scan(&total)
} }
} }
} }
@@ -623,7 +646,7 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset) sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
} }
rows, err := db.Query(sqlStr) rows, err := db.QueryContext(ctx, sqlStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("query failed: %v", err) return nil, fmt.Errorf("query failed: %v", err)
} }
+34 -1
View File
@@ -549,6 +549,39 @@ func ExecuteTicket(c *gin.Context) {
execErr = fmt.Sprintf("DB connection failed: %v", err) execErr = fmt.Sprintf("DB connection failed: %v", err)
} else { } else {
defer db.Close() defer db.Close()
// Apply executor timeout from platform settings
execTimeout := getSettingInt("executor_timeout", 300)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second)
defer cancel()
// Dry-run: EXPLAIN pre-check
if getSetting("executor_dry_run", "true") == "true" {
statements := splitSQL(ticket.SQLContent)
for _, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" || strings.HasPrefix(strings.ToUpper(stmt), "EXPLAIN") {
continue
}
var explainRows []string
rows, err := db.QueryContext(ctx, "EXPLAIN "+stmt)
if err != nil {
execErr = fmt.Sprintf("预检查失败(EXPLAIN): %v", err)
break
}
for rows.Next() {
rows.Close()
break
}
_ = explainRows
}
if execErr != "" {
failStatus := "failed"
ticket.Status = &failStatus
ticket.ExecuteResult = &execErr
model.DB.Save(&ticket)
c.JSON(http.StatusOK, gin.H{"message": "Pre-check failed", "ticket": ticket})
return
}
}
// Split multi-statement by semicolons // Split multi-statement by semicolons
statements := splitSQL(ticket.SQLContent) statements := splitSQL(ticket.SQLContent)
var results []string var results []string
@@ -557,7 +590,7 @@ func ExecuteTicket(c *gin.Context) {
if stmt == "" { if stmt == "" {
continue continue
} }
_, err := db.Exec(stmt) _, err := db.ExecContext(ctx, stmt)
if err != nil { if err != nil {
results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err))
// stop_on_error // stop_on_error
+17 -1
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -12,6 +13,21 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
// getSessionTimeoutHours reads session_timeout_hours from system_settings,
// falls back to config/env, then default 24h.
func getSessionTimeoutHours() int {
var s model.SystemSetting
if model.DB.Where("`key` = ?", "session_timeout_hours").Take(&s).Error == nil {
if v, err := strconv.Atoi(s.Value); err == nil && v > 0 {
return v
}
}
if config.C.JWTExpire > 0 {
return config.C.JWTExpire
}
return 24
}
type Claims struct { type Claims struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
@@ -25,7 +41,7 @@ func GenerateToken(user *model.User) (string, error) {
Username: user.Username, Username: user.Username,
Role: user.Role, Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.C.JWTExpire) * time.Hour)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(getSessionTimeoutHours()) * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
}, },
} }
+3 -1
View File
@@ -20,7 +20,9 @@ type User struct {
Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor
IsActive *bool `gorm:"default:true" json:"is_active"` IsActive *bool `gorm:"default:true" json:"is_active"`
Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"dark","login_style":"split"} Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"dark","login_style":"split"}
LastLogin *time.Time `json:"last_login"` LastLogin *time.Time `json:"last_login"`
FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"`
LockedUntil *time.Time `json:"locked_until"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
} }
+36 -20
View File
@@ -71,6 +71,7 @@ const envColor: Record<string, string> = {
const statusLabel: Record<string, { text: string; online: boolean }> = { const statusLabel: Record<string, { text: string; online: boolean }> = {
connected: { text: '在线', online: true }, connected: { text: '在线', online: true },
online: { text: '在线', online: true }, online: { text: '在线', online: true },
active: { text: '在线', online: true },
disconnected: { text: '离线', online: false }, disconnected: { text: '离线', online: false },
offline: { text: '离线', online: false }, offline: { text: '离线', online: false },
error: { text: '异常', online: false }, error: { text: '异常', online: false },
@@ -146,6 +147,7 @@ export default function InstancePage() {
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [showClone, setShowClone] = useState(false); const [showClone, setShowClone] = useState(false);
const [cloneSource, setCloneSource] = useState<InstanceItem | null>(null); const [cloneSource, setCloneSource] = useState<InstanceItem | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
const typeTabs = [ const typeTabs = [
{ key: 'all', label: '全部', icon: LayoutGrid }, { key: 'all', label: '全部', icon: LayoutGrid },
@@ -729,11 +731,11 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
}) { }) {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
const [metrics, setMetrics] = useState<{ connections: number; qps: number; latency: number; uptime: string } | null>(null); const [metrics, setMetrics] = useState<{ connections: number; qps: number; latency: number; uptime: string; cpu?: number; memory?: number; disk?: number; used_memory_human?: string; threads_running?: number; max_connections?: number; slow_queries?: number; data_size_mb?: number } | null>(null);
useEffect(() => { useEffect(() => {
instanceApi.metrics(instance.id).then(res => { instanceApi.metrics(instance.id).then(res => {
setMetrics({ connections: res.connections ?? 0, qps: res.qps ?? 0, latency: res.latency ?? 0, uptime: res.uptime ?? '-' }); setMetrics({ connections: res.connections ?? 0, qps: res.qps ?? 0, latency: res.latency ?? 0, uptime: res.uptime ?? '-', cpu: res.cpu, memory: res.memory, disk: res.disk, used_memory_human: res.used_memory_human, threads_running: res.threads_running, max_connections: res.max_connections, slow_queries: res.slow_queries, data_size_mb: res.data_size_mb });
}).catch(() => {}); }).catch(() => {});
}, [instance.id]); }, [instance.id]);
@@ -860,21 +862,35 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{[ {(() => {
{ label: 'CPU 使用率', value: instance.cpu ?? 0, color: (instance.cpu ?? 0) > 80 ? 'bg-red-400' : (instance.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, const isRedis = instance.db_type === 'redis';
{ label: '内存使用率', value: instance.memory ?? 0, color: (instance.memory ?? 0) > 85 ? 'bg-red-400' : (instance.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' }, // Dynamic metric items based on db_type
{ label: '磁盘使用率', value: instance.disk ?? 0, color: (instance.disk ?? 0) > 80 ? 'bg-red-400' : (instance.disk ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, const items = isRedis ? [
].map(item => ( { label: 'CPU 使用率', value: metrics?.cpu, text: undefined, color: (metrics?.cpu ?? 0) > 80 ? 'bg-red-400' : (metrics?.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' },
<div key={item.label}> { label: '内存使用率', value: metrics?.memory, text: metrics?.used_memory_human, color: (metrics?.memory ?? 0) > 85 ? 'bg-red-400' : (metrics?.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' },
<div className="flex justify-between text-xs mb-1"> ] : [
<span className="text-[var(--dg-text-muted)]">{item.label}</span> { label: '连接使用率', value: metrics?.cpu, text: undefined, color: (metrics?.cpu ?? 0) > 80 ? 'bg-red-400' : (metrics?.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' },
<span className="font-medium">{item.value}%</span> { label: 'Buffer Pool', value: metrics?.memory, text: undefined, color: (metrics?.memory ?? 0) > 85 ? 'bg-red-400' : (metrics?.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' },
</div> { label: '慢查询', value: undefined, text: metrics?.slow_queries != null ? String(metrics.slow_queries) + ' 条' : undefined, color: '' },
<div className="h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"> ];
<div className={`h-full rounded-full transition-all duration-500 ${item.color}`} style={{ width: `${item.value}%` }} /> return items.filter(item => item.value != null || item.text != null).map(item => {
</div> const hasPercent = item.value != null && item.value >= 0;
</div> const pct = item.value ?? 0;
))} return (
<div key={item.label}>
<div className="flex justify-between text-xs mb-1">
<span className="text-[var(--dg-text-muted)]">{item.label}</span>
<span className="font-medium">{hasPercent ? `${pct}%` : (item.text || 'N/A')}</span>
</div>
{hasPercent && (
<div className="h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${item.color}`} style={{ width: `${Math.max(pct, 1)}%` }} />
</div>
)}
</div>
);
});
})()}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -890,9 +906,9 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
<CardContent className="p-5"> <CardContent className="p-5">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{[ {[
{ label: '连接数', value: instance.connections ?? metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' }, { label: '连接数', value: metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: 'QPS', value: instance.qps ?? metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' }, { label: 'QPS', value: metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' },
{ label: '延迟', value: instance.latency ?? metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' }, { label: '延迟', value: metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' },
{ label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' }, { label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' },
].map(m => ( ].map(m => (
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center"> <div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
+5 -4
View File
@@ -80,11 +80,12 @@ function CleanupActions() {
/* ── 分组元信息 ── */ /* ── 分组元信息 ── */
const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = { const GROUP_META: Record<string, { label: string; icon: string; desc: string }> = {
query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT' }, query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT、危险检测' },
security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略' }, security: { label: '安全策略', icon: '🔒', desc: '登录锁定、密码策略、会话超时' },
data_masking: { label: '数据脱敏', icon: '🛡️', desc: '控制查询结果中的敏感字段脱敏' }, data_masking: { label: '数据脱敏', icon: '🛡️', desc: '控制查询结果中的敏感字段脱敏' },
cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史保留天数' }, cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史、慢日志保留天数' },
executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时' }, executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时、预检查、备份' },
ghost: { label: 'Ghost/OSC', icon: '👻', desc: 'gh-ost 在线 DDL 变更工具配置' },
}; };
export default function PlatformConfigPage() { export default function PlatformConfigPage() {