package controller import ( "errors" "net/http" "strconv" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" user_migration "github.com/QuantumNous/new-api/service/user_migration" "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type migrationVerifier interface { VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) } type migrationCandidateClient interface { ListUsers(page, pageSize int, keyword string) (*user_migration.ListRemoteUsersResponse, error) } var newMigrationVerifier = func() migrationVerifier { return newMigrationService() } var newMigrationCandidateClient = func() migrationCandidateClient { settings := system_setting.GetRegionSyncSettings() endpoint := settings.MasterEndpoint if settings.IsMaster && len(settings.SlaveEndpoints) > 0 { endpoint = settings.SlaveEndpoints[0] } return user_migration.NewClient(endpoint, settings.SyncApiKey) } func CreateUserMigrationBatch(c *gin.Context) { var req struct { Name string `json:"name" binding:"required"` SourceRegion string `json:"source_region" binding:"required"` TargetRegion string `json:"target_region" binding:"required"` SelectionMode string `json:"selection_mode"` SourceUserIDs []int `json:"source_user_ids"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) return } operatorID, _ := c.Get("id") if req.SelectionMode == "" { req.SelectionMode = user_migration.SelectionModeAll } if req.SelectionMode != user_migration.SelectionModeAll && req.SelectionMode != user_migration.SelectionModeExplicitIDs { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"}) return } payload := user_migration.SelectionPayload{} if req.SelectionMode == user_migration.SelectionModeExplicitIDs { if len(req.SourceUserIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "source_user_ids is required"}) return } seen := make(map[int]struct{}, len(req.SourceUserIDs)) payload.SourceUserIDs = make([]int, 0, len(req.SourceUserIDs)) for _, id := range req.SourceUserIDs { if id <= 0 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid source_user_ids"}) return } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} payload.SourceUserIDs = append(payload.SourceUserIDs, id) } if len(payload.SourceUserIDs) > 1000 { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "too many source_user_ids"}) return } conflicts, err := model.FindActiveMigrationUserConflicts(payload.SourceUserIDs) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } if len(conflicts) > 0 { c.JSON(http.StatusConflict, gin.H{ "success": false, "error": "some selected users already exist in active migration batches", "conflicts": conflicts, }) return } } payloadJSON, _ := common.Marshal(payload) batch := &model.UserMigrationBatch{ Name: req.Name, SourceRegion: req.SourceRegion, TargetRegion: req.TargetRegion, SelectionMode: req.SelectionMode, SelectionPayload: string(payloadJSON), RequestedUserCount: len(payload.SourceUserIDs), OperatorId: operatorID.(int), } if err := model.CreateUserMigrationBatch(batch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": batch}) } func ListMigrationCandidateUsers(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) keyword := c.Query("keyword") if pageSize <= 0 || pageSize > 200 { pageSize = 50 } resp, err := newMigrationCandidateClient().ListUsers(page, pageSize, keyword) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "success": true, "data": resp.Data, "total": resp.Total, }) } func ListUserMigrationBatches(c *gin.Context) { var batches []model.UserMigrationBatch if err := model.DB.Order("id desc").Find(&batches).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true, "data": batches}) } func GetUserMigrationBatch(c *gin.Context) { batchID, _ := strconv.Atoi(c.Param("id")) var batch model.UserMigrationBatch if err := model.DB.First(&batch, batchID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "batch not found"}) return } page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) if pageSize <= 0 { pageSize = 50 } if pageSize > 200 { pageSize = 200 } var items []model.UserMigrationItem var total int64 model.DB.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batchID).Count(&total) model.DB.Where("batch_id = ?", batchID).Order("id asc").Offset(page * pageSize).Limit(pageSize).Find(&items) c.JSON(http.StatusOK, gin.H{ "success": true, "data": batch, "items": items, "total": total, }) } func ScanUserMigrationBatch(c *gin.Context) { batchID, _ := strconv.Atoi(c.Param("id")) locked, err := model.TryLockBatchForScan(batchID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } if !locked { c.JSON(http.StatusConflict, gin.H{"success": false, "error": "batch is already being scanned or not in a scannable state"}) return } svc := newMigrationService() go func() { if err := svc.ScanBatch(batchID); err != nil { model.DB.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID). Update("status", model.UserMigrationBatchStatusFailed) } }() c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "scan started"}) } func ResolveUserMigrationItem(c *gin.Context) { itemID, _ := strconv.Atoi(c.Param("id")) var item model.UserMigrationItem if err := model.DB.Select("id", "batch_id").First(&item, itemID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "item not found"}) return } var req struct { ResolutionStrategy string `json:"resolution_strategy" binding:"required"` TargetUserId int `json:"target_user_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) return } validStrategies := map[string]bool{ model.UserMigrationStrategyCreateNew: true, model.UserMigrationStrategyMergeExisting: true, model.UserMigrationStrategySkip: true, } if !validStrategies[req.ResolutionStrategy] { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "error": "invalid resolution_strategy: must be create_new, merge_into_existing, or skip", }) return } if req.ResolutionStrategy == model.UserMigrationStrategyMergeExisting && req.TargetUserId == 0 { c.JSON(http.StatusBadRequest, gin.H{ "success": false, "error": "merge_into_existing requires target_user_id", }) return } updates := map[string]any{ "resolution_strategy": req.ResolutionStrategy, "status": model.UserMigrationItemStatusReady, } if req.TargetUserId != 0 { updates["target_user_id"] = req.TargetUserId } if err := model.DB.Model(&model.UserMigrationItem{}).Where("id = ?", itemID).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } if err := model.RefreshUserMigrationBatchStats(item.BatchId); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } func ExecuteUserMigrationBatch(c *gin.Context) { batchID, _ := strconv.Atoi(c.Param("id")) locked, err := model.TryLockBatchForExecution(batchID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } if !locked { c.JSON(http.StatusConflict, gin.H{"success": false, "error": "batch is already running or not in an executable state"}) return } svc := newMigrationService() go func() { _ = svc.ExecuteBatch(batchID) }() c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "execution started"}) } func RetryUserMigrationBatch(c *gin.Context) { batchID, _ := strconv.Atoi(c.Param("id")) model.DB.Model(&model.UserMigrationItem{}). Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusFailed). Updates(map[string]any{"status": model.UserMigrationItemStatusReady, "error_message": ""}) locked, err := model.TryLockBatchForExecution(batchID) if err != nil || !locked { c.JSON(http.StatusConflict, gin.H{"success": false, "error": "batch cannot be retried now"}) return } svc := newMigrationService() go func() { _ = svc.ExecuteBatch(batchID) }() c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "retry started"}) } func CancelUserMigrationBatch(c *gin.Context) { batchID, _ := strconv.Atoi(c.Param("id")) cancelled, err := model.CancelUserMigrationBatch(batchID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "batch not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) return } if !cancelled { c.JSON(http.StatusConflict, gin.H{ "success": false, "error": "batch cannot be cancelled in current status", }) return } c.JSON(http.StatusOK, gin.H{"success": true, "message": "batch cancelled"}) } func VerifyUserMigrationBatch(c *gin.Context) { batchID, _ := strconv.Atoi(c.Param("id")) var items []model.UserMigrationItem model.DB.Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusMigrated).Find(&items) svc := newMigrationVerifier() results := make([]map[string]any, 0, len(items)) for _, item := range items { result, err := svc.VerifyItem(&item) entry := map[string]any{ "source_user_id": item.SourceUserId, "target_user_id": item.TargetUserId, } if err != nil { entry["error"] = err.Error() } else { entry["result"] = result } results = append(results, entry) } c.JSON(http.StatusOK, gin.H{"success": true, "data": results}) } func newMigrationService() *user_migration.Service { settings := system_setting.GetRegionSyncSettings() endpoint := settings.MasterEndpoint if settings.IsMaster && len(settings.SlaveEndpoints) > 0 { endpoint = settings.SlaveEndpoints[0] } client := user_migration.NewClient(endpoint, settings.SyncApiKey) return user_migration.NewService(client) }