diff --git a/controller/internal_user_migration.go b/controller/internal_user_migration.go
index f115d24..d7804af 100644
--- a/controller/internal_user_migration.go
+++ b/controller/internal_user_migration.go
@@ -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 {
diff --git a/controller/internal_user_migration_test.go b/controller/internal_user_migration_test.go
index a9d70b2..2423484 100644
--- a/controller/internal_user_migration_test.go
+++ b/controller/internal_user_migration_test.go
@@ -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()
diff --git a/controller/user_migration.go b/controller/user_migration.go
index 1f522bd..1ce812f 100644
--- a/controller/user_migration.go
+++ b/controller/user_migration.go
@@ -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"))
diff --git a/controller/user_migration_test.go b/controller/user_migration_test.go
index 55235f1..390ef85 100644
--- a/controller/user_migration_test.go
+++ b/controller/user_migration_test.go
@@ -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()
diff --git a/model/region_migration_user_test.go b/model/region_migration_user_test.go
index 933a8aa..f962e69 100644
--- a/model/region_migration_user_test.go
+++ b/model/region_migration_user_test.go
@@ -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
diff --git a/model/user.go b/model/user.go
index 92db93f..13c9706 100644
--- a/model/user.go
+++ b/model/user.go
@@ -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
}
diff --git a/model/user_migration.go b/model/user_migration.go
index 869e0d4..c622ac0 100644
--- a/model/user_migration.go
+++ b/model/user_migration.go
@@ -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)
diff --git a/model/user_migration_test.go b/model/user_migration_test.go
index 0569437..ce5aafc 100644
--- a/model/user_migration_test.go
+++ b/model/user_migration_test.go
@@ -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)
+}
diff --git a/router/api-router.go b/router/api-router.go
index f76842b..b254d6c 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -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)
}
diff --git a/router/user_migration_router_test.go b/router/user_migration_router_test.go
index 10e7d0a..65e47e3 100644
--- a/router/user_migration_router_test.go
+++ b/router/user_migration_router_test.go
@@ -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")
}
diff --git a/service/user_migration/client.go b/service/user_migration/client.go
index 1d1b67b..358a50e 100644
--- a/service/user_migration/client.go
+++ b/service/user_migration/client.go
@@ -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)
diff --git a/service/user_migration/scanner.go b/service/user_migration/scanner.go
index 87ea455..3536bc9 100644
--- a/service/user_migration/scanner.go
+++ b/service/user_migration/scanner.go
@@ -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)
}
diff --git a/service/user_migration/service_test.go b/service/user_migration/service_test.go
index b608bdd..24132e9 100644
--- a/service/user_migration/service_test.go
+++ b/service/user_migration/service_test.go
@@ -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
diff --git a/service/user_migration/types.go b/service/user_migration/types.go
index 050aab1..021bc27 100644
--- a/service/user_migration/types.go
+++ b/service/user_migration/types.go
@@ -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"`
diff --git a/test/e2e/user_migration_test.go b/test/e2e/user_migration_test.go
index 32a420a..d121653 100644
--- a/test/e2e/user_migration_test.go
+++ b/test/e2e/user_migration_test.go
@@ -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
diff --git a/web/e2e/user-migration.spec.ts b/web/e2e/user-migration.spec.ts
index 6ba4d23..c598b4c 100644
--- a/web/e2e/user-migration.spec.ts
+++ b/web/e2e/user-migration.spec.ts
@@ -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',
+ );
});
});
diff --git a/web/src/components/table/users/migration/BatchDetail.jsx b/web/src/components/table/users/migration/BatchDetail.jsx
index c4584dd..322ddb1 100644
--- a/web/src/components/table/users/migration/BatchDetail.jsx
+++ b/web/src/components/table/users/migration/BatchDetail.jsx
@@ -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}
+
+ {selectedBatch?.selection_mode === 'explicit_ids' && (
+
+
+ {t('已选 {{count}} 人', {
+ count: selectedBatch.requested_user_count || 0,
+ })}
+
+
+
+ {t('命中 {{count}} 人', {
+ count: selectedBatch.matched_user_count || 0,
+ })}
+
+
+
+ {t('排除 {{count}} 人', {
+ count: selectedBatch.excluded_user_count || 0,
+ })}
+
+ {selectionSummary?.excluded?.length > 0 && (
+ <>
+
+
+ {selectionSummary.excluded
+ .map((item) => `${item.user_id}:${item.reason}`)
+ .join(', ')}
+
+ >
+ )}
+
+ )}
+
{
+ 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) => {status},
+ render: (status) => (
+ {status}
+ ),
},
{ 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('创建批次')}
}
@@ -84,6 +166,14 @@ export default function BatchList({
>
{t('刷新')}
+ {selectedBatch && (
+
+ )}
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('暂无数据')}
/>
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}
>
-
-
-
-
+ {step === 0 ? (
+
+ {t('迁移范围')}
+ {
+ const nextValue = event?.target?.value || 'all';
+ setSelectionMode(nextValue);
+ if (nextValue === 'explicit_ids') {
+ void fetchCandidateUsers(
+ 0,
+ candidatePageSize,
+ candidateKeyword,
+ );
+ }
+ }}
+ data-testid='migration-selection-mode-group'
+ >
+
+ {t('全部')}
+
+
+ {t('指定用户')}
+
+
+
+ {selectionMode === 'explicit_ids' && (
+ <>
+
+ void fetchCandidateUsers(0, candidatePageSize, value)
+ }
+ placeholder={t('按用户ID / 用户名 / 邮箱搜索')}
+ data-testid='migration-candidate-search-input'
+ />
+
+
+
+ {t('已选 {{count}} 人', {
+ count: selectedCandidateIds.length,
+ })}
+
+
+
+
+ {selectedCandidateIds.length > 0 && (
+
+ {selectedCandidateIds.map((id) => (
+ removeCandidateSelection(id)}
+ >
+ {id}
+
+ ))}
+
+ )}
+
+ 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}`,
+ })}
+ />
+ >
+ )}
+
+ ) : (
+
+
+
+
+ )}
);
diff --git a/web/src/components/table/users/migration/ExecutionPanel.jsx b/web/src/components/table/users/migration/ExecutionPanel.jsx
index 1b5faf6..3bb2363 100644
--- a/web/src/components/table/users/migration/ExecutionPanel.jsx
+++ b/web/src/components/table/users/migration/ExecutionPanel.jsx
@@ -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('重试失败项')}
+ cancelBatch(selectedBatch.id)}
+ okButtonProps={{ 'data-testid': 'migration-cancel-confirm' }}
+ cancelButtonProps={{ 'data-testid': 'migration-cancel-abort' }}
+ >
+
+