Просмотр исходного кода

feat(user-migration): support selective batches and cancellation

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
fengsilin 3 дней назад
Родитель
Сommit
65e6a82d34
22 измененных файлов: 5154 добавлений и 3314 удалений
  1. +73
    -1
      controller/internal_user_migration.go
  2. +101
    -0
      controller/internal_user_migration_test.go
  3. +118
    -7
      controller/user_migration.go
  4. +329
    -0
      controller/user_migration_test.go
  5. +32
    -0
      model/region_migration_user_test.go
  6. +13
    -0
      model/user.go
  7. +133
    -0
      model/user_migration.go
  8. +71
    -0
      model/user_migration_test.go
  9. +3
    -0
      router/api-router.go
  10. +13
    -0
      router/user_migration_router_test.go
  11. +26
    -4
      service/user_migration/client.go
  12. +46
    -5
      service/user_migration/scanner.go
  13. +166
    -2
      service/user_migration/service_test.go
  14. +35
    -0
      service/user_migration/types.go
  15. +120
    -3
      test/e2e/user_migration_test.go
  16. +116
    -22
      web/e2e/user-migration.spec.ts
  17. +63
    -4
      web/src/components/table/users/migration/BatchDetail.jsx
  18. +288
    -35
      web/src/components/table/users/migration/BatchList.jsx
  19. +26
    -1
      web/src/components/table/users/migration/ExecutionPanel.jsx
  20. +177
    -55
      web/src/hooks/users/useUserMigrationData.jsx
  21. +3196
    -3170
      web/src/i18n/locales/en.json
  22. +9
    -5
      web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx

+ 73
- 1
controller/internal_user_migration.go Просмотреть файл

@@ -14,11 +14,12 @@ import (
func ListMigrationUsers(c *gin.Context) { func ListMigrationUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
keyword := c.Query("keyword")
if pageSize <= 0 || pageSize > 200 { if pageSize <= 0 || pageSize > 200 {
pageSize = 50 pageSize = 50
} }


users, total, err := model.ListLocalUsersForMigration(page, pageSize)
users, total, err := model.QueryLocalUsersForMigration(page, pageSize, keyword)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"})
return return
@@ -32,6 +33,77 @@ func ListMigrationUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": snapshots, "total": total}) c.JSON(http.StatusOK, gin.H{"success": true, "data": snapshots, "total": total})
} }


func QueryMigrationUsers(c *gin.Context) {
var req user_migration.QueryRemoteUsersRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid request body"})
return
}
if req.SelectionMode != user_migration.SelectionModeExplicitIDs {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"})
return
}
if req.PageSize <= 0 || req.PageSize > 200 {
req.PageSize = 50
}

indexed := make(map[int]struct{}, len(req.SourceUserIDs))
orderedIDs := make([]int, 0, len(req.SourceUserIDs))
for _, id := range req.SourceUserIDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid source_user_ids"})
return
}
if _, ok := indexed[id]; ok {
continue
}
indexed[id] = struct{}{}
orderedIDs = append(orderedIDs, id)
}

summary := &user_migration.SelectionSummary{
Requested: len(orderedIDs),
Excluded: make([]user_migration.SelectionExcluded, 0),
}
snapshots := make([]user_migration.RemoteUserSnapshot, 0, len(orderedIDs))

for _, userID := range orderedIDs {
user, err := model.GetUserById(userID, true)
if err != nil || user == nil {
summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "not_found"})
continue
}
if user.Role == common.RoleRootUser {
summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "root_user_not_migratable"})
continue
}
if user.Source == common.UserSourceSynced {
summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "already_synced"})
continue
}

bindings, _ := model.GetUserOAuthBindingsByUserId(userID)
snapshots = append(snapshots, buildRemoteUserSnapshot(user, bindings))
}

summary.Matched = len(snapshots)
start := req.Page * req.PageSize
if start > len(snapshots) {
start = len(snapshots)
}
end := start + req.PageSize
if end > len(snapshots) {
end = len(snapshots)
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": snapshots[start:end],
"total": len(snapshots),
"selection_summary": summary,
})
}

func GetMigrationUser(c *gin.Context) { func GetMigrationUser(c *gin.Context) {
userId, err := strconv.Atoi(c.Param("id")) userId, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {


+ 101
- 0
controller/internal_user_migration_test.go Просмотреть файл

@@ -9,6 +9,7 @@ import (


"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
user_migration "github.com/QuantumNous/new-api/service/user_migration"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -80,6 +81,106 @@ func TestListMigrationUsers_ReturnsLocalOnly(t *testing.T) {
require.EqualValues(t, 1, body["total"]) require.EqualValues(t, 1, body["total"])
} }


func TestListMigrationUsers_FiltersByKeyword(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.GET("/api/internal/migration/users", ListMigrationUsers)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{
Id: 10000011,
Username: "alice-local",
Password: "hash",
Email: "alice@example.com",
AffCode: "A11",
Source: common.UserSourceLocal,
}).Error)
require.NoError(t, db.Create(&model.User{
Id: 10000012,
Username: "bob-local",
Password: "hash",
Email: "bob@example.com",
AffCode: "A12",
Source: common.UserSourceLocal,
}).Error)

req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20&keyword=alice", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var body struct {
Success bool `json:"success"`
Data []user_migration.RemoteUserSnapshot `json:"data"`
Total int `json:"total"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Success)
require.Equal(t, 1, body.Total)
require.Len(t, body.Data, 1)
require.Equal(t, 10000011, body.Data[0].Id)
}

func TestQueryMigrationUsers_OnlyReturnsSelectedLocalUsers(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.POST("/api/internal/migration/users/query", QueryMigrationUsers)

db := setupInternalMigrationDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.User{Id: 10000001, Username: "u1", Password: "hash", AffCode: "A1", Source: common.UserSourceLocal}).Error)
require.NoError(t, db.Create(&model.User{Id: 10000002, Username: "u2", Password: "hash", AffCode: "A2", Source: common.UserSourceLocal, Role: common.RoleRootUser}).Error)
require.NoError(t, db.Create(&model.User{Id: 10000003, Username: "u3", Password: "hash", AffCode: "A3", Source: common.UserSourceSynced}).Error)

req := httptest.NewRequest(http.MethodPost, "/api/internal/migration/users/query", strings.NewReader(`{"page":0,"page_size":50,"selection_mode":"explicit_ids","source_user_ids":[10000001,10000002,10000003,10000004]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var body struct {
Success bool `json:"success"`
Data []user_migration.RemoteUserSnapshot `json:"data"`
Total int `json:"total"`
SelectionSummary user_migration.SelectionSummary `json:"selection_summary"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Success)
require.Len(t, body.Data, 1)
require.Equal(t, 10000001, body.Data[0].Id)
require.Equal(t, 1, body.Total)
require.Equal(t, 4, body.SelectionSummary.Requested)
require.Equal(t, 1, body.SelectionSummary.Matched)
require.Len(t, body.SelectionSummary.Excluded, 3)
}

func TestQueryMigrationUsers_InvalidSelectionModeRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testSyncAuthMiddleware())
router.POST("/api/internal/migration/users/query", QueryMigrationUsers)

req := httptest.NewRequest(http.MethodPost, "/api/internal/migration/users/query", strings.NewReader(`{"page":0,"page_size":50,"selection_mode":"all","source_user_ids":[1]}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusBadRequest, w.Code)
}

func TestGetMigrationUser_RootExcluded(t *testing.T) { func TestGetMigrationUser_RootExcluded(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()


+ 118
- 7
controller/user_migration.go Просмотреть файл

@@ -1,28 +1,46 @@
package controller package controller


import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"


"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
user_migration "github.com/QuantumNous/new-api/service/user_migration" user_migration "github.com/QuantumNous/new-api/service/user_migration"
"github.com/QuantumNous/new-api/setting/system_setting" "github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )


type migrationVerifier interface { type migrationVerifier interface {
VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error)
} }


type migrationCandidateClient interface {
ListUsers(page, pageSize int, keyword string) (*user_migration.ListRemoteUsersResponse, error)
}

var newMigrationVerifier = func() migrationVerifier { var newMigrationVerifier = func() migrationVerifier {
return newMigrationService() return newMigrationService()
} }


var newMigrationCandidateClient = func() migrationCandidateClient {
settings := system_setting.GetRegionSyncSettings()
endpoint := settings.MasterEndpoint
if settings.IsMaster && len(settings.SlaveEndpoints) > 0 {
endpoint = settings.SlaveEndpoints[0]
}
return user_migration.NewClient(endpoint, settings.SyncApiKey)
}

func CreateUserMigrationBatch(c *gin.Context) { func CreateUserMigrationBatch(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name" binding:"required"`
SourceRegion string `json:"source_region" binding:"required"`
TargetRegion string `json:"target_region" binding:"required"`
Name string `json:"name" binding:"required"`
SourceRegion string `json:"source_region" binding:"required"`
TargetRegion string `json:"target_region" binding:"required"`
SelectionMode string `json:"selection_mode"`
SourceUserIDs []int `json:"source_user_ids"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
@@ -30,11 +48,61 @@ func CreateUserMigrationBatch(c *gin.Context) {
} }


operatorID, _ := c.Get("id") operatorID, _ := c.Get("id")
if req.SelectionMode == "" {
req.SelectionMode = user_migration.SelectionModeAll
}
if req.SelectionMode != user_migration.SelectionModeAll && req.SelectionMode != user_migration.SelectionModeExplicitIDs {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"})
return
}

payload := user_migration.SelectionPayload{}
if req.SelectionMode == user_migration.SelectionModeExplicitIDs {
if len(req.SourceUserIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "source_user_ids is required"})
return
}
seen := make(map[int]struct{}, len(req.SourceUserIDs))
payload.SourceUserIDs = make([]int, 0, len(req.SourceUserIDs))
for _, id := range req.SourceUserIDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid source_user_ids"})
return
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
payload.SourceUserIDs = append(payload.SourceUserIDs, id)
}
if len(payload.SourceUserIDs) > 1000 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "too many source_user_ids"})
return
}

conflicts, err := model.FindActiveMigrationUserConflicts(payload.SourceUserIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
if len(conflicts) > 0 {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": "some selected users already exist in active migration batches",
"conflicts": conflicts,
})
return
}
}
payloadJSON, _ := common.Marshal(payload)
batch := &model.UserMigrationBatch{ batch := &model.UserMigrationBatch{
Name: req.Name,
SourceRegion: req.SourceRegion,
TargetRegion: req.TargetRegion,
OperatorId: operatorID.(int),
Name: req.Name,
SourceRegion: req.SourceRegion,
TargetRegion: req.TargetRegion,
SelectionMode: req.SelectionMode,
SelectionPayload: string(payloadJSON),
RequestedUserCount: len(payload.SourceUserIDs),
OperatorId: operatorID.(int),
} }
if err := model.CreateUserMigrationBatch(batch); err != nil { if err := model.CreateUserMigrationBatch(batch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
@@ -43,6 +111,26 @@ func CreateUserMigrationBatch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": batch}) c.JSON(http.StatusOK, gin.H{"success": true, "data": batch})
} }


func ListMigrationCandidateUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
keyword := c.Query("keyword")
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}

resp, err := newMigrationCandidateClient().ListUsers(page, pageSize, keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": resp.Data,
"total": resp.Total,
})
}

func ListUserMigrationBatches(c *gin.Context) { func ListUserMigrationBatches(c *gin.Context) {
var batches []model.UserMigrationBatch var batches []model.UserMigrationBatch
if err := model.DB.Order("id desc").Find(&batches).Error; err != nil { if err := model.DB.Order("id desc").Find(&batches).Error; err != nil {
@@ -204,6 +292,29 @@ func RetryUserMigrationBatch(c *gin.Context) {
c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "retry started"}) c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "retry started"})
} }


func CancelUserMigrationBatch(c *gin.Context) {
batchID, _ := strconv.Atoi(c.Param("id"))

cancelled, err := model.CancelUserMigrationBatch(batchID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "batch not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
if !cancelled {
c.JSON(http.StatusConflict, gin.H{
"success": false,
"error": "batch cannot be cancelled in current status",
})
return
}

c.JSON(http.StatusOK, gin.H{"success": true, "message": "batch cancelled"})
}

func VerifyUserMigrationBatch(c *gin.Context) { func VerifyUserMigrationBatch(c *gin.Context) {
batchID, _ := strconv.Atoi(c.Param("id")) batchID, _ := strconv.Atoi(c.Param("id"))




+ 329
- 0
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 { type fakeMigrationVerifier struct {
results map[int]*user_migration.VerifyResult results map[int]*user_migration.VerifyResult
errs map[int]error errs map[int]error
@@ -94,6 +112,205 @@ func TestCreateUserMigrationBatch_Success(t *testing.T) {
require.NotNil(t, resp["data"]) require.NotNil(t, resp["data"])
} }


func TestCreateUserMigrationBatch_ExplicitIDsSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches", CreateUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{
"name":"explicit-batch",
"source_region":"ov",
"target_region":"cn",
"selection_mode":"explicit_ids",
"source_user_ids":[1001,1002,1002]
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var batch model.UserMigrationBatch
require.NoError(t, db.First(&batch, 1).Error)
require.Equal(t, "explicit_ids", batch.SelectionMode)
require.Equal(t, 2, batch.RequestedUserCount)
}

func TestCreateUserMigrationBatch_ExplicitIDsValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches", CreateUserMigrationBatch)

cases := []string{
`{"name":"b1","source_region":"ov","target_region":"cn","selection_mode":"explicit_ids","source_user_ids":[]}`,
`{"name":"b2","source_region":"ov","target_region":"cn","selection_mode":"explicit_ids","source_user_ids":[0]}`,
`{"name":"b3","source_region":"ov","target_region":"cn","selection_mode":"invalid","source_user_ids":[1]}`,
}

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

for _, body := range cases {
req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
}
}

func TestCreateUserMigrationBatch_ExplicitIDs_RejectsActiveBatchOverlap(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches", CreateUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

existingBatch := &model.UserMigrationBatch{
Name: "existing-draft-batch",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: "explicit_ids",
SelectionPayload: `{"source_user_ids":[1002]}`,
Status: model.UserMigrationBatchStatusDraft,
OperatorId: 1,
}
require.NoError(t, db.Create(existingBatch).Error)

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{
"name":"explicit-batch",
"source_region":"ov",
"target_region":"cn",
"selection_mode":"explicit_ids",
"source_user_ids":[1001,1002]
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusConflict, w.Code)
var resp struct {
Success bool `json:"success"`
Error string `json:"error"`
Conflicts []struct {
SourceUserID int `json:"source_user_id"`
BatchID int `json:"batch_id"`
BatchStatus string `json:"batch_status"`
} `json:"conflicts"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.False(t, resp.Success)
require.Equal(t, "some selected users already exist in active migration batches", resp.Error)
require.Len(t, resp.Conflicts, 1)
require.Equal(t, 1002, resp.Conflicts[0].SourceUserID)
require.Equal(t, existingBatch.Id, resp.Conflicts[0].BatchID)
require.Equal(t, model.UserMigrationBatchStatusDraft, resp.Conflicts[0].BatchStatus)
}

func TestListMigrationCandidateUsers_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())

origFactory := newMigrationCandidateClient
defer func() {
newMigrationCandidateClient = origFactory
}()
newMigrationCandidateClient = func() migrationCandidateClient {
return &fakeMigrationCandidateClient{
resp: &user_migration.ListRemoteUsersResponse{
Success: true,
Data: []user_migration.RemoteUserSnapshot{
{Id: 10000001, Username: "ov-a", Email: "a@example.com", Quota: 100},
},
Total: 1,
},
}
}

router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers)
req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=0&page_size=50", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
var resp map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.True(t, resp["success"].(bool))
require.EqualValues(t, 1, resp["total"])
}

func TestListMigrationCandidateUsers_UpstreamFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())

origFactory := newMigrationCandidateClient
defer func() {
newMigrationCandidateClient = origFactory
}()
newMigrationCandidateClient = func() migrationCandidateClient {
return &fakeMigrationCandidateClient{err: errors.New("upstream unavailable")}
}

router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers)
req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=0&page_size=50", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusInternalServerError, w.Code)
}

func TestListMigrationCandidateUsers_ForwardsKeyword(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())

client := &fakeMigrationCandidateClient{
resp: &user_migration.ListRemoteUsersResponse{
Success: true,
Data: []user_migration.RemoteUserSnapshot{},
Total: 0,
},
}

origFactory := newMigrationCandidateClient
defer func() {
newMigrationCandidateClient = origFactory
}()
newMigrationCandidateClient = func() migrationCandidateClient {
return client
}

router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers)
req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=2&page_size=30&keyword=alice", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, 2, client.gotPage)
require.Equal(t, 30, client.gotPageSize)
require.Equal(t, "alice", client.gotKeyword)
}

func TestListUserMigrationBatches_Success(t *testing.T) { func TestListUserMigrationBatches_Success(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
@@ -389,6 +606,118 @@ func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) {
require.Equal(t, model.UserMigrationItemStatusSkipped, skippedItem.Status) require.Equal(t, model.UserMigrationItemStatusSkipped, skippedItem.Status)
} }


func TestCancelUserMigrationBatch_SoftDeleteSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches/:id/cancel", CancelUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "cancel-me",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: "explicit_ids",
SelectionPayload: `{"source_user_ids":[11,12]}`,
OperatorId: 1,
Status: model.UserMigrationBatchStatusScanned,
}).Error)

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)

var batch model.UserMigrationBatch
require.NoError(t, db.First(&batch, 1).Error)
require.Equal(t, model.UserMigrationBatchStatusCancelled, batch.Status)
}

func TestCancelUserMigrationBatch_InvalidStateRejected(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches/:id/cancel", CancelUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

for _, status := range []string{
model.UserMigrationBatchStatusScanning,
model.UserMigrationBatchStatusRunning,
model.UserMigrationBatchStatusCompleted,
} {
require.NoError(t, db.Exec("DELETE FROM user_migration_batches").Error)
require.NoError(t, db.Create(&model.UserMigrationBatch{
Id: 1,
Name: "cannot-cancel",
SourceRegion: "ov",
TargetRegion: "cn",
OperatorId: 1,
Status: status,
}).Error)

req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
}
}

func TestCreateUserMigrationBatch_ExplicitIDs_AllowsReuseAfterCancel(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(testRootAuthMiddleware())
router.POST("/api/user-migrations/batches", CreateUserMigrationBatch)
router.POST("/api/user-migrations/batches/:id/cancel", CancelUserMigrationBatch)

db := setupUserMigrationControllerDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

existingBatch := &model.UserMigrationBatch{
Name: "existing-scanned-batch",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: "explicit_ids",
SelectionPayload: `{"source_user_ids":[11,12]}`,
Status: model.UserMigrationBatchStatusScanned,
OperatorId: 1,
}
require.NoError(t, db.Create(existingBatch).Error)

cancelReq := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil)
cancelW := httptest.NewRecorder()
router.ServeHTTP(cancelW, cancelReq)
require.Equal(t, http.StatusOK, cancelW.Code)

createReq := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{
"name":"recreated-batch",
"source_region":"ov",
"target_region":"cn",
"selection_mode":"explicit_ids",
"source_user_ids":[11,12]
}`))
createReq.Header.Set("Content-Type", "application/json")
createW := httptest.NewRecorder()
router.ServeHTTP(createW, createReq)
require.Equal(t, http.StatusOK, createW.Code)
}

func TestVerifyUserMigrationBatch_AggregatesResult(t *testing.T) { func TestVerifyUserMigrationBatch_AggregatesResult(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()


+ 32
- 0
model/region_migration_user_test.go Просмотреть файл

@@ -195,6 +195,38 @@ func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) {
require.Equal(t, "local-1", users[0].Username) require.Equal(t, "local-1", users[0].Username)
} }


func TestQueryLocalUsersForMigration_FiltersByKeyword(t *testing.T) {
db := setupRegionMigrationUserDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

require.NoError(t, db.Create(&User{
Id: 10000011,
Username: "alice-local",
Password: "h",
Email: "alice@example.com",
AffCode: "AL1",
Source: common.UserSourceLocal,
}).Error)
require.NoError(t, db.Create(&User{
Id: 10000012,
Username: "bob-local",
Password: "h",
Email: "bob@example.com",
AffCode: "BO1",
Source: common.UserSourceLocal,
}).Error)

users, total, err := QueryLocalUsersForMigration(0, 20, "alice")
require.NoError(t, err)
require.EqualValues(t, 1, total)
require.Len(t, users, 1)
require.Equal(t, "alice-local", users[0].Username)
}

func TestConvertUserToSynced_RejectsRootUser(t *testing.T) { func TestConvertUserToSynced_RejectsRootUser(t *testing.T) {
db := setupRegionMigrationUserDB(t) db := setupRegionMigrationUserDB(t)
orig := DB orig := DB


+ 13
- 0
model/user.go Просмотреть файл

@@ -1099,6 +1099,10 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
} }


func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) {
return QueryLocalUsersForMigration(page, pageSize, "")
}

func QueryLocalUsersForMigration(page, pageSize int, keyword string) ([]*User, int64, error) {
if page < 0 { if page < 0 {
page = 0 page = 0
} }
@@ -1111,6 +1115,15 @@ func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) {
query := DB.Model(&User{}). query := DB.Model(&User{}).
Where("source = ?", common.UserSourceLocal). Where("source = ?", common.UserSourceLocal).
Where("role <> ?", common.RoleRootUser) Where("role <> ?", common.RoleRootUser)
keyword = strings.TrimSpace(keyword)
if keyword != "" {
likeKeyword := "%" + strings.ToLower(keyword) + "%"
if id, err := strconv.Atoi(keyword); err == nil {
query = query.Where("id = ? OR lower(username) LIKE ? OR lower(email) LIKE ?", id, likeKeyword, likeKeyword)
} else {
query = query.Where("lower(username) LIKE ? OR lower(email) LIKE ?", likeKeyword, likeKeyword)
}
}
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
return nil, 0, err return nil, 0, err
} }


+ 133
- 0
model/user_migration.go Просмотреть файл

@@ -1,6 +1,9 @@
package model package model


import ( import (
"fmt"
"slices"

"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -34,6 +37,12 @@ type UserMigrationBatch struct {
Name string `json:"name" gorm:"type:varchar(128);not null"` Name string `json:"name" gorm:"type:varchar(128);not null"`
SourceRegion string `json:"source_region" gorm:"type:varchar(32);not null"` SourceRegion string `json:"source_region" gorm:"type:varchar(32);not null"`
TargetRegion string `json:"target_region" gorm:"type:varchar(32);not null"` TargetRegion string `json:"target_region" gorm:"type:varchar(32);not null"`
SelectionMode string `json:"selection_mode" gorm:"type:varchar(32);not null;default:'all'"`
SelectionPayload string `json:"selection_payload" gorm:"type:text"`
RequestedUserCount int `json:"requested_user_count" gorm:"default:0"`
MatchedUserCount int `json:"matched_user_count" gorm:"default:0"`
ExcludedUserCount int `json:"excluded_user_count" gorm:"default:0"`
SelectionSummary string `json:"selection_summary" gorm:"type:text"`
Status string `json:"status" gorm:"type:varchar(32);not null;default:'draft';index"` Status string `json:"status" gorm:"type:varchar(32);not null;default:'draft';index"`
TotalUsers int `json:"total_users" gorm:"default:0"` TotalUsers int `json:"total_users" gorm:"default:0"`
ConflictUsers int `json:"conflict_users" gorm:"default:0"` ConflictUsers int `json:"conflict_users" gorm:"default:0"`
@@ -69,11 +78,20 @@ type MigrationQuotaGrant struct {
CreatedAt int64 `json:"created_at" gorm:"bigint"` CreatedAt int64 `json:"created_at" gorm:"bigint"`
} }


type ActiveMigrationUserConflict struct {
SourceUserID int `json:"source_user_id"`
BatchID int `json:"batch_id"`
BatchStatus string `json:"batch_status"`
}

func (b *UserMigrationBatch) BeforeCreate(tx *gorm.DB) error { func (b *UserMigrationBatch) BeforeCreate(tx *gorm.DB) error {
now := common.GetTimestamp() now := common.GetTimestamp()
if b.Status == "" { if b.Status == "" {
b.Status = UserMigrationBatchStatusDraft b.Status = UserMigrationBatchStatusDraft
} }
if b.SelectionMode == "" {
b.SelectionMode = "all"
}
b.CreatedAt = now b.CreatedAt = now
b.UpdatedAt = now b.UpdatedAt = now
return nil return nil
@@ -108,6 +126,121 @@ func CreateUserMigrationBatch(batch *UserMigrationBatch) error {
return DB.Create(batch).Error return DB.Create(batch).Error
} }


func CancelUserMigrationBatch(batchId int) (bool, error) {
var batch UserMigrationBatch
if err := DB.First(&batch, batchId).Error; err != nil {
return false, err
}

switch batch.Status {
case UserMigrationBatchStatusCancelled:
return true, nil
case UserMigrationBatchStatusDraft,
UserMigrationBatchStatusScanned,
UserMigrationBatchStatusReady,
UserMigrationBatchStatusFailed:
return true, DB.Model(&UserMigrationBatch{}).
Where("id = ?", batchId).
Update("status", UserMigrationBatchStatusCancelled).Error
default:
return false, nil
}
}

func FindActiveMigrationUserConflicts(sourceUserIDs []int) ([]ActiveMigrationUserConflict, error) {
if len(sourceUserIDs) == 0 {
return nil, nil
}

activeStatuses := []string{
UserMigrationBatchStatusDraft,
UserMigrationBatchStatusScanning,
UserMigrationBatchStatusScanned,
UserMigrationBatchStatusReady,
UserMigrationBatchStatusRunning,
UserMigrationBatchStatusFailed,
}

type activeBatchSelection struct {
ID int
Status string
SelectionMode string
SelectionPayload string
}

conflictMap := make(map[string]ActiveMigrationUserConflict)
addConflict := func(conflict ActiveMigrationUserConflict) {
key := fmt.Sprintf("%d:%d", conflict.SourceUserID, conflict.BatchID)
if _, exists := conflictMap[key]; exists {
return
}
conflictMap[key] = conflict
}

var itemConflicts []ActiveMigrationUserConflict
err := DB.Table("user_migration_items AS umi").
Select("umi.source_user_id, umb.id AS batch_id, umb.status AS batch_status").
Joins("JOIN user_migration_batches AS umb ON umb.id = umi.batch_id").
Where("umi.source_user_id IN ?", sourceUserIDs).
Where("umb.status IN ?", activeStatuses).
Order("umi.source_user_id ASC, umb.id ASC").
Scan(&itemConflicts).Error
if err != nil {
return nil, err
}
for _, conflict := range itemConflicts {
addConflict(conflict)
}

var activeBatches []activeBatchSelection
if err := DB.Model(&UserMigrationBatch{}).
Select("id, status, selection_mode, selection_payload").
Where("status IN ?", activeStatuses).
Where("selection_mode = ?", "explicit_ids").
Find(&activeBatches).Error; err != nil {
return nil, err
}

sourceIDSet := make(map[int]struct{}, len(sourceUserIDs))
for _, id := range sourceUserIDs {
sourceIDSet[id] = struct{}{}
}

for _, batch := range activeBatches {
if batch.SelectionPayload == "" {
continue
}
var payload struct {
SourceUserIDs []int `json:"source_user_ids"`
}
if err := common.UnmarshalJsonStr(batch.SelectionPayload, &payload); err != nil {
return nil, err
}
for _, sourceUserID := range payload.SourceUserIDs {
if _, ok := sourceIDSet[sourceUserID]; !ok {
continue
}
addConflict(ActiveMigrationUserConflict{
SourceUserID: sourceUserID,
BatchID: batch.ID,
BatchStatus: batch.Status,
})
}
}

conflicts := make([]ActiveMigrationUserConflict, 0, len(conflictMap))
for _, conflict := range conflictMap {
conflicts = append(conflicts, conflict)
}
slices.SortFunc(conflicts, func(a, b ActiveMigrationUserConflict) int {
if a.SourceUserID != b.SourceUserID {
return a.SourceUserID - b.SourceUserID
}
return a.BatchID - b.BatchID
})
return conflicts, nil
}

func UpsertUserMigrationItem(item *UserMigrationItem) error { func UpsertUserMigrationItem(item *UserMigrationItem) error {
var existing UserMigrationItem var existing UserMigrationItem
result := DB.Where("batch_id = ? AND source_user_id = ?", item.BatchId, item.SourceUserId).Limit(1).Find(&existing) result := DB.Where("batch_id = ? AND source_user_id = ?", item.BatchId, item.SourceUserId).Limit(1).Find(&existing)


+ 71
- 0
model/user_migration_test.go Просмотреть файл

@@ -1,6 +1,7 @@
package model package model


import ( import (
"encoding/json"
"testing" "testing"


"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
@@ -55,9 +56,43 @@ func TestCreateUserMigrationBatch_DefaultStatus(t *testing.T) {


require.NoError(t, CreateUserMigrationBatch(batch)) require.NoError(t, CreateUserMigrationBatch(batch))
require.Equal(t, UserMigrationBatchStatusDraft, batch.Status) require.Equal(t, UserMigrationBatchStatusDraft, batch.Status)
require.Equal(t, "all", batch.SelectionMode)
require.Greater(t, batch.Id, 0) require.Greater(t, batch.Id, 0)
} }


func TestCreateUserMigrationBatch_StoresExplicitIDsSelection(t *testing.T) {
db := setupUserMigrationTestDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

payload := map[string]any{"source_user_ids": []int{1001, 1002, 1003}}
payloadJSON, err := json.Marshal(payload)
require.NoError(t, err)

batch := &UserMigrationBatch{
Name: "partial-wave",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: "explicit_ids",
SelectionPayload: string(payloadJSON),
RequestedUserCount: 3,
OperatorId: 1,
}

require.NoError(t, CreateUserMigrationBatch(batch))

var saved UserMigrationBatch
require.NoError(t, DB.First(&saved, batch.Id).Error)
require.Equal(t, "explicit_ids", saved.SelectionMode)
require.Equal(t, string(payloadJSON), saved.SelectionPayload)
require.Equal(t, 3, saved.RequestedUserCount)
require.Equal(t, 0, saved.MatchedUserCount)
require.Equal(t, 0, saved.ExcludedUserCount)
}

func TestUpsertMigrationItem_PreservesHumanSetStrategy(t *testing.T) { func TestUpsertMigrationItem_PreservesHumanSetStrategy(t *testing.T) {
db := setupUserMigrationTestDB(t) db := setupUserMigrationTestDB(t)
orig := DB orig := DB
@@ -212,3 +247,39 @@ func TestRefreshUserMigrationBatchExecutionStats_MigratingLeakHandled(t *testing
require.Equal(t, 0, refreshed.MigratedUsers) require.Equal(t, 0, refreshed.MigratedUsers)
require.Equal(t, 0, refreshed.FailedUsers) require.Equal(t, 0, refreshed.FailedUsers)
} }

func TestCancelUserMigrationBatch_ReleasesBatch(t *testing.T) {
db := setupUserMigrationTestDB(t)
orig := DB
DB = db
defer func() {
DB = orig
}()

batch := &UserMigrationBatch{
Name: "cancel-batch",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: "explicit_ids",
SelectionPayload: `{"source_user_ids":[1001,1002]}`,
OperatorId: 1,
Status: UserMigrationBatchStatusScanned,
}
require.NoError(t, CreateUserMigrationBatch(batch))

conflicts, err := FindActiveMigrationUserConflicts([]int{1001, 1002})
require.NoError(t, err)
require.Len(t, conflicts, 2)

cancelled, err := CancelUserMigrationBatch(batch.Id)
require.NoError(t, err)
require.True(t, cancelled)

var refreshed UserMigrationBatch
require.NoError(t, DB.First(&refreshed, batch.Id).Error)
require.Equal(t, UserMigrationBatchStatusCancelled, refreshed.Status)

conflicts, err = FindActiveMigrationUserConflicts([]int{1001, 1002})
require.NoError(t, err)
require.Len(t, conflicts, 0)
}

+ 3
- 0
router/api-router.go Просмотреть файл

@@ -411,6 +411,7 @@ func SetApiRouter(router *gin.Engine) {
{ {
migrationRoute.GET("/users/check-synced-copy", controller.CheckSyncedCopy) migrationRoute.GET("/users/check-synced-copy", controller.CheckSyncedCopy)
migrationRoute.GET("/users", controller.ListMigrationUsers) migrationRoute.GET("/users", controller.ListMigrationUsers)
migrationRoute.POST("/users/query", controller.QueryMigrationUsers)
migrationRoute.GET("/users/:id", controller.GetMigrationUser) migrationRoute.GET("/users/:id", controller.GetMigrationUser)
migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced) migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced)
} }
@@ -418,12 +419,14 @@ func SetApiRouter(router *gin.Engine) {
userMigrationRoute := apiRouter.Group("/user-migrations") userMigrationRoute := apiRouter.Group("/user-migrations")
userMigrationRoute.Use(middleware.RootAuth()) userMigrationRoute.Use(middleware.RootAuth())
{ {
userMigrationRoute.GET("/candidate-users", controller.ListMigrationCandidateUsers)
userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch) userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch)
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.GET("/batches/:id", controller.GetUserMigrationBatch) userMigrationRoute.GET("/batches/:id", controller.GetUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/scan", controller.ScanUserMigrationBatch) userMigrationRoute.POST("/batches/:id/scan", controller.ScanUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/retry", controller.RetryUserMigrationBatch) userMigrationRoute.POST("/batches/:id/retry", controller.RetryUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/verify", controller.VerifyUserMigrationBatch) userMigrationRoute.POST("/batches/:id/verify", controller.VerifyUserMigrationBatch)
userMigrationRoute.POST("/items/:id/resolve", controller.ResolveUserMigrationItem) userMigrationRoute.POST("/items/:id/resolve", controller.ResolveUserMigrationItem)
} }


+ 13
- 0
router/user_migration_router_test.go Просмотреть файл

@@ -65,6 +65,7 @@ func setupUserMigrationRootRouter() *gin.Engine {
{ {
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch)
} }


return r return r
@@ -96,6 +97,7 @@ func setupUserMigrationAdminRouter() *gin.Engine {
{ {
userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches)
userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch)
userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch)
} }


return r return r
@@ -129,6 +131,11 @@ func TestUserMigrationRoutes_RootCanAccess(t *testing.T) {
w = httptest.NewRecorder() w = httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusUnauthorized, w.Code) require.NotEqual(t, http.StatusUnauthorized, w.Code)

req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.NotEqual(t, http.StatusUnauthorized, w.Code)
} }


func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { func TestUserMigrationRoutes_AdminForbidden(t *testing.T) {
@@ -152,4 +159,10 @@ func TestUserMigrationRoutes_AdminForbidden(t *testing.T) {
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "success\":false") require.Contains(t, w.Body.String(), "success\":false")

req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "success\":false")
} }

+ 26
- 4
service/user_migration/client.go Просмотреть файл

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"


@@ -12,7 +13,8 @@ import (
) )


type migrationClient interface { type migrationClient interface {
ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error)
ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error)
QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error)
GetUser(userId int) (*RemoteUserSnapshot, error) GetUser(userId int) (*RemoteUserSnapshot, error)
ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error
HasSyncedCopy(cnUserId int) (bool, error) HasSyncedCopy(cnUserId int) (bool, error)
@@ -68,9 +70,12 @@ func (c *Client) doRequest(method, path string, body any) ([]byte, error) {
return data, nil return data, nil
} }


func (c *Client) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) {
data, err := c.doRequest(http.MethodGet,
fmt.Sprintf("/api/internal/migration/users?page=%d&page_size=%d", page, pageSize), nil)
func (c *Client) ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) {
path := fmt.Sprintf("/api/internal/migration/users?page=%d&page_size=%d", page, pageSize)
if keyword != "" {
path += "&keyword=" + url.QueryEscape(keyword)
}
data, err := c.doRequest(http.MethodGet, path, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -81,6 +86,23 @@ func (c *Client) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error)
return &resp, nil return &resp, nil
} }


func (c *Client) QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) {
data, err := c.doRequest(http.MethodPost, "/api/internal/migration/users/query", &QueryRemoteUsersRequest{
Page: page,
PageSize: pageSize,
SelectionMode: SelectionModeExplicitIDs,
SourceUserIDs: sourceUserIDs,
})
if err != nil {
return nil, err
}
var resp QueryRemoteUsersResponse
if err := common.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &resp, nil
}

func (c *Client) GetUser(userId int) (*RemoteUserSnapshot, error) { func (c *Client) GetUser(userId int) (*RemoteUserSnapshot, error) {
data, err := c.doRequest(http.MethodGet, data, err := c.doRequest(http.MethodGet,
fmt.Sprintf("/api/internal/migration/users/%d", userId), nil) fmt.Sprintf("/api/internal/migration/users/%d", userId), nil)


+ 46
- 5
service/user_migration/scanner.go Просмотреть файл

@@ -25,13 +25,40 @@ func (s *Service) ScanBatch(batchId int) error {
return err return err
} }


var batch model.UserMigrationBatch
if err := model.DB.First(&batch, batchId).Error; err != nil {
return err
}

var selectionPayload SelectionPayload
if batch.SelectionPayload != "" {
if err := common.UnmarshalJsonStr(batch.SelectionPayload, &selectionPayload); err != nil {
return fmt.Errorf("parse selection payload: %w", err)
}
}

page := 0 page := 0
var latestSummary *SelectionSummary
for { for {
resp, err := s.client.ListUsers(page, scanPageSize)
if err != nil {
return fmt.Errorf("list users page %d: %w", page, err)
var respData []RemoteUserSnapshot
var total int
if batch.SelectionMode == SelectionModeExplicitIDs {
resp, err := s.client.QueryUsers(page, scanPageSize, selectionPayload.SourceUserIDs)
if err != nil {
return fmt.Errorf("query users page %d: %w", page, err)
}
respData = resp.Data
total = resp.Total
latestSummary = resp.SelectionSummary
} else {
resp, err := s.client.ListUsers(page, scanPageSize, "")
if err != nil {
return fmt.Errorf("list users page %d: %w", page, err)
}
respData = resp.Data
total = resp.Total
} }
for _, remoteUser := range resp.Data {
for _, remoteUser := range respData {
result, err := analyzeConflicts(&remoteUser, providerSlugMap, s.client) result, err := analyzeConflicts(&remoteUser, providerSlugMap, s.client)
if err != nil { if err != nil {
return fmt.Errorf("analyze conflicts for user %d: %w", remoteUser.Id, err) return fmt.Errorf("analyze conflicts for user %d: %w", remoteUser.Id, err)
@@ -64,12 +91,26 @@ func (s *Service) ScanBatch(batchId int) error {
} }
} }


if len(resp.Data) < scanPageSize {
if len(respData) < scanPageSize || total <= (page+1)*scanPageSize {
break break
} }
page++ page++
} }


if latestSummary != nil {
summaryJSON, err := common.Marshal(latestSummary)
if err != nil {
return err
}
if err := model.DB.Model(&model.UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{
"matched_user_count": latestSummary.Matched,
"excluded_user_count": len(latestSummary.Excluded),
"selection_summary": string(summaryJSON),
}).Error; err != nil {
return err
}
}

return model.RefreshUserMigrationBatchStats(batchId) return model.RefreshUserMigrationBatchStats(batchId)
} }




+ 166
- 2
service/user_migration/service_test.go Просмотреть файл

@@ -1,6 +1,7 @@
package user_migration package user_migration


import ( import (
"encoding/json"
"fmt" "fmt"
"testing" "testing"


@@ -13,6 +14,8 @@ import (


type fakeClient struct { type fakeClient struct {
listPages map[int][]RemoteUserSnapshot listPages map[int][]RemoteUserSnapshot
queryPages map[int][]RemoteUserSnapshot
querySummary *SelectionSummary
getUserResp *RemoteUserSnapshot getUserResp *RemoteUserSnapshot
getUserByID map[int]*RemoteUserSnapshot getUserByID map[int]*RemoteUserSnapshot
hasCopyResult bool hasCopyResult bool
@@ -21,12 +24,30 @@ type fakeClient struct {
convertReqs map[int]*ConvertRemoteUserRequest convertReqs map[int]*ConvertRemoteUserRequest
} }


func (f *fakeClient) ListUsers(page, pageSize int) (*ListRemoteUsersResponse, error) {
func (f *fakeClient) ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) {
users := f.listPages[page] users := f.listPages[page]
total := 0
for _, pageUsers := range f.listPages {
total += len(pageUsers)
}
return &ListRemoteUsersResponse{ return &ListRemoteUsersResponse{
Success: true, Success: true,
Data: users, Data: users,
Total: len(users),
Total: total,
}, nil
}

func (f *fakeClient) QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) {
users := f.queryPages[page]
total := len(users)
if f.querySummary != nil {
total = f.querySummary.Matched
}
return &QueryRemoteUsersResponse{
Success: true,
Data: users,
Total: total,
SelectionSummary: f.querySummary,
}, nil }, nil
} }


@@ -253,6 +274,149 @@ func TestScanBatch_GitHubId_SameUser_RecommendsMerge(t *testing.T) {
require.Contains(t, items[0].ConflictFlags, "github_id") require.Contains(t, items[0].ConflictFlags, "github_id")
} }


func TestScanBatch_ExplicitIDs_OnlySelectedUsersProcessed(t *testing.T) {
db := setupMigrationServiceDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000021, 10000023}})
require.NoError(t, err)

batch := &model.UserMigrationBatch{
Name: "explicit-scan",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: SelectionModeExplicitIDs,
SelectionPayload: string(payloadJSON),
RequestedUserCount: 2,
OperatorId: 1,
}
require.NoError(t, model.CreateUserMigrationBatch(batch))

client := &fakeClient{
queryPages: map[int][]RemoteUserSnapshot{
0: {
{Id: 10000021, Username: "ov-a", Quota: 10, AffCode: "OVA"},
{Id: 10000023, Username: "ov-b", Quota: 20, AffCode: "OVB"},
},
},
querySummary: &SelectionSummary{
Requested: 2,
Matched: 2,
Excluded: nil,
},
}
svc := &Service{client: client}
require.NoError(t, svc.ScanBatch(batch.Id))

items, err := model.ListUserMigrationItemsByBatch(batch.Id)
require.NoError(t, err)
require.Len(t, items, 2)
require.ElementsMatch(t, []int{10000021, 10000023}, []int{items[0].SourceUserId, items[1].SourceUserId})
}

func TestScanBatch_ExplicitIDs_SelectionSummaryRecorded(t *testing.T) {
db := setupMigrationServiceDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000021, 10000022, 10000023}})
require.NoError(t, err)

batch := &model.UserMigrationBatch{
Name: "explicit-summary",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: SelectionModeExplicitIDs,
SelectionPayload: string(payloadJSON),
RequestedUserCount: 3,
OperatorId: 1,
}
require.NoError(t, model.CreateUserMigrationBatch(batch))

client := &fakeClient{
queryPages: map[int][]RemoteUserSnapshot{
0: {
{Id: 10000021, Username: "ov-a", Quota: 10, AffCode: "OVA"},
{Id: 10000023, Username: "ov-b", Quota: 20, AffCode: "OVB"},
},
},
querySummary: &SelectionSummary{
Requested: 3,
Matched: 2,
Excluded: []SelectionExcluded{
{UserID: 10000022, Reason: "already_synced"},
},
},
}
svc := &Service{client: client}
require.NoError(t, svc.ScanBatch(batch.Id))

var refreshed model.UserMigrationBatch
require.NoError(t, db.First(&refreshed, batch.Id).Error)
require.Equal(t, 2, refreshed.MatchedUserCount)
require.Equal(t, 1, refreshed.ExcludedUserCount)

var summary SelectionSummary
require.NoError(t, json.Unmarshal([]byte(refreshed.SelectionSummary), &summary))
require.Equal(t, 3, summary.Requested)
require.Equal(t, 2, summary.Matched)
require.Len(t, summary.Excluded, 1)
require.Equal(t, 10000022, summary.Excluded[0].UserID)
}

func TestScanBatch_ExplicitIDs_ExcludedUsersDoNotCreateItems(t *testing.T) {
db := setupMigrationServiceDB(t)
orig := model.DB
model.DB = db
defer func() {
model.DB = orig
}()

payloadJSON, err := common.Marshal(SelectionPayload{SourceUserIDs: []int{10000031, 10000032, 10000033}})
require.NoError(t, err)

batch := &model.UserMigrationBatch{
Name: "explicit-excluded",
SourceRegion: "ov",
TargetRegion: "cn",
SelectionMode: SelectionModeExplicitIDs,
SelectionPayload: string(payloadJSON),
RequestedUserCount: 3,
OperatorId: 1,
}
require.NoError(t, model.CreateUserMigrationBatch(batch))

client := &fakeClient{
queryPages: map[int][]RemoteUserSnapshot{
0: {
{Id: 10000031, Username: "ov-a", Quota: 10, AffCode: "OVA"},
},
},
querySummary: &SelectionSummary{
Requested: 3,
Matched: 1,
Excluded: []SelectionExcluded{
{UserID: 10000032, Reason: "root_user_not_migratable"},
{UserID: 10000033, Reason: "already_synced"},
},
},
}
svc := &Service{client: client}
require.NoError(t, svc.ScanBatch(batch.Id))

items, err := model.ListUserMigrationItemsByBatch(batch.Id)
require.NoError(t, err)
require.Len(t, items, 1)
require.Equal(t, 10000031, items[0].SourceUserId)
}

func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) { func TestScanBatch_OAuthId_DifferentUsers_MultipleMergeTargets(t *testing.T) {
db := setupMigrationServiceDB(t) db := setupMigrationServiceDB(t)
orig := model.DB orig := model.DB


+ 35
- 0
service/user_migration/types.go Просмотреть файл

@@ -5,6 +5,26 @@ type RemoteOAuthBinding struct {
ProviderUserId string `json:"provider_user_id"` ProviderUserId string `json:"provider_user_id"`
} }


const (
SelectionModeAll = "all"
SelectionModeExplicitIDs = "explicit_ids"
)

type SelectionPayload struct {
SourceUserIDs []int `json:"source_user_ids"`
}

type SelectionExcluded struct {
UserID int `json:"user_id"`
Reason string `json:"reason"`
}

type SelectionSummary struct {
Requested int `json:"requested"`
Matched int `json:"matched"`
Excluded []SelectionExcluded `json:"excluded"`
}

type RemoteUserSnapshot struct { type RemoteUserSnapshot struct {
Id int `json:"id"` Id int `json:"id"`
Username string `json:"username"` Username string `json:"username"`
@@ -37,6 +57,21 @@ type ListRemoteUsersResponse struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }


type QueryRemoteUsersRequest struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
SelectionMode string `json:"selection_mode"`
SourceUserIDs []int `json:"source_user_ids"`
}

type QueryRemoteUsersResponse struct {
Success bool `json:"success"`
Data []RemoteUserSnapshot `json:"data"`
Total int `json:"total"`
SelectionSummary *SelectionSummary `json:"selection_summary,omitempty"`
Error string `json:"error,omitempty"`
}

type ConvertRemoteUserRequest struct { type ConvertRemoteUserRequest struct {
RemoteUserId int `json:"remote_user_id"` RemoteUserId int `json:"remote_user_id"`
SyncedQuota int `json:"synced_quota"` SyncedQuota int `json:"synced_quota"`


+ 120
- 3
test/e2e/user_migration_test.go Просмотреть файл

@@ -9,6 +9,7 @@ import (


"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
user_migration "github.com/QuantumNous/new-api/service/user_migration"
helpers "github.com/QuantumNous/new-api/test/e2e/helpers" helpers "github.com/QuantumNous/new-api/test/e2e/helpers"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -356,6 +357,110 @@ func TestUserMigrationE2E_SkipNeverConvertsRemote(t *testing.T) {
require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId) require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId)
} }


func TestUserMigrationE2E_ExplicitIDs_OnlySelectedUsersScanned(t *testing.T) {
env := newEnvironment(t)

batchID := createBatchWithSelection(t, env, "explicit-selected", []int{
helpers.OVCreateUserID,
helpers.OVMergeUserID,
})

var scanResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID),
nil,
202,
&scanResp,
))
require.True(t, scanResp.Success)

detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusReady, 20*time.Second)
require.NoError(t, err)
require.EqualValues(t, 2, detail.Total)
require.Equal(t, 2, detail.Batch.TotalUsers)
require.Equal(t, user_migration.SelectionModeExplicitIDs, detail.Batch.SelectionMode)
require.Equal(t, 2, detail.Batch.RequestedUserCount)

items := indexItemsBySourceUserID(detail.Items)
require.Len(t, items, 2)
require.Contains(t, items, helpers.OVCreateUserID)
require.Contains(t, items, helpers.OVMergeUserID)
require.NotContains(t, items, helpers.OVHardSyncedUserID)
}

func TestUserMigrationE2E_ExplicitIDs_SelectionSummaryRecorded(t *testing.T) {
env := newEnvironment(t)

batchID := createBatchWithSelection(t, env, "explicit-summary", []int{
helpers.OVCreateUserID,
helpers.OVSyncedCopyUserID,
1,
19999999,
})

var scanResp simpleResponse
require.NoError(t, env.CNClient.PostJSON(
fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID),
nil,
202,
&scanResp,
))
require.True(t, scanResp.Success)

detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusReady, 20*time.Second)
require.NoError(t, err)
require.Equal(t, 4, detail.Batch.RequestedUserCount)
require.Equal(t, 1, detail.Batch.MatchedUserCount)
require.Equal(t, 3, detail.Batch.ExcludedUserCount)
require.EqualValues(t, 1, detail.Total)

var summary user_migration.SelectionSummary
require.NoError(t, common.UnmarshalJsonStr(detail.Batch.SelectionSummary, &summary))
require.Equal(t, 4, summary.Requested)
require.Equal(t, 1, summary.Matched)
require.Len(t, summary.Excluded, 3)

reasonsByID := make(map[int]string, len(summary.Excluded))
for _, item := range summary.Excluded {
reasonsByID[item.UserID] = item.Reason
}
require.Equal(t, "already_synced", reasonsByID[helpers.OVSyncedCopyUserID])
require.Equal(t, "root_user_not_migratable", reasonsByID[1])
require.Equal(t, "not_found", reasonsByID[19999999])
}

func TestUserMigrationE2E_ExplicitIDs_ActiveBatchOverlapRejected(t *testing.T) {
env := newEnvironment(t)

firstBatchID := createBatchWithSelection(t, env, "explicit-overlap-first", []int{
helpers.OVCreateUserID,
})
require.NotZero(t, firstBatchID)

var resp struct {
Success bool `json:"success"`
Error string `json:"error"`
Conflicts []struct {
SourceUserID int `json:"source_user_id"`
BatchID int `json:"batch_id"`
BatchStatus string `json:"batch_status"`
} `json:"conflicts"`
}
require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{
"name": "explicit-overlap-second",
"source_region": "ov",
"target_region": "cn",
"selection_mode": "explicit_ids",
"source_user_ids": []int{helpers.OVCreateUserID, helpers.OVMergeUserID},
}, 409, &resp))
require.False(t, resp.Success)
require.Equal(t, "some selected users already exist in active migration batches", resp.Error)
require.Len(t, resp.Conflicts, 1)
require.Equal(t, helpers.OVCreateUserID, resp.Conflicts[0].SourceUserID)
require.Equal(t, firstBatchID, resp.Conflicts[0].BatchID)
require.Equal(t, model.UserMigrationBatchStatusDraft, resp.Conflicts[0].BatchStatus)
}

func newEnvironment(t *testing.T) *helpers.Environment { func newEnvironment(t *testing.T) *helpers.Environment {
t.Helper() t.Helper()


@@ -509,12 +614,24 @@ func scanGrayScenarioBatch(t *testing.T, env *helpers.Environment, name string)
func createBatch(t *testing.T, env *helpers.Environment, name string) int { func createBatch(t *testing.T, env *helpers.Environment, name string) int {
t.Helper() t.Helper()


var resp createBatchResponse
require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{
return createBatchWithSelection(t, env, name, nil)
}

func createBatchWithSelection(t *testing.T, env *helpers.Environment, name string, sourceUserIDs []int) int {
t.Helper()

payload := map[string]any{
"name": name, "name": name,
"source_region": "ov", "source_region": "ov",
"target_region": "cn", "target_region": "cn",
}, 200, &resp))
}
if len(sourceUserIDs) > 0 {
payload["selection_mode"] = "explicit_ids"
payload["source_user_ids"] = sourceUserIDs
}

var resp createBatchResponse
require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", payload, 200, &resp))
require.True(t, resp.Success) require.True(t, resp.Success)
require.NotZero(t, resp.Data.Id) require.NotZero(t, resp.Data.Id)
return resp.Data.Id return resp.Data.Id


+ 116
- 22
web/e2e/user-migration.spec.ts Просмотреть файл

@@ -22,20 +22,46 @@ async function openMigrationTab(page: Page) {
await expect(page.getByTestId('user-migration-center')).toBeVisible(); await expect(page.getByTestId('user-migration-center')).toBeVisible();
} }


async function createBatch(page: Page, batchName: string) {
await page.getByTestId('migration-create-batch-button').click();
async function selectCandidateUser(page: Page, userId: number) {
const row = page.getByTestId(`migration-candidate-row-${userId}`);
await expect(row).toBeVisible();

const checkboxInput = row.locator('input[type="checkbox"]');
if ((await checkboxInput.count()) > 0) {
await checkboxInput.first().check({ force: true });
return;
}


const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await row.locator('.semi-checkbox').first().click();
}


const nameField = dialog.getByRole('textbox').first();
async function createBatch(
page: Page,
batchName: string,
selectedUserIds: number[] = [],
) {
await page.getByTestId('migration-create-batch-button').click();
await expect(
page.getByTestId('migration-selection-mode-group'),
).toBeVisible();

if (selectedUserIds.length > 0) {
await page.getByTestId('migration-selection-mode-explicit').click();
await expect(page.getByTestId('migration-candidate-table')).toBeVisible();
for (const userId of selectedUserIds) {
await selectCandidateUser(page, userId);
}
}
await page.getByTestId('migration-create-batch-submit').click();

const nameField = page.getByTestId('migration-batch-name-field');
await expect(nameField).toBeVisible(); await expect(nameField).toBeVisible();
await nameField.fill(batchName); await nameField.fill(batchName);


await dialog
.getByRole('button', { name: /confirm|确定/i })
.click();
await expect(page.getByTestId('migration-batch-table')).toContainText(batchName);
await page.getByTestId('migration-create-batch-submit').click();
await expect(page.getByTestId('migration-batch-table')).toContainText(
batchName,
);
await page.getByTestId('migration-batch-table').getByText(batchName).click(); await page.getByTestId('migration-batch-table').getByText(batchName).click();
await expect(page.getByTestId('migration-execution-panel')).toBeVisible(); await expect(page.getByTestId('migration-execution-panel')).toBeVisible();
await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); await expect(page.getByTestId('migration-batch-detail')).toBeVisible();
@@ -53,14 +79,71 @@ test.describe('User Migration Access', () => {
test.describe.serial('User Migration Smoke', () => { test.describe.serial('User Migration Smoke', () => {
test.use({ storageState: rootStorageStatePath }); test.use({ storageState: rootStorageStatePath });


test('root can open migration center and create a batch', async ({ page }) => {
test('root can open migration center and create a batch', async ({
page,
}) => {
const batchName = nextBatchName('ui-create'); const batchName = nextBatchName('ui-create');


await gotoUserPage(page); await gotoUserPage(page);
await openMigrationTab(page); await openMigrationTab(page);
await createBatch(page, batchName); await createBatch(page, batchName);


await expect(page.getByTestId('migration-batch-status')).toHaveText('draft');
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'draft',
);
});

test('root can clear selected batch from the list', async ({ page }) => {
const batchName = nextBatchName('ui-clear');

await gotoUserPage(page);
await openMigrationTab(page);
await createBatch(page, batchName);

await expect(page.getByTestId('migration-batch-detail')).toBeVisible();
await page.getByTestId('migration-clear-batch-selection-button').click();
await expect(page.getByTestId('migration-batch-detail')).toHaveCount(0);
await expect(page.getByTestId('migration-execution-panel')).toHaveCount(0);
});

test('root can create explicit_ids batch from candidate users', async ({
page,
}) => {
const batchName = nextBatchName('ui-explicit');

await gotoUserPage(page);
await openMigrationTab(page);
await createBatch(page, batchName, [10000003, 10000004]);

await expect(page.getByTestId('migration-batch-table')).toContainText(
'指定 2 人',
);
await expect(page.getByTestId('migration-selection-summary')).toContainText(
'已选 2 人',
);
});

test('root can cancel a batch and recreate selected users in a new batch', async ({
page,
}) => {
const firstBatchName = nextBatchName('ui-cancel-source');
const secondBatchName = nextBatchName('ui-cancel-recreate');

await gotoUserPage(page);
await openMigrationTab(page);
await createBatch(page, firstBatchName, [10000001, 10000002]);

await page.getByTestId('migration-cancel-button').click();
await expect(page.getByTestId('migration-cancel-confirm')).toBeVisible();
await page.getByTestId('migration-cancel-confirm').click();
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'cancelled',
);

await createBatch(page, secondBatchName, [10000001, 10000002]);
await expect(page.getByTestId('migration-batch-table')).toContainText(
secondBatchName,
);
}); });


test('root can scan, resolve conflicts, execute, and verify a batch', async ({ test('root can scan, resolve conflicts, execute, and verify a batch', async ({
@@ -86,7 +169,9 @@ test.describe.serial('User Migration Smoke', () => {
await expect(page.getByTestId('migration-conflict-table')).toBeVisible(); await expect(page.getByTestId('migration-conflict-table')).toBeVisible();
await page.getByTestId('migration-resolve-skip-10000003').click(); await page.getByTestId('migration-resolve-skip-10000003').click();
await page.getByTestId('migration-resolve-skip-10000004').click(); await page.getByTestId('migration-resolve-skip-10000004').click();
await expect(page.getByTestId('migration-batch-status')).toHaveText('ready');
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'ready',
);


await page.getByTestId('migration-execute-button').click(); await page.getByTestId('migration-execute-button').click();
await expect(page.getByTestId('migration-execute-confirm')).toBeVisible(); await expect(page.getByTestId('migration-execute-confirm')).toBeVisible();
@@ -103,13 +188,15 @@ test.describe.serial('User Migration Smoke', () => {
); );


await page.getByTestId('migration-verify-button').click(); await page.getByTestId('migration-verify-button').click();
await expect(page.getByTestId('migration-verify-results-table')).toBeVisible();
await expect(page.getByTestId('migration-verify-results-table')).toContainText(
'10000001',
);
await expect(page.getByTestId('migration-batch-detail-table')).toContainText(
'migrated',
);
await expect(
page.getByTestId('migration-verify-results-table'),
).toBeVisible();
await expect(
page.getByTestId('migration-verify-results-table'),
).toContainText('10000001');
await expect(
page.getByTestId('migration-batch-detail-table'),
).toContainText('migrated');
}); });


test('page reload keeps batch list and detail loading available', async ({ test('page reload keeps batch list and detail loading available', async ({
@@ -133,9 +220,16 @@ test.describe.serial('User Migration Smoke', () => {
await gotoUserPage(page); await gotoUserPage(page);
await openMigrationTab(page); await openMigrationTab(page);


await expect(page.getByTestId('migration-batch-table')).toContainText(batchName);
await page.getByTestId('migration-batch-table').getByText(batchName).click();
await expect(page.getByTestId('migration-batch-table')).toContainText(
batchName,
);
await page
.getByTestId('migration-batch-table')
.getByText(batchName)
.click();
await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); await expect(page.getByTestId('migration-batch-detail')).toBeVisible();
await expect(page.getByTestId('migration-batch-status')).toHaveText('scanned');
await expect(page.getByTestId('migration-batch-status')).toHaveText(
'scanned',
);
}); });
}); });

+ 63
- 4
web/src/components/table/users/migration/BatchDetail.jsx Просмотреть файл

@@ -30,13 +30,35 @@ export default function BatchDetail({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();


const selectionSummary = (() => {
try {
return selectedBatch?.selection_summary
? JSON.parse(selectedBatch.selection_summary)
: null;
} catch {
return null;
}
})();

const columns = [ const columns = [
{ title: t('海外用户ID'), dataIndex: 'source_user_id' }, { title: t('海外用户ID'), dataIndex: 'source_user_id' },
{ title: t('用户名'), dataIndex: 'source_username' }, { title: t('用户名'), dataIndex: 'source_username' },
{ title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' },
{
title: t('邮箱'),
dataIndex: 'source_email',
render: (value) => value || '-',
},
{ title: t('海外额度'), dataIndex: 'source_quota' }, { title: t('海外额度'), dataIndex: 'source_quota' },
{ title: t('冲突类型'), dataIndex: 'conflict_flags', render: renderFlags },
{ title: t('处理策略'), dataIndex: 'resolution_strategy', render: (value) => value || '-' },
{
title: t('冲突类型'),
dataIndex: 'conflict_flags',
render: renderFlags,
},
{
title: t('处理策略'),
dataIndex: 'resolution_strategy',
render: (value) => value || '-',
},
{ {
title: t('状态'), title: t('状态'),
dataIndex: 'status', dataIndex: 'status',
@@ -47,7 +69,11 @@ export default function BatchDetail({
dataIndex: 'target_user_id', dataIndex: 'target_user_id',
render: (value) => value || '-', render: (value) => value || '-',
}, },
{ title: t('错误信息'), dataIndex: 'error_message', render: (value) => value || '-' },
{
title: t('错误信息'),
dataIndex: 'error_message',
render: (value) => value || '-',
},
]; ];


return ( return (
@@ -59,6 +85,39 @@ export default function BatchDetail({
> >
{t('批次详情')} #{selectedBatch.id} {t('批次详情')} #{selectedBatch.id}
</Typography.Title> </Typography.Title>

{selectedBatch?.selection_mode === 'explicit_ids' && (
<div style={{ marginBottom: 12 }} data-testid='migration-selection-summary'>
<Typography.Text>
{t('已选 {{count}} 人', {
count: selectedBatch.requested_user_count || 0,
})}
</Typography.Text>
<br />
<Typography.Text>
{t('命中 {{count}} 人', {
count: selectedBatch.matched_user_count || 0,
})}
</Typography.Text>
<br />
<Typography.Text>
{t('排除 {{count}} 人', {
count: selectedBatch.excluded_user_count || 0,
})}
</Typography.Text>
{selectionSummary?.excluded?.length > 0 && (
<>
<br />
<Typography.Text type='secondary'>
{selectionSummary.excluded
.map((item) => `${item.user_id}:${item.reason}`)
.join(', ')}
</Typography.Text>
</>
)}
</div>
)}

<Table <Table
rowKey='id' rowKey='id'
columns={columns} columns={columns}


+ 288
- 35
web/src/components/table/users/migration/BatchList.jsx Просмотреть файл

@@ -1,8 +1,11 @@
import React, { useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Button, Button,
Form, Form,
Input,
Modal, Modal,
Radio,
RadioGroup,
Space, Space,
Table, Table,
Tag, Tag,
@@ -29,17 +32,53 @@ export default function BatchList({
createBatch, createBatch,
refresh, refresh,
selectBatch, selectBatch,
clearSelectedBatch,
candidateUsers,
candidateTotal,
candidatePage,
candidatePageSize,
candidateKeyword,
selectedCandidateIds,
fetchCandidateUsers,
syncCandidateSelections,
removeCandidateSelection,
clearCandidateSelection,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [step, setStep] = useState(0);
const [selectionMode, setSelectionMode] = useState('all');
const formApiRef = useRef(null); const formApiRef = useRef(null);


useEffect(() => {
if (visible && step === 0 && selectionMode === 'explicit_ids') {
void fetchCandidateUsers(0, candidatePageSize, candidateKeyword);
}
}, [
visible,
step,
selectionMode,
fetchCandidateUsers,
candidatePageSize,
candidateKeyword,
]);

const columns = [ const columns = [
{ title: t('批次名称'), dataIndex: 'name' }, { title: t('批次名称'), dataIndex: 'name' },
{
title: t('迁移范围'),
dataIndex: 'requested_user_count',
render: (_, record) =>
record.selection_mode === 'explicit_ids'
? t('指定 {{count}} 人', { count: record.requested_user_count || 0 })
: t('全部'),
},
{ {
title: t('状态'), title: t('状态'),
dataIndex: 'status', dataIndex: 'status',
render: (status) => <Tag color={statusColorMap[status] || 'grey'}>{status}</Tag>,
render: (status) => (
<Tag color={statusColorMap[status] || 'grey'}>{status}</Tag>
),
}, },
{ title: t('总用户数'), dataIndex: 'total_users' }, { title: t('总用户数'), dataIndex: 'total_users' },
{ title: t('冲突数'), dataIndex: 'conflict_users' }, { title: t('冲突数'), dataIndex: 'conflict_users' },
@@ -52,11 +91,54 @@ export default function BatchList({
}, },
]; ];


const candidateColumns = [
{ title: t('海外用户ID'), dataIndex: 'id' },
{ title: t('用户名'), dataIndex: 'username' },
{
title: t('邮箱'),
dataIndex: 'email',
render: (value) => value || '-',
},
{ title: t('海外额度'), dataIndex: 'quota' },
{ title: t('分组'), dataIndex: 'group' },
];

const selectedCandidateSummary = useMemo(
() =>
`${selectedCandidateIds.slice(0, 10).join(', ')}${
selectedCandidateIds.length > 10 ? ' ...' : ''
}`,
[selectedCandidateIds],
);

const closeModal = () => {
setVisible(false);
setStep(0);
setSelectionMode('all');
clearCandidateSelection();
};

const handleCreate = async () => { const handleCreate = async () => {
if (step === 0) {
if (
selectionMode === 'explicit_ids' &&
selectedCandidateIds.length === 0
) {
return;
}
setStep(1);
return;
}

const values = await formApiRef.current.validate(); const values = await formApiRef.current.validate();
const success = await createBatch(values);
const success = await createBatch({
...values,
selection_mode: selectionMode,
source_user_ids:
selectionMode === 'explicit_ids' ? selectedCandidateIds : [],
});
if (success) { if (success) {
setVisible(false);
closeModal();
} }
}; };


@@ -75,7 +157,7 @@ export default function BatchList({
onClick={() => setVisible(true)} onClick={() => setVisible(true)}
data-testid='migration-create-batch-button' data-testid='migration-create-batch-button'
> >
{t('建批次')}
{t('建批次')}
</Button> </Button>
<Button <Button
icon={<IconRefresh />} icon={<IconRefresh />}
@@ -84,6 +166,14 @@ export default function BatchList({
> >
{t('刷新')} {t('刷新')}
</Button> </Button>
{selectedBatch && (
<Button
onClick={clearSelectedBatch}
data-testid='migration-clear-batch-selection-button'
>
{t('清空选择')}
</Button>
)}
</Space> </Space>
<Table <Table
rowKey='id' rowKey='id'
@@ -95,48 +185,211 @@ export default function BatchList({
rowSelection={{ rowSelection={{
type: 'radio', type: 'radio',
selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], selectedRowKeys: selectedBatch ? [selectedBatch.id] : [],
onChange: (_, rows) => rows[0] && selectBatch(rows[0]),
onChange: (_, rows) => {
if (rows[0]) {
void selectBatch(rows[0]);
return;
}
clearSelectedBatch();
},
}} }}
onRow={(record) => ({ onRow={(record) => ({
onClick: () => selectBatch(record),
onClick: () => {
if (selectedBatch?.id === record.id) {
clearSelectedBatch();
return;
}
void selectBatch(record);
},
})} })}
empty={t('暂无数据')} empty={t('暂无数据')}
/> />


<Modal <Modal
visible={visible} visible={visible}
title={t('创建批次')}
onCancel={() => setVisible(false)}
title={step === 0 ? t('候选用户') : t('创建批次')}
onCancel={closeModal}
onOk={handleCreate} onOk={handleCreate}
okButtonProps={{ 'data-testid': 'migration-create-batch-submit' }}
okText={step === 0 ? t('下一步') : t('创建批次')}
okButtonProps={{
'data-testid': 'migration-create-batch-submit',
disabled:
step === 0 &&
selectionMode === 'explicit_ids' &&
selectedCandidateIds.length === 0,
}}
cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }} cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }}
data-testid='migration-create-batch-modal' data-testid='migration-create-batch-modal'
width={step === 0 ? 960 : 640}
> >
<Form
getFormApi={(api) => {
formApiRef.current = api;
}}
initValues={{ source_region: 'overseas', target_region: 'cn' }}
>
<Form.Input
field='name'
label={t('批次名称')}
rules={[{ required: true, message: t('请输入批次名称') }]}
data-testid='migration-batch-name-field'
/>
<Form.Input
field='source_region'
label={t('源区域')}
rules={[{ required: true, message: t('请输入源区域') }]}
data-testid='migration-batch-source-region-field'
/>
<Form.Input
field='target_region'
label={t('目标区域')}
rules={[{ required: true, message: t('请输入目标区域') }]}
data-testid='migration-batch-target-region-field'
/>
</Form>
{step === 0 ? (
<Space vertical style={{ width: '100%' }}>
<Typography.Text>{t('迁移范围')}</Typography.Text>
<RadioGroup
type='button'
buttonSize='middle'
value={selectionMode}
onChange={(event) => {
const nextValue = event?.target?.value || 'all';
setSelectionMode(nextValue);
if (nextValue === 'explicit_ids') {
void fetchCandidateUsers(
0,
candidatePageSize,
candidateKeyword,
);
}
}}
data-testid='migration-selection-mode-group'
>
<Radio value='all' data-testid='migration-selection-mode-all'>
{t('全部')}
</Radio>
<Radio
value='explicit_ids'
data-testid='migration-selection-mode-explicit'
>
{t('指定用户')}
</Radio>
</RadioGroup>

{selectionMode === 'explicit_ids' && (
<>
<Input
value={candidateKeyword}
onChange={(value) =>
void fetchCandidateUsers(0, candidatePageSize, value)
}
placeholder={t('按用户ID / 用户名 / 邮箱搜索')}
data-testid='migration-candidate-search-input'
/>
<Space>
<Typography.Text type='secondary'>
<span data-testid='migration-candidate-selected-count'>
{t('已选 {{count}} 人', {
count: selectedCandidateIds.length,
})}
</span>
</Typography.Text>
<Button
theme='borderless'
size='small'
disabled={selectedCandidateIds.length === 0}
onClick={clearCandidateSelection}
data-testid='migration-candidate-clear-selection-button'
>
{t('清空选择')}
</Button>
</Space>
{selectedCandidateIds.length > 0 && (
<Space
wrap
data-testid='migration-candidate-selected-summary'
>
{selectedCandidateIds.map((id) => (
<Tag
key={id}
closable
onClose={() => removeCandidateSelection(id)}
>
{id}
</Tag>
))}
</Space>
)}
<Table
rowKey='id'
columns={candidateColumns}
dataSource={candidateUsers}
size='small'
data-testid='migration-candidate-table'
pagination={{
currentPage: candidatePage + 1,
pageSize: candidatePageSize,
total: candidateTotal,
pageSizeOpts: [20, 50, 100],
showSizeChanger: true,
onPageChange: (page) =>
void fetchCandidateUsers(
page - 1,
candidatePageSize,
candidateKeyword,
),
onPageSizeChange: (size) =>
void fetchCandidateUsers(0, size, candidateKeyword),
}}
rowSelection={{
type: 'checkbox',
selectedRowKeys: selectedCandidateIds,
onChange: (keys) =>
syncCandidateSelections(
candidateUsers.map((user) => user.id),
keys,
),
}}
onRow={(record) => ({
'data-testid': `migration-candidate-row-${record.id}`,
})}
/>
</>
)}
</Space>
) : (
<Form
getFormApi={(api) => {
formApiRef.current = api;
}}
initValues={{ source_region: 'ov', target_region: 'cn' }}
>
<Typography.Text
type='secondary'
data-testid='migration-selection-range'
>
{selectionMode === 'explicit_ids'
? t('指定 {{count}} 人', {
count: selectedCandidateIds.length,
})
: t('全部')}
</Typography.Text>
{selectionMode === 'explicit_ids' &&
selectedCandidateIds.length > 0 && (
<Typography.Paragraph
type='secondary'
data-testid='migration-selected-candidate-summary'
>
{selectedCandidateSummary}
</Typography.Paragraph>
)}
<Button
theme='borderless'
style={{ paddingLeft: 0 }}
onClick={() => setStep(0)}
data-testid='migration-back-to-candidates-button'
>
{selectionMode === 'explicit_ids'
? t('返回选择用户')
: t('返回设置范围')}
</Button>
<Form.Input
field='name'
label={t('批次名称')}
rules={[{ required: true, message: t('请输入批次名称') }]}
data-testid='migration-batch-name-field'
/>
<Form.Input
field='source_region'
label={t('源区域')}
rules={[{ required: true, message: t('请输入源区域') }]}
data-testid='migration-batch-source-region-field'
/>
<Form.Input
field='target_region'
label={t('目标区域')}
rules={[{ required: true, message: t('请输入目标区域') }]}
data-testid='migration-batch-target-region-field'
/>
</Form>
)}
</Modal> </Modal>
</div> </div>
); );


+ 26
- 1
web/src/components/table/users/migration/ExecutionPanel.jsx Просмотреть файл

@@ -19,10 +19,18 @@ export default function ExecutionPanel({
scanBatch, scanBatch,
executeBatch, executeBatch,
retryBatch, retryBatch,
cancelBatch,
verifyBatch, verifyBatch,
verifyResults, verifyResults,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const canCancel = [
'draft',
'scanned',
'ready',
'failed',
'cancelled',
].includes(selectedBatch.status);


const verifyColumns = [ const verifyColumns = [
{ title: t('海外用户ID'), dataIndex: 'source_user_id' }, { title: t('海外用户ID'), dataIndex: 'source_user_id' },
@@ -79,6 +87,21 @@ export default function ExecutionPanel({
> >
{t('重试失败项')} {t('重试失败项')}
</Button> </Button>
<Popconfirm
title={t('确认删除该批次?')}
content={t('这是软删除,只会将批次标记为 cancelled')}
onConfirm={() => cancelBatch(selectedBatch.id)}
okButtonProps={{ 'data-testid': 'migration-cancel-confirm' }}
cancelButtonProps={{ 'data-testid': 'migration-cancel-abort' }}
>
<Button
type='danger'
disabled={!canCancel}
data-testid='migration-cancel-button'
>
{t('删除批次')}
</Button>
</Popconfirm>
<Button <Button
onClick={() => verifyBatch(selectedBatch.id)} onClick={() => verifyBatch(selectedBatch.id)}
data-testid='migration-verify-button' data-testid='migration-verify-button'
@@ -88,7 +111,9 @@ export default function ExecutionPanel({
</Space> </Space>
{verifyResults.length > 0 && ( {verifyResults.length > 0 && (
<Table <Table
rowKey={(record) => `${record.source_user_id}-${record.target_user_id}`}
rowKey={(record) =>
`${record.source_user_id}-${record.target_user_id}`
}
columns={verifyColumns} columns={verifyColumns}
dataSource={verifyResults} dataSource={verifyResults}
pagination={false} pagination={false}


+ 177
- 55
web/src/hooks/users/useUserMigrationData.jsx Просмотреть файл

@@ -9,6 +9,12 @@ export function useUserMigrationData() {
const { t } = useTranslation(); const { t } = useTranslation();
const [batches, setBatches] = useState([]); const [batches, setBatches] = useState([]);
const [selectedBatch, setSelectedBatch] = useState(null); const [selectedBatch, setSelectedBatch] = useState(null);
const [candidateUsers, setCandidateUsers] = useState([]);
const [candidateTotal, setCandidateTotal] = useState(0);
const [candidatePage, setCandidatePage] = useState(0);
const [candidatePageSize, setCandidatePageSize] = useState(50);
const [candidateKeyword, setCandidateKeyword] = useState('');
const [selectedCandidateIds, setSelectedCandidateIds] = useState([]);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [itemsTotal, setItemsTotal] = useState(0); const [itemsTotal, setItemsTotal] = useState(0);
const [itemsPage, setItemsPage] = useState(0); const [itemsPage, setItemsPage] = useState(0);
@@ -49,7 +55,8 @@ export function useUserMigrationData() {
const data = res.data.data || []; const data = res.data.data || [];
setBatches(data); setBatches(data);
if (selectedBatchIdRef.current) { if (selectedBatchIdRef.current) {
const current = data.find((batch) => batch.id === selectedBatchIdRef.current) || null;
const current =
data.find((batch) => batch.id === selectedBatchIdRef.current) || null;
setSelectedBatch(current); setSelectedBatch(current);
} }
return data; return data;
@@ -57,64 +64,138 @@ export function useUserMigrationData() {
showError(error.message); showError(error.message);
return []; return [];
} }
}, []);
}, [t]);


const fetchItems = useCallback(async (batchId, page = 0, pageSize = 50) => {
if (!batchId) {
return;
}
try {
const res = await API.get(`/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`);
if (!res.data.success) {
showError(res.data.error || t('获取批次详情失败'));
const fetchItems = useCallback(
async (batchId, page = 0, pageSize = 50) => {
if (!batchId) {
return; return;
} }
setItems(res.data.items || []);
setItemsTotal(res.data.total || 0);
setItemsPage(page);
setItemsPageSize(pageSize);
setSelectedBatch(res.data.data || null);
} catch (error) {
showError(error.message);
}
try {
const res = await API.get(
`/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`,
);
if (!res.data.success) {
showError(res.data.error || t('获取批次详情失败'));
return;
}
setItems(res.data.items || []);
setItemsTotal(res.data.total || 0);
setItemsPage(page);
setItemsPageSize(pageSize);
setSelectedBatch(res.data.data || null);
} catch (error) {
showError(error.message);
}
},
[t],
);

const fetchCandidateUsers = useCallback(
async (page = 0, pageSize = 50, keyword = '') => {
try {
const params = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
});
if (keyword) {
params.set('keyword', keyword);
}
const res = await API.get(
`/api/user-migrations/candidate-users?${params.toString()}`,
);
if (!res.data.success) {
showError(res.data.error || t('获取候选用户失败'));
return;
}
setCandidateUsers(res.data.data || []);
setCandidateTotal(res.data.total || 0);
setCandidatePage(page);
setCandidatePageSize(pageSize);
setCandidateKeyword(keyword);
} catch (error) {
showError(error.message);
}
},
[t],
);

const syncCandidateSelections = useCallback((pageIds, ids) => {
setSelectedCandidateIds((prev) => {
const pageIdSet = new Set(pageIds);
const next = prev.filter((id) => !pageIdSet.has(id));
ids.forEach((id) => {
if (!next.includes(id)) {
next.push(id);
}
});
return next;
});
}, []); }, []);


const refresh = useCallback(async (silent = false) => {
if (!silent) {
setLoading(true);
}
const latestBatches = await fetchBatches();
if (selectedBatchIdRef.current) {
await fetchItems(
selectedBatchIdRef.current,
itemsPageRef.current,
itemsPageSizeRef.current,
const removeCandidateSelection = useCallback((id) => {
setSelectedCandidateIds((prev) =>
prev.filter((candidateId) => candidateId !== id),
);
}, []);

const clearCandidateSelection = useCallback(() => {
setSelectedCandidateIds([]);
}, []);

const clearSelectedBatch = useCallback(() => {
selectedBatchIdRef.current = null;
setSelectedBatch(null);
setItems([]);
setItemsTotal(0);
setItemsPage(0);
setVerifyResults([]);
}, []);

const refresh = useCallback(
async (silent = false) => {
if (!silent) {
setLoading(true);
}
const latestBatches = await fetchBatches();
if (selectedBatchIdRef.current) {
await fetchItems(
selectedBatchIdRef.current,
itemsPageRef.current,
itemsPageSizeRef.current,
);
}
const hasRunning = latestBatches.some((batch) =>
RUNNING_STATUSES.has(batch.status),
); );
}
const hasRunning = latestBatches.some((batch) => RUNNING_STATUSES.has(batch.status));
if (hasRunning && !pollTimerRef.current) {
pollTimerRef.current = setInterval(() => {
void refresh(true);
}, POLL_INTERVAL_MS);
}
if (!hasRunning) {
stopPolling();
}
if (!silent) {
setLoading(false);
}
}, [fetchBatches, fetchItems, stopPolling]);
if (hasRunning && !pollTimerRef.current) {
pollTimerRef.current = setInterval(() => {
void refresh(true);
}, POLL_INTERVAL_MS);
}
if (!hasRunning) {
stopPolling();
}
if (!silent) {
setLoading(false);
}
},
[fetchBatches, fetchItems, stopPolling],
);


useEffect(() => { useEffect(() => {
void refresh(); void refresh();
return () => stopPolling(); return () => stopPolling();
}, [refresh, stopPolling]); }, [refresh, stopPolling]);


const selectBatch = useCallback(async (batch) => {
setVerifyResults([]);
setSelectedBatch(batch);
await fetchItems(batch.id, 0, itemsPageSizeRef.current);
}, [fetchItems]);
const selectBatch = useCallback(
async (batch) => {
setVerifyResults([]);
setSelectedBatch(batch);
await fetchItems(batch.id, 0, itemsPageSizeRef.current);
},
[fetchItems],
);


const createBatch = async (payload) => { const createBatch = async (payload) => {
try { try {
@@ -134,7 +215,9 @@ export function useUserMigrationData() {


const scanBatch = async (batchId) => { const scanBatch = async (batchId) => {
try { try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/scan`);
const res = await API.post(
`/api/user-migrations/batches/${batchId}/scan`,
);
if (!res.data.success) { if (!res.data.success) {
showError(res.data.error || t('预检查启动失败')); showError(res.data.error || t('预检查启动失败'));
return false; return false;
@@ -150,10 +233,13 @@ export function useUserMigrationData() {


const resolveItem = async (itemId, strategy, targetUserId = 0) => { const resolveItem = async (itemId, strategy, targetUserId = 0) => {
try { try {
const res = await API.post(`/api/user-migrations/items/${itemId}/resolve`, {
resolution_strategy: strategy,
target_user_id: targetUserId,
});
const res = await API.post(
`/api/user-migrations/items/${itemId}/resolve`,
{
resolution_strategy: strategy,
target_user_id: targetUserId,
},
);
if (!res.data.success) { if (!res.data.success) {
showError(res.data.error || t('更新策略失败')); showError(res.data.error || t('更新策略失败'));
return false; return false;
@@ -168,7 +254,9 @@ export function useUserMigrationData() {


const executeBatch = async (batchId) => { const executeBatch = async (batchId) => {
try { try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/execute`);
const res = await API.post(
`/api/user-migrations/batches/${batchId}/execute`,
);
if (!res.data.success) { if (!res.data.success) {
showError(res.data.error || t('迁移启动失败')); showError(res.data.error || t('迁移启动失败'));
return false; return false;
@@ -184,7 +272,9 @@ export function useUserMigrationData() {


const retryBatch = async (batchId) => { const retryBatch = async (batchId) => {
try { try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/retry`);
const res = await API.post(
`/api/user-migrations/batches/${batchId}/retry`,
);
if (!res.data.success) { if (!res.data.success) {
showError(res.data.error || t('重试启动失败')); showError(res.data.error || t('重试启动失败'));
return false; return false;
@@ -198,9 +288,29 @@ export function useUserMigrationData() {
} }
}; };


const cancelBatch = async (batchId) => {
try {
const res = await API.post(
`/api/user-migrations/batches/${batchId}/cancel`,
);
if (!res.data.success) {
showError(res.data.error || t('删除批次失败'));
return false;
}
showSuccess(t('批次已删除'));
await refresh();
return true;
} catch (error) {
showError(error.message);
return false;
}
};

const verifyBatch = async (batchId) => { const verifyBatch = async (batchId) => {
try { try {
const res = await API.post(`/api/user-migrations/batches/${batchId}/verify`);
const res = await API.post(
`/api/user-migrations/batches/${batchId}/verify`,
);
if (!res.data.success) { if (!res.data.success) {
showError(res.data.error || t('验证失败')); showError(res.data.error || t('验证失败'));
return []; return [];
@@ -218,6 +328,12 @@ export function useUserMigrationData() {
return { return {
batches, batches,
selectedBatch, selectedBatch,
candidateUsers,
candidateTotal,
candidatePage,
candidatePageSize,
candidateKeyword,
selectedCandidateIds,
items, items,
itemsTotal, itemsTotal,
itemsPage, itemsPage,
@@ -226,12 +342,18 @@ export function useUserMigrationData() {
verifyResults, verifyResults,
refresh, refresh,
selectBatch, selectBatch,
clearSelectedBatch,
fetchCandidateUsers,
syncCandidateSelections,
removeCandidateSelection,
clearCandidateSelection,
fetchItems, fetchItems,
createBatch, createBatch,
scanBatch, scanBatch,
resolveItem, resolveItem,
executeBatch, executeBatch,
retryBatch, retryBatch,
cancelBatch,
verifyBatch, verifyBatch,
t, t,
}; };


+ 3196
- 3170
web/src/i18n/locales/en.json
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 9
- 5
web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx Просмотреть файл

@@ -145,19 +145,23 @@ export default function ModelRatioNotSetEditor(props) {
}; };


try { try {
// 数据转换 - 只处理已修改的模型
// ???????????????????????????????????????
models.forEach((model) => { models.forEach((model) => {
// 只有当用户设置了值时才更新
delete output.ModelPrice[model.name];
delete output.ModelRatio[model.name];
delete output.CompletionRatio[model.name];

if (model.price !== '') { if (model.price !== '') {
// 如果价格不为空,则转换为浮点数,忽略倍率参数
output.ModelPrice[model.name] = parseFloat(model.price); output.ModelPrice[model.name] = parseFloat(model.price);
} else { } else {
if (model.ratio !== '')
if (model.ratio !== '') {
output.ModelRatio[model.name] = parseFloat(model.ratio); output.ModelRatio[model.name] = parseFloat(model.ratio);
if (model.completionRatio !== '')
}
if (model.completionRatio !== '') {
output.CompletionRatio[model.name] = parseFloat( output.CompletionRatio[model.name] = parseFloat(
model.completionRatio, model.completionRatio,
); );
}
} }
}); });




Загрузка…
Отмена
Сохранить