package main import ( "bufio" "flag" "fmt" "log" "os" "regexp" "sort" "strings" "git.mchus.pro/mchus/priceforge/internal/config" "git.mchus.pro/mchus/priceforge/internal/models" "github.com/google/uuid" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) type configRow struct { ID uint UUID string OwnerUsername string Name string ProjectUUID *string } type migrationAction struct { ConfigID uint ConfigUUID string ConfigName string OwnerUsername string TargetProjectName string CurrentProject string NeedCreateProject bool NeedReactivate bool } func main() { configPath := flag.String("config", "config.yaml", "path to config file") apply := flag.Bool("apply", false, "apply migration (default is preview only)") yes := flag.Bool("yes", false, "skip interactive confirmation (works only with -apply)") flag.Parse() cfg, err := config.Load(*configPath) if err != nil { log.Fatalf("failed to load config: %v", err) } db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("failed to connect database: %v", err) } if err := ensureProjectsTable(db); err != nil { log.Fatalf("precheck failed: %v", err) } actions, existingProjects, err := buildPlan(db, cfg.Database.User) if err != nil { log.Fatalf("failed to build migration plan: %v", err) } printPlan(actions) if len(actions) == 0 { fmt.Println("Nothing to migrate.") return } if !*apply { fmt.Println("\nPreview complete. Re-run with -apply to execute.") return } if !*yes { ok, confirmErr := askForConfirmation() if confirmErr != nil { log.Fatalf("confirmation failed: %v", confirmErr) } if !ok { fmt.Println("Aborted.") return } } if err := executePlan(db, actions, existingProjects); err != nil { log.Fatalf("migration failed: %v", err) } fmt.Println("Migration completed successfully.") } func ensureProjectsTable(db *gorm.DB) error { var count int64 if err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'qt_projects'").Scan(&count).Error; err != nil { return fmt.Errorf("checking qt_projects table: %w", err) } if count == 0 { return fmt.Errorf("table qt_projects does not exist; run migration 009_add_projects.sql first") } return nil } func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string]*models.Project, error) { var configs []configRow if err := db.Table("qt_configurations"). Select("id, uuid, owner_username, name, project_uuid"). Find(&configs).Error; err != nil { return nil, nil, fmt.Errorf("load configurations: %w", err) } codeRegex := regexp.MustCompile(`^(OPS-[0-9]{4})`) owners := make(map[string]struct{}) projectNames := make(map[string]struct{}) type candidate struct { config configRow code string owner string } candidates := make([]candidate, 0) for _, cfg := range configs { match := codeRegex.FindStringSubmatch(strings.TrimSpace(cfg.Name)) if len(match) < 2 { continue } owner := strings.TrimSpace(cfg.OwnerUsername) if owner == "" { owner = strings.TrimSpace(fallbackOwner) } if owner == "" { continue } code := match[1] owners[owner] = struct{}{} projectNames[code] = struct{}{} candidates = append(candidates, candidate{config: cfg, code: code, owner: owner}) } ownerList := setKeys(owners) nameList := setKeys(projectNames) existingProjects := make(map[string]*models.Project) if len(ownerList) > 0 && len(nameList) > 0 { var projects []models.Project if err := db.Where("owner_username IN ? AND name IN ?", ownerList, nameList).Find(&projects).Error; err != nil { return nil, nil, fmt.Errorf("load existing projects: %w", err) } for i := range projects { p := projects[i] existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p } } actions := make([]migrationAction, 0) for _, c := range candidates { key := projectKey(c.owner, c.code) existing := existingProjects[key] currentProject := "" if c.config.ProjectUUID != nil { currentProject = *c.config.ProjectUUID } if existing != nil && currentProject == existing.UUID { continue } action := migrationAction{ ConfigID: c.config.ID, ConfigUUID: c.config.UUID, ConfigName: c.config.Name, OwnerUsername: c.owner, TargetProjectName: c.code, CurrentProject: currentProject, } if existing == nil { action.NeedCreateProject = true } else if !existing.IsActive { action.NeedReactivate = true } actions = append(actions, action) } return actions, existingProjects, nil } func printPlan(actions []migrationAction) { createCount := 0 reactivateCount := 0 for _, a := range actions { if a.NeedCreateProject { createCount++ } if a.NeedReactivate { reactivateCount++ } } fmt.Printf("Planned actions: %d\n", len(actions)) fmt.Printf("Projects to create: %d\n", createCount) fmt.Printf("Projects to reactivate: %d\n", reactivateCount) fmt.Println("\nDetails:") for _, a := range actions { extra := "" if a.NeedCreateProject { extra = " [create project]" } else if a.NeedReactivate { extra = " [reactivate project]" } current := a.CurrentProject if current == "" { current = "NULL" } fmt.Printf("- %s | owner=%s | \"%s\" | project: %s -> %s%s\n", a.ConfigUUID, a.OwnerUsername, a.ConfigName, current, a.TargetProjectName, extra) } } func askForConfirmation() (bool, error) { fmt.Print("\nApply these changes? type 'yes' to continue: ") reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { return false, err } return strings.EqualFold(strings.TrimSpace(line), "yes"), nil } func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[string]*models.Project) error { return db.Transaction(func(tx *gorm.DB) error { projectCache := make(map[string]*models.Project, len(existingProjects)) for k, v := range existingProjects { cp := *v projectCache[k] = &cp } for _, action := range actions { key := projectKey(action.OwnerUsername, action.TargetProjectName) project := projectCache[key] if project == nil { project = &models.Project{ UUID: uuid.NewString(), OwnerUsername: action.OwnerUsername, Name: action.TargetProjectName, IsActive: true, IsSystem: false, } if err := tx.Create(project).Error; err != nil { return fmt.Errorf("create project %s for owner %s: %w", action.TargetProjectName, action.OwnerUsername, err) } projectCache[key] = project } else if !project.IsActive { if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil { return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err) } project.IsActive = true } if err := tx.Table("qt_configurations").Where("id = ?", action.ConfigID).Update("project_uuid", project.UUID).Error; err != nil { return fmt.Errorf("move configuration %s to project %s: %w", action.ConfigUUID, project.UUID, err) } } return nil }) } func setKeys(set map[string]struct{}) []string { keys := make([]string, 0, len(set)) for k := range set { keys = append(keys, k) } sort.Strings(keys) return keys } func projectKey(owner, name string) string { return owner + "||" + name }