Add candidate user selection and explicit_ids batch creation flow for overseas migration. Also add cancellable batches to release selected-user locks and extend backend, frontend, and e2e coverage for the new flow. Co-Authored-By: Codex <noreply@anthropic.com>master
| @@ -14,11 +14,12 @@ import ( | |||||
| func ListMigrationUsers(c *gin.Context) { | func ListMigrationUsers(c *gin.Context) { | ||||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) | page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) | ||||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) | pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) | ||||
| keyword := c.Query("keyword") | |||||
| if pageSize <= 0 || pageSize > 200 { | if pageSize <= 0 || pageSize > 200 { | ||||
| pageSize = 50 | pageSize = 50 | ||||
| } | } | ||||
| users, total, err := model.ListLocalUsersForMigration(page, pageSize) | |||||
| users, total, err := model.QueryLocalUsersForMigration(page, pageSize, keyword) | |||||
| if err != nil { | if err != nil { | ||||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"}) | c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"}) | ||||
| return | return | ||||
| @@ -32,6 +33,77 @@ func ListMigrationUsers(c *gin.Context) { | |||||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": snapshots, "total": total}) | c.JSON(http.StatusOK, gin.H{"success": true, "data": snapshots, "total": total}) | ||||
| } | } | ||||
| func QueryMigrationUsers(c *gin.Context) { | |||||
| var req user_migration.QueryRemoteUsersRequest | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid request body"}) | |||||
| return | |||||
| } | |||||
| if req.SelectionMode != user_migration.SelectionModeExplicitIDs { | |||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"}) | |||||
| return | |||||
| } | |||||
| if req.PageSize <= 0 || req.PageSize > 200 { | |||||
| req.PageSize = 50 | |||||
| } | |||||
| indexed := make(map[int]struct{}, len(req.SourceUserIDs)) | |||||
| orderedIDs := 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 := indexed[id]; ok { | |||||
| continue | |||||
| } | |||||
| indexed[id] = struct{}{} | |||||
| orderedIDs = append(orderedIDs, id) | |||||
| } | |||||
| summary := &user_migration.SelectionSummary{ | |||||
| Requested: len(orderedIDs), | |||||
| Excluded: make([]user_migration.SelectionExcluded, 0), | |||||
| } | |||||
| snapshots := make([]user_migration.RemoteUserSnapshot, 0, len(orderedIDs)) | |||||
| for _, userID := range orderedIDs { | |||||
| user, err := model.GetUserById(userID, true) | |||||
| if err != nil || user == nil { | |||||
| summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "not_found"}) | |||||
| continue | |||||
| } | |||||
| if user.Role == common.RoleRootUser { | |||||
| summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "root_user_not_migratable"}) | |||||
| continue | |||||
| } | |||||
| if user.Source == common.UserSourceSynced { | |||||
| summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "already_synced"}) | |||||
| continue | |||||
| } | |||||
| bindings, _ := model.GetUserOAuthBindingsByUserId(userID) | |||||
| snapshots = append(snapshots, buildRemoteUserSnapshot(user, bindings)) | |||||
| } | |||||
| summary.Matched = len(snapshots) | |||||
| start := req.Page * req.PageSize | |||||
| if start > len(snapshots) { | |||||
| start = len(snapshots) | |||||
| } | |||||
| end := start + req.PageSize | |||||
| if end > len(snapshots) { | |||||
| end = len(snapshots) | |||||
| } | |||||
| c.JSON(http.StatusOK, gin.H{ | |||||
| "success": true, | |||||
| "data": snapshots[start:end], | |||||
| "total": len(snapshots), | |||||
| "selection_summary": summary, | |||||
| }) | |||||
| } | |||||
| func GetMigrationUser(c *gin.Context) { | func GetMigrationUser(c *gin.Context) { | ||||
| userId, err := strconv.Atoi(c.Param("id")) | userId, err := strconv.Atoi(c.Param("id")) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -9,6 +9,7 @@ import ( | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/glebarez/sqlite" | "github.com/glebarez/sqlite" | ||||
| "github.com/stretchr/testify/require" | "github.com/stretchr/testify/require" | ||||
| @@ -80,6 +81,106 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) { | |||||
| require.EqualValues(t, 1, body["total"]) | require.EqualValues(t, 1, body["total"]) | ||||
| } | } | ||||
| func TestListMigrationUsers_FiltersByKeyword(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testSyncAuthMiddleware()) | |||||
| router.GET("/api/internal/migration/users", ListMigrationUsers) | |||||
| db := setupInternalMigrationDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| require.NoError(t, db.Create(&model.User{ | |||||
| Id: 10000011, | |||||
| Username: "alice-local", | |||||
| Password: "hash", | |||||
| Email: "alice@example.com", | |||||
| AffCode: "A11", | |||||
| Source: common.UserSourceLocal, | |||||
| }).Error) | |||||
| require.NoError(t, db.Create(&model.User{ | |||||
| Id: 10000012, | |||||
| Username: "bob-local", | |||||
| Password: "hash", | |||||
| Email: "bob@example.com", | |||||
| AffCode: "A12", | |||||
| Source: common.UserSourceLocal, | |||||
| }).Error) | |||||
| req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20&keyword=alice", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| var body struct { | |||||
| Success bool `json:"success"` | |||||
| Data []user_migration.RemoteUserSnapshot `json:"data"` | |||||
| Total int `json:"total"` | |||||
| } | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||||
| require.True(t, body.Success) | |||||
| require.Equal(t, 1, body.Total) | |||||
| require.Len(t, body.Data, 1) | |||||
| require.Equal(t, 10000011, body.Data[0].Id) | |||||
| } | |||||
| func TestQueryMigrationUsers_OnlyReturnsSelectedLocalUsers(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testSyncAuthMiddleware()) | |||||
| router.POST("/api/internal/migration/users/query", QueryMigrationUsers) | |||||
| db := setupInternalMigrationDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| require.NoError(t, db.Create(&model.User{Id: 10000001, Username: "u1", Password: "hash", AffCode: "A1", Source: common.UserSourceLocal}).Error) | |||||
| require.NoError(t, db.Create(&model.User{Id: 10000002, Username: "u2", Password: "hash", AffCode: "A2", Source: common.UserSourceLocal, Role: common.RoleRootUser}).Error) | |||||
| require.NoError(t, db.Create(&model.User{Id: 10000003, Username: "u3", Password: "hash", AffCode: "A3", Source: common.UserSourceSynced}).Error) | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/internal/migration/users/query", strings.NewReader(`{"page":0,"page_size":50,"selection_mode":"explicit_ids","source_user_ids":[10000001,10000002,10000003,10000004]}`)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| var body struct { | |||||
| Success bool `json:"success"` | |||||
| Data []user_migration.RemoteUserSnapshot `json:"data"` | |||||
| Total int `json:"total"` | |||||
| SelectionSummary user_migration.SelectionSummary `json:"selection_summary"` | |||||
| } | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||||
| require.True(t, body.Success) | |||||
| require.Len(t, body.Data, 1) | |||||
| require.Equal(t, 10000001, body.Data[0].Id) | |||||
| require.Equal(t, 1, body.Total) | |||||
| require.Equal(t, 4, body.SelectionSummary.Requested) | |||||
| require.Equal(t, 1, body.SelectionSummary.Matched) | |||||
| require.Len(t, body.SelectionSummary.Excluded, 3) | |||||
| } | |||||
| func TestQueryMigrationUsers_InvalidSelectionModeRejected(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testSyncAuthMiddleware()) | |||||
| router.POST("/api/internal/migration/users/query", QueryMigrationUsers) | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/internal/migration/users/query", strings.NewReader(`{"page":0,"page_size":50,"selection_mode":"all","source_user_ids":[1]}`)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||||
| } | |||||
| func TestGetMigrationUser_RootExcluded(t *testing.T) { | func TestGetMigrationUser_RootExcluded(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -1,28 +1,46 @@ | |||||
| package controller | package controller | ||||
| import ( | import ( | ||||
| "errors" | |||||
| "net/http" | "net/http" | ||||
| "strconv" | "strconv" | ||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | user_migration "github.com/QuantumNous/new-api/service/user_migration" | ||||
| "github.com/QuantumNous/new-api/setting/system_setting" | "github.com/QuantumNous/new-api/setting/system_setting" | ||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "gorm.io/gorm" | |||||
| ) | ) | ||||
| type migrationVerifier interface { | type migrationVerifier interface { | ||||
| VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) | 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 { | var newMigrationVerifier = func() migrationVerifier { | ||||
| return newMigrationService() | 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) { | func CreateUserMigrationBatch(c *gin.Context) { | ||||
| var req struct { | var req struct { | ||||
| Name string `json:"name" binding:"required"` | |||||
| SourceRegion string `json:"source_region" binding:"required"` | |||||
| TargetRegion string `json:"target_region" binding:"required"` | |||||
| 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 { | if err := c.ShouldBindJSON(&req); err != nil { | ||||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) | c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) | ||||
| @@ -30,11 +48,61 @@ func CreateUserMigrationBatch(c *gin.Context) { | |||||
| } | } | ||||
| operatorID, _ := c.Get("id") | 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{ | batch := &model.UserMigrationBatch{ | ||||
| Name: req.Name, | |||||
| SourceRegion: req.SourceRegion, | |||||
| TargetRegion: req.TargetRegion, | |||||
| OperatorId: operatorID.(int), | |||||
| 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 { | if err := model.CreateUserMigrationBatch(batch); err != nil { | ||||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | ||||
| @@ -43,6 +111,26 @@ func CreateUserMigrationBatch(c *gin.Context) { | |||||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": batch}) | 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) { | func ListUserMigrationBatches(c *gin.Context) { | ||||
| var batches []model.UserMigrationBatch | var batches []model.UserMigrationBatch | ||||
| if err := model.DB.Order("id desc").Find(&batches).Error; err != nil { | if err := model.DB.Order("id desc").Find(&batches).Error; err != nil { | ||||
| @@ -204,6 +292,29 @@ func RetryUserMigrationBatch(c *gin.Context) { | |||||
| c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "retry started"}) | 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) { | func VerifyUserMigrationBatch(c *gin.Context) { | ||||
| batchID, _ := strconv.Atoi(c.Param("id")) | batchID, _ := strconv.Atoi(c.Param("id")) | ||||
| @@ -46,6 +46,24 @@ func testRootAuthMiddleware() gin.HandlerFunc { | |||||
| } | } | ||||
| } | } | ||||
| type fakeMigrationCandidateClient struct { | |||||
| resp *user_migration.ListRemoteUsersResponse | |||||
| err error | |||||
| gotKeyword string | |||||
| gotPage int | |||||
| gotPageSize int | |||||
| } | |||||
| func (f *fakeMigrationCandidateClient) ListUsers(page, pageSize int, keyword string) (*user_migration.ListRemoteUsersResponse, error) { | |||||
| f.gotKeyword = keyword | |||||
| f.gotPage = page | |||||
| f.gotPageSize = pageSize | |||||
| if f.err != nil { | |||||
| return nil, f.err | |||||
| } | |||||
| return f.resp, nil | |||||
| } | |||||
| type fakeMigrationVerifier struct { | type fakeMigrationVerifier struct { | ||||
| results map[int]*user_migration.VerifyResult | results map[int]*user_migration.VerifyResult | ||||
| errs map[int]error | errs map[int]error | ||||
| @@ -94,6 +112,205 @@ func TestCreateUserMigrationBatch_Success(t *testing.T) { | |||||
| require.NotNil(t, resp["data"]) | require.NotNil(t, resp["data"]) | ||||
| } | } | ||||
| func TestCreateUserMigrationBatch_ExplicitIDsSuccess(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||||
| db := setupUserMigrationControllerDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{ | |||||
| "name":"explicit-batch", | |||||
| "source_region":"ov", | |||||
| "target_region":"cn", | |||||
| "selection_mode":"explicit_ids", | |||||
| "source_user_ids":[1001,1002,1002] | |||||
| }`)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| var batch model.UserMigrationBatch | |||||
| require.NoError(t, db.First(&batch, 1).Error) | |||||
| require.Equal(t, "explicit_ids", batch.SelectionMode) | |||||
| require.Equal(t, 2, batch.RequestedUserCount) | |||||
| } | |||||
| func TestCreateUserMigrationBatch_ExplicitIDsValidation(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||||
| cases := []string{ | |||||
| `{"name":"b1","source_region":"ov","target_region":"cn","selection_mode":"explicit_ids","source_user_ids":[]}`, | |||||
| `{"name":"b2","source_region":"ov","target_region":"cn","selection_mode":"explicit_ids","source_user_ids":[0]}`, | |||||
| `{"name":"b3","source_region":"ov","target_region":"cn","selection_mode":"invalid","source_user_ids":[1]}`, | |||||
| } | |||||
| db := setupUserMigrationControllerDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| for _, body := range cases { | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(body)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||||
| } | |||||
| } | |||||
| func TestCreateUserMigrationBatch_ExplicitIDs_RejectsActiveBatchOverlap(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||||
| db := setupUserMigrationControllerDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| existingBatch := &model.UserMigrationBatch{ | |||||
| Name: "existing-draft-batch", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: "explicit_ids", | |||||
| SelectionPayload: `{"source_user_ids":[1002]}`, | |||||
| Status: model.UserMigrationBatchStatusDraft, | |||||
| OperatorId: 1, | |||||
| } | |||||
| require.NoError(t, db.Create(existingBatch).Error) | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{ | |||||
| "name":"explicit-batch", | |||||
| "source_region":"ov", | |||||
| "target_region":"cn", | |||||
| "selection_mode":"explicit_ids", | |||||
| "source_user_ids":[1001,1002] | |||||
| }`)) | |||||
| req.Header.Set("Content-Type", "application/json") | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusConflict, w.Code) | |||||
| var resp struct { | |||||
| Success bool `json:"success"` | |||||
| Error string `json:"error"` | |||||
| Conflicts []struct { | |||||
| SourceUserID int `json:"source_user_id"` | |||||
| BatchID int `json:"batch_id"` | |||||
| BatchStatus string `json:"batch_status"` | |||||
| } `json:"conflicts"` | |||||
| } | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| require.False(t, resp.Success) | |||||
| require.Equal(t, "some selected users already exist in active migration batches", resp.Error) | |||||
| require.Len(t, resp.Conflicts, 1) | |||||
| require.Equal(t, 1002, resp.Conflicts[0].SourceUserID) | |||||
| require.Equal(t, existingBatch.Id, resp.Conflicts[0].BatchID) | |||||
| require.Equal(t, model.UserMigrationBatchStatusDraft, resp.Conflicts[0].BatchStatus) | |||||
| } | |||||
| func TestListMigrationCandidateUsers_Success(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| origFactory := newMigrationCandidateClient | |||||
| defer func() { | |||||
| newMigrationCandidateClient = origFactory | |||||
| }() | |||||
| newMigrationCandidateClient = func() migrationCandidateClient { | |||||
| return &fakeMigrationCandidateClient{ | |||||
| resp: &user_migration.ListRemoteUsersResponse{ | |||||
| Success: true, | |||||
| Data: []user_migration.RemoteUserSnapshot{ | |||||
| {Id: 10000001, Username: "ov-a", Email: "a@example.com", Quota: 100}, | |||||
| }, | |||||
| Total: 1, | |||||
| }, | |||||
| } | |||||
| } | |||||
| router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers) | |||||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=0&page_size=50", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| var resp map[string]any | |||||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||||
| require.True(t, resp["success"].(bool)) | |||||
| require.EqualValues(t, 1, resp["total"]) | |||||
| } | |||||
| func TestListMigrationCandidateUsers_UpstreamFailure(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| origFactory := newMigrationCandidateClient | |||||
| defer func() { | |||||
| newMigrationCandidateClient = origFactory | |||||
| }() | |||||
| newMigrationCandidateClient = func() migrationCandidateClient { | |||||
| return &fakeMigrationCandidateClient{err: errors.New("upstream unavailable")} | |||||
| } | |||||
| router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers) | |||||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=0&page_size=50", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusInternalServerError, w.Code) | |||||
| } | |||||
| func TestListMigrationCandidateUsers_ForwardsKeyword(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| client := &fakeMigrationCandidateClient{ | |||||
| resp: &user_migration.ListRemoteUsersResponse{ | |||||
| Success: true, | |||||
| Data: []user_migration.RemoteUserSnapshot{}, | |||||
| Total: 0, | |||||
| }, | |||||
| } | |||||
| origFactory := newMigrationCandidateClient | |||||
| defer func() { | |||||
| newMigrationCandidateClient = origFactory | |||||
| }() | |||||
| newMigrationCandidateClient = func() migrationCandidateClient { | |||||
| return client | |||||
| } | |||||
| router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers) | |||||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=2&page_size=30&keyword=alice", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Equal(t, 2, client.gotPage) | |||||
| require.Equal(t, 30, client.gotPageSize) | |||||
| require.Equal(t, "alice", client.gotKeyword) | |||||
| } | |||||
| func TestListUserMigrationBatches_Success(t *testing.T) { | func TestListUserMigrationBatches_Success(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -389,6 +606,118 @@ func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) { | |||||
| require.Equal(t, model.UserMigrationItemStatusSkipped, skippedItem.Status) | require.Equal(t, model.UserMigrationItemStatusSkipped, skippedItem.Status) | ||||
| } | } | ||||
| func TestCancelUserMigrationBatch_SoftDeleteSuccess(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| router.POST("/api/user-migrations/batches/:id/cancel", CancelUserMigrationBatch) | |||||
| db := setupUserMigrationControllerDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||||
| Id: 1, | |||||
| Name: "cancel-me", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: "explicit_ids", | |||||
| SelectionPayload: `{"source_user_ids":[11,12]}`, | |||||
| OperatorId: 1, | |||||
| Status: model.UserMigrationBatchStatusScanned, | |||||
| }).Error) | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| var batch model.UserMigrationBatch | |||||
| require.NoError(t, db.First(&batch, 1).Error) | |||||
| require.Equal(t, model.UserMigrationBatchStatusCancelled, batch.Status) | |||||
| } | |||||
| func TestCancelUserMigrationBatch_InvalidStateRejected(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| router.POST("/api/user-migrations/batches/:id/cancel", CancelUserMigrationBatch) | |||||
| db := setupUserMigrationControllerDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| for _, status := range []string{ | |||||
| model.UserMigrationBatchStatusScanning, | |||||
| model.UserMigrationBatchStatusRunning, | |||||
| model.UserMigrationBatchStatusCompleted, | |||||
| } { | |||||
| require.NoError(t, db.Exec("DELETE FROM user_migration_batches").Error) | |||||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||||
| Id: 1, | |||||
| Name: "cannot-cancel", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| OperatorId: 1, | |||||
| Status: status, | |||||
| }).Error) | |||||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||||
| w := httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusConflict, w.Code) | |||||
| } | |||||
| } | |||||
| func TestCreateUserMigrationBatch_ExplicitIDs_AllowsReuseAfterCancel(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| router := gin.New() | |||||
| router.Use(testRootAuthMiddleware()) | |||||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||||
| router.POST("/api/user-migrations/batches/:id/cancel", CancelUserMigrationBatch) | |||||
| db := setupUserMigrationControllerDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| existingBatch := &model.UserMigrationBatch{ | |||||
| Name: "existing-scanned-batch", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: "explicit_ids", | |||||
| SelectionPayload: `{"source_user_ids":[11,12]}`, | |||||
| Status: model.UserMigrationBatchStatusScanned, | |||||
| OperatorId: 1, | |||||
| } | |||||
| require.NoError(t, db.Create(existingBatch).Error) | |||||
| cancelReq := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||||
| cancelW := httptest.NewRecorder() | |||||
| router.ServeHTTP(cancelW, cancelReq) | |||||
| require.Equal(t, http.StatusOK, cancelW.Code) | |||||
| createReq := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{ | |||||
| "name":"recreated-batch", | |||||
| "source_region":"ov", | |||||
| "target_region":"cn", | |||||
| "selection_mode":"explicit_ids", | |||||
| "source_user_ids":[11,12] | |||||
| }`)) | |||||
| createReq.Header.Set("Content-Type", "application/json") | |||||
| createW := httptest.NewRecorder() | |||||
| router.ServeHTTP(createW, createReq) | |||||
| require.Equal(t, http.StatusOK, createW.Code) | |||||
| } | |||||
| func TestVerifyUserMigrationBatch_AggregatesResult(t *testing.T) { | func TestVerifyUserMigrationBatch_AggregatesResult(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| router := gin.New() | router := gin.New() | ||||
| @@ -195,6 +195,38 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | |||||
| require.Equal(t, "local-1", users[0].Username) | require.Equal(t, "local-1", users[0].Username) | ||||
| } | } | ||||
| func TestQueryLocalUsersForMigration_FiltersByKeyword(t *testing.T) { | |||||
| db := setupRegionMigrationUserDB(t) | |||||
| orig := DB | |||||
| DB = db | |||||
| defer func() { | |||||
| DB = orig | |||||
| }() | |||||
| require.NoError(t, db.Create(&User{ | |||||
| Id: 10000011, | |||||
| Username: "alice-local", | |||||
| Password: "h", | |||||
| Email: "alice@example.com", | |||||
| AffCode: "AL1", | |||||
| Source: common.UserSourceLocal, | |||||
| }).Error) | |||||
| require.NoError(t, db.Create(&User{ | |||||
| Id: 10000012, | |||||
| Username: "bob-local", | |||||
| Password: "h", | |||||
| Email: "bob@example.com", | |||||
| AffCode: "BO1", | |||||
| Source: common.UserSourceLocal, | |||||
| }).Error) | |||||
| users, total, err := QueryLocalUsersForMigration(0, 20, "alice") | |||||
| require.NoError(t, err) | |||||
| require.EqualValues(t, 1, total) | |||||
| require.Len(t, users, 1) | |||||
| require.Equal(t, "alice-local", users[0].Username) | |||||
| } | |||||
| func TestConvertUserToSynced_RejectsRootUser(t *testing.T) { | func TestConvertUserToSynced_RejectsRootUser(t *testing.T) { | ||||
| db := setupRegionMigrationUserDB(t) | db := setupRegionMigrationUserDB(t) | ||||
| orig := DB | orig := DB | ||||
| @@ -1099,6 +1099,10 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) { | |||||
| } | } | ||||
| func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { | func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { | ||||
| return QueryLocalUsersForMigration(page, pageSize, "") | |||||
| } | |||||
| func QueryLocalUsersForMigration(page, pageSize int, keyword string) ([]*User, int64, error) { | |||||
| if page < 0 { | if page < 0 { | ||||
| page = 0 | page = 0 | ||||
| } | } | ||||
| @@ -1111,6 +1115,15 @@ func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { | |||||
| query := DB.Model(&User{}). | query := DB.Model(&User{}). | ||||
| Where("source = ?", common.UserSourceLocal). | Where("source = ?", common.UserSourceLocal). | ||||
| Where("role <> ?", common.RoleRootUser) | Where("role <> ?", common.RoleRootUser) | ||||
| keyword = strings.TrimSpace(keyword) | |||||
| if keyword != "" { | |||||
| likeKeyword := "%" + strings.ToLower(keyword) + "%" | |||||
| if id, err := strconv.Atoi(keyword); err == nil { | |||||
| query = query.Where("id = ? OR lower(username) LIKE ? OR lower(email) LIKE ?", id, likeKeyword, likeKeyword) | |||||
| } else { | |||||
| query = query.Where("lower(username) LIKE ? OR lower(email) LIKE ?", likeKeyword, likeKeyword) | |||||
| } | |||||
| } | |||||
| if err := query.Count(&total).Error; err != nil { | if err := query.Count(&total).Error; err != nil { | ||||
| return nil, 0, err | return nil, 0, err | ||||
| } | } | ||||
| @@ -1,6 +1,9 @@ | |||||
| package model | package model | ||||
| import ( | import ( | ||||
| "fmt" | |||||
| "slices" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "gorm.io/gorm" | "gorm.io/gorm" | ||||
| "gorm.io/gorm/clause" | "gorm.io/gorm/clause" | ||||
| @@ -34,6 +37,12 @@ type UserMigrationBatch struct { | |||||
| Name string `json:"name" gorm:"type:varchar(128);not null"` | Name string `json:"name" gorm:"type:varchar(128);not null"` | ||||
| SourceRegion string `json:"source_region" gorm:"type:varchar(32);not null"` | SourceRegion string `json:"source_region" gorm:"type:varchar(32);not null"` | ||||
| TargetRegion string `json:"target_region" gorm:"type:varchar(32);not null"` | TargetRegion string `json:"target_region" gorm:"type:varchar(32);not null"` | ||||
| SelectionMode string `json:"selection_mode" gorm:"type:varchar(32);not null;default:'all'"` | |||||
| SelectionPayload string `json:"selection_payload" gorm:"type:text"` | |||||
| RequestedUserCount int `json:"requested_user_count" gorm:"default:0"` | |||||
| MatchedUserCount int `json:"matched_user_count" gorm:"default:0"` | |||||
| ExcludedUserCount int `json:"excluded_user_count" gorm:"default:0"` | |||||
| SelectionSummary string `json:"selection_summary" gorm:"type:text"` | |||||
| Status string `json:"status" gorm:"type:varchar(32);not null;default:'draft';index"` | Status string `json:"status" gorm:"type:varchar(32);not null;default:'draft';index"` | ||||
| TotalUsers int `json:"total_users" gorm:"default:0"` | TotalUsers int `json:"total_users" gorm:"default:0"` | ||||
| ConflictUsers int `json:"conflict_users" gorm:"default:0"` | ConflictUsers int `json:"conflict_users" gorm:"default:0"` | ||||
| @@ -69,11 +78,20 @@ type MigrationQuotaGrant struct { | |||||
| CreatedAt int64 `json:"created_at" gorm:"bigint"` | CreatedAt int64 `json:"created_at" gorm:"bigint"` | ||||
| } | } | ||||
| type ActiveMigrationUserConflict struct { | |||||
| SourceUserID int `json:"source_user_id"` | |||||
| BatchID int `json:"batch_id"` | |||||
| BatchStatus string `json:"batch_status"` | |||||
| } | |||||
| func (b *UserMigrationBatch) BeforeCreate(tx *gorm.DB) error { | func (b *UserMigrationBatch) BeforeCreate(tx *gorm.DB) error { | ||||
| now := common.GetTimestamp() | now := common.GetTimestamp() | ||||
| if b.Status == "" { | if b.Status == "" { | ||||
| b.Status = UserMigrationBatchStatusDraft | b.Status = UserMigrationBatchStatusDraft | ||||
| } | } | ||||
| if b.SelectionMode == "" { | |||||
| b.SelectionMode = "all" | |||||
| } | |||||
| b.CreatedAt = now | b.CreatedAt = now | ||||
| b.UpdatedAt = now | b.UpdatedAt = now | ||||
| return nil | return nil | ||||
| @@ -108,6 +126,121 @@ func CreateUserMigrationBatch(batch *UserMigrationBatch) error { | |||||
| return DB.Create(batch).Error | return DB.Create(batch).Error | ||||
| } | } | ||||
| func CancelUserMigrationBatch(batchId int) (bool, error) { | |||||
| var batch UserMigrationBatch | |||||
| if err := DB.First(&batch, batchId).Error; err != nil { | |||||
| return false, err | |||||
| } | |||||
| switch batch.Status { | |||||
| case UserMigrationBatchStatusCancelled: | |||||
| return true, nil | |||||
| case UserMigrationBatchStatusDraft, | |||||
| UserMigrationBatchStatusScanned, | |||||
| UserMigrationBatchStatusReady, | |||||
| UserMigrationBatchStatusFailed: | |||||
| return true, DB.Model(&UserMigrationBatch{}). | |||||
| Where("id = ?", batchId). | |||||
| Update("status", UserMigrationBatchStatusCancelled).Error | |||||
| default: | |||||
| return false, nil | |||||
| } | |||||
| } | |||||
| func FindActiveMigrationUserConflicts(sourceUserIDs []int) ([]ActiveMigrationUserConflict, error) { | |||||
| if len(sourceUserIDs) == 0 { | |||||
| return nil, nil | |||||
| } | |||||
| activeStatuses := []string{ | |||||
| UserMigrationBatchStatusDraft, | |||||
| UserMigrationBatchStatusScanning, | |||||
| UserMigrationBatchStatusScanned, | |||||
| UserMigrationBatchStatusReady, | |||||
| UserMigrationBatchStatusRunning, | |||||
| UserMigrationBatchStatusFailed, | |||||
| } | |||||
| type activeBatchSelection struct { | |||||
| ID int | |||||
| Status string | |||||
| SelectionMode string | |||||
| SelectionPayload string | |||||
| } | |||||
| conflictMap := make(map[string]ActiveMigrationUserConflict) | |||||
| addConflict := func(conflict ActiveMigrationUserConflict) { | |||||
| key := fmt.Sprintf("%d:%d", conflict.SourceUserID, conflict.BatchID) | |||||
| if _, exists := conflictMap[key]; exists { | |||||
| return | |||||
| } | |||||
| conflictMap[key] = conflict | |||||
| } | |||||
| var itemConflicts []ActiveMigrationUserConflict | |||||
| err := DB.Table("user_migration_items AS umi"). | |||||
| Select("umi.source_user_id, umb.id AS batch_id, umb.status AS batch_status"). | |||||
| Joins("JOIN user_migration_batches AS umb ON umb.id = umi.batch_id"). | |||||
| Where("umi.source_user_id IN ?", sourceUserIDs). | |||||
| Where("umb.status IN ?", activeStatuses). | |||||
| Order("umi.source_user_id ASC, umb.id ASC"). | |||||
| Scan(&itemConflicts).Error | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| for _, conflict := range itemConflicts { | |||||
| addConflict(conflict) | |||||
| } | |||||
| var activeBatches []activeBatchSelection | |||||
| if err := DB.Model(&UserMigrationBatch{}). | |||||
| Select("id, status, selection_mode, selection_payload"). | |||||
| Where("status IN ?", activeStatuses). | |||||
| Where("selection_mode = ?", "explicit_ids"). | |||||
| Find(&activeBatches).Error; err != nil { | |||||
| return nil, err | |||||
| } | |||||
| sourceIDSet := make(map[int]struct{}, len(sourceUserIDs)) | |||||
| for _, id := range sourceUserIDs { | |||||
| sourceIDSet[id] = struct{}{} | |||||
| } | |||||
| for _, batch := range activeBatches { | |||||
| if batch.SelectionPayload == "" { | |||||
| continue | |||||
| } | |||||
| var payload struct { | |||||
| SourceUserIDs []int `json:"source_user_ids"` | |||||
| } | |||||
| if err := common.UnmarshalJsonStr(batch.SelectionPayload, &payload); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| for _, sourceUserID := range payload.SourceUserIDs { | |||||
| if _, ok := sourceIDSet[sourceUserID]; !ok { | |||||
| continue | |||||
| } | |||||
| addConflict(ActiveMigrationUserConflict{ | |||||
| SourceUserID: sourceUserID, | |||||
| BatchID: batch.ID, | |||||
| BatchStatus: batch.Status, | |||||
| }) | |||||
| } | |||||
| } | |||||
| conflicts := make([]ActiveMigrationUserConflict, 0, len(conflictMap)) | |||||
| for _, conflict := range conflictMap { | |||||
| conflicts = append(conflicts, conflict) | |||||
| } | |||||
| slices.SortFunc(conflicts, func(a, b ActiveMigrationUserConflict) int { | |||||
| if a.SourceUserID != b.SourceUserID { | |||||
| return a.SourceUserID - b.SourceUserID | |||||
| } | |||||
| return a.BatchID - b.BatchID | |||||
| }) | |||||
| return conflicts, nil | |||||
| } | |||||
| func UpsertUserMigrationItem(item *UserMigrationItem) error { | func UpsertUserMigrationItem(item *UserMigrationItem) error { | ||||
| var existing UserMigrationItem | var existing UserMigrationItem | ||||
| result := DB.Where("batch_id = ? AND source_user_id = ?", item.BatchId, item.SourceUserId).Limit(1).Find(&existing) | result := DB.Where("batch_id = ? AND source_user_id = ?", item.BatchId, item.SourceUserId).Limit(1).Find(&existing) | ||||
| @@ -1,6 +1,7 @@ | |||||
| package model | package model | ||||
| import ( | import ( | ||||
| "encoding/json" | |||||
| "testing" | "testing" | ||||
| "github.com/glebarez/sqlite" | "github.com/glebarez/sqlite" | ||||
| @@ -55,9 +56,43 @@ func TestCreateUserMigrationBatch_DefaultStatus(t *testing.T) { | |||||
| require.NoError(t, CreateUserMigrationBatch(batch)) | require.NoError(t, CreateUserMigrationBatch(batch)) | ||||
| require.Equal(t, UserMigrationBatchStatusDraft, batch.Status) | require.Equal(t, UserMigrationBatchStatusDraft, batch.Status) | ||||
| require.Equal(t, "all", batch.SelectionMode) | |||||
| require.Greater(t, batch.Id, 0) | require.Greater(t, batch.Id, 0) | ||||
| } | } | ||||
| func TestCreateUserMigrationBatch_StoresExplicitIDsSelection(t *testing.T) { | |||||
| db := setupUserMigrationTestDB(t) | |||||
| orig := DB | |||||
| DB = db | |||||
| defer func() { | |||||
| DB = orig | |||||
| }() | |||||
| payload := map[string]any{"source_user_ids": []int{1001, 1002, 1003}} | |||||
| payloadJSON, err := json.Marshal(payload) | |||||
| require.NoError(t, err) | |||||
| batch := &UserMigrationBatch{ | |||||
| Name: "partial-wave", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: "explicit_ids", | |||||
| SelectionPayload: string(payloadJSON), | |||||
| RequestedUserCount: 3, | |||||
| OperatorId: 1, | |||||
| } | |||||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||||
| var saved UserMigrationBatch | |||||
| require.NoError(t, DB.First(&saved, batch.Id).Error) | |||||
| require.Equal(t, "explicit_ids", saved.SelectionMode) | |||||
| require.Equal(t, string(payloadJSON), saved.SelectionPayload) | |||||
| require.Equal(t, 3, saved.RequestedUserCount) | |||||
| require.Equal(t, 0, saved.MatchedUserCount) | |||||
| require.Equal(t, 0, saved.ExcludedUserCount) | |||||
| } | |||||
| func TestUpsertMigrationItem_PreservesHumanSetStrategy(t *testing.T) { | func TestUpsertMigrationItem_PreservesHumanSetStrategy(t *testing.T) { | ||||
| db := setupUserMigrationTestDB(t) | db := setupUserMigrationTestDB(t) | ||||
| orig := DB | orig := DB | ||||
| @@ -212,3 +247,39 @@ func TestRefreshUserMigrationBatchExecutionStats_MigratingLeakHandled(t *testing | |||||
| require.Equal(t, 0, refreshed.MigratedUsers) | require.Equal(t, 0, refreshed.MigratedUsers) | ||||
| require.Equal(t, 0, refreshed.FailedUsers) | require.Equal(t, 0, refreshed.FailedUsers) | ||||
| } | } | ||||
| func TestCancelUserMigrationBatch_ReleasesBatch(t *testing.T) { | |||||
| db := setupUserMigrationTestDB(t) | |||||
| orig := DB | |||||
| DB = db | |||||
| defer func() { | |||||
| DB = orig | |||||
| }() | |||||
| batch := &UserMigrationBatch{ | |||||
| Name: "cancel-batch", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: "explicit_ids", | |||||
| SelectionPayload: `{"source_user_ids":[1001,1002]}`, | |||||
| OperatorId: 1, | |||||
| Status: UserMigrationBatchStatusScanned, | |||||
| } | |||||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||||
| conflicts, err := FindActiveMigrationUserConflicts([]int{1001, 1002}) | |||||
| require.NoError(t, err) | |||||
| require.Len(t, conflicts, 2) | |||||
| cancelled, err := CancelUserMigrationBatch(batch.Id) | |||||
| require.NoError(t, err) | |||||
| require.True(t, cancelled) | |||||
| var refreshed UserMigrationBatch | |||||
| require.NoError(t, DB.First(&refreshed, batch.Id).Error) | |||||
| require.Equal(t, UserMigrationBatchStatusCancelled, refreshed.Status) | |||||
| conflicts, err = FindActiveMigrationUserConflicts([]int{1001, 1002}) | |||||
| require.NoError(t, err) | |||||
| require.Len(t, conflicts, 0) | |||||
| } | |||||
| @@ -411,6 +411,7 @@ func SetApiRouter(router *gin.Engine) { | |||||
| { | { | ||||
| migrationRoute.GET("/users/check-synced-copy", controller.CheckSyncedCopy) | migrationRoute.GET("/users/check-synced-copy", controller.CheckSyncedCopy) | ||||
| migrationRoute.GET("/users", controller.ListMigrationUsers) | migrationRoute.GET("/users", controller.ListMigrationUsers) | ||||
| migrationRoute.POST("/users/query", controller.QueryMigrationUsers) | |||||
| migrationRoute.GET("/users/:id", controller.GetMigrationUser) | migrationRoute.GET("/users/:id", controller.GetMigrationUser) | ||||
| migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced) | migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced) | ||||
| } | } | ||||
| @@ -418,12 +419,14 @@ func SetApiRouter(router *gin.Engine) { | |||||
| userMigrationRoute := apiRouter.Group("/user-migrations") | userMigrationRoute := apiRouter.Group("/user-migrations") | ||||
| userMigrationRoute.Use(middleware.RootAuth()) | userMigrationRoute.Use(middleware.RootAuth()) | ||||
| { | { | ||||
| userMigrationRoute.GET("/candidate-users", controller.ListMigrationCandidateUsers) | |||||
| userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch) | userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch) | ||||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | ||||
| userMigrationRoute.GET("/batches/:id", controller.GetUserMigrationBatch) | userMigrationRoute.GET("/batches/:id", controller.GetUserMigrationBatch) | ||||
| userMigrationRoute.POST("/batches/:id/scan", controller.ScanUserMigrationBatch) | userMigrationRoute.POST("/batches/:id/scan", controller.ScanUserMigrationBatch) | ||||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | ||||
| userMigrationRoute.POST("/batches/:id/retry", controller.RetryUserMigrationBatch) | userMigrationRoute.POST("/batches/:id/retry", controller.RetryUserMigrationBatch) | ||||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||||
| userMigrationRoute.POST("/batches/:id/verify", controller.VerifyUserMigrationBatch) | userMigrationRoute.POST("/batches/:id/verify", controller.VerifyUserMigrationBatch) | ||||
| userMigrationRoute.POST("/items/:id/resolve", controller.ResolveUserMigrationItem) | userMigrationRoute.POST("/items/:id/resolve", controller.ResolveUserMigrationItem) | ||||
| } | } | ||||
| @@ -65,6 +65,7 @@ func setupUserMigrationRootRouter() *gin.Engine { | |||||
| { | { | ||||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | ||||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | ||||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||||
| } | } | ||||
| return r | return r | ||||
| @@ -96,6 +97,7 @@ func setupUserMigrationAdminRouter() *gin.Engine { | |||||
| { | { | ||||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | ||||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | ||||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||||
| } | } | ||||
| return r | return r | ||||
| @@ -129,6 +131,11 @@ func TestUserMigrationRoutes_RootCanAccess(t *testing.T) { | |||||
| w = httptest.NewRecorder() | w = httptest.NewRecorder() | ||||
| router.ServeHTTP(w, req) | router.ServeHTTP(w, req) | ||||
| require.NotEqual(t, http.StatusUnauthorized, w.Code) | require.NotEqual(t, http.StatusUnauthorized, w.Code) | ||||
| req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||||
| w = httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.NotEqual(t, http.StatusUnauthorized, w.Code) | |||||
| } | } | ||||
| func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { | func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { | ||||
| @@ -152,4 +159,10 @@ func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { | |||||
| router.ServeHTTP(w, req) | router.ServeHTTP(w, req) | ||||
| require.Equal(t, http.StatusOK, w.Code) | require.Equal(t, http.StatusOK, w.Code) | ||||
| require.Contains(t, w.Body.String(), "success\":false") | require.Contains(t, w.Body.String(), "success\":false") | ||||
| req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||||
| w = httptest.NewRecorder() | |||||
| router.ServeHTTP(w, req) | |||||
| require.Equal(t, http.StatusOK, w.Code) | |||||
| require.Contains(t, w.Body.String(), "success\":false") | |||||
| } | } | ||||
| @@ -5,6 +5,7 @@ import ( | |||||
| "fmt" | "fmt" | ||||
| "io" | "io" | ||||
| "net/http" | "net/http" | ||||
| "net/url" | |||||
| "strings" | "strings" | ||||
| "time" | "time" | ||||
| @@ -12,7 +13,8 @@ import ( | |||||
| ) | ) | ||||
| type migrationClient interface { | type migrationClient interface { | ||||
| ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) | |||||
| ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) | |||||
| QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) | |||||
| GetUser(userId int) (*RemoteUserSnapshot, error) | GetUser(userId int) (*RemoteUserSnapshot, error) | ||||
| ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error | ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error | ||||
| HasSyncedCopy(cnUserId int) (bool, error) | HasSyncedCopy(cnUserId int) (bool, error) | ||||
| @@ -68,9 +70,12 @@ func (c *Client) doRequest(method, path string, body any) ([]byte, error) { | |||||
| return data, nil | return data, nil | ||||
| } | } | ||||
| func (c *Client) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) { | |||||
| data, err := c.doRequest(http.MethodGet, | |||||
| fmt.Sprintf("/api/internal/migration/users?page=%d&page_size=%d", page, pageSize), nil) | |||||
| func (c *Client) ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) { | |||||
| path := fmt.Sprintf("/api/internal/migration/users?page=%d&page_size=%d", page, pageSize) | |||||
| if keyword != "" { | |||||
| path += "&keyword=" + url.QueryEscape(keyword) | |||||
| } | |||||
| data, err := c.doRequest(http.MethodGet, path, nil) | |||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| @@ -81,6 +86,23 @@ func (c *Client) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) | |||||
| return &resp, nil | return &resp, nil | ||||
| } | } | ||||
| func (c *Client) QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) { | |||||
| data, err := c.doRequest(http.MethodPost, "/api/internal/migration/users/query", &QueryRemoteUsersRequest{ | |||||
| Page: page, | |||||
| PageSize: pageSize, | |||||
| SelectionMode: SelectionModeExplicitIDs, | |||||
| SourceUserIDs: sourceUserIDs, | |||||
| }) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| var resp QueryRemoteUsersResponse | |||||
| if err := common.Unmarshal(data, &resp); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return &resp, nil | |||||
| } | |||||
| func (c *Client) GetUser(userId int) (*RemoteUserSnapshot, error) { | func (c *Client) GetUser(userId int) (*RemoteUserSnapshot, error) { | ||||
| data, err := c.doRequest(http.MethodGet, | data, err := c.doRequest(http.MethodGet, | ||||
| fmt.Sprintf("/api/internal/migration/users/%d", userId), nil) | fmt.Sprintf("/api/internal/migration/users/%d", userId), nil) | ||||
| @@ -25,13 +25,40 @@ func (s *Service) ScanBatch(batchId int) error { | |||||
| return err | return err | ||||
| } | } | ||||
| var batch model.UserMigrationBatch | |||||
| if err := model.DB.First(&batch, batchId).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| var selectionPayload SelectionPayload | |||||
| if batch.SelectionPayload != "" { | |||||
| if err := common.UnmarshalJsonStr(batch.SelectionPayload, &selectionPayload); err != nil { | |||||
| return fmt.Errorf("parse selection payload: %w", err) | |||||
| } | |||||
| } | |||||
| page := 0 | page := 0 | ||||
| var latestSummary *SelectionSummary | |||||
| for { | for { | ||||
| resp, err := s.client.ListUsers(page, scanPageSize) | |||||
| if err != nil { | |||||
| return fmt.Errorf("list users page %d: %w", page, err) | |||||
| var respData []RemoteUserSnapshot | |||||
| var total int | |||||
| if batch.SelectionMode == SelectionModeExplicitIDs { | |||||
| resp, err := s.client.QueryUsers(page, scanPageSize, selectionPayload.SourceUserIDs) | |||||
| if err != nil { | |||||
| return fmt.Errorf("query users page %d: %w", page, err) | |||||
| } | |||||
| respData = resp.Data | |||||
| total = resp.Total | |||||
| latestSummary = resp.SelectionSummary | |||||
| } else { | |||||
| resp, err := s.client.ListUsers(page, scanPageSize, "") | |||||
| if err != nil { | |||||
| return fmt.Errorf("list users page %d: %w", page, err) | |||||
| } | |||||
| respData = resp.Data | |||||
| total = resp.Total | |||||
| } | } | ||||
| for _, remoteUser := range resp.Data { | |||||
| for _, remoteUser := range respData { | |||||
| result, err := analyzeConflicts(&remoteUser, providerSlugMap, s.client) | result, err := analyzeConflicts(&remoteUser, providerSlugMap, s.client) | ||||
| if err != nil { | if err != nil { | ||||
| return fmt.Errorf("analyze conflicts for user %d: %w", remoteUser.Id, err) | return fmt.Errorf("analyze conflicts for user %d: %w", remoteUser.Id, err) | ||||
| @@ -64,12 +91,26 @@ func (s *Service) ScanBatch(batchId int) error { | |||||
| } | } | ||||
| } | } | ||||
| if len(resp.Data) < scanPageSize { | |||||
| if len(respData) < scanPageSize || total <= (page+1)*scanPageSize { | |||||
| break | break | ||||
| } | } | ||||
| page++ | page++ | ||||
| } | } | ||||
| if latestSummary != nil { | |||||
| summaryJSON, err := common.Marshal(latestSummary) | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| if err := model.DB.Model(&model.UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ | |||||
| "matched_user_count": latestSummary.Matched, | |||||
| "excluded_user_count": len(latestSummary.Excluded), | |||||
| "selection_summary": string(summaryJSON), | |||||
| }).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| } | |||||
| return model.RefreshUserMigrationBatchStats(batchId) | return model.RefreshUserMigrationBatchStats(batchId) | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package user_migration | package user_migration | ||||
| import ( | import ( | ||||
| "encoding/json" | |||||
| "fmt" | "fmt" | ||||
| "testing" | "testing" | ||||
| @@ -13,6 +14,8 @@ import ( | |||||
| type fakeClient struct { | type fakeClient struct { | ||||
| listPages map[int][]RemoteUserSnapshot | listPages map[int][]RemoteUserSnapshot | ||||
| queryPages map[int][]RemoteUserSnapshot | |||||
| querySummary *SelectionSummary | |||||
| getUserResp *RemoteUserSnapshot | getUserResp *RemoteUserSnapshot | ||||
| getUserByID map[int]*RemoteUserSnapshot | getUserByID map[int]*RemoteUserSnapshot | ||||
| hasCopyResult bool | hasCopyResult bool | ||||
| @@ -21,12 +24,30 @@ type fakeClient struct { | |||||
| convertReqs map[int]*ConvertRemoteUserRequest | convertReqs map[int]*ConvertRemoteUserRequest | ||||
| } | } | ||||
| func (f *fakeClient) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) { | |||||
| func (f *fakeClient) ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) { | |||||
| users := f.listPages[page] | users := f.listPages[page] | ||||
| total := 0 | |||||
| for _, pageUsers := range f.listPages { | |||||
| total += len(pageUsers) | |||||
| } | |||||
| return &ListRemoteUsersResponse{ | return &ListRemoteUsersResponse{ | ||||
| Success: true, | Success: true, | ||||
| Data: users, | Data: users, | ||||
| Total: len(users), | |||||
| Total: total, | |||||
| }, nil | |||||
| } | |||||
| func (f *fakeClient) QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) { | |||||
| users := f.queryPages[page] | |||||
| total := len(users) | |||||
| if f.querySummary != nil { | |||||
| total = f.querySummary.Matched | |||||
| } | |||||
| return &QueryRemoteUsersResponse{ | |||||
| Success: true, | |||||
| Data: users, | |||||
| Total: total, | |||||
| SelectionSummary: f.querySummary, | |||||
| }, nil | }, nil | ||||
| } | } | ||||
| @@ -253,6 +274,149 @@ func TestScanBatch_GitHubId_SameUser_RecommendsMerge(t *testing.T) { | |||||
| require.Contains(t, items[0].ConflictFlags, "github_id") | require.Contains(t, items[0].ConflictFlags, "github_id") | ||||
| } | } | ||||
| func TestScanBatch_ExplicitIDs_OnlySelectedUsersProcessed(t *testing.T) { | |||||
| db := setupMigrationServiceDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000021, 10000023}}) | |||||
| require.NoError(t, err) | |||||
| batch := &model.UserMigrationBatch{ | |||||
| Name: "explicit-scan", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: SelectionModeExplicitIDs, | |||||
| SelectionPayload: string(payloadJSON), | |||||
| RequestedUserCount: 2, | |||||
| OperatorId: 1, | |||||
| } | |||||
| require.NoError(t, model.CreateUserMigrationBatch(batch)) | |||||
| client := &fakeClient{ | |||||
| queryPages: map[int][]RemoteUserSnapshot{ | |||||
| 0: { | |||||
| {Id: 10000021, Username: "ov-a", Quota: 10, AffCode: "OVA"}, | |||||
| {Id: 10000023, Username: "ov-b", Quota: 20, AffCode: "OVB"}, | |||||
| }, | |||||
| }, | |||||
| querySummary: &SelectionSummary{ | |||||
| Requested: 2, | |||||
| Matched: 2, | |||||
| Excluded: nil, | |||||
| }, | |||||
| } | |||||
| svc := &Service{client: client} | |||||
| require.NoError(t, svc.ScanBatch(batch.Id)) | |||||
| items, err := model.ListUserMigrationItemsByBatch(batch.Id) | |||||
| require.NoError(t, err) | |||||
| require.Len(t, items, 2) | |||||
| require.ElementsMatch(t, []int{10000021, 10000023}, []int{items[0].SourceUserId, items[1].SourceUserId}) | |||||
| } | |||||
| func TestScanBatch_ExplicitIDs_SelectionSummaryRecorded(t *testing.T) { | |||||
| db := setupMigrationServiceDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000021, 10000022, 10000023}}) | |||||
| require.NoError(t, err) | |||||
| batch := &model.UserMigrationBatch{ | |||||
| Name: "explicit-summary", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: SelectionModeExplicitIDs, | |||||
| SelectionPayload: string(payloadJSON), | |||||
| RequestedUserCount: 3, | |||||
| OperatorId: 1, | |||||
| } | |||||
| require.NoError(t, model.CreateUserMigrationBatch(batch)) | |||||
| client := &fakeClient{ | |||||
| queryPages: map[int][]RemoteUserSnapshot{ | |||||
| 0: { | |||||
| {Id: 10000021, Username: "ov-a", Quota: 10, AffCode: "OVA"}, | |||||
| {Id: 10000023, Username: "ov-b", Quota: 20, AffCode: "OVB"}, | |||||
| }, | |||||
| }, | |||||
| querySummary: &SelectionSummary{ | |||||
| Requested: 3, | |||||
| Matched: 2, | |||||
| Excluded: []SelectionExcluded{ | |||||
| {UserID: 10000022, Reason: "already_synced"}, | |||||
| }, | |||||
| }, | |||||
| } | |||||
| svc := &Service{client: client} | |||||
| require.NoError(t, svc.ScanBatch(batch.Id)) | |||||
| var refreshed model.UserMigrationBatch | |||||
| require.NoError(t, db.First(&refreshed, batch.Id).Error) | |||||
| require.Equal(t, 2, refreshed.MatchedUserCount) | |||||
| require.Equal(t, 1, refreshed.ExcludedUserCount) | |||||
| var summary SelectionSummary | |||||
| require.NoError(t, json.Unmarshal([]byte(refreshed.SelectionSummary), &summary)) | |||||
| require.Equal(t, 3, summary.Requested) | |||||
| require.Equal(t, 2, summary.Matched) | |||||
| require.Len(t, summary.Excluded, 1) | |||||
| require.Equal(t, 10000022, summary.Excluded[0].UserID) | |||||
| } | |||||
| func TestScanBatch_ExplicitIDs_ExcludedUsersDoNotCreateItems(t *testing.T) { | |||||
| db := setupMigrationServiceDB(t) | |||||
| orig := model.DB | |||||
| model.DB = db | |||||
| defer func() { | |||||
| model.DB = orig | |||||
| }() | |||||
| payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000031, 10000032, 10000033}}) | |||||
| require.NoError(t, err) | |||||
| batch := &model.UserMigrationBatch{ | |||||
| Name: "explicit-excluded", | |||||
| SourceRegion: "ov", | |||||
| TargetRegion: "cn", | |||||
| SelectionMode: SelectionModeExplicitIDs, | |||||
| SelectionPayload: string(payloadJSON), | |||||
| RequestedUserCount: 3, | |||||
| OperatorId: 1, | |||||
| } | |||||
| require.NoError(t, model.CreateUserMigrationBatch(batch)) | |||||
| client := &fakeClient{ | |||||
| queryPages: map[int][]RemoteUserSnapshot{ | |||||
| 0: { | |||||
| {Id: 10000031, Username: "ov-a", Quota: 10, AffCode: "OVA"}, | |||||
| }, | |||||
| }, | |||||
| querySummary: &SelectionSummary{ | |||||
| Requested: 3, | |||||
| Matched: 1, | |||||
| Excluded: []SelectionExcluded{ | |||||
| {UserID: 10000032, Reason: "root_user_not_migratable"}, | |||||
| {UserID: 10000033, Reason: "already_synced"}, | |||||
| }, | |||||
| }, | |||||
| } | |||||
| svc := &Service{client: client} | |||||
| require.NoError(t, svc.ScanBatch(batch.Id)) | |||||
| items, err := model.ListUserMigrationItemsByBatch(batch.Id) | |||||
| require.NoError(t, err) | |||||
| require.Len(t, items, 1) | |||||
| require.Equal(t, 10000031, items[0].SourceUserId) | |||||
| } | |||||
| func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) { | func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) { | ||||
| db := setupMigrationServiceDB(t) | db := setupMigrationServiceDB(t) | ||||
| orig := model.DB | orig := model.DB | ||||
| @@ -5,6 +5,26 @@ type RemoteOAuthBinding struct { | |||||
| ProviderUserId string `json:"provider_user_id"` | ProviderUserId string `json:"provider_user_id"` | ||||
| } | } | ||||
| const ( | |||||
| SelectionModeAll = "all" | |||||
| SelectionModeExplicitIDs = "explicit_ids" | |||||
| ) | |||||
| type SelectionPayload struct { | |||||
| SourceUserIDs []int `json:"source_user_ids"` | |||||
| } | |||||
| type SelectionExcluded struct { | |||||
| UserID int `json:"user_id"` | |||||
| Reason string `json:"reason"` | |||||
| } | |||||
| type SelectionSummary struct { | |||||
| Requested int `json:"requested"` | |||||
| Matched int `json:"matched"` | |||||
| Excluded []SelectionExcluded `json:"excluded"` | |||||
| } | |||||
| type RemoteUserSnapshot struct { | type RemoteUserSnapshot struct { | ||||
| Id int `json:"id"` | Id int `json:"id"` | ||||
| Username string `json:"username"` | Username string `json:"username"` | ||||
| @@ -37,6 +57,21 @@ type ListRemoteUsersResponse struct { | |||||
| Error string `json:"error,omitempty"` | Error string `json:"error,omitempty"` | ||||
| } | } | ||||
| type QueryRemoteUsersRequest struct { | |||||
| Page int `json:"page"` | |||||
| PageSize int `json:"page_size"` | |||||
| SelectionMode string `json:"selection_mode"` | |||||
| SourceUserIDs []int `json:"source_user_ids"` | |||||
| } | |||||
| type QueryRemoteUsersResponse struct { | |||||
| Success bool `json:"success"` | |||||
| Data []RemoteUserSnapshot `json:"data"` | |||||
| Total int `json:"total"` | |||||
| SelectionSummary *SelectionSummary `json:"selection_summary,omitempty"` | |||||
| Error string `json:"error,omitempty"` | |||||
| } | |||||
| type ConvertRemoteUserRequest struct { | type ConvertRemoteUserRequest struct { | ||||
| RemoteUserId int `json:"remote_user_id"` | RemoteUserId int `json:"remote_user_id"` | ||||
| SyncedQuota int `json:"synced_quota"` | SyncedQuota int `json:"synced_quota"` | ||||
| @@ -9,6 +9,7 @@ import ( | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "github.com/QuantumNous/new-api/model" | ||||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||||
| helpers "github.com/QuantumNous/new-api/test/e2e/helpers" | helpers "github.com/QuantumNous/new-api/test/e2e/helpers" | ||||
| "github.com/stretchr/testify/require" | "github.com/stretchr/testify/require" | ||||
| ) | ) | ||||
| @@ -356,6 +357,110 @@ func TestUserMigrationE2E_SkipNeverConvertsRemote(t *testing.T) { | |||||
| require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId) | require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId) | ||||
| } | } | ||||
| func TestUserMigrationE2E_ExplicitIDs_OnlySelectedUsersScanned(t *testing.T) { | |||||
| env := newEnvironment(t) | |||||
| batchID := createBatchWithSelection(t, env, "explicit-selected", []int{ | |||||
| helpers.OVCreateUserID, | |||||
| helpers.OVMergeUserID, | |||||
| }) | |||||
| var scanResp simpleResponse | |||||
| require.NoError(t, env.CNClient.PostJSON( | |||||
| fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), | |||||
| nil, | |||||
| 202, | |||||
| &scanResp, | |||||
| )) | |||||
| require.True(t, scanResp.Success) | |||||
| detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusReady, 20*time.Second) | |||||
| require.NoError(t, err) | |||||
| require.EqualValues(t, 2, detail.Total) | |||||
| require.Equal(t, 2, detail.Batch.TotalUsers) | |||||
| require.Equal(t, user_migration.SelectionModeExplicitIDs, detail.Batch.SelectionMode) | |||||
| require.Equal(t, 2, detail.Batch.RequestedUserCount) | |||||
| items := indexItemsBySourceUserID(detail.Items) | |||||
| require.Len(t, items, 2) | |||||
| require.Contains(t, items, helpers.OVCreateUserID) | |||||
| require.Contains(t, items, helpers.OVMergeUserID) | |||||
| require.NotContains(t, items, helpers.OVHardSyncedUserID) | |||||
| } | |||||
| func TestUserMigrationE2E_ExplicitIDs_SelectionSummaryRecorded(t *testing.T) { | |||||
| env := newEnvironment(t) | |||||
| batchID := createBatchWithSelection(t, env, "explicit-summary", []int{ | |||||
| helpers.OVCreateUserID, | |||||
| helpers.OVSyncedCopyUserID, | |||||
| 1, | |||||
| 19999999, | |||||
| }) | |||||
| var scanResp simpleResponse | |||||
| require.NoError(t, env.CNClient.PostJSON( | |||||
| fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), | |||||
| nil, | |||||
| 202, | |||||
| &scanResp, | |||||
| )) | |||||
| require.True(t, scanResp.Success) | |||||
| detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusReady, 20*time.Second) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, 4, detail.Batch.RequestedUserCount) | |||||
| require.Equal(t, 1, detail.Batch.MatchedUserCount) | |||||
| require.Equal(t, 3, detail.Batch.ExcludedUserCount) | |||||
| require.EqualValues(t, 1, detail.Total) | |||||
| var summary user_migration.SelectionSummary | |||||
| require.NoError(t, common.UnmarshalJsonStr(detail.Batch.SelectionSummary, &summary)) | |||||
| require.Equal(t, 4, summary.Requested) | |||||
| require.Equal(t, 1, summary.Matched) | |||||
| require.Len(t, summary.Excluded, 3) | |||||
| reasonsByID := make(map[int]string, len(summary.Excluded)) | |||||
| for _, item := range summary.Excluded { | |||||
| reasonsByID[item.UserID] = item.Reason | |||||
| } | |||||
| require.Equal(t, "already_synced", reasonsByID[helpers.OVSyncedCopyUserID]) | |||||
| require.Equal(t, "root_user_not_migratable", reasonsByID[1]) | |||||
| require.Equal(t, "not_found", reasonsByID[19999999]) | |||||
| } | |||||
| func TestUserMigrationE2E_ExplicitIDs_ActiveBatchOverlapRejected(t *testing.T) { | |||||
| env := newEnvironment(t) | |||||
| firstBatchID := createBatchWithSelection(t, env, "explicit-overlap-first", []int{ | |||||
| helpers.OVCreateUserID, | |||||
| }) | |||||
| require.NotZero(t, firstBatchID) | |||||
| var resp struct { | |||||
| Success bool `json:"success"` | |||||
| Error string `json:"error"` | |||||
| Conflicts []struct { | |||||
| SourceUserID int `json:"source_user_id"` | |||||
| BatchID int `json:"batch_id"` | |||||
| BatchStatus string `json:"batch_status"` | |||||
| } `json:"conflicts"` | |||||
| } | |||||
| require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{ | |||||
| "name": "explicit-overlap-second", | |||||
| "source_region": "ov", | |||||
| "target_region": "cn", | |||||
| "selection_mode": "explicit_ids", | |||||
| "source_user_ids": []int{helpers.OVCreateUserID, helpers.OVMergeUserID}, | |||||
| }, 409, &resp)) | |||||
| require.False(t, resp.Success) | |||||
| require.Equal(t, "some selected users already exist in active migration batches", resp.Error) | |||||
| require.Len(t, resp.Conflicts, 1) | |||||
| require.Equal(t, helpers.OVCreateUserID, resp.Conflicts[0].SourceUserID) | |||||
| require.Equal(t, firstBatchID, resp.Conflicts[0].BatchID) | |||||
| require.Equal(t, model.UserMigrationBatchStatusDraft, resp.Conflicts[0].BatchStatus) | |||||
| } | |||||
| func newEnvironment(t *testing.T) *helpers.Environment { | func newEnvironment(t *testing.T) *helpers.Environment { | ||||
| t.Helper() | t.Helper() | ||||
| @@ -509,12 +614,24 @@ func scanGrayScenarioBatch(t *testing.T, env *helpers.Environment, name string) | |||||
| func createBatch(t *testing.T, env *helpers.Environment, name string) int { | func createBatch(t *testing.T, env *helpers.Environment, name string) int { | ||||
| t.Helper() | t.Helper() | ||||
| var resp createBatchResponse | |||||
| require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{ | |||||
| return createBatchWithSelection(t, env, name, nil) | |||||
| } | |||||
| func createBatchWithSelection(t *testing.T, env *helpers.Environment, name string, sourceUserIDs []int) int { | |||||
| t.Helper() | |||||
| payload := map[string]any{ | |||||
| "name": name, | "name": name, | ||||
| "source_region": "ov", | "source_region": "ov", | ||||
| "target_region": "cn", | "target_region": "cn", | ||||
| }, 200, &resp)) | |||||
| } | |||||
| if len(sourceUserIDs) > 0 { | |||||
| payload["selection_mode"] = "explicit_ids" | |||||
| payload["source_user_ids"] = sourceUserIDs | |||||
| } | |||||
| var resp createBatchResponse | |||||
| require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", payload, 200, &resp)) | |||||
| require.True(t, resp.Success) | require.True(t, resp.Success) | ||||
| require.NotZero(t, resp.Data.Id) | require.NotZero(t, resp.Data.Id) | ||||
| return resp.Data.Id | return resp.Data.Id | ||||
| @@ -22,20 +22,46 @@ async function openMigrationTab(page: Page) { | |||||
| await expect(page.getByTestId('user-migration-center')).toBeVisible(); | await expect(page.getByTestId('user-migration-center')).toBeVisible(); | ||||
| } | } | ||||
| async function createBatch(page: Page, batchName: string) { | |||||
| await page.getByTestId('migration-create-batch-button').click(); | |||||
| async function selectCandidateUser(page: Page, userId: number) { | |||||
| const row = page.getByTestId(`migration-candidate-row-${userId}`); | |||||
| await expect(row).toBeVisible(); | |||||
| const checkboxInput = row.locator('input[type="checkbox"]'); | |||||
| if ((await checkboxInput.count()) > 0) { | |||||
| await checkboxInput.first().check({ force: true }); | |||||
| return; | |||||
| } | |||||
| const dialog = page.getByRole('dialog'); | |||||
| await expect(dialog).toBeVisible(); | |||||
| await row.locator('.semi-checkbox').first().click(); | |||||
| } | |||||
| const nameField = dialog.getByRole('textbox').first(); | |||||
| async function createBatch( | |||||
| page: Page, | |||||
| batchName: string, | |||||
| selectedUserIds: number[] = [], | |||||
| ) { | |||||
| await page.getByTestId('migration-create-batch-button').click(); | |||||
| await expect( | |||||
| page.getByTestId('migration-selection-mode-group'), | |||||
| ).toBeVisible(); | |||||
| if (selectedUserIds.length > 0) { | |||||
| await page.getByTestId('migration-selection-mode-explicit').click(); | |||||
| await expect(page.getByTestId('migration-candidate-table')).toBeVisible(); | |||||
| for (const userId of selectedUserIds) { | |||||
| await selectCandidateUser(page, userId); | |||||
| } | |||||
| } | |||||
| await page.getByTestId('migration-create-batch-submit').click(); | |||||
| const nameField = page.getByTestId('migration-batch-name-field'); | |||||
| await expect(nameField).toBeVisible(); | await expect(nameField).toBeVisible(); | ||||
| await nameField.fill(batchName); | await nameField.fill(batchName); | ||||
| await dialog | |||||
| .getByRole('button', { name: /confirm|确定/i }) | |||||
| .click(); | |||||
| await expect(page.getByTestId('migration-batch-table')).toContainText(batchName); | |||||
| await page.getByTestId('migration-create-batch-submit').click(); | |||||
| await expect(page.getByTestId('migration-batch-table')).toContainText( | |||||
| batchName, | |||||
| ); | |||||
| await page.getByTestId('migration-batch-table').getByText(batchName).click(); | await page.getByTestId('migration-batch-table').getByText(batchName).click(); | ||||
| await expect(page.getByTestId('migration-execution-panel')).toBeVisible(); | await expect(page.getByTestId('migration-execution-panel')).toBeVisible(); | ||||
| await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | ||||
| @@ -53,14 +79,71 @@ test.describe('User Migration Access', () => { | |||||
| test.describe.serial('User Migration Smoke', () => { | test.describe.serial('User Migration Smoke', () => { | ||||
| test.use({ storageState: rootStorageStatePath }); | test.use({ storageState: rootStorageStatePath }); | ||||
| test('root can open migration center and create a batch', async ({ page }) => { | |||||
| test('root can open migration center and create a batch', async ({ | |||||
| page, | |||||
| }) => { | |||||
| const batchName = nextBatchName('ui-create'); | const batchName = nextBatchName('ui-create'); | ||||
| await gotoUserPage(page); | await gotoUserPage(page); | ||||
| await openMigrationTab(page); | await openMigrationTab(page); | ||||
| await createBatch(page, batchName); | await createBatch(page, batchName); | ||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText('draft'); | |||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||||
| 'draft', | |||||
| ); | |||||
| }); | |||||
| test('root can clear selected batch from the list', async ({ page }) => { | |||||
| const batchName = nextBatchName('ui-clear'); | |||||
| await gotoUserPage(page); | |||||
| await openMigrationTab(page); | |||||
| await createBatch(page, batchName); | |||||
| await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | |||||
| await page.getByTestId('migration-clear-batch-selection-button').click(); | |||||
| await expect(page.getByTestId('migration-batch-detail')).toHaveCount(0); | |||||
| await expect(page.getByTestId('migration-execution-panel')).toHaveCount(0); | |||||
| }); | |||||
| test('root can create explicit_ids batch from candidate users', async ({ | |||||
| page, | |||||
| }) => { | |||||
| const batchName = nextBatchName('ui-explicit'); | |||||
| await gotoUserPage(page); | |||||
| await openMigrationTab(page); | |||||
| await createBatch(page, batchName, [10000003, 10000004]); | |||||
| await expect(page.getByTestId('migration-batch-table')).toContainText( | |||||
| '指定 2 人', | |||||
| ); | |||||
| await expect(page.getByTestId('migration-selection-summary')).toContainText( | |||||
| '已选 2 人', | |||||
| ); | |||||
| }); | |||||
| test('root can cancel a batch and recreate selected users in a new batch', async ({ | |||||
| page, | |||||
| }) => { | |||||
| const firstBatchName = nextBatchName('ui-cancel-source'); | |||||
| const secondBatchName = nextBatchName('ui-cancel-recreate'); | |||||
| await gotoUserPage(page); | |||||
| await openMigrationTab(page); | |||||
| await createBatch(page, firstBatchName, [10000001, 10000002]); | |||||
| await page.getByTestId('migration-cancel-button').click(); | |||||
| await expect(page.getByTestId('migration-cancel-confirm')).toBeVisible(); | |||||
| await page.getByTestId('migration-cancel-confirm').click(); | |||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||||
| 'cancelled', | |||||
| ); | |||||
| await createBatch(page, secondBatchName, [10000001, 10000002]); | |||||
| await expect(page.getByTestId('migration-batch-table')).toContainText( | |||||
| secondBatchName, | |||||
| ); | |||||
| }); | }); | ||||
| test('root can scan, resolve conflicts, execute, and verify a batch', async ({ | test('root can scan, resolve conflicts, execute, and verify a batch', async ({ | ||||
| @@ -86,7 +169,9 @@ test.describe.serial('User Migration Smoke', () => { | |||||
| await expect(page.getByTestId('migration-conflict-table')).toBeVisible(); | await expect(page.getByTestId('migration-conflict-table')).toBeVisible(); | ||||
| await page.getByTestId('migration-resolve-skip-10000003').click(); | await page.getByTestId('migration-resolve-skip-10000003').click(); | ||||
| await page.getByTestId('migration-resolve-skip-10000004').click(); | await page.getByTestId('migration-resolve-skip-10000004').click(); | ||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText('ready'); | |||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||||
| 'ready', | |||||
| ); | |||||
| await page.getByTestId('migration-execute-button').click(); | await page.getByTestId('migration-execute-button').click(); | ||||
| await expect(page.getByTestId('migration-execute-confirm')).toBeVisible(); | await expect(page.getByTestId('migration-execute-confirm')).toBeVisible(); | ||||
| @@ -103,13 +188,15 @@ test.describe.serial('User Migration Smoke', () => { | |||||
| ); | ); | ||||
| await page.getByTestId('migration-verify-button').click(); | await page.getByTestId('migration-verify-button').click(); | ||||
| await expect(page.getByTestId('migration-verify-results-table')).toBeVisible(); | |||||
| await expect(page.getByTestId('migration-verify-results-table')).toContainText( | |||||
| '10000001', | |||||
| ); | |||||
| await expect(page.getByTestId('migration-batch-detail-table')).toContainText( | |||||
| 'migrated', | |||||
| ); | |||||
| await expect( | |||||
| page.getByTestId('migration-verify-results-table'), | |||||
| ).toBeVisible(); | |||||
| await expect( | |||||
| page.getByTestId('migration-verify-results-table'), | |||||
| ).toContainText('10000001'); | |||||
| await expect( | |||||
| page.getByTestId('migration-batch-detail-table'), | |||||
| ).toContainText('migrated'); | |||||
| }); | }); | ||||
| test('page reload keeps batch list and detail loading available', async ({ | test('page reload keeps batch list and detail loading available', async ({ | ||||
| @@ -133,9 +220,16 @@ test.describe.serial('User Migration Smoke', () => { | |||||
| await gotoUserPage(page); | await gotoUserPage(page); | ||||
| await openMigrationTab(page); | await openMigrationTab(page); | ||||
| await expect(page.getByTestId('migration-batch-table')).toContainText(batchName); | |||||
| await page.getByTestId('migration-batch-table').getByText(batchName).click(); | |||||
| await expect(page.getByTestId('migration-batch-table')).toContainText( | |||||
| batchName, | |||||
| ); | |||||
| await page | |||||
| .getByTestId('migration-batch-table') | |||||
| .getByText(batchName) | |||||
| .click(); | |||||
| await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | ||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText('scanned'); | |||||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||||
| 'scanned', | |||||
| ); | |||||
| }); | }); | ||||
| }); | }); | ||||
| @@ -30,13 +30,35 @@ export default function BatchDetail({ | |||||
| }) { | }) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const selectionSummary = (() => { | |||||
| try { | |||||
| return selectedBatch?.selection_summary | |||||
| ? JSON.parse(selectedBatch.selection_summary) | |||||
| : null; | |||||
| } catch { | |||||
| return null; | |||||
| } | |||||
| })(); | |||||
| const columns = [ | const columns = [ | ||||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | ||||
| { title: t('用户名'), dataIndex: 'source_username' }, | { title: t('用户名'), dataIndex: 'source_username' }, | ||||
| { title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' }, | |||||
| { | |||||
| title: t('邮箱'), | |||||
| dataIndex: 'source_email', | |||||
| render: (value) => value || '-', | |||||
| }, | |||||
| { title: t('海外额度'), dataIndex: 'source_quota' }, | { title: t('海外额度'), dataIndex: 'source_quota' }, | ||||
| { title: t('冲突类型'), dataIndex: 'conflict_flags', render: renderFlags }, | |||||
| { title: t('处理策略'), dataIndex: 'resolution_strategy', render: (value) => value || '-' }, | |||||
| { | |||||
| title: t('冲突类型'), | |||||
| dataIndex: 'conflict_flags', | |||||
| render: renderFlags, | |||||
| }, | |||||
| { | |||||
| title: t('处理策略'), | |||||
| dataIndex: 'resolution_strategy', | |||||
| render: (value) => value || '-', | |||||
| }, | |||||
| { | { | ||||
| title: t('状态'), | title: t('状态'), | ||||
| dataIndex: 'status', | dataIndex: 'status', | ||||
| @@ -47,7 +69,11 @@ export default function BatchDetail({ | |||||
| dataIndex: 'target_user_id', | dataIndex: 'target_user_id', | ||||
| render: (value) => value || '-', | render: (value) => value || '-', | ||||
| }, | }, | ||||
| { title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' }, | |||||
| { | |||||
| title: t('错误信息'), | |||||
| dataIndex: 'error_message', | |||||
| render: (value) => value || '-', | |||||
| }, | |||||
| ]; | ]; | ||||
| return ( | return ( | ||||
| @@ -59,6 +85,39 @@ export default function BatchDetail({ | |||||
| > | > | ||||
| {t('批次详情')} #{selectedBatch.id} | {t('批次详情')} #{selectedBatch.id} | ||||
| </Typography.Title> | </Typography.Title> | ||||
| {selectedBatch?.selection_mode === 'explicit_ids' && ( | |||||
| <div style={{ marginBottom: 12 }} data-testid='migration-selection-summary'> | |||||
| <Typography.Text> | |||||
| {t('已选 {{count}} 人', { | |||||
| count: selectedBatch.requested_user_count || 0, | |||||
| })} | |||||
| </Typography.Text> | |||||
| <br /> | |||||
| <Typography.Text> | |||||
| {t('命中 {{count}} 人', { | |||||
| count: selectedBatch.matched_user_count || 0, | |||||
| })} | |||||
| </Typography.Text> | |||||
| <br /> | |||||
| <Typography.Text> | |||||
| {t('排除 {{count}} 人', { | |||||
| count: selectedBatch.excluded_user_count || 0, | |||||
| })} | |||||
| </Typography.Text> | |||||
| {selectionSummary?.excluded?.length > 0 && ( | |||||
| <> | |||||
| <br /> | |||||
| <Typography.Text type='secondary'> | |||||
| {selectionSummary.excluded | |||||
| .map((item) => `${item.user_id}:${item.reason}`) | |||||
| .join(', ')} | |||||
| </Typography.Text> | |||||
| </> | |||||
| )} | |||||
| </div> | |||||
| )} | |||||
| <Table | <Table | ||||
| rowKey='id' | rowKey='id' | ||||
| columns={columns} | columns={columns} | ||||
| @@ -1,8 +1,11 @@ | |||||
| import React, { useRef, useState } from 'react'; | |||||
| import React, { useEffect, useMemo, useRef, useState } from 'react'; | |||||
| import { | import { | ||||
| Button, | Button, | ||||
| Form, | Form, | ||||
| Input, | |||||
| Modal, | Modal, | ||||
| Radio, | |||||
| RadioGroup, | |||||
| Space, | Space, | ||||
| Table, | Table, | ||||
| Tag, | Tag, | ||||
| @@ -29,17 +32,53 @@ export default function BatchList({ | |||||
| createBatch, | createBatch, | ||||
| refresh, | refresh, | ||||
| selectBatch, | selectBatch, | ||||
| clearSelectedBatch, | |||||
| candidateUsers, | |||||
| candidateTotal, | |||||
| candidatePage, | |||||
| candidatePageSize, | |||||
| candidateKeyword, | |||||
| selectedCandidateIds, | |||||
| fetchCandidateUsers, | |||||
| syncCandidateSelections, | |||||
| removeCandidateSelection, | |||||
| clearCandidateSelection, | |||||
| }) { | }) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [visible, setVisible] = useState(false); | const [visible, setVisible] = useState(false); | ||||
| const [step, setStep] = useState(0); | |||||
| const [selectionMode, setSelectionMode] = useState('all'); | |||||
| const formApiRef = useRef(null); | const formApiRef = useRef(null); | ||||
| useEffect(() => { | |||||
| if (visible && step === 0 && selectionMode === 'explicit_ids') { | |||||
| void fetchCandidateUsers(0, candidatePageSize, candidateKeyword); | |||||
| } | |||||
| }, [ | |||||
| visible, | |||||
| step, | |||||
| selectionMode, | |||||
| fetchCandidateUsers, | |||||
| candidatePageSize, | |||||
| candidateKeyword, | |||||
| ]); | |||||
| const columns = [ | const columns = [ | ||||
| { title: t('批次名称'), dataIndex: 'name' }, | { title: t('批次名称'), dataIndex: 'name' }, | ||||
| { | |||||
| title: t('迁移范围'), | |||||
| dataIndex: 'requested_user_count', | |||||
| render: (_, record) => | |||||
| record.selection_mode === 'explicit_ids' | |||||
| ? t('指定 {{count}} 人', { count: record.requested_user_count || 0 }) | |||||
| : t('全部'), | |||||
| }, | |||||
| { | { | ||||
| title: t('状态'), | title: t('状态'), | ||||
| dataIndex: 'status', | dataIndex: 'status', | ||||
| render: (status) => <Tag color={statusColorMap[status] || 'grey'}>{status}</Tag>, | |||||
| render: (status) => ( | |||||
| <Tag color={statusColorMap[status] || 'grey'}>{status}</Tag> | |||||
| ), | |||||
| }, | }, | ||||
| { title: t('总用户数'), dataIndex: 'total_users' }, | { title: t('总用户数'), dataIndex: 'total_users' }, | ||||
| { title: t('冲突数'), dataIndex: 'conflict_users' }, | { title: t('冲突数'), dataIndex: 'conflict_users' }, | ||||
| @@ -52,11 +91,54 @@ export default function BatchList({ | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| const candidateColumns = [ | |||||
| { title: t('海外用户ID'), dataIndex: 'id' }, | |||||
| { title: t('用户名'), dataIndex: 'username' }, | |||||
| { | |||||
| title: t('邮箱'), | |||||
| dataIndex: 'email', | |||||
| render: (value) => value || '-', | |||||
| }, | |||||
| { title: t('海外额度'), dataIndex: 'quota' }, | |||||
| { title: t('分组'), dataIndex: 'group' }, | |||||
| ]; | |||||
| const selectedCandidateSummary = useMemo( | |||||
| () => | |||||
| `${selectedCandidateIds.slice(0, 10).join(', ')}${ | |||||
| selectedCandidateIds.length > 10 ? ' ...' : '' | |||||
| }`, | |||||
| [selectedCandidateIds], | |||||
| ); | |||||
| const closeModal = () => { | |||||
| setVisible(false); | |||||
| setStep(0); | |||||
| setSelectionMode('all'); | |||||
| clearCandidateSelection(); | |||||
| }; | |||||
| const handleCreate = async () => { | const handleCreate = async () => { | ||||
| if (step === 0) { | |||||
| if ( | |||||
| selectionMode === 'explicit_ids' && | |||||
| selectedCandidateIds.length === 0 | |||||
| ) { | |||||
| return; | |||||
| } | |||||
| setStep(1); | |||||
| return; | |||||
| } | |||||
| const values = await formApiRef.current.validate(); | const values = await formApiRef.current.validate(); | ||||
| const success = await createBatch(values); | |||||
| const success = await createBatch({ | |||||
| ...values, | |||||
| selection_mode: selectionMode, | |||||
| source_user_ids: | |||||
| selectionMode === 'explicit_ids' ? selectedCandidateIds : [], | |||||
| }); | |||||
| if (success) { | if (success) { | ||||
| setVisible(false); | |||||
| closeModal(); | |||||
| } | } | ||||
| }; | }; | ||||
| @@ -75,7 +157,7 @@ export default function BatchList({ | |||||
| onClick={() => setVisible(true)} | onClick={() => setVisible(true)} | ||||
| data-testid='migration-create-batch-button' | data-testid='migration-create-batch-button' | ||||
| > | > | ||||
| {t('新建批次')} | |||||
| {t('创建批次')} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| icon={<IconRefresh />} | icon={<IconRefresh />} | ||||
| @@ -84,6 +166,14 @@ export default function BatchList({ | |||||
| > | > | ||||
| {t('刷新')} | {t('刷新')} | ||||
| </Button> | </Button> | ||||
| {selectedBatch && ( | |||||
| <Button | |||||
| onClick={clearSelectedBatch} | |||||
| data-testid='migration-clear-batch-selection-button' | |||||
| > | |||||
| {t('清空选择')} | |||||
| </Button> | |||||
| )} | |||||
| </Space> | </Space> | ||||
| <Table | <Table | ||||
| rowKey='id' | rowKey='id' | ||||
| @@ -95,48 +185,211 @@ export default function BatchList({ | |||||
| rowSelection={{ | rowSelection={{ | ||||
| type: 'radio', | type: 'radio', | ||||
| selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | ||||
| onChange: (_, rows) => rows[0] && selectBatch(rows[0]), | |||||
| onChange: (_, rows) => { | |||||
| if (rows[0]) { | |||||
| void selectBatch(rows[0]); | |||||
| return; | |||||
| } | |||||
| clearSelectedBatch(); | |||||
| }, | |||||
| }} | }} | ||||
| onRow={(record) => ({ | onRow={(record) => ({ | ||||
| onClick: () => selectBatch(record), | |||||
| onClick: () => { | |||||
| if (selectedBatch?.id === record.id) { | |||||
| clearSelectedBatch(); | |||||
| return; | |||||
| } | |||||
| void selectBatch(record); | |||||
| }, | |||||
| })} | })} | ||||
| empty={t('暂无数据')} | empty={t('暂无数据')} | ||||
| /> | /> | ||||
| <Modal | <Modal | ||||
| visible={visible} | visible={visible} | ||||
| title={t('创建批次')} | |||||
| onCancel={() => setVisible(false)} | |||||
| title={step === 0 ? t('候选用户') : t('创建批次')} | |||||
| onCancel={closeModal} | |||||
| onOk={handleCreate} | onOk={handleCreate} | ||||
| okButtonProps={{ 'data-testid': 'migration-create-batch-submit' }} | |||||
| okText={step === 0 ? t('下一步') : t('创建批次')} | |||||
| okButtonProps={{ | |||||
| 'data-testid': 'migration-create-batch-submit', | |||||
| disabled: | |||||
| step === 0 && | |||||
| selectionMode === 'explicit_ids' && | |||||
| selectedCandidateIds.length === 0, | |||||
| }} | |||||
| cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }} | cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }} | ||||
| data-testid='migration-create-batch-modal' | data-testid='migration-create-batch-modal' | ||||
| width={step === 0 ? 960 : 640} | |||||
| > | > | ||||
| <Form | |||||
| getFormApi={(api) => { | |||||
| formApiRef.current = api; | |||||
| }} | |||||
| initValues={{ source_region: 'overseas', target_region: 'cn' }} | |||||
| > | |||||
| <Form.Input | |||||
| field='name' | |||||
| label={t('批次名称')} | |||||
| rules={[{ required: true, message: t('请输入批次名称') }]} | |||||
| data-testid='migration-batch-name-field' | |||||
| /> | |||||
| <Form.Input | |||||
| field='source_region' | |||||
| label={t('源区域')} | |||||
| rules={[{ required: true, message: t('请输入源区域') }]} | |||||
| data-testid='migration-batch-source-region-field' | |||||
| /> | |||||
| <Form.Input | |||||
| field='target_region' | |||||
| label={t('目标区域')} | |||||
| rules={[{ required: true, message: t('请输入目标区域') }]} | |||||
| data-testid='migration-batch-target-region-field' | |||||
| /> | |||||
| </Form> | |||||
| {step === 0 ? ( | |||||
| <Space vertical style={{ width: '100%' }}> | |||||
| <Typography.Text>{t('迁移范围')}</Typography.Text> | |||||
| <RadioGroup | |||||
| type='button' | |||||
| buttonSize='middle' | |||||
| value={selectionMode} | |||||
| onChange={(event) => { | |||||
| const nextValue = event?.target?.value || 'all'; | |||||
| setSelectionMode(nextValue); | |||||
| if (nextValue === 'explicit_ids') { | |||||
| void fetchCandidateUsers( | |||||
| 0, | |||||
| candidatePageSize, | |||||
| candidateKeyword, | |||||
| ); | |||||
| } | |||||
| }} | |||||
| data-testid='migration-selection-mode-group' | |||||
| > | |||||
| <Radio value='all' data-testid='migration-selection-mode-all'> | |||||
| {t('全部')} | |||||
| </Radio> | |||||
| <Radio | |||||
| value='explicit_ids' | |||||
| data-testid='migration-selection-mode-explicit' | |||||
| > | |||||
| {t('指定用户')} | |||||
| </Radio> | |||||
| </RadioGroup> | |||||
| {selectionMode === 'explicit_ids' && ( | |||||
| <> | |||||
| <Input | |||||
| value={candidateKeyword} | |||||
| onChange={(value) => | |||||
| void fetchCandidateUsers(0, candidatePageSize, value) | |||||
| } | |||||
| placeholder={t('按用户ID / 用户名 / 邮箱搜索')} | |||||
| data-testid='migration-candidate-search-input' | |||||
| /> | |||||
| <Space> | |||||
| <Typography.Text type='secondary'> | |||||
| <span data-testid='migration-candidate-selected-count'> | |||||
| {t('已选 {{count}} 人', { | |||||
| count: selectedCandidateIds.length, | |||||
| })} | |||||
| </span> | |||||
| </Typography.Text> | |||||
| <Button | |||||
| theme='borderless' | |||||
| size='small' | |||||
| disabled={selectedCandidateIds.length === 0} | |||||
| onClick={clearCandidateSelection} | |||||
| data-testid='migration-candidate-clear-selection-button' | |||||
| > | |||||
| {t('清空选择')} | |||||
| </Button> | |||||
| </Space> | |||||
| {selectedCandidateIds.length > 0 && ( | |||||
| <Space | |||||
| wrap | |||||
| data-testid='migration-candidate-selected-summary' | |||||
| > | |||||
| {selectedCandidateIds.map((id) => ( | |||||
| <Tag | |||||
| key={id} | |||||
| closable | |||||
| onClose={() => removeCandidateSelection(id)} | |||||
| > | |||||
| {id} | |||||
| </Tag> | |||||
| ))} | |||||
| </Space> | |||||
| )} | |||||
| <Table | |||||
| rowKey='id' | |||||
| columns={candidateColumns} | |||||
| dataSource={candidateUsers} | |||||
| size='small' | |||||
| data-testid='migration-candidate-table' | |||||
| pagination={{ | |||||
| currentPage: candidatePage + 1, | |||||
| pageSize: candidatePageSize, | |||||
| total: candidateTotal, | |||||
| pageSizeOpts: [20, 50, 100], | |||||
| showSizeChanger: true, | |||||
| onPageChange: (page) => | |||||
| void fetchCandidateUsers( | |||||
| page - 1, | |||||
| candidatePageSize, | |||||
| candidateKeyword, | |||||
| ), | |||||
| onPageSizeChange: (size) => | |||||
| void fetchCandidateUsers(0, size, candidateKeyword), | |||||
| }} | |||||
| rowSelection={{ | |||||
| type: 'checkbox', | |||||
| selectedRowKeys: selectedCandidateIds, | |||||
| onChange: (keys) => | |||||
| syncCandidateSelections( | |||||
| candidateUsers.map((user) => user.id), | |||||
| keys, | |||||
| ), | |||||
| }} | |||||
| onRow={(record) => ({ | |||||
| 'data-testid': `migration-candidate-row-${record.id}`, | |||||
| })} | |||||
| /> | |||||
| </> | |||||
| )} | |||||
| </Space> | |||||
| ) : ( | |||||
| <Form | |||||
| getFormApi={(api) => { | |||||
| formApiRef.current = api; | |||||
| }} | |||||
| initValues={{ source_region: 'ov', target_region: 'cn' }} | |||||
| > | |||||
| <Typography.Text | |||||
| type='secondary' | |||||
| data-testid='migration-selection-range' | |||||
| > | |||||
| {selectionMode === 'explicit_ids' | |||||
| ? t('指定 {{count}} 人', { | |||||
| count: selectedCandidateIds.length, | |||||
| }) | |||||
| : t('全部')} | |||||
| </Typography.Text> | |||||
| {selectionMode === 'explicit_ids' && | |||||
| selectedCandidateIds.length > 0 && ( | |||||
| <Typography.Paragraph | |||||
| type='secondary' | |||||
| data-testid='migration-selected-candidate-summary' | |||||
| > | |||||
| {selectedCandidateSummary} | |||||
| </Typography.Paragraph> | |||||
| )} | |||||
| <Button | |||||
| theme='borderless' | |||||
| style={{ paddingLeft: 0 }} | |||||
| onClick={() => setStep(0)} | |||||
| data-testid='migration-back-to-candidates-button' | |||||
| > | |||||
| {selectionMode === 'explicit_ids' | |||||
| ? t('返回选择用户') | |||||
| : t('返回设置范围')} | |||||
| </Button> | |||||
| <Form.Input | |||||
| field='name' | |||||
| label={t('批次名称')} | |||||
| rules={[{ required: true, message: t('请输入批次名称') }]} | |||||
| data-testid='migration-batch-name-field' | |||||
| /> | |||||
| <Form.Input | |||||
| field='source_region' | |||||
| label={t('源区域')} | |||||
| rules={[{ required: true, message: t('请输入源区域') }]} | |||||
| data-testid='migration-batch-source-region-field' | |||||
| /> | |||||
| <Form.Input | |||||
| field='target_region' | |||||
| label={t('目标区域')} | |||||
| rules={[{ required: true, message: t('请输入目标区域') }]} | |||||
| data-testid='migration-batch-target-region-field' | |||||
| /> | |||||
| </Form> | |||||
| )} | |||||
| </Modal> | </Modal> | ||||
| </div> | </div> | ||||
| ); | ); | ||||
| @@ -19,10 +19,18 @@ export default function ExecutionPanel({ | |||||
| scanBatch, | scanBatch, | ||||
| executeBatch, | executeBatch, | ||||
| retryBatch, | retryBatch, | ||||
| cancelBatch, | |||||
| verifyBatch, | verifyBatch, | ||||
| verifyResults, | verifyResults, | ||||
| }) { | }) { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const canCancel = [ | |||||
| 'draft', | |||||
| 'scanned', | |||||
| 'ready', | |||||
| 'failed', | |||||
| 'cancelled', | |||||
| ].includes(selectedBatch.status); | |||||
| const verifyColumns = [ | const verifyColumns = [ | ||||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | ||||
| @@ -79,6 +87,21 @@ export default function ExecutionPanel({ | |||||
| > | > | ||||
| {t('重试失败项')} | {t('重试失败项')} | ||||
| </Button> | </Button> | ||||
| <Popconfirm | |||||
| title={t('确认删除该批次?')} | |||||
| content={t('这是软删除,只会将批次标记为 cancelled')} | |||||
| onConfirm={() => cancelBatch(selectedBatch.id)} | |||||
| okButtonProps={{ 'data-testid': 'migration-cancel-confirm' }} | |||||
| cancelButtonProps={{ 'data-testid': 'migration-cancel-abort' }} | |||||
| > | |||||
| <Button | |||||
| type='danger' | |||||
| disabled={!canCancel} | |||||
| data-testid='migration-cancel-button' | |||||
| > | |||||
| {t('删除批次')} | |||||
| </Button> | |||||
| </Popconfirm> | |||||
| <Button | <Button | ||||
| onClick={() => verifyBatch(selectedBatch.id)} | onClick={() => verifyBatch(selectedBatch.id)} | ||||
| data-testid='migration-verify-button' | data-testid='migration-verify-button' | ||||
| @@ -88,7 +111,9 @@ export default function ExecutionPanel({ | |||||
| </Space> | </Space> | ||||
| {verifyResults.length > 0 && ( | {verifyResults.length > 0 && ( | ||||
| <Table | <Table | ||||
| rowKey={(record) => `${record.source_user_id}-${record.target_user_id}`} | |||||
| rowKey={(record) => | |||||
| `${record.source_user_id}-${record.target_user_id}` | |||||
| } | |||||
| columns={verifyColumns} | columns={verifyColumns} | ||||
| dataSource={verifyResults} | dataSource={verifyResults} | ||||
| pagination={false} | pagination={false} | ||||
| @@ -9,6 +9,12 @@ export function useUserMigrationData() { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [batches, setBatches] = useState([]); | const [batches, setBatches] = useState([]); | ||||
| const [selectedBatch, setSelectedBatch] = useState(null); | const [selectedBatch, setSelectedBatch] = useState(null); | ||||
| const [candidateUsers, setCandidateUsers] = useState([]); | |||||
| const [candidateTotal, setCandidateTotal] = useState(0); | |||||
| const [candidatePage, setCandidatePage] = useState(0); | |||||
| const [candidatePageSize, setCandidatePageSize] = useState(50); | |||||
| const [candidateKeyword, setCandidateKeyword] = useState(''); | |||||
| const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); | |||||
| const [items, setItems] = useState([]); | const [items, setItems] = useState([]); | ||||
| const [itemsTotal, setItemsTotal] = useState(0); | const [itemsTotal, setItemsTotal] = useState(0); | ||||
| const [itemsPage, setItemsPage] = useState(0); | const [itemsPage, setItemsPage] = useState(0); | ||||
| @@ -49,7 +55,8 @@ export function useUserMigrationData() { | |||||
| const data = res.data.data || []; | const data = res.data.data || []; | ||||
| setBatches(data); | setBatches(data); | ||||
| if (selectedBatchIdRef.current) { | if (selectedBatchIdRef.current) { | ||||
| const current = data.find((batch) => batch.id === selectedBatchIdRef.current) || null; | |||||
| const current = | |||||
| data.find((batch) => batch.id === selectedBatchIdRef.current) || null; | |||||
| setSelectedBatch(current); | setSelectedBatch(current); | ||||
| } | } | ||||
| return data; | return data; | ||||
| @@ -57,64 +64,138 @@ export function useUserMigrationData() { | |||||
| showError(error.message); | showError(error.message); | ||||
| return []; | return []; | ||||
| } | } | ||||
| }, []); | |||||
| }, [t]); | |||||
| const fetchItems = useCallback(async (batchId, page = 0, pageSize = 50) => { | |||||
| if (!batchId) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| const res = await API.get(`/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`); | |||||
| if (!res.data.success) { | |||||
| showError(res.data.error || t('获取批次详情失败')); | |||||
| const fetchItems = useCallback( | |||||
| async (batchId, page = 0, pageSize = 50) => { | |||||
| if (!batchId) { | |||||
| return; | return; | ||||
| } | } | ||||
| setItems(res.data.items || []); | |||||
| setItemsTotal(res.data.total || 0); | |||||
| setItemsPage(page); | |||||
| setItemsPageSize(pageSize); | |||||
| setSelectedBatch(res.data.data || null); | |||||
| } catch (error) { | |||||
| showError(error.message); | |||||
| } | |||||
| try { | |||||
| const res = await API.get( | |||||
| `/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`, | |||||
| ); | |||||
| if (!res.data.success) { | |||||
| showError(res.data.error || t('获取批次详情失败')); | |||||
| return; | |||||
| } | |||||
| setItems(res.data.items || []); | |||||
| setItemsTotal(res.data.total || 0); | |||||
| setItemsPage(page); | |||||
| setItemsPageSize(pageSize); | |||||
| setSelectedBatch(res.data.data || null); | |||||
| } catch (error) { | |||||
| showError(error.message); | |||||
| } | |||||
| }, | |||||
| [t], | |||||
| ); | |||||
| const fetchCandidateUsers = useCallback( | |||||
| async (page = 0, pageSize = 50, keyword = '') => { | |||||
| try { | |||||
| const params = new URLSearchParams({ | |||||
| page: String(page), | |||||
| page_size: String(pageSize), | |||||
| }); | |||||
| if (keyword) { | |||||
| params.set('keyword', keyword); | |||||
| } | |||||
| const res = await API.get( | |||||
| `/api/user-migrations/candidate-users?${params.toString()}`, | |||||
| ); | |||||
| if (!res.data.success) { | |||||
| showError(res.data.error || t('获取候选用户失败')); | |||||
| return; | |||||
| } | |||||
| setCandidateUsers(res.data.data || []); | |||||
| setCandidateTotal(res.data.total || 0); | |||||
| setCandidatePage(page); | |||||
| setCandidatePageSize(pageSize); | |||||
| setCandidateKeyword(keyword); | |||||
| } catch (error) { | |||||
| showError(error.message); | |||||
| } | |||||
| }, | |||||
| [t], | |||||
| ); | |||||
| const syncCandidateSelections = useCallback((pageIds, ids) => { | |||||
| setSelectedCandidateIds((prev) => { | |||||
| const pageIdSet = new Set(pageIds); | |||||
| const next = prev.filter((id) => !pageIdSet.has(id)); | |||||
| ids.forEach((id) => { | |||||
| if (!next.includes(id)) { | |||||
| next.push(id); | |||||
| } | |||||
| }); | |||||
| return next; | |||||
| }); | |||||
| }, []); | }, []); | ||||
| const refresh = useCallback(async (silent = false) => { | |||||
| if (!silent) { | |||||
| setLoading(true); | |||||
| } | |||||
| const latestBatches = await fetchBatches(); | |||||
| if (selectedBatchIdRef.current) { | |||||
| await fetchItems( | |||||
| selectedBatchIdRef.current, | |||||
| itemsPageRef.current, | |||||
| itemsPageSizeRef.current, | |||||
| const removeCandidateSelection = useCallback((id) => { | |||||
| setSelectedCandidateIds((prev) => | |||||
| prev.filter((candidateId) => candidateId !== id), | |||||
| ); | |||||
| }, []); | |||||
| const clearCandidateSelection = useCallback(() => { | |||||
| setSelectedCandidateIds([]); | |||||
| }, []); | |||||
| const clearSelectedBatch = useCallback(() => { | |||||
| selectedBatchIdRef.current = null; | |||||
| setSelectedBatch(null); | |||||
| setItems([]); | |||||
| setItemsTotal(0); | |||||
| setItemsPage(0); | |||||
| setVerifyResults([]); | |||||
| }, []); | |||||
| const refresh = useCallback( | |||||
| async (silent = false) => { | |||||
| if (!silent) { | |||||
| setLoading(true); | |||||
| } | |||||
| const latestBatches = await fetchBatches(); | |||||
| if (selectedBatchIdRef.current) { | |||||
| await fetchItems( | |||||
| selectedBatchIdRef.current, | |||||
| itemsPageRef.current, | |||||
| itemsPageSizeRef.current, | |||||
| ); | |||||
| } | |||||
| const hasRunning = latestBatches.some((batch) => | |||||
| RUNNING_STATUSES.has(batch.status), | |||||
| ); | ); | ||||
| } | |||||
| const hasRunning = latestBatches.some((batch) => RUNNING_STATUSES.has(batch.status)); | |||||
| if (hasRunning && !pollTimerRef.current) { | |||||
| pollTimerRef.current = setInterval(() => { | |||||
| void refresh(true); | |||||
| }, POLL_INTERVAL_MS); | |||||
| } | |||||
| if (!hasRunning) { | |||||
| stopPolling(); | |||||
| } | |||||
| if (!silent) { | |||||
| setLoading(false); | |||||
| } | |||||
| }, [fetchBatches, fetchItems, stopPolling]); | |||||
| if (hasRunning && !pollTimerRef.current) { | |||||
| pollTimerRef.current = setInterval(() => { | |||||
| void refresh(true); | |||||
| }, POLL_INTERVAL_MS); | |||||
| } | |||||
| if (!hasRunning) { | |||||
| stopPolling(); | |||||
| } | |||||
| if (!silent) { | |||||
| setLoading(false); | |||||
| } | |||||
| }, | |||||
| [fetchBatches, fetchItems, stopPolling], | |||||
| ); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| void refresh(); | void refresh(); | ||||
| return () => stopPolling(); | return () => stopPolling(); | ||||
| }, [refresh, stopPolling]); | }, [refresh, stopPolling]); | ||||
| const selectBatch = useCallback(async (batch) => { | |||||
| setVerifyResults([]); | |||||
| setSelectedBatch(batch); | |||||
| await fetchItems(batch.id, 0, itemsPageSizeRef.current); | |||||
| }, [fetchItems]); | |||||
| const selectBatch = useCallback( | |||||
| async (batch) => { | |||||
| setVerifyResults([]); | |||||
| setSelectedBatch(batch); | |||||
| await fetchItems(batch.id, 0, itemsPageSizeRef.current); | |||||
| }, | |||||
| [fetchItems], | |||||
| ); | |||||
| const createBatch = async (payload) => { | const createBatch = async (payload) => { | ||||
| try { | try { | ||||
| @@ -134,7 +215,9 @@ export function useUserMigrationData() { | |||||
| const scanBatch = async (batchId) => { | const scanBatch = async (batchId) => { | ||||
| try { | try { | ||||
| const res = await API.post(`/api/user-migrations/batches/${batchId}/scan`); | |||||
| const res = await API.post( | |||||
| `/api/user-migrations/batches/${batchId}/scan`, | |||||
| ); | |||||
| if (!res.data.success) { | if (!res.data.success) { | ||||
| showError(res.data.error || t('预检查启动失败')); | showError(res.data.error || t('预检查启动失败')); | ||||
| return false; | return false; | ||||
| @@ -150,10 +233,13 @@ export function useUserMigrationData() { | |||||
| const resolveItem = async (itemId, strategy, targetUserId = 0) => { | const resolveItem = async (itemId, strategy, targetUserId = 0) => { | ||||
| try { | try { | ||||
| const res = await API.post(`/api/user-migrations/items/${itemId}/resolve`, { | |||||
| resolution_strategy: strategy, | |||||
| target_user_id: targetUserId, | |||||
| }); | |||||
| const res = await API.post( | |||||
| `/api/user-migrations/items/${itemId}/resolve`, | |||||
| { | |||||
| resolution_strategy: strategy, | |||||
| target_user_id: targetUserId, | |||||
| }, | |||||
| ); | |||||
| if (!res.data.success) { | if (!res.data.success) { | ||||
| showError(res.data.error || t('更新策略失败')); | showError(res.data.error || t('更新策略失败')); | ||||
| return false; | return false; | ||||
| @@ -168,7 +254,9 @@ export function useUserMigrationData() { | |||||
| const executeBatch = async (batchId) => { | const executeBatch = async (batchId) => { | ||||
| try { | try { | ||||
| const res = await API.post(`/api/user-migrations/batches/${batchId}/execute`); | |||||
| const res = await API.post( | |||||
| `/api/user-migrations/batches/${batchId}/execute`, | |||||
| ); | |||||
| if (!res.data.success) { | if (!res.data.success) { | ||||
| showError(res.data.error || t('迁移启动失败')); | showError(res.data.error || t('迁移启动失败')); | ||||
| return false; | return false; | ||||
| @@ -184,7 +272,9 @@ export function useUserMigrationData() { | |||||
| const retryBatch = async (batchId) => { | const retryBatch = async (batchId) => { | ||||
| try { | try { | ||||
| const res = await API.post(`/api/user-migrations/batches/${batchId}/retry`); | |||||
| const res = await API.post( | |||||
| `/api/user-migrations/batches/${batchId}/retry`, | |||||
| ); | |||||
| if (!res.data.success) { | if (!res.data.success) { | ||||
| showError(res.data.error || t('重试启动失败')); | showError(res.data.error || t('重试启动失败')); | ||||
| return false; | return false; | ||||
| @@ -198,9 +288,29 @@ export function useUserMigrationData() { | |||||
| } | } | ||||
| }; | }; | ||||
| const cancelBatch = async (batchId) => { | |||||
| try { | |||||
| const res = await API.post( | |||||
| `/api/user-migrations/batches/${batchId}/cancel`, | |||||
| ); | |||||
| if (!res.data.success) { | |||||
| showError(res.data.error || t('删除批次失败')); | |||||
| return false; | |||||
| } | |||||
| showSuccess(t('批次已删除')); | |||||
| await refresh(); | |||||
| return true; | |||||
| } catch (error) { | |||||
| showError(error.message); | |||||
| return false; | |||||
| } | |||||
| }; | |||||
| const verifyBatch = async (batchId) => { | const verifyBatch = async (batchId) => { | ||||
| try { | try { | ||||
| const res = await API.post(`/api/user-migrations/batches/${batchId}/verify`); | |||||
| const res = await API.post( | |||||
| `/api/user-migrations/batches/${batchId}/verify`, | |||||
| ); | |||||
| if (!res.data.success) { | if (!res.data.success) { | ||||
| showError(res.data.error || t('验证失败')); | showError(res.data.error || t('验证失败')); | ||||
| return []; | return []; | ||||
| @@ -218,6 +328,12 @@ export function useUserMigrationData() { | |||||
| return { | return { | ||||
| batches, | batches, | ||||
| selectedBatch, | selectedBatch, | ||||
| candidateUsers, | |||||
| candidateTotal, | |||||
| candidatePage, | |||||
| candidatePageSize, | |||||
| candidateKeyword, | |||||
| selectedCandidateIds, | |||||
| items, | items, | ||||
| itemsTotal, | itemsTotal, | ||||
| itemsPage, | itemsPage, | ||||
| @@ -226,12 +342,18 @@ export function useUserMigrationData() { | |||||
| verifyResults, | verifyResults, | ||||
| refresh, | refresh, | ||||
| selectBatch, | selectBatch, | ||||
| clearSelectedBatch, | |||||
| fetchCandidateUsers, | |||||
| syncCandidateSelections, | |||||
| removeCandidateSelection, | |||||
| clearCandidateSelection, | |||||
| fetchItems, | fetchItems, | ||||
| createBatch, | createBatch, | ||||
| scanBatch, | scanBatch, | ||||
| resolveItem, | resolveItem, | ||||
| executeBatch, | executeBatch, | ||||
| retryBatch, | retryBatch, | ||||
| cancelBatch, | |||||
| verifyBatch, | verifyBatch, | ||||
| t, | t, | ||||
| }; | }; | ||||
| @@ -145,19 +145,23 @@ export default function ModelRatioNotSetEditor(props) { | |||||
| }; | }; | ||||
| try { | try { | ||||
| // 数据转换 - 只处理已修改的模型 | |||||
| // ??????????????????????????????????????? | |||||
| models.forEach((model) => { | models.forEach((model) => { | ||||
| // 只有当用户设置了值时才更新 | |||||
| delete output.ModelPrice[model.name]; | |||||
| delete output.ModelRatio[model.name]; | |||||
| delete output.CompletionRatio[model.name]; | |||||
| if (model.price !== '') { | if (model.price !== '') { | ||||
| // 如果价格不为空,则转换为浮点数,忽略倍率参数 | |||||
| output.ModelPrice[model.name] = parseFloat(model.price); | output.ModelPrice[model.name] = parseFloat(model.price); | ||||
| } else { | } else { | ||||
| if (model.ratio !== '') | |||||
| if (model.ratio !== '') { | |||||
| output.ModelRatio[model.name] = parseFloat(model.ratio); | output.ModelRatio[model.name] = parseFloat(model.ratio); | ||||
| if (model.completionRatio !== '') | |||||
| } | |||||
| if (model.completionRatio !== '') { | |||||
| output.CompletionRatio[model.name] = parseFloat( | output.CompletionRatio[model.name] = parseFloat( | ||||
| model.completionRatio, | model.completionRatio, | ||||
| ); | ); | ||||
| } | |||||
| } | } | ||||
| }); | }); | ||||