Files
opsxsql/backend/internal/handler/ghost.go
T
logan 78986fe5bf feat: Native DDL智能推荐 + MySQL版本检测 + 云数据库自动跳过gh-ost
- 新增 getMySQLVersion(): 检测MySQL实例版本
- 新增 supportsInstantDDL(): 判断是否支持 INSTANT DDL (8.0.12+)
- analyze API新增字段: mysql_version, supports_instant, cloud_type
- 云数据库(aliyun_rds/polardb/aws_aurora): 自动推荐Native DDL,跳过gh-ost
- 自建MySQL: 根据版本+环境+行数综合推荐
- MySQL 8.4.9 检测通过, supports_instant=true
2026-05-27 00:24:59 +08:00

531 lines
15 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)
}
task := &ghost.GhOstTask{
Host: inst.Host,
Port: inst.Port,
User: inst.Username,
Password: inst.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
model.DB.Order("id").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 {
ID int `json:"id"`
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("id = ?", item.ID).Update("value", item.Value)
}
c.JSON(http.StatusOK, gin.H{"message": "Settings updated"})
}
// GET /api/v1/settings/cleanup/preview
func SettingsCleanupPreview(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{})
}
// POST /api/v1/settings/cleanup/run
func SettingsCleanupRun(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Cleanup done (placeholder)", "stats": map[string]interface{}{}})
}
// 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)"})
}