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) { | |||
| 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 | |||
| } | |||
| users, total, err := model.ListLocalUsersForMigration(page, pageSize) | |||
| users, total, err := model.QueryLocalUsersForMigration(page, pageSize, keyword) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"}) | |||
| return | |||
| @@ -32,6 +33,77 @@ func ListMigrationUsers(c *gin.Context) { | |||
| 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) { | |||
| userId, err := strconv.Atoi(c.Param("id")) | |||
| if err != nil { | |||
| @@ -9,6 +9,7 @@ import ( | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| @@ -80,6 +81,106 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) { | |||
| 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) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| @@ -1,28 +1,46 @@ | |||
| package controller | |||
| import ( | |||
| "errors" | |||
| "net/http" | |||
| "strconv" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||
| "github.com/gin-gonic/gin" | |||
| "gorm.io/gorm" | |||
| ) | |||
| type migrationVerifier interface { | |||
| VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) | |||
| } | |||
| type migrationCandidateClient interface { | |||
| ListUsers(page, pageSize int, keyword string) (*user_migration.ListRemoteUsersResponse, error) | |||
| } | |||
| var newMigrationVerifier = func() migrationVerifier { | |||
| return newMigrationService() | |||
| } | |||
| var newMigrationCandidateClient = func() migrationCandidateClient { | |||
| settings := system_setting.GetRegionSyncSettings() | |||
| endpoint := settings.MasterEndpoint | |||
| if settings.IsMaster && len(settings.SlaveEndpoints) > 0 { | |||
| endpoint = settings.SlaveEndpoints[0] | |||
| } | |||
| return user_migration.NewClient(endpoint, settings.SyncApiKey) | |||
| } | |||
| func CreateUserMigrationBatch(c *gin.Context) { | |||
| var req struct { | |||
| Name string `json:"name" binding:"required"` | |||
| SourceRegion string `json:"source_region" binding:"required"` | |||
| TargetRegion string `json:"target_region" binding:"required"` | |||
| Name string `json:"name" binding:"required"` | |||
| SourceRegion string `json:"source_region" binding:"required"` | |||
| TargetRegion string `json:"target_region" binding:"required"` | |||
| SelectionMode string `json:"selection_mode"` | |||
| SourceUserIDs []int `json:"source_user_ids"` | |||
| } | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) | |||
| @@ -30,11 +48,61 @@ func CreateUserMigrationBatch(c *gin.Context) { | |||
| } | |||
| operatorID, _ := c.Get("id") | |||
| if req.SelectionMode == "" { | |||
| req.SelectionMode = user_migration.SelectionModeAll | |||
| } | |||
| if req.SelectionMode != user_migration.SelectionModeAll && req.SelectionMode != user_migration.SelectionModeExplicitIDs { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"}) | |||
| return | |||
| } | |||
| payload := user_migration.SelectionPayload{} | |||
| if req.SelectionMode == user_migration.SelectionModeExplicitIDs { | |||
| if len(req.SourceUserIDs) == 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "source_user_ids is required"}) | |||
| return | |||
| } | |||
| seen := make(map[int]struct{}, len(req.SourceUserIDs)) | |||
| payload.SourceUserIDs = make([]int, 0, len(req.SourceUserIDs)) | |||
| for _, id := range req.SourceUserIDs { | |||
| if id <= 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid source_user_ids"}) | |||
| return | |||
| } | |||
| if _, ok := seen[id]; ok { | |||
| continue | |||
| } | |||
| seen[id] = struct{}{} | |||
| payload.SourceUserIDs = append(payload.SourceUserIDs, id) | |||
| } | |||
| if len(payload.SourceUserIDs) > 1000 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "too many source_user_ids"}) | |||
| return | |||
| } | |||
| conflicts, err := model.FindActiveMigrationUserConflicts(payload.SourceUserIDs) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if len(conflicts) > 0 { | |||
| c.JSON(http.StatusConflict, gin.H{ | |||
| "success": false, | |||
| "error": "some selected users already exist in active migration batches", | |||
| "conflicts": conflicts, | |||
| }) | |||
| return | |||
| } | |||
| } | |||
| payloadJSON, _ := common.Marshal(payload) | |||
| batch := &model.UserMigrationBatch{ | |||
| Name: req.Name, | |||
| SourceRegion: req.SourceRegion, | |||
| TargetRegion: req.TargetRegion, | |||
| 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 { | |||
| 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}) | |||
| } | |||
| func ListMigrationCandidateUsers(c *gin.Context) { | |||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) | |||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) | |||
| keyword := c.Query("keyword") | |||
| if pageSize <= 0 || pageSize > 200 { | |||
| pageSize = 50 | |||
| } | |||
| resp, err := newMigrationCandidateClient().ListUsers(page, pageSize, keyword) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{ | |||
| "success": true, | |||
| "data": resp.Data, | |||
| "total": resp.Total, | |||
| }) | |||
| } | |||
| func ListUserMigrationBatches(c *gin.Context) { | |||
| var batches []model.UserMigrationBatch | |||
| if err := model.DB.Order("id desc").Find(&batches).Error; err != nil { | |||
| @@ -204,6 +292,29 @@ func RetryUserMigrationBatch(c *gin.Context) { | |||
| c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "retry started"}) | |||
| } | |||
| func CancelUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| cancelled, err := model.CancelUserMigrationBatch(batchID) | |||
| if err != nil { | |||
| if errors.Is(err, gorm.ErrRecordNotFound) { | |||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "batch not found"}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if !cancelled { | |||
| c.JSON(http.StatusConflict, gin.H{ | |||
| "success": false, | |||
| "error": "batch cannot be cancelled in current status", | |||
| }) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "message": "batch cancelled"}) | |||
| } | |||
| func VerifyUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| @@ -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 { | |||
| results map[int]*user_migration.VerifyResult | |||
| errs map[int]error | |||
| @@ -94,6 +112,205 @@ func TestCreateUserMigrationBatch_Success(t *testing.T) { | |||
| 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) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| @@ -389,6 +606,118 @@ func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) { | |||
| 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) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| @@ -195,6 +195,38 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | |||
| 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) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| @@ -1099,6 +1099,10 @@ func DeltaUpdateUserQuota(id int, delta int) (err 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 { | |||
| page = 0 | |||
| } | |||
| @@ -1111,6 +1115,15 @@ func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { | |||
| query := DB.Model(&User{}). | |||
| Where("source = ?", common.UserSourceLocal). | |||
| 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 { | |||
| return nil, 0, err | |||
| } | |||
| @@ -1,6 +1,9 @@ | |||
| package model | |||
| import ( | |||
| "fmt" | |||
| "slices" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "gorm.io/gorm" | |||
| "gorm.io/gorm/clause" | |||
| @@ -34,6 +37,12 @@ type UserMigrationBatch struct { | |||
| Name string `json:"name" gorm:"type:varchar(128);not null"` | |||
| SourceRegion string `json:"source_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"` | |||
| TotalUsers int `json:"total_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"` | |||
| } | |||
| 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 { | |||
| now := common.GetTimestamp() | |||
| if b.Status == "" { | |||
| b.Status = UserMigrationBatchStatusDraft | |||
| } | |||
| if b.SelectionMode == "" { | |||
| b.SelectionMode = "all" | |||
| } | |||
| b.CreatedAt = now | |||
| b.UpdatedAt = now | |||
| return nil | |||
| @@ -108,6 +126,121 @@ func CreateUserMigrationBatch(batch *UserMigrationBatch) 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 { | |||
| var existing UserMigrationItem | |||
| result := DB.Where("batch_id = ? AND source_user_id = ?", item.BatchId, item.SourceUserId).Limit(1).Find(&existing) | |||
| @@ -1,6 +1,7 @@ | |||
| package model | |||
| import ( | |||
| "encoding/json" | |||
| "testing" | |||
| "github.com/glebarez/sqlite" | |||
| @@ -55,9 +56,43 @@ func TestCreateUserMigrationBatch_DefaultStatus(t *testing.T) { | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| require.Equal(t, UserMigrationBatchStatusDraft, batch.Status) | |||
| require.Equal(t, "all", batch.SelectionMode) | |||
| 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) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| @@ -212,3 +247,39 @@ func TestRefreshUserMigrationBatchExecutionStats_MigratingLeakHandled(t *testing | |||
| require.Equal(t, 0, refreshed.MigratedUsers) | |||
| 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", controller.ListMigrationUsers) | |||
| migrationRoute.POST("/users/query", controller.QueryMigrationUsers) | |||
| migrationRoute.GET("/users/:id", controller.GetMigrationUser) | |||
| migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced) | |||
| } | |||
| @@ -418,12 +419,14 @@ func SetApiRouter(router *gin.Engine) { | |||
| userMigrationRoute := apiRouter.Group("/user-migrations") | |||
| userMigrationRoute.Use(middleware.RootAuth()) | |||
| { | |||
| userMigrationRoute.GET("/candidate-users", controller.ListMigrationCandidateUsers) | |||
| userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch) | |||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | |||
| userMigrationRoute.GET("/batches/:id", controller.GetUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/scan", controller.ScanUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/retry", controller.RetryUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/verify", controller.VerifyUserMigrationBatch) | |||
| userMigrationRoute.POST("/items/:id/resolve", controller.ResolveUserMigrationItem) | |||
| } | |||
| @@ -65,6 +65,7 @@ func setupUserMigrationRootRouter() *gin.Engine { | |||
| { | |||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | |||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||
| } | |||
| return r | |||
| @@ -96,6 +97,7 @@ func setupUserMigrationAdminRouter() *gin.Engine { | |||
| { | |||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | |||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||
| } | |||
| return r | |||
| @@ -129,6 +131,11 @@ func TestUserMigrationRoutes_RootCanAccess(t *testing.T) { | |||
| w = httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| 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) { | |||
| @@ -152,4 +159,10 @@ func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| 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" | |||
| "io" | |||
| "net/http" | |||
| "net/url" | |||
| "strings" | |||
| "time" | |||
| @@ -12,7 +13,8 @@ import ( | |||
| ) | |||
| 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) | |||
| ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error | |||
| HasSyncedCopy(cnUserId int) (bool, error) | |||
| @@ -68,9 +70,12 @@ func (c *Client) doRequest(method, path string, body any) ([]byte, error) { | |||
| 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 { | |||
| return nil, err | |||
| } | |||
| @@ -81,6 +86,23 @@ func (c *Client) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) | |||
| 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) { | |||
| data, err := c.doRequest(http.MethodGet, | |||
| fmt.Sprintf("/api/internal/migration/users/%d", userId), nil) | |||
| @@ -25,13 +25,40 @@ func (s *Service) ScanBatch(batchId int) error { | |||
| 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 | |||
| var latestSummary *SelectionSummary | |||
| 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) | |||
| if err != nil { | |||
| 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 | |||
| } | |||
| 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) | |||
| } | |||
| @@ -1,6 +1,7 @@ | |||
| package user_migration | |||
| import ( | |||
| "encoding/json" | |||
| "fmt" | |||
| "testing" | |||
| @@ -13,6 +14,8 @@ import ( | |||
| type fakeClient struct { | |||
| listPages map[int][]RemoteUserSnapshot | |||
| queryPages map[int][]RemoteUserSnapshot | |||
| querySummary *SelectionSummary | |||
| getUserResp *RemoteUserSnapshot | |||
| getUserByID map[int]*RemoteUserSnapshot | |||
| hasCopyResult bool | |||
| @@ -21,12 +24,30 @@ type fakeClient struct { | |||
| 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] | |||
| total := 0 | |||
| for _, pageUsers := range f.listPages { | |||
| total += len(pageUsers) | |||
| } | |||
| return &ListRemoteUsersResponse{ | |||
| Success: true, | |||
| 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 | |||
| } | |||
| @@ -253,6 +274,149 @@ func TestScanBatch_GitHubId_SameUser_RecommendsMerge(t *testing.T) { | |||
| 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) { | |||
| db := setupMigrationServiceDB(t) | |||
| orig := model.DB | |||
| @@ -5,6 +5,26 @@ type RemoteOAuthBinding struct { | |||
| 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 { | |||
| Id int `json:"id"` | |||
| Username string `json:"username"` | |||
| @@ -37,6 +57,21 @@ type ListRemoteUsersResponse struct { | |||
| 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 { | |||
| RemoteUserId int `json:"remote_user_id"` | |||
| SyncedQuota int `json:"synced_quota"` | |||
| @@ -9,6 +9,7 @@ import ( | |||
| "github.com/QuantumNous/new-api/common" | |||
| "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" | |||
| "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) | |||
| } | |||
| 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 { | |||
| 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 { | |||
| 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, | |||
| "source_region": "ov", | |||
| "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.NotZero(t, resp.Data.Id) | |||
| return resp.Data.Id | |||
| @@ -22,20 +22,46 @@ async function openMigrationTab(page: Page) { | |||
| 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 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 expect(page.getByTestId('migration-execution-panel')).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.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'); | |||
| await gotoUserPage(page); | |||
| await openMigrationTab(page); | |||
| 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 ({ | |||
| @@ -86,7 +169,9 @@ test.describe.serial('User Migration Smoke', () => { | |||
| await expect(page.getByTestId('migration-conflict-table')).toBeVisible(); | |||
| await page.getByTestId('migration-resolve-skip-10000003').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 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 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 ({ | |||
| @@ -133,9 +220,16 @@ test.describe.serial('User Migration Smoke', () => { | |||
| await gotoUserPage(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-status')).toHaveText('scanned'); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'scanned', | |||
| ); | |||
| }); | |||
| }); | |||
| @@ -30,13 +30,35 @@ export default function BatchDetail({ | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const selectionSummary = (() => { | |||
| try { | |||
| return selectedBatch?.selection_summary | |||
| ? JSON.parse(selectedBatch.selection_summary) | |||
| : null; | |||
| } catch { | |||
| return null; | |||
| } | |||
| })(); | |||
| const columns = [ | |||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | |||
| { 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: '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('状态'), | |||
| dataIndex: 'status', | |||
| @@ -47,7 +69,11 @@ export default function BatchDetail({ | |||
| dataIndex: 'target_user_id', | |||
| render: (value) => value || '-', | |||
| }, | |||
| { title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' }, | |||
| { | |||
| title: t('错误信息'), | |||
| dataIndex: 'error_message', | |||
| render: (value) => value || '-', | |||
| }, | |||
| ]; | |||
| return ( | |||
| @@ -59,6 +85,39 @@ export default function BatchDetail({ | |||
| > | |||
| {t('批次详情')} #{selectedBatch.id} | |||
| </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 | |||
| rowKey='id' | |||
| columns={columns} | |||
| @@ -1,8 +1,11 @@ | |||
| import React, { useRef, useState } from 'react'; | |||
| import React, { useEffect, useMemo, useRef, useState } from 'react'; | |||
| import { | |||
| Button, | |||
| Form, | |||
| Input, | |||
| Modal, | |||
| Radio, | |||
| RadioGroup, | |||
| Space, | |||
| Table, | |||
| Tag, | |||
| @@ -29,17 +32,53 @@ export default function BatchList({ | |||
| createBatch, | |||
| refresh, | |||
| selectBatch, | |||
| clearSelectedBatch, | |||
| candidateUsers, | |||
| candidateTotal, | |||
| candidatePage, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| selectedCandidateIds, | |||
| fetchCandidateUsers, | |||
| syncCandidateSelections, | |||
| removeCandidateSelection, | |||
| clearCandidateSelection, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const [visible, setVisible] = useState(false); | |||
| const [step, setStep] = useState(0); | |||
| const [selectionMode, setSelectionMode] = useState('all'); | |||
| 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 = [ | |||
| { 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('状态'), | |||
| 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: '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 () => { | |||
| if (step === 0) { | |||
| if ( | |||
| selectionMode === 'explicit_ids' && | |||
| selectedCandidateIds.length === 0 | |||
| ) { | |||
| return; | |||
| } | |||
| setStep(1); | |||
| return; | |||
| } | |||
| 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) { | |||
| setVisible(false); | |||
| closeModal(); | |||
| } | |||
| }; | |||
| @@ -75,7 +157,7 @@ export default function BatchList({ | |||
| onClick={() => setVisible(true)} | |||
| data-testid='migration-create-batch-button' | |||
| > | |||
| {t('新建批次')} | |||
| {t('创建批次')} | |||
| </Button> | |||
| <Button | |||
| icon={<IconRefresh />} | |||
| @@ -84,6 +166,14 @@ export default function BatchList({ | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| {selectedBatch && ( | |||
| <Button | |||
| onClick={clearSelectedBatch} | |||
| data-testid='migration-clear-batch-selection-button' | |||
| > | |||
| {t('清空选择')} | |||
| </Button> | |||
| )} | |||
| </Space> | |||
| <Table | |||
| rowKey='id' | |||
| @@ -95,48 +185,211 @@ export default function BatchList({ | |||
| rowSelection={{ | |||
| type: 'radio', | |||
| selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | |||
| onChange: (_, rows) => rows[0] && selectBatch(rows[0]), | |||
| onChange: (_, rows) => { | |||
| if (rows[0]) { | |||
| void selectBatch(rows[0]); | |||
| return; | |||
| } | |||
| clearSelectedBatch(); | |||
| }, | |||
| }} | |||
| onRow={(record) => ({ | |||
| onClick: () => selectBatch(record), | |||
| onClick: () => { | |||
| if (selectedBatch?.id === record.id) { | |||
| clearSelectedBatch(); | |||
| return; | |||
| } | |||
| void selectBatch(record); | |||
| }, | |||
| })} | |||
| empty={t('暂无数据')} | |||
| /> | |||
| <Modal | |||
| visible={visible} | |||
| title={t('创建批次')} | |||
| onCancel={() => setVisible(false)} | |||
| title={step === 0 ? t('候选用户') : t('创建批次')} | |||
| onCancel={closeModal} | |||
| 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' }} | |||
| 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> | |||
| </div> | |||
| ); | |||
| @@ -19,10 +19,18 @@ export default function ExecutionPanel({ | |||
| scanBatch, | |||
| executeBatch, | |||
| retryBatch, | |||
| cancelBatch, | |||
| verifyBatch, | |||
| verifyResults, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const canCancel = [ | |||
| 'draft', | |||
| 'scanned', | |||
| 'ready', | |||
| 'failed', | |||
| 'cancelled', | |||
| ].includes(selectedBatch.status); | |||
| const verifyColumns = [ | |||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | |||
| @@ -79,6 +87,21 @@ export default function ExecutionPanel({ | |||
| > | |||
| {t('重试失败项')} | |||
| </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 | |||
| onClick={() => verifyBatch(selectedBatch.id)} | |||
| data-testid='migration-verify-button' | |||
| @@ -88,7 +111,9 @@ export default function ExecutionPanel({ | |||
| </Space> | |||
| {verifyResults.length > 0 && ( | |||
| <Table | |||
| rowKey={(record) => `${record.source_user_id}-${record.target_user_id}`} | |||
| rowKey={(record) => | |||
| `${record.source_user_id}-${record.target_user_id}` | |||
| } | |||
| columns={verifyColumns} | |||
| dataSource={verifyResults} | |||
| pagination={false} | |||
| @@ -9,6 +9,12 @@ export function useUserMigrationData() { | |||
| const { t } = useTranslation(); | |||
| const [batches, setBatches] = useState([]); | |||
| 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 [itemsTotal, setItemsTotal] = useState(0); | |||
| const [itemsPage, setItemsPage] = useState(0); | |||
| @@ -49,7 +55,8 @@ export function useUserMigrationData() { | |||
| const data = res.data.data || []; | |||
| setBatches(data); | |||
| 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); | |||
| } | |||
| return data; | |||
| @@ -57,64 +64,138 @@ export function useUserMigrationData() { | |||
| showError(error.message); | |||
| 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; | |||
| } | |||
| 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(() => { | |||
| void refresh(); | |||
| return () => 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) => { | |||
| try { | |||
| @@ -134,7 +215,9 @@ export function useUserMigrationData() { | |||
| const scanBatch = async (batchId) => { | |||
| 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) { | |||
| showError(res.data.error || t('预检查启动失败')); | |||
| return false; | |||
| @@ -150,10 +233,13 @@ export function useUserMigrationData() { | |||
| const resolveItem = async (itemId, strategy, targetUserId = 0) => { | |||
| 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) { | |||
| showError(res.data.error || t('更新策略失败')); | |||
| return false; | |||
| @@ -168,7 +254,9 @@ export function useUserMigrationData() { | |||
| const executeBatch = async (batchId) => { | |||
| 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) { | |||
| showError(res.data.error || t('迁移启动失败')); | |||
| return false; | |||
| @@ -184,7 +272,9 @@ export function useUserMigrationData() { | |||
| const retryBatch = async (batchId) => { | |||
| 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) { | |||
| showError(res.data.error || t('重试启动失败')); | |||
| 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) => { | |||
| 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) { | |||
| showError(res.data.error || t('验证失败')); | |||
| return []; | |||
| @@ -218,6 +328,12 @@ export function useUserMigrationData() { | |||
| return { | |||
| batches, | |||
| selectedBatch, | |||
| candidateUsers, | |||
| candidateTotal, | |||
| candidatePage, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| selectedCandidateIds, | |||
| items, | |||
| itemsTotal, | |||
| itemsPage, | |||
| @@ -226,12 +342,18 @@ export function useUserMigrationData() { | |||
| verifyResults, | |||
| refresh, | |||
| selectBatch, | |||
| clearSelectedBatch, | |||
| fetchCandidateUsers, | |||
| syncCandidateSelections, | |||
| removeCandidateSelection, | |||
| clearCandidateSelection, | |||
| fetchItems, | |||
| createBatch, | |||
| scanBatch, | |||
| resolveItem, | |||
| executeBatch, | |||
| retryBatch, | |||
| cancelBatch, | |||
| verifyBatch, | |||
| t, | |||
| }; | |||
| @@ -145,19 +145,23 @@ export default function ModelRatioNotSetEditor(props) { | |||
| }; | |||
| try { | |||
| // 数据转换 - 只处理已修改的模型 | |||
| // ??????????????????????????????????????? | |||
| models.forEach((model) => { | |||
| // 只有当用户设置了值时才更新 | |||
| delete output.ModelPrice[model.name]; | |||
| delete output.ModelRatio[model.name]; | |||
| delete output.CompletionRatio[model.name]; | |||
| if (model.price !== '') { | |||
| // 如果价格不为空,则转换为浮点数,忽略倍率参数 | |||
| output.ModelPrice[model.name] = parseFloat(model.price); | |||
| } else { | |||
| if (model.ratio !== '') | |||
| if (model.ratio !== '') { | |||
| output.ModelRatio[model.name] = parseFloat(model.ratio); | |||
| if (model.completionRatio !== '') | |||
| } | |||
| if (model.completionRatio !== '') { | |||
| output.CompletionRatio[model.name] = parseFloat( | |||
| model.completionRatio, | |||
| ); | |||
| } | |||
| } | |||
| }); | |||