# Conflicts: # controller/user.go # web/src/i18n/locales/en.json # web/src/i18n/locales/zh-CN.jsonmaster
| @@ -0,0 +1,203 @@ | |||
| package controller | |||
| import ( | |||
| "errors" | |||
| "net/http" | |||
| "strconv" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| func ListMigrationUsers(c *gin.Context) { | |||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) | |||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) | |||
| keyword := c.Query("keyword") | |||
| if pageSize <= 0 || pageSize > 200 { | |||
| pageSize = 50 | |||
| } | |||
| users, total, err := model.QueryLocalUsersForMigration(page, pageSize, keyword) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "database error"}) | |||
| return | |||
| } | |||
| snapshots := make([]user_migration.RemoteUserSnapshot, 0, len(users)) | |||
| for _, user := range users { | |||
| bindings, _ := model.GetUserOAuthBindingsByUserId(user.Id) | |||
| snapshots = append(snapshots, buildRemoteUserSnapshot(user, bindings)) | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": snapshots, "total": total}) | |||
| } | |||
| func QueryMigrationUsers(c *gin.Context) { | |||
| var req user_migration.QueryRemoteUsersRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid request body"}) | |||
| return | |||
| } | |||
| if req.SelectionMode != user_migration.SelectionModeExplicitIDs { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"}) | |||
| return | |||
| } | |||
| if req.PageSize <= 0 || req.PageSize > 200 { | |||
| req.PageSize = 50 | |||
| } | |||
| indexed := make(map[int]struct{}, len(req.SourceUserIDs)) | |||
| orderedIDs := make([]int, 0, len(req.SourceUserIDs)) | |||
| for _, id := range req.SourceUserIDs { | |||
| if id <= 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid source_user_ids"}) | |||
| return | |||
| } | |||
| if _, ok := indexed[id]; ok { | |||
| continue | |||
| } | |||
| indexed[id] = struct{}{} | |||
| orderedIDs = append(orderedIDs, id) | |||
| } | |||
| summary := &user_migration.SelectionSummary{ | |||
| Requested: len(orderedIDs), | |||
| Excluded: make([]user_migration.SelectionExcluded, 0), | |||
| } | |||
| snapshots := make([]user_migration.RemoteUserSnapshot, 0, len(orderedIDs)) | |||
| for _, userID := range orderedIDs { | |||
| user, err := model.GetUserById(userID, true) | |||
| if err != nil || user == nil { | |||
| summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "not_found"}) | |||
| continue | |||
| } | |||
| if user.Role == common.RoleRootUser { | |||
| summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "root_user_not_migratable"}) | |||
| continue | |||
| } | |||
| if user.Source == common.UserSourceSynced { | |||
| summary.Excluded = append(summary.Excluded, user_migration.SelectionExcluded{UserID: userID, Reason: "already_synced"}) | |||
| continue | |||
| } | |||
| bindings, _ := model.GetUserOAuthBindingsByUserId(userID) | |||
| snapshots = append(snapshots, buildRemoteUserSnapshot(user, bindings)) | |||
| } | |||
| summary.Matched = len(snapshots) | |||
| start := req.Page * req.PageSize | |||
| if start > len(snapshots) { | |||
| start = len(snapshots) | |||
| } | |||
| end := start + req.PageSize | |||
| if end > len(snapshots) { | |||
| end = len(snapshots) | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{ | |||
| "success": true, | |||
| "data": snapshots[start:end], | |||
| "total": len(snapshots), | |||
| "selection_summary": summary, | |||
| }) | |||
| } | |||
| func GetMigrationUser(c *gin.Context) { | |||
| userId, err := strconv.Atoi(c.Param("id")) | |||
| if err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid user id"}) | |||
| return | |||
| } | |||
| user, err := model.GetUserById(userId, true) | |||
| if err != nil || user == nil { | |||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"}) | |||
| return | |||
| } | |||
| if user.Role == common.RoleRootUser { | |||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "user not found"}) | |||
| return | |||
| } | |||
| bindings, _ := model.GetUserOAuthBindingsByUserId(userId) | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": buildRemoteUserSnapshot(user, bindings)}) | |||
| } | |||
| func ConvertMigrationUserToSynced(c *gin.Context) { | |||
| userId, err := strconv.Atoi(c.Param("id")) | |||
| if err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid user id"}) | |||
| return | |||
| } | |||
| var req user_migration.ConvertRemoteUserRequest | |||
| if err := c.ShouldBindJSON(&req); err != nil || req.RemoteUserId == 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid request body"}) | |||
| return | |||
| } | |||
| if err := model.ConvertUserToSynced(userId, req.RemoteUserId, req.SyncedQuota); err != nil { | |||
| if errors.Is(err, model.ErrConvertRootUserToSynced) || | |||
| errors.Is(err, model.ErrSyncedUserRemoteUserIDImmutable) { | |||
| c.JSON(http.StatusConflict, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true}) | |||
| } | |||
| func CheckSyncedCopy(c *gin.Context) { | |||
| cnUserId, err := strconv.Atoi(c.Query("cn_user_id")) | |||
| if err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid cn_user_id"}) | |||
| return | |||
| } | |||
| hasCopy, err := model.HasOVSyncedCopyByRemoteId(cnUserId) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "has_copy": hasCopy}) | |||
| } | |||
| func buildRemoteUserSnapshot(user *model.User, bindings []*model.UserOAuthBinding) user_migration.RemoteUserSnapshot { | |||
| snapshot := user_migration.RemoteUserSnapshot{ | |||
| Id: user.Id, | |||
| Username: user.Username, | |||
| Password: user.Password, | |||
| Email: user.Email, | |||
| DisplayName: user.DisplayName, | |||
| Status: user.Status, | |||
| Role: user.Role, | |||
| Group: user.Group, | |||
| Quota: user.Quota, | |||
| AffCode: user.AffCode, | |||
| CreatedAt: user.CreatedAt, | |||
| Setting: user.Setting, | |||
| GitHubId: user.GitHubId, | |||
| DiscordId: user.DiscordId, | |||
| OidcId: user.OidcId, | |||
| WeChatId: user.WeChatId, | |||
| TelegramId: user.TelegramId, | |||
| LinuxDOId: user.LinuxDOId, | |||
| Source: user.Source, | |||
| RemoteUserId: user.RemoteUserId, | |||
| SyncedQuota: user.SyncedQuota, | |||
| } | |||
| for _, binding := range bindings { | |||
| provider, err := model.GetCustomOAuthProviderById(binding.ProviderId) | |||
| if err != nil { | |||
| continue | |||
| } | |||
| snapshot.OAuthBindings = append(snapshot.OAuthBindings, user_migration.RemoteOAuthBinding{ | |||
| ProviderSlug: provider.Slug, | |||
| ProviderUserId: binding.ProviderUserId, | |||
| }) | |||
| } | |||
| return snapshot | |||
| } | |||
| @@ -0,0 +1,491 @@ | |||
| package controller | |||
| import ( | |||
| "encoding/json" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "strings" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupInternalMigrationDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| require.NoError(t, db.AutoMigrate(&model.User{}, &model.CustomOAuthProvider{}, &model.UserOAuthBinding{})) | |||
| return db | |||
| } | |||
| func TestListMigrationUsers_ReturnsLocalOnly(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: 10000001, | |||
| Username: "ov-1", | |||
| Password: "hash", | |||
| Email: "u1@example.com", | |||
| AffCode: "A1", | |||
| Source: common.UserSourceLocal, | |||
| Quota: 99, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000002, | |||
| Username: "ov-2", | |||
| Password: "hash", | |||
| AffCode: "A2", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 1, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000003, | |||
| Username: "root", | |||
| Password: "hash", | |||
| AffCode: "A3", | |||
| Source: common.UserSourceLocal, | |||
| Role: common.RoleRootUser, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var body map[string]any | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||
| require.True(t, body["success"].(bool)) | |||
| require.EqualValues(t, 1, body["total"]) | |||
| } | |||
| func TestListMigrationUsers_FiltersByKeyword(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.GET("/api/internal/migration/users", ListMigrationUsers) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000011, | |||
| Username: "alice-local", | |||
| Password: "hash", | |||
| Email: "alice@example.com", | |||
| AffCode: "A11", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000012, | |||
| Username: "bob-local", | |||
| Password: "hash", | |||
| Email: "bob@example.com", | |||
| AffCode: "A12", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users?page=0&page_size=20&keyword=alice", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var body struct { | |||
| Success bool `json:"success"` | |||
| Data []user_migration.RemoteUserSnapshot `json:"data"` | |||
| Total int `json:"total"` | |||
| } | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||
| require.True(t, body.Success) | |||
| require.Equal(t, 1, body.Total) | |||
| require.Len(t, body.Data, 1) | |||
| require.Equal(t, 10000011, body.Data[0].Id) | |||
| } | |||
| func TestQueryMigrationUsers_OnlyReturnsSelectedLocalUsers(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/query", QueryMigrationUsers) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{Id: 10000001, Username: "u1", Password: "hash", AffCode: "A1", Source: common.UserSourceLocal}).Error) | |||
| require.NoError(t, db.Create(&model.User{Id: 10000002, Username: "u2", Password: "hash", AffCode: "A2", Source: common.UserSourceLocal, Role: common.RoleRootUser}).Error) | |||
| require.NoError(t, db.Create(&model.User{Id: 10000003, Username: "u3", Password: "hash", AffCode: "A3", Source: common.UserSourceSynced}).Error) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/internal/migration/users/query", strings.NewReader(`{"page":0,"page_size":50,"selection_mode":"explicit_ids","source_user_ids":[10000001,10000002,10000003,10000004]}`)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var body struct { | |||
| Success bool `json:"success"` | |||
| Data []user_migration.RemoteUserSnapshot `json:"data"` | |||
| Total int `json:"total"` | |||
| SelectionSummary user_migration.SelectionSummary `json:"selection_summary"` | |||
| } | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||
| require.True(t, body.Success) | |||
| require.Len(t, body.Data, 1) | |||
| require.Equal(t, 10000001, body.Data[0].Id) | |||
| require.Equal(t, 1, body.Total) | |||
| require.Equal(t, 4, body.SelectionSummary.Requested) | |||
| require.Equal(t, 1, body.SelectionSummary.Matched) | |||
| require.Len(t, body.SelectionSummary.Excluded, 3) | |||
| } | |||
| func TestQueryMigrationUsers_InvalidSelectionModeRejected(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/query", QueryMigrationUsers) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/internal/migration/users/query", strings.NewReader(`{"page":0,"page_size":50,"selection_mode":"all","source_user_ids":[1]}`)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||
| } | |||
| func TestGetMigrationUser_RootExcluded(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.GET("/api/internal/migration/users/:id", GetMigrationUser) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000011, | |||
| Username: "root", | |||
| Password: "hash", | |||
| AffCode: "ROOT1", | |||
| Source: common.UserSourceLocal, | |||
| Role: common.RoleRootUser, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/10000011", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusNotFound, w.Code) | |||
| } | |||
| func TestGetMigrationUser_SyncedUserStillReadableForVerify(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.GET("/api/internal/migration/users/:id", GetMigrationUser) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000021, | |||
| Username: "synced-user", | |||
| Password: "hash", | |||
| AffCode: "SYNC1", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 88, | |||
| SyncedQuota: 123, | |||
| Role: common.RoleCommonUser, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/10000021", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var body map[string]any | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||
| require.True(t, body["success"].(bool)) | |||
| } | |||
| func TestGetMigrationUser_InvalidID(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.GET("/api/internal/migration/users/:id", GetMigrationUser) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/internal/migration/users/not-a-number", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||
| } | |||
| func TestConvertMigrationUserToSynced_Idempotent(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000002, | |||
| Username: "ov-2", | |||
| Password: "hash", | |||
| AffCode: "A2", | |||
| Source: common.UserSourceLocal, | |||
| Quota: 66, | |||
| }).Error) | |||
| body := `{"remote_user_id":2002,"synced_quota":66}` | |||
| for i := 0; i < 2; i++ { | |||
| req := httptest.NewRequest(http.MethodPost, | |||
| "/api/internal/migration/users/10000002/convert-to-synced", | |||
| strings.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| } | |||
| } | |||
| func TestConvertMigrationUserToSynced_ConflictRemoteId(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000003, | |||
| Username: "ov-3", | |||
| Password: "hash", | |||
| AffCode: "A3", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 5000, | |||
| }).Error) | |||
| body := `{"remote_user_id":9999,"synced_quota":100}` | |||
| req := httptest.NewRequest(http.MethodPost, | |||
| "/api/internal/migration/users/10000003/convert-to-synced", | |||
| strings.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| } | |||
| func TestConvertMigrationUserToSynced_ConflictUsesSentinelErrors(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000013, | |||
| Username: "ov-13", | |||
| Password: "hash", | |||
| AffCode: "A13", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 5001, | |||
| }).Error) | |||
| body := `{"remote_user_id":9999,"synced_quota":100}` | |||
| req := httptest.NewRequest(http.MethodPost, | |||
| "/api/internal/migration/users/10000013/convert-to-synced", | |||
| strings.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| } | |||
| func TestConvertMigrationUserToSynced_RejectsRootUser(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000012, | |||
| Username: "root", | |||
| Password: "hash", | |||
| AffCode: "ROOT2", | |||
| Source: common.UserSourceLocal, | |||
| Role: common.RoleRootUser, | |||
| }).Error) | |||
| body := `{"remote_user_id":9999,"synced_quota":100}` | |||
| req := httptest.NewRequest(http.MethodPost, | |||
| "/api/internal/migration/users/10000012/convert-to-synced", | |||
| strings.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| } | |||
| func TestConvertMigrationUserToSynced_InvalidRemoteUserID(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000014, | |||
| Username: "ov-14", | |||
| Password: "hash", | |||
| AffCode: "A14", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| body := `{"remote_user_id":0,"synced_quota":100}` | |||
| req := httptest.NewRequest(http.MethodPost, | |||
| "/api/internal/migration/users/10000014/convert-to-synced", | |||
| strings.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||
| } | |||
| func TestConvertMigrationUserToSynced_PersistsSyncedFields(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.POST("/api/internal/migration/users/:id/convert-to-synced", ConvertMigrationUserToSynced) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000015, | |||
| Username: "ov-15", | |||
| Password: "hash", | |||
| AffCode: "A15", | |||
| Source: common.UserSourceLocal, | |||
| Quota: 88, | |||
| }).Error) | |||
| body := `{"remote_user_id":2015,"synced_quota":166}` | |||
| req := httptest.NewRequest(http.MethodPost, | |||
| "/api/internal/migration/users/10000015/convert-to-synced", | |||
| strings.NewReader(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var saved model.User | |||
| require.NoError(t, db.First(&saved, 10000015).Error) | |||
| require.Equal(t, common.UserSourceSynced, saved.Source) | |||
| require.Equal(t, 2015, saved.RemoteUserId) | |||
| require.Equal(t, 166, saved.SyncedQuota) | |||
| require.Greater(t, saved.LastSyncAt, int64(0)) | |||
| } | |||
| func TestCheckSyncedCopy(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testSyncAuthMiddleware()) | |||
| router.GET("/api/internal/migration/users/check-synced-copy", CheckSyncedCopy) | |||
| db := setupInternalMigrationDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 10000010, | |||
| Username: "synced", | |||
| Password: "hash", | |||
| AffCode: "S1", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 456, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, | |||
| "/api/internal/migration/users/check-synced-copy?cn_user_id=456", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var body map[string]any | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) | |||
| require.True(t, body["has_copy"].(bool)) | |||
| } | |||
| @@ -562,9 +562,15 @@ func UpdateUser(c *gin.Context) { | |||
| common.ApiError(c, err) | |||
| return | |||
| } | |||
| if originUser.IsSyncedUser() && originUser.SyncedQuota != updatedUser.Quota { | |||
| common.ApiErrorI18n(c, i18n.MsgSyncedUserQuotaCannotModify) | |||
| return | |||
| if originUser.IsSyncedUser() { | |||
| // 对同步用户而言,界面展示和用户感知的“余额”是 synced_quota, | |||
| // 不能用本地 quota 列做比较,否则在 quota / synced_quota 漂移后会误判。 | |||
| if originUser.SyncedQuota != updatedUser.Quota { | |||
| common.ApiErrorI18n(c, i18n.MsgSyncedUserQuotaCannotModify) | |||
| return | |||
| } | |||
| // 提交其他字段编辑时,保留本地 quota 原值,避免把展示用 synced_quota 回写到 quota 列。 | |||
| updatedUser.Quota = originUser.Quota | |||
| } | |||
| myRole := c.GetInt("role") | |||
| if myRole <= originUser.Role && myRole != common.RoleRootUser { | |||
| @@ -0,0 +1,352 @@ | |||
| package controller | |||
| import ( | |||
| "errors" | |||
| "net/http" | |||
| "strconv" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/QuantumNous/new-api/setting/system_setting" | |||
| "github.com/gin-gonic/gin" | |||
| "gorm.io/gorm" | |||
| ) | |||
| type migrationVerifier interface { | |||
| VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) | |||
| } | |||
| type migrationCandidateClient interface { | |||
| ListUsers(page, pageSize int, keyword string) (*user_migration.ListRemoteUsersResponse, error) | |||
| } | |||
| var newMigrationVerifier = func() migrationVerifier { | |||
| return newMigrationService() | |||
| } | |||
| var newMigrationCandidateClient = func() migrationCandidateClient { | |||
| settings := system_setting.GetRegionSyncSettings() | |||
| endpoint := settings.MasterEndpoint | |||
| if settings.IsMaster && len(settings.SlaveEndpoints) > 0 { | |||
| endpoint = settings.SlaveEndpoints[0] | |||
| } | |||
| return user_migration.NewClient(endpoint, settings.SyncApiKey) | |||
| } | |||
| func CreateUserMigrationBatch(c *gin.Context) { | |||
| var req struct { | |||
| Name string `json:"name" binding:"required"` | |||
| SourceRegion string `json:"source_region" binding:"required"` | |||
| TargetRegion string `json:"target_region" binding:"required"` | |||
| SelectionMode string `json:"selection_mode"` | |||
| SourceUserIDs []int `json:"source_user_ids"` | |||
| } | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| operatorID, _ := c.Get("id") | |||
| if req.SelectionMode == "" { | |||
| req.SelectionMode = user_migration.SelectionModeAll | |||
| } | |||
| if req.SelectionMode != user_migration.SelectionModeAll && req.SelectionMode != user_migration.SelectionModeExplicitIDs { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid selection_mode"}) | |||
| return | |||
| } | |||
| payload := user_migration.SelectionPayload{} | |||
| if req.SelectionMode == user_migration.SelectionModeExplicitIDs { | |||
| if len(req.SourceUserIDs) == 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "source_user_ids is required"}) | |||
| return | |||
| } | |||
| seen := make(map[int]struct{}, len(req.SourceUserIDs)) | |||
| payload.SourceUserIDs = make([]int, 0, len(req.SourceUserIDs)) | |||
| for _, id := range req.SourceUserIDs { | |||
| if id <= 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid source_user_ids"}) | |||
| return | |||
| } | |||
| if _, ok := seen[id]; ok { | |||
| continue | |||
| } | |||
| seen[id] = struct{}{} | |||
| payload.SourceUserIDs = append(payload.SourceUserIDs, id) | |||
| } | |||
| if len(payload.SourceUserIDs) > 1000 { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "too many source_user_ids"}) | |||
| return | |||
| } | |||
| conflicts, err := model.FindActiveMigrationUserConflicts(payload.SourceUserIDs) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if len(conflicts) > 0 { | |||
| c.JSON(http.StatusConflict, gin.H{ | |||
| "success": false, | |||
| "error": "some selected users already exist in active migration batches", | |||
| "conflicts": conflicts, | |||
| }) | |||
| return | |||
| } | |||
| } | |||
| payloadJSON, _ := common.Marshal(payload) | |||
| batch := &model.UserMigrationBatch{ | |||
| Name: req.Name, | |||
| SourceRegion: req.SourceRegion, | |||
| TargetRegion: req.TargetRegion, | |||
| SelectionMode: req.SelectionMode, | |||
| SelectionPayload: string(payloadJSON), | |||
| RequestedUserCount: len(payload.SourceUserIDs), | |||
| OperatorId: operatorID.(int), | |||
| } | |||
| if err := model.CreateUserMigrationBatch(batch); err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": batch}) | |||
| } | |||
| func ListMigrationCandidateUsers(c *gin.Context) { | |||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) | |||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) | |||
| keyword := c.Query("keyword") | |||
| if pageSize <= 0 || pageSize > 200 { | |||
| pageSize = 50 | |||
| } | |||
| resp, err := newMigrationCandidateClient().ListUsers(page, pageSize, keyword) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{ | |||
| "success": true, | |||
| "data": resp.Data, | |||
| "total": resp.Total, | |||
| }) | |||
| } | |||
| func ListUserMigrationBatches(c *gin.Context) { | |||
| var batches []model.UserMigrationBatch | |||
| if err := model.DB.Order("id desc").Find(&batches).Error; err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": batches}) | |||
| } | |||
| func GetUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| var batch model.UserMigrationBatch | |||
| if err := model.DB.First(&batch, batchID).Error; err != nil { | |||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "batch not found"}) | |||
| return | |||
| } | |||
| page, _ := strconv.Atoi(c.DefaultQuery("page", "0")) | |||
| pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) | |||
| if pageSize <= 0 { | |||
| pageSize = 50 | |||
| } | |||
| if pageSize > 200 { | |||
| pageSize = 200 | |||
| } | |||
| var items []model.UserMigrationItem | |||
| var total int64 | |||
| model.DB.Model(&model.UserMigrationItem{}).Where("batch_id = ?", batchID).Count(&total) | |||
| model.DB.Where("batch_id = ?", batchID).Order("id asc").Offset(page * pageSize).Limit(pageSize).Find(&items) | |||
| c.JSON(http.StatusOK, gin.H{ | |||
| "success": true, | |||
| "data": batch, | |||
| "items": items, | |||
| "total": total, | |||
| }) | |||
| } | |||
| func ScanUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| locked, err := model.TryLockBatchForScan(batchID) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if !locked { | |||
| c.JSON(http.StatusConflict, gin.H{"success": false, "error": "batch is already being scanned or not in a scannable state"}) | |||
| return | |||
| } | |||
| svc := newMigrationService() | |||
| go func() { | |||
| if err := svc.ScanBatch(batchID); err != nil { | |||
| model.DB.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID). | |||
| Update("status", model.UserMigrationBatchStatusFailed) | |||
| } | |||
| }() | |||
| c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "scan started"}) | |||
| } | |||
| func ResolveUserMigrationItem(c *gin.Context) { | |||
| itemID, _ := strconv.Atoi(c.Param("id")) | |||
| var item model.UserMigrationItem | |||
| if err := model.DB.Select("id", "batch_id").First(&item, itemID).Error; err != nil { | |||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "item not found"}) | |||
| return | |||
| } | |||
| var req struct { | |||
| ResolutionStrategy string `json:"resolution_strategy" binding:"required"` | |||
| TargetUserId int `json:"target_user_id"` | |||
| } | |||
| if err := c.ShouldBindJSON(&req); err != nil { | |||
| c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| validStrategies := map[string]bool{ | |||
| model.UserMigrationStrategyCreateNew: true, | |||
| model.UserMigrationStrategyMergeExisting: true, | |||
| model.UserMigrationStrategySkip: true, | |||
| } | |||
| if !validStrategies[req.ResolutionStrategy] { | |||
| c.JSON(http.StatusBadRequest, gin.H{ | |||
| "success": false, | |||
| "error": "invalid resolution_strategy: must be create_new, merge_into_existing, or skip", | |||
| }) | |||
| return | |||
| } | |||
| if req.ResolutionStrategy == model.UserMigrationStrategyMergeExisting && req.TargetUserId == 0 { | |||
| c.JSON(http.StatusBadRequest, gin.H{ | |||
| "success": false, | |||
| "error": "merge_into_existing requires target_user_id", | |||
| }) | |||
| return | |||
| } | |||
| updates := map[string]any{ | |||
| "resolution_strategy": req.ResolutionStrategy, | |||
| "status": model.UserMigrationItemStatusReady, | |||
| } | |||
| if req.TargetUserId != 0 { | |||
| updates["target_user_id"] = req.TargetUserId | |||
| } | |||
| if err := model.DB.Model(&model.UserMigrationItem{}).Where("id = ?", itemID).Updates(updates).Error; err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if err := model.RefreshUserMigrationBatchStats(item.BatchId); err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true}) | |||
| } | |||
| func ExecuteUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| locked, err := model.TryLockBatchForExecution(batchID) | |||
| if err != nil { | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if !locked { | |||
| c.JSON(http.StatusConflict, gin.H{"success": false, "error": "batch is already running or not in an executable state"}) | |||
| return | |||
| } | |||
| svc := newMigrationService() | |||
| go func() { | |||
| _ = svc.ExecuteBatch(batchID) | |||
| }() | |||
| c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "execution started"}) | |||
| } | |||
| func RetryUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| model.DB.Model(&model.UserMigrationItem{}). | |||
| Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusFailed). | |||
| Updates(map[string]any{"status": model.UserMigrationItemStatusReady, "error_message": ""}) | |||
| locked, err := model.TryLockBatchForExecution(batchID) | |||
| if err != nil || !locked { | |||
| c.JSON(http.StatusConflict, gin.H{"success": false, "error": "batch cannot be retried now"}) | |||
| return | |||
| } | |||
| svc := newMigrationService() | |||
| go func() { | |||
| _ = svc.ExecuteBatch(batchID) | |||
| }() | |||
| c.JSON(http.StatusAccepted, gin.H{"success": true, "message": "retry started"}) | |||
| } | |||
| func CancelUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| cancelled, err := model.CancelUserMigrationBatch(batchID) | |||
| if err != nil { | |||
| if errors.Is(err, gorm.ErrRecordNotFound) { | |||
| c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "batch not found"}) | |||
| return | |||
| } | |||
| c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()}) | |||
| return | |||
| } | |||
| if !cancelled { | |||
| c.JSON(http.StatusConflict, gin.H{ | |||
| "success": false, | |||
| "error": "batch cannot be cancelled in current status", | |||
| }) | |||
| return | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "message": "batch cancelled"}) | |||
| } | |||
| func VerifyUserMigrationBatch(c *gin.Context) { | |||
| batchID, _ := strconv.Atoi(c.Param("id")) | |||
| var items []model.UserMigrationItem | |||
| model.DB.Where("batch_id = ? AND status = ?", batchID, model.UserMigrationItemStatusMigrated).Find(&items) | |||
| svc := newMigrationVerifier() | |||
| results := make([]map[string]any, 0, len(items)) | |||
| for _, item := range items { | |||
| result, err := svc.VerifyItem(&item) | |||
| entry := map[string]any{ | |||
| "source_user_id": item.SourceUserId, | |||
| "target_user_id": item.TargetUserId, | |||
| } | |||
| if err != nil { | |||
| entry["error"] = err.Error() | |||
| } else { | |||
| entry["result"] = result | |||
| } | |||
| results = append(results, entry) | |||
| } | |||
| c.JSON(http.StatusOK, gin.H{"success": true, "data": results}) | |||
| } | |||
| func newMigrationService() *user_migration.Service { | |||
| settings := system_setting.GetRegionSyncSettings() | |||
| endpoint := settings.MasterEndpoint | |||
| if settings.IsMaster && len(settings.SlaveEndpoints) > 0 { | |||
| endpoint = settings.SlaveEndpoints[0] | |||
| } | |||
| client := user_migration.NewClient(endpoint, settings.SyncApiKey) | |||
| return user_migration.NewService(client) | |||
| } | |||
| @@ -0,0 +1,799 @@ | |||
| package controller | |||
| import ( | |||
| "bytes" | |||
| "encoding/json" | |||
| "errors" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupUserMigrationControllerDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| require.NoError(t, db.AutoMigrate( | |||
| &model.User{}, | |||
| &model.CustomOAuthProvider{}, | |||
| &model.UserOAuthBinding{}, | |||
| &model.UserMigrationBatch{}, | |||
| &model.UserMigrationItem{}, | |||
| &model.MigrationQuotaGrant{}, | |||
| )) | |||
| return db | |||
| } | |||
| func testRootAuthMiddleware() gin.HandlerFunc { | |||
| return func(c *gin.Context) { | |||
| c.Set("id", 1) | |||
| c.Set("role", 100) | |||
| c.Next() | |||
| } | |||
| } | |||
| type fakeMigrationCandidateClient struct { | |||
| resp *user_migration.ListRemoteUsersResponse | |||
| err error | |||
| gotKeyword string | |||
| gotPage int | |||
| gotPageSize int | |||
| } | |||
| func (f *fakeMigrationCandidateClient) ListUsers(page, pageSize int, keyword string) (*user_migration.ListRemoteUsersResponse, error) { | |||
| f.gotKeyword = keyword | |||
| f.gotPage = page | |||
| f.gotPageSize = pageSize | |||
| if f.err != nil { | |||
| return nil, f.err | |||
| } | |||
| return f.resp, nil | |||
| } | |||
| type fakeMigrationVerifier struct { | |||
| results map[int]*user_migration.VerifyResult | |||
| errs map[int]error | |||
| } | |||
| func (f *fakeMigrationVerifier) VerifyItem(item *model.UserMigrationItem) (*user_migration.VerifyResult, error) { | |||
| if err, ok := f.errs[item.SourceUserId]; ok { | |||
| return nil, err | |||
| } | |||
| if result, ok := f.results[item.SourceUserId]; ok { | |||
| return result, nil | |||
| } | |||
| return nil, errors.New("unexpected source user") | |||
| } | |||
| func TestCreateUserMigrationBatch_Success(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 | |||
| }() | |||
| body := map[string]any{ | |||
| "name": "wave-root-1", | |||
| "source_region": "overseas", | |||
| "target_region": "cn", | |||
| } | |||
| bodyBytes, err := json.Marshal(body) | |||
| require.NoError(t, err) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBuffer(bodyBytes)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| 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.NotNil(t, resp["data"]) | |||
| } | |||
| func TestCreateUserMigrationBatch_ExplicitIDsSuccess(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{ | |||
| "name":"explicit-batch", | |||
| "source_region":"ov", | |||
| "target_region":"cn", | |||
| "selection_mode":"explicit_ids", | |||
| "source_user_ids":[1001,1002,1002] | |||
| }`)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var batch model.UserMigrationBatch | |||
| require.NoError(t, db.First(&batch, 1).Error) | |||
| require.Equal(t, "explicit_ids", batch.SelectionMode) | |||
| require.Equal(t, 2, batch.RequestedUserCount) | |||
| } | |||
| func TestCreateUserMigrationBatch_ExplicitIDsValidation(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||
| cases := []string{ | |||
| `{"name":"b1","source_region":"ov","target_region":"cn","selection_mode":"explicit_ids","source_user_ids":[]}`, | |||
| `{"name":"b2","source_region":"ov","target_region":"cn","selection_mode":"explicit_ids","source_user_ids":[0]}`, | |||
| `{"name":"b3","source_region":"ov","target_region":"cn","selection_mode":"invalid","source_user_ids":[1]}`, | |||
| } | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| for _, body := range cases { | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(body)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||
| } | |||
| } | |||
| func TestCreateUserMigrationBatch_ExplicitIDs_RejectsActiveBatchOverlap(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches", CreateUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| existingBatch := &model.UserMigrationBatch{ | |||
| Name: "existing-draft-batch", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| SelectionMode: "explicit_ids", | |||
| SelectionPayload: `{"source_user_ids":[1002]}`, | |||
| Status: model.UserMigrationBatchStatusDraft, | |||
| OperatorId: 1, | |||
| } | |||
| require.NoError(t, db.Create(existingBatch).Error) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches", bytes.NewBufferString(`{ | |||
| "name":"explicit-batch", | |||
| "source_region":"ov", | |||
| "target_region":"cn", | |||
| "selection_mode":"explicit_ids", | |||
| "source_user_ids":[1001,1002] | |||
| }`)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| var resp struct { | |||
| Success bool `json:"success"` | |||
| Error string `json:"error"` | |||
| Conflicts []struct { | |||
| SourceUserID int `json:"source_user_id"` | |||
| BatchID int `json:"batch_id"` | |||
| BatchStatus string `json:"batch_status"` | |||
| } `json:"conflicts"` | |||
| } | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| require.False(t, resp.Success) | |||
| require.Equal(t, "some selected users already exist in active migration batches", resp.Error) | |||
| require.Len(t, resp.Conflicts, 1) | |||
| require.Equal(t, 1002, resp.Conflicts[0].SourceUserID) | |||
| require.Equal(t, existingBatch.Id, resp.Conflicts[0].BatchID) | |||
| require.Equal(t, model.UserMigrationBatchStatusDraft, resp.Conflicts[0].BatchStatus) | |||
| } | |||
| func TestListMigrationCandidateUsers_Success(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| origFactory := newMigrationCandidateClient | |||
| defer func() { | |||
| newMigrationCandidateClient = origFactory | |||
| }() | |||
| newMigrationCandidateClient = func() migrationCandidateClient { | |||
| return &fakeMigrationCandidateClient{ | |||
| resp: &user_migration.ListRemoteUsersResponse{ | |||
| Success: true, | |||
| Data: []user_migration.RemoteUserSnapshot{ | |||
| {Id: 10000001, Username: "ov-a", Email: "a@example.com", Quota: 100}, | |||
| }, | |||
| Total: 1, | |||
| }, | |||
| } | |||
| } | |||
| router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=0&page_size=50", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var resp map[string]any | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| require.True(t, resp["success"].(bool)) | |||
| require.EqualValues(t, 1, resp["total"]) | |||
| } | |||
| func TestListMigrationCandidateUsers_UpstreamFailure(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| origFactory := newMigrationCandidateClient | |||
| defer func() { | |||
| newMigrationCandidateClient = origFactory | |||
| }() | |||
| newMigrationCandidateClient = func() migrationCandidateClient { | |||
| return &fakeMigrationCandidateClient{err: errors.New("upstream unavailable")} | |||
| } | |||
| router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=0&page_size=50", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusInternalServerError, w.Code) | |||
| } | |||
| func TestListMigrationCandidateUsers_ForwardsKeyword(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| client := &fakeMigrationCandidateClient{ | |||
| resp: &user_migration.ListRemoteUsersResponse{ | |||
| Success: true, | |||
| Data: []user_migration.RemoteUserSnapshot{}, | |||
| Total: 0, | |||
| }, | |||
| } | |||
| origFactory := newMigrationCandidateClient | |||
| defer func() { | |||
| newMigrationCandidateClient = origFactory | |||
| }() | |||
| newMigrationCandidateClient = func() migrationCandidateClient { | |||
| return client | |||
| } | |||
| router.GET("/api/user-migrations/candidate-users", ListMigrationCandidateUsers) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/candidate-users?page=2&page_size=30&keyword=alice", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| require.Equal(t, 2, client.gotPage) | |||
| require.Equal(t, 30, client.gotPageSize) | |||
| require.Equal(t, "alice", client.gotKeyword) | |||
| } | |||
| func TestListUserMigrationBatches_Success(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.GET("/api/user-migrations/batches", ListUserMigrationBatches) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||
| Id: 1, | |||
| Name: "batch-1", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||
| Id: 2, | |||
| Name: "batch-2", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", 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.Len(t, resp["data"], 2) | |||
| } | |||
| func TestGetUserMigrationBatch_Success(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.GET("/api/user-migrations/batches/:id", GetUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||
| Id: 1, | |||
| Name: "detail-batch", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| BatchId: 1, | |||
| SourceUserId: 1001, | |||
| SourceUsername: "ov-u1", | |||
| Status: model.UserMigrationItemStatusReady, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches/1?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.NotNil(t, resp["data"]) | |||
| require.Len(t, resp["items"], 1) | |||
| require.EqualValues(t, 1, resp["total"]) | |||
| } | |||
| func TestGetUserMigrationBatch_NotFound(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.GET("/api/user-migrations/batches/:id", GetUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches/999", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusNotFound, w.Code) | |||
| } | |||
| func TestExecuteUserMigrationBatch_AlreadyRunning(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches/:id/execute", ExecuteUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| batch := &model.UserMigrationBatch{ | |||
| Name: "b", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| Status: model.UserMigrationBatchStatusRunning, | |||
| } | |||
| require.NoError(t, db.Create(batch).Error) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| } | |||
| func TestExecuteUserMigrationBatch_InvalidStateRejected(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches/:id/execute", ExecuteUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| for _, status := range []string{model.UserMigrationBatchStatusDraft, model.UserMigrationBatchStatusScanned} { | |||
| require.NoError(t, db.Exec("DELETE FROM user_migration_batches").Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||
| Id: 1, | |||
| Name: "invalid-exec", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| Status: status, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| } | |||
| } | |||
| func TestResolveUserMigrationItem_InvalidStrategy(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/items/:id/resolve", ResolveUserMigrationItem) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| BatchId: 1, | |||
| SourceUserId: 10000001, | |||
| SourceUsername: "u", | |||
| Status: model.UserMigrationItemStatusConflict, | |||
| }).Error) | |||
| req := httptest.NewRequest( | |||
| http.MethodPost, | |||
| "/api/user-migrations/items/1/resolve", | |||
| bytes.NewBufferString(`{"resolution_strategy":"invalid_strategy"}`), | |||
| ) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusBadRequest, w.Code) | |||
| } | |||
| func TestResolveUserMigrationItem_RefreshesBatchStatusToReady(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/items/:id/resolve", ResolveUserMigrationItem) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| batch := &model.UserMigrationBatch{ | |||
| Name: "resolve-ready", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| Status: model.UserMigrationBatchStatusScanned, | |||
| } | |||
| require.NoError(t, model.CreateUserMigrationBatch(batch)) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| BatchId: batch.Id, | |||
| SourceUserId: 1001, | |||
| SourceUsername: "ov-u1", | |||
| Status: model.UserMigrationItemStatusConflict, | |||
| }).Error) | |||
| req := httptest.NewRequest( | |||
| http.MethodPost, | |||
| "/api/user-migrations/items/1/resolve", | |||
| bytes.NewBufferString(`{"resolution_strategy":"create_new"}`), | |||
| ) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var refreshed model.UserMigrationBatch | |||
| require.NoError(t, db.First(&refreshed, batch.Id).Error) | |||
| require.Equal(t, model.UserMigrationBatchStatusReady, refreshed.Status) | |||
| } | |||
| func TestRetryUserMigrationBatch_OnlyFailedItemsReset(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches/:id/retry", RetryUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||
| Id: 1, | |||
| Name: "retry-batch", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| Status: model.UserMigrationBatchStatusRunning, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| Id: 1, | |||
| BatchId: 1, | |||
| SourceUserId: 1001, | |||
| SourceUsername: "failed-user", | |||
| Status: model.UserMigrationItemStatusFailed, | |||
| ErrorMessage: "boom", | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| Id: 2, | |||
| BatchId: 1, | |||
| SourceUserId: 1002, | |||
| SourceUsername: "migrated-user", | |||
| Status: model.UserMigrationItemStatusMigrated, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| Id: 3, | |||
| BatchId: 1, | |||
| SourceUserId: 1003, | |||
| SourceUsername: "skipped-user", | |||
| Status: model.UserMigrationItemStatusSkipped, | |||
| }).Error) | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/retry", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusConflict, w.Code) | |||
| var failedItem, migratedItem, skippedItem model.UserMigrationItem | |||
| require.NoError(t, db.First(&failedItem, 1).Error) | |||
| require.NoError(t, db.First(&migratedItem, 2).Error) | |||
| require.NoError(t, db.First(&skippedItem, 3).Error) | |||
| require.Equal(t, model.UserMigrationItemStatusReady, failedItem.Status) | |||
| require.Empty(t, failedItem.ErrorMessage) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, migratedItem.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) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.POST("/api/user-migrations/batches/:id/verify", VerifyUserMigrationBatch) | |||
| db := setupUserMigrationControllerDB(t) | |||
| orig := model.DB | |||
| origFactory := newMigrationVerifier | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| newMigrationVerifier = origFactory | |||
| }() | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| Id: 1, | |||
| BatchId: 1, | |||
| SourceUserId: 1001, | |||
| TargetUserId: 1, | |||
| Status: model.UserMigrationItemStatusMigrated, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| Id: 2, | |||
| BatchId: 1, | |||
| SourceUserId: 1002, | |||
| TargetUserId: 2, | |||
| Status: model.UserMigrationItemStatusMigrated, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&model.UserMigrationItem{ | |||
| Id: 3, | |||
| BatchId: 1, | |||
| SourceUserId: 1003, | |||
| TargetUserId: 3, | |||
| Status: model.UserMigrationItemStatusMigrated, | |||
| }).Error) | |||
| newMigrationVerifier = func() migrationVerifier { | |||
| return &fakeMigrationVerifier{ | |||
| results: map[int]*user_migration.VerifyResult{ | |||
| 1001: { | |||
| TargetUserExists: true, | |||
| RemoteConverted: true, | |||
| QuotaMatched: true, | |||
| }, | |||
| 1002: { | |||
| TargetUserExists: false, | |||
| RemoteConverted: true, | |||
| QuotaMatched: false, | |||
| }, | |||
| }, | |||
| errs: map[int]error{ | |||
| 1003: errors.New("remote user not found"), | |||
| }, | |||
| } | |||
| } | |||
| req := httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/verify", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| var resp struct { | |||
| Success bool `json:"success"` | |||
| Data []map[string]any `json:"data"` | |||
| } | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| require.True(t, resp.Success) | |||
| require.Len(t, resp.Data, 3) | |||
| bySource := make(map[int]map[string]any, len(resp.Data)) | |||
| for _, entry := range resp.Data { | |||
| bySource[int(entry["source_user_id"].(float64))] = entry | |||
| } | |||
| require.NotNil(t, bySource[1001]["result"]) | |||
| require.NotNil(t, bySource[1002]["result"]) | |||
| require.Equal(t, "remote user not found", bySource[1003]["error"]) | |||
| } | |||
| @@ -0,0 +1,146 @@ | |||
| package controller | |||
| import ( | |||
| "bytes" | |||
| "encoding/json" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupUserControllerDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| require.NoError(t, db.AutoMigrate(&model.User{})) | |||
| return db | |||
| } | |||
| func TestUpdateUser_SyncedUserAllowsNonQuotaEditsWithoutOverwritingQuota(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.PUT("/api/user/", UpdateUser) | |||
| db := setupUserControllerDB(t) | |||
| orig := model.DB | |||
| origRedisEnabled := common.RedisEnabled | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| common.RedisEnabled = origRedisEnabled | |||
| }() | |||
| common.RedisEnabled = false | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 1001, | |||
| Username: "synced-user", | |||
| Password: "hashed-password", | |||
| DisplayName: "Old Name", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Group: "default", | |||
| Quota: 0, | |||
| SyncedQuota: 100000, | |||
| Source: common.UserSourceSynced, | |||
| AffCode: "SYNC1", | |||
| }).Error) | |||
| body := map[string]any{ | |||
| "id": 1001, | |||
| "username": "synced-user", | |||
| "display_name": "New Name", | |||
| "password": "", | |||
| "group": "default", | |||
| "quota": 100000, | |||
| } | |||
| bodyBytes, err := json.Marshal(body) | |||
| require.NoError(t, err) | |||
| req := httptest.NewRequest(http.MethodPut, "/api/user/", bytes.NewBuffer(bodyBytes)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| 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)) | |||
| var saved model.User | |||
| require.NoError(t, db.First(&saved, 1001).Error) | |||
| require.Equal(t, "New Name", saved.DisplayName) | |||
| require.Equal(t, 0, saved.Quota) | |||
| require.Equal(t, 100000, saved.SyncedQuota) | |||
| } | |||
| func TestUpdateUser_SyncedUserQuotaChangeRejected(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| router.Use(testRootAuthMiddleware()) | |||
| router.PUT("/api/user/", UpdateUser) | |||
| db := setupUserControllerDB(t) | |||
| orig := model.DB | |||
| origRedisEnabled := common.RedisEnabled | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| common.RedisEnabled = origRedisEnabled | |||
| }() | |||
| common.RedisEnabled = false | |||
| require.NoError(t, db.Create(&model.User{ | |||
| Id: 1002, | |||
| Username: "synced-user-2", | |||
| Password: "hashed-password", | |||
| DisplayName: "Synced User 2", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Group: "default", | |||
| Quota: 0, | |||
| SyncedQuota: 100000, | |||
| Source: common.UserSourceSynced, | |||
| AffCode: "SYNC2", | |||
| }).Error) | |||
| body := map[string]any{ | |||
| "id": 1002, | |||
| "username": "synced-user-2", | |||
| "display_name": "Synced User 2", | |||
| "password": "", | |||
| "group": "default", | |||
| "quota": 100001, | |||
| } | |||
| bodyBytes, err := json.Marshal(body) | |||
| require.NoError(t, err) | |||
| req := httptest.NewRequest(http.MethodPut, "/api/user/", bytes.NewBuffer(bodyBytes)) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| 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.False(t, resp["success"].(bool)) | |||
| require.Equal(t, "user.synced_user_quota_cannot_modify", resp["message"]) | |||
| var saved model.User | |||
| require.NoError(t, db.First(&saved, 1002).Error) | |||
| require.Equal(t, 0, saved.Quota) | |||
| require.Equal(t, 100000, saved.SyncedQuota) | |||
| } | |||
| @@ -0,0 +1,131 @@ | |||
| # User Migration Delivery Plan | |||
| ## 背景 | |||
| 当前 `feat-overseas-user-migration` worktree 已经完成海外用户迁移主链路实现,并补齐了: | |||
| - 模型层迁移表与状态机 | |||
| - 海外内部迁移接口 | |||
| - 国内管理端迁移 API | |||
| - 迁移中心前端 | |||
| - 单元测试 / 控制器测试 / 路由测试 | |||
| - API E2E | |||
| - Playwright UI E2E | |||
| 本计划的目标不是继续扩功能,而是把当前能力收口到“可交付、可审阅、可部署”的状态。 | |||
| ## 当前已验证通过的内容 | |||
| 以下能力已经通过真实测试验证: | |||
| - `go test ./service/user_migration/... -v` | |||
| - `go test ./controller -run "Test.*Migration|Test.*Synced" -v` | |||
| - `go test ./model -run "Test.*Migration|Test.*Synced" -v` | |||
| - `go test ./router -run "TestUserMigrationRoutes" -v` | |||
| - `go test -v ./test/e2e -count=1` | |||
| - `cd web && bunx playwright test e2e/user-migration.spec.ts` | |||
| - `powershell -NoProfile -ExecutionPolicy Bypass -File scripts/run-user-migration-e2e.ps1 -All` | |||
| 这些验证已经覆盖: | |||
| - 扫描层冲突分析 | |||
| - create_new / merge / skip | |||
| - fast path 恢复与 drift | |||
| - root-only 访问控制 | |||
| - verify 聚合结果 | |||
| - 内部迁移 API 边界 | |||
| - UI 迁移中心主流程 | |||
| ## 剩余工作目标 | |||
| 把当前 worktree 收敛为: | |||
| 1. 改动范围清晰 | |||
| 2. 非主线噪音最小 | |||
| 3. 文档完整 | |||
| 4. 提交路径明确 | |||
| ## 需要处理的剩余事项 | |||
| ### 1. 清理非主线前端脏 diff | |||
| 当前与迁移主线无强耦合、但会影响审阅质量的内容: | |||
| - `web/src/i18n/locales/en.json` | |||
| - `web/src/pages/Setting/Ratio/ModelRationNotSetEditor.jsx` | |||
| 目标: | |||
| - 只保留本轮确有必要的改动 | |||
| - 避免大面积格式化/编码噪音 | |||
| - 确保 `bun run build` 继续通过 | |||
| ### 2. 产出测试覆盖矩阵文档 | |||
| 新增一份文档,明确: | |||
| - 迁移功能需求点 | |||
| - 对应自动化测试名 | |||
| - 测试层级(unit / controller / router / api-e2e / ui-e2e) | |||
| 建议文件: | |||
| - `docs/testing/user-migration-test-matrix.md` | |||
| ### 3. 收敛提交范围 | |||
| 建议把当前 worktree 的改动按下面 3 组看待: | |||
| #### A. 核心后端与测试 | |||
| - `model/*migration*` | |||
| - `service/user_migration/*` | |||
| - `controller/internal_user_migration*` | |||
| - `controller/user_migration*` | |||
| - `router/user_migration_router_test.go` | |||
| #### B. 前端迁移中心 | |||
| - `web/src/components/table/users/migration/*` | |||
| - `web/src/hooks/users/useUserMigrationData.jsx` | |||
| - `web/src/pages/User/index.jsx` | |||
| - `web/e2e/*` | |||
| - `web/playwright.config.ts` | |||
| #### C. E2E 基础设施与文档 | |||
| - `test/e2e/*` | |||
| - `scripts/run-user-migration-e2e.ps1` | |||
| - `docs/testing/*` | |||
| ### 4. 最终交付前整体验证 | |||
| 在准备提交前至少再次执行: | |||
| ```bash | |||
| go test ./service/user_migration/... -v | |||
| go test ./controller -run "Test.*Migration|Test.*Synced" -v | |||
| go test ./model -run "Test.*Migration|Test.*Synced" -v | |||
| go test ./router -run "TestUserMigrationRoutes" -v | |||
| go test -v ./test/e2e -count=1 | |||
| cd web && bun run build | |||
| cd web && bunx playwright test e2e/user-migration.spec.ts | |||
| ``` | |||
| ## 建议执行顺序 | |||
| 1. 清理 `en.json` 和 `ModelRationNotSetEditor.jsx` | |||
| 2. 产出测试覆盖矩阵文档 | |||
| 3. 再跑一轮最终回归 | |||
| 4. 审查 `git diff --stat` | |||
| 5. 进入提交阶段 | |||
| ## 完成标准 | |||
| 只有同时满足以下条件,才算本轮迁移能力真正收尾: | |||
| - 迁移主链路代码无新增功能性缺口 | |||
| - Go 测试、API E2E、UI E2E 全绿 | |||
| - worktree 中无明显无关脏改动 | |||
| - 测试覆盖矩阵可供审阅 | |||
| - 提交范围已经可清晰切分 | |||
| @@ -0,0 +1,591 @@ | |||
| # 用户迁移 E2E 执行计划 | |||
| ## 背景 | |||
| 本计划用于为“海外历史用户迁移到国内主站”能力补齐完整的 E2E 测试体系。目标不是补几条临时脚本,而是形成一套可以本地稳定复跑、能定位失败原因、后续可接 CI 的测试资产。 | |||
| 本计划覆盖四个层面: | |||
| - 双节点真实运行环境 | |||
| - API 黑盒 E2E | |||
| - UI Smoke E2E | |||
| - 一键执行脚本、失败产物与文档 | |||
| ## 当前仓库现状 | |||
| | 项目 | 现状 | | |||
| |------|------| | |||
| | 浏览器 E2E 框架 | 仓库内暂无 Playwright/Cypress | | |||
| | 现有 E2E 风格 | 只有零散的 `test/e2e_*.go`、`test/e2e_*.py` | | |||
| | 迁移相关单测/集成测 | 已较完整,`service/user_migration/service_test.go` 已覆盖主链路 | | |||
| | 自动初始化 | 已支持 `POST /api/setup` 创建 root | | |||
| | 自动登录 | 已支持 `POST /api/user/login` | | |||
| | region_sync 配置 | 走 root `option` API 写入数据库 | | |||
| | CI 工作流 | 当前 worktree 下未见 `.github/workflows/` | | |||
| | 现成 `test/e2e/` 目录 | 尚未建立 | | |||
| ## 固定约定 | |||
| 以下约定在第一版中固定,不在实施过程中反复修改: | |||
| | 项目 | 固定值 | | |||
| |------|--------| | |||
| | CN 节点端口 | `3100` | | |||
| | OV 节点端口 | `3101` | | |||
| | CN 数据库 | `test-artifacts/user-migration-e2e/cn.db` | | |||
| | OV 数据库 | `test-artifacts/user-migration-e2e/ov.db` | | |||
| | 产物目录 | `test-artifacts/user-migration-e2e/` | | |||
| | CN session 名 | `cn_session` | | |||
| | OV session 名 | `ov_session` | | |||
| | Sync API Key | `e2e-sync-key` | | |||
| | 第一版运行方式 | 双进程 + 双 SQLite,不先上 Docker | | |||
| | 第一版 UI 覆盖 | Playwright smoke,不做重业务断言 | | |||
| ## 非目标 | |||
| 本计划明确不做以下事情: | |||
| - 第一版不引入 Docker Compose | |||
| - 第一版不把所有迁移正确性断言都放到浏览器层 | |||
| - 第一版不先接 CI,再回头补本地一键执行 | |||
| - 第一版不做全站级别的通用 E2E 基建重构 | |||
| ## 交付物 | |||
| 本计划完成后,仓库中应至少新增以下内容: | |||
| - `test/e2e/` Go API 黑盒 E2E | |||
| - `test/e2e/helpers/` 编排、登录、配置、seed、断言 helper | |||
| - `web/e2e/` Playwright UI smoke 测试 | |||
| - `web/playwright.config.ts` | |||
| - `scripts/run-user-migration-e2e.ps1` | |||
| - `test-artifacts/user-migration-e2e/.gitignore` | |||
| - `docs/testing/user-migration-e2e.md` | |||
| ## 总体执行顺序 | |||
| 严格按以下顺序推进,避免返工: | |||
| 1. 建目录与产物骨架 | |||
| 2. 做双节点进程编排 | |||
| 3. 做 HTTP / root setup / login / option 配置 helper | |||
| 4. 做 seed 和断言 helper | |||
| 5. 做 API E2E 主链路 | |||
| 6. 做 API E2E 边界场景 | |||
| 7. 再上 Playwright 基础设施 | |||
| 8. 做 UI smoke | |||
| 9. 做一键执行脚本 | |||
| 10. 做文档 | |||
| 11. 做全量回归验证 | |||
| 12. 视情况接 CI | |||
| ## 阶段 0:冻结基础决策 | |||
| **目标**:锁定实现边界,避免中途改架构。 | |||
| **任务** | |||
| - [ ] 确认 API E2E 使用 Go | |||
| - [ ] 确认 UI E2E 使用 Playwright | |||
| - [ ] 确认第一版仅做 UI smoke | |||
| - [ ] 确认双节点端口、数据库文件路径、session 名称 | |||
| - [ ] 确认第一版不做 Docker 化 | |||
| **验收** | |||
| - 后续阶段不再调整端口、数据库路径、session 命名 | |||
| ## 阶段 1:目录与文档骨架 | |||
| **目标**:先把结构搭起来。 | |||
| **文件** | |||
| - Create: `test/e2e/` | |||
| - Create: `test/e2e/helpers/` | |||
| - Create: `web/e2e/` | |||
| - Create: `test-artifacts/user-migration-e2e/.gitignore` | |||
| - Create: `docs/testing/user-migration-e2e.md` | |||
| **任务** | |||
| - [ ] 建立 `test/e2e/` 和 `test/e2e/helpers/` | |||
| - [ ] 建立 `web/e2e/` | |||
| - [ ] 建立 `test-artifacts/user-migration-e2e/` | |||
| - [ ] 写入产物目录 `.gitignore` | |||
| - [ ] 创建 E2E 文档骨架 | |||
| **验收** | |||
| - 所有目录和骨架文件已存在 | |||
| **建议提交** | |||
| - `chore(e2e): scaffold user migration e2e directories` | |||
| ## 阶段 2:双节点进程编排 | |||
| **目标**:自动拉起 `cn` 和 `ov` 两个真实后端实例。 | |||
| **文件** | |||
| - Create: `test/e2e/helpers/cluster.go` | |||
| **任务** | |||
| - [ ] 定义 `NodeProcess` | |||
| - [ ] 定义 `Cluster` | |||
| - [ ] 实现 `StartNode` | |||
| - [ ] 实现 `StartCluster` | |||
| - [ ] 实现 `WaitReady` | |||
| - [ ] 实现 `Stop` | |||
| - [ ] 将 stdout/stderr 重定向到 `cn.log`、`ov.log` | |||
| **强制环境变量** | |||
| - `PORT` | |||
| - `SQLITE_PATH` | |||
| - `SESSION_SECRET` | |||
| - `SESSION_NAME` | |||
| - `SESSION_SECURE=false` | |||
| - `SESSION_SAMESITE=lax` | |||
| - `GLOBAL_API_RATE_LIMIT_ENABLE=false` | |||
| - `GLOBAL_WEB_RATE_LIMIT_ENABLE=false` | |||
| - `CRITICAL_RATE_LIMIT_ENABLE=false` | |||
| - `GIN_MODE=release` | |||
| **节点差异** | |||
| - CN:`NODE_TYPE=master` | |||
| - OV:`NODE_TYPE=slave` | |||
| **验收** | |||
| - `3100` 和 `3101` 均能返回 `/api/status` | |||
| - 测试退出时双进程都能被回收 | |||
| **建议提交** | |||
| - `test(e2e): add dual-node cluster runner for migration tests` | |||
| ## 阶段 3:HTTP 客户端与认证 helper | |||
| **目标**:封装请求、会话、root 初始化和登录。 | |||
| **文件** | |||
| - Create: `test/e2e/helpers/http_client.go` | |||
| - Create: `test/e2e/helpers/auth.go` | |||
| **任务** | |||
| - [ ] 定义 `APIClient` | |||
| - [ ] 实现 `GetJSON` | |||
| - [ ] 实现 `PostJSON` | |||
| - [ ] 实现 `PutJSON` | |||
| - [ ] 实现 `DeleteJSON` | |||
| - [ ] 支持 cookie jar | |||
| - [ ] 失败时落请求/响应 dump | |||
| - [ ] 实现 `SetupRoot` | |||
| - [ ] 实现 `Login` | |||
| - [ ] 实现 root 登录快捷函数 | |||
| **验收** | |||
| - 可通过 `POST /api/setup` 自动创建 root | |||
| - 可通过 `POST /api/user/login` 自动拿到会话 | |||
| **建议提交** | |||
| - `test(e2e): add setup and session helpers for migration tests` | |||
| ## 阶段 4:region_sync 配置 helper | |||
| **目标**:通过 root `option` API 配好双节点联通。 | |||
| **文件** | |||
| - Create: `test/e2e/helpers/options.go` | |||
| **任务** | |||
| - [ ] 实现 `SetOption` | |||
| - [ ] 实现 `ConfigureCNRegionSync` | |||
| - [ ] 实现 `ConfigureOVRegionSync` | |||
| - [ ] 实现 `WaitOptionApplied` | |||
| **需要写入的配置** | |||
| CN 侧: | |||
| - `region_sync.enabled = true` | |||
| - `region_sync.region_id = "cn"` | |||
| - `region_sync.is_master = true` | |||
| - `region_sync.master_endpoint = "http://127.0.0.1:3100"` | |||
| - `region_sync.slave_endpoints = ["http://127.0.0.1:3101"]` | |||
| - `region_sync.sync_api_key = "e2e-sync-key"` | |||
| OV 侧: | |||
| - `region_sync.enabled = true` | |||
| - `region_sync.region_id = "ov"` | |||
| - `region_sync.is_master = false` | |||
| - `region_sync.master_endpoint = "http://127.0.0.1:3100"` | |||
| - `region_sync.slave_endpoints = []` | |||
| - `region_sync.sync_api_key = "e2e-sync-key"` | |||
| **注意** | |||
| - `slave_endpoints` 通过 JSON 字符串写入,不要直接写 Go slice 的字符串表示 | |||
| **验收** | |||
| - CN 能通过真实 HTTP 访问 OV 的内部迁移接口 | |||
| **建议提交** | |||
| - `test(e2e): add region sync configuration helpers` | |||
| ## 阶段 5:测试 seed helper | |||
| **目标**:每次测试从固定数据出发,结果稳定。 | |||
| **文件** | |||
| - Create: `test/e2e/helpers/seed.go` | |||
| **任务** | |||
| - [ ] 实现 `ResetDB` | |||
| - [ ] 实现 `OpenSQLite` | |||
| - [ ] 实现 `SeedCNData` | |||
| - [ ] 实现 `SeedOVData` | |||
| - [ ] 实现 `SeedOVSyncedCopy` | |||
| - [ ] 可选:实现 `SeedCustomOAuthData` | |||
| **固定数据集** | |||
| CN: | |||
| - `cn-merge-target` | |||
| - `cn-synced-copy-owner` | |||
| - `taken-username` | |||
| - `aff-owner` | |||
| OV: | |||
| - `ov-create` | |||
| - `ov-merge` | |||
| - `ov-hard-synced` | |||
| - `ov-username-conflict` | |||
| - `ov-aff-soft` | |||
| 额外: | |||
| - 一条 OV `source=synced` 用户,`remote_user_id` 指向 `cn-synced-copy-owner` | |||
| **验收** | |||
| - 重复跑测试时,扫描结果和执行结果稳定一致 | |||
| **建议提交** | |||
| - `test(e2e): add migration seed data for cn and ov nodes` | |||
| ## 阶段 6:断言与轮询 helper | |||
| **目标**:把复杂轮询和 DB 断言收敛到 helper,主测试保持清晰。 | |||
| **文件** | |||
| - Create: `test/e2e/helpers/assert.go` | |||
| **任务** | |||
| - [ ] 实现 `WaitBatchStatus` | |||
| - [ ] 实现 `GetBatchDetail` | |||
| - [ ] 实现 `FindItemBySourceUserID` | |||
| - [ ] 实现 `AssertCNUserExists` | |||
| - [ ] 实现 `AssertOVUserSynced` | |||
| - [ ] 实现 `AssertQuota` | |||
| - [ ] 实现 `AssertNoDuplicateQuotaGrant` | |||
| **验收** | |||
| - 主测试文件中不再出现重复的轮询和 JSON 解包样板 | |||
| **建议提交** | |||
| - `test(e2e): add assertion helpers for migration flow` | |||
| ## 阶段 7:API E2E 主测试骨架 | |||
| **目标**:建立统一生命周期和测试入口。 | |||
| **文件** | |||
| - Create: `test/e2e/user_migration_test.go` | |||
| **任务** | |||
| - [ ] 实现 `TestMain` | |||
| - [ ] 在 `TestMain` 中启动 cluster | |||
| - [ ] 在 `TestMain` 中 setup root | |||
| - [ ] 在 `TestMain` 中 login root | |||
| - [ ] 在 `TestMain` 中配置 region sync | |||
| - [ ] 为每个测试实现 DB 重建与 seed | |||
| **建议** | |||
| - 为保证稳定性,每个测试单独重建数据库并重启双节点 | |||
| **验收** | |||
| - 测试环境可完全自动初始化,无人工前置 | |||
| **建议提交** | |||
| - `test(e2e): add user migration test main lifecycle` | |||
| ## 阶段 8:API E2E P0 主链路 | |||
| **目标**:先证明迁移全链路真实可用。 | |||
| **文件** | |||
| - Modify: `test/e2e/user_migration_test.go` | |||
| **必须实现的用例** | |||
| - [ ] `TestUserMigrationE2E_FullFlow_GrayScenarios` | |||
| - [ ] `TestUserMigrationE2E_IdempotentRetry` | |||
| - [ ] `TestUserMigrationE2E_ExecutionTimeDriftFails` | |||
| **`FullFlow_GrayScenarios` 覆盖点** | |||
| - 创建批次 | |||
| - 调 `scan` | |||
| - 检查 5 类用户扫描结果 | |||
| - 冲突项人工 `resolve` | |||
| - 执行 `execute` | |||
| - 调 `verify` | |||
| - 校验 CN 用户状态 | |||
| - 校验 OV `source=synced` | |||
| - 校验 `remote_user_id` | |||
| - 校验 `synced_quota == cn.quota` | |||
| **验收命令** | |||
| - `go test -v ./test/e2e -run TestUserMigrationE2E_FullFlow_GrayScenarios -count=1` | |||
| - `go test -v ./test/e2e -run TestUserMigrationE2E_IdempotentRetry -count=1` | |||
| - `go test -v ./test/e2e -run TestUserMigrationE2E_ExecutionTimeDriftFails -count=1` | |||
| **建议提交** | |||
| - `test(e2e): add core migration flow tests` | |||
| ## 阶段 9:API E2E P1 边界与稳定性 | |||
| **目标**:补齐重扫、分页、权限、异常输入等场景。 | |||
| **文件** | |||
| - Modify: `test/e2e/user_migration_test.go` | |||
| **必须实现的用例** | |||
| - [ ] `TestUserMigrationE2E_RescanPreservesHumanDecision` | |||
| - [ ] `TestUserMigrationE2E_ScanPagination` | |||
| - [ ] `TestUserMigrationE2E_RootPermission` | |||
| - [ ] `TestUserMigrationE2E_ResolveInvalidPayload` | |||
| - [ ] `TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning` | |||
| - [ ] `TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning` | |||
| - [ ] `TestUserMigrationE2E_VerifyQuotaMismatchDetected` | |||
| - [ ] `TestUserMigrationE2E_SkipNeverConvertsRemote` | |||
| **验收命令** | |||
| - `go test -v ./test/e2e -count=1` | |||
| **建议提交** | |||
| - `test(e2e): add migration edge case coverage` | |||
| ## 阶段 10:Playwright 基础设施 | |||
| **目标**:搭 UI smoke 运行底座。 | |||
| **文件** | |||
| - Create: `web/playwright.config.ts` | |||
| - Create: `web/e2e/global-setup.ts` | |||
| - Create: `web/e2e/global-teardown.ts` | |||
| - Create: `web/e2e/fixtures/cluster.ts` | |||
| **任务** | |||
| - [ ] 安装 `@playwright/test` | |||
| - [ ] 配置 `testDir` | |||
| - [ ] 配置 `trace` | |||
| - [ ] 配置 `screenshot` | |||
| - [ ] 配置 `video` | |||
| - [ ] 在 `globalSetup` 中启动双节点 | |||
| - [ ] 在 `globalSetup` 中 setup root | |||
| - [ ] 在 `globalSetup` 中创建普通用户 | |||
| - [ ] 在 `globalSetup` 中 seed UI 测试数据 | |||
| - [ ] 生成 `root-storage.json` | |||
| - [ ] 生成 `user-storage.json` | |||
| - [ ] 在 `globalTeardown` 中回收进程 | |||
| **验收** | |||
| - `cd web && bunx playwright test --list` 可正常执行 | |||
| **建议提交** | |||
| - `test(ui): add playwright infrastructure for migration smoke tests` | |||
| ## 阶段 11:UI Smoke 测试 | |||
| **目标**:验证 root 迁移中心页面真正可用。 | |||
| **文件** | |||
| - Create: `web/e2e/user-migration.spec.ts` | |||
| - Modify: `web/src/components/table/users/migration/*`(如需补 `data-testid`) | |||
| **必须实现的用例** | |||
| - [ ] `root sees migration tab` | |||
| - [ ] `non-root cannot see migration tab` | |||
| - [ ] `can create batch` | |||
| - [ ] `scan updates status with polling` | |||
| - [ ] `can resolve conflict item` | |||
| - [ ] `execute shows freeze-window confirmation` | |||
| - [ ] `verify results table is rendered` | |||
| - [ ] `page refresh preserves data loading` | |||
| **规则** | |||
| - UI 层只断言页面行为,不做 DB 最终值断言 | |||
| - 重业务正确性仍由 API E2E 兜底 | |||
| **验收** | |||
| - `cd web && bunx playwright test` | |||
| **建议提交** | |||
| - `test(ui): add migration center smoke tests` | |||
| ## 阶段 12:一键执行脚本 | |||
| **目标**:本地单命令跑完全部 E2E。 | |||
| **文件** | |||
| - Create: `scripts/run-user-migration-e2e.ps1` | |||
| **任务** | |||
| - [ ] 支持 `-ApiOnly` | |||
| - [ ] 支持 `-UiOnly` | |||
| - [ ] 支持 `-All` | |||
| - [ ] 先清理旧产物 | |||
| - [ ] 跑 API E2E | |||
| - [ ] 跑 UI E2E | |||
| - [ ] 汇总结果 | |||
| - [ ] 失败时保留日志、DB、trace | |||
| **验收** | |||
| - 一条命令可以跑完整套测试 | |||
| **建议提交** | |||
| - `chore(e2e): add user migration e2e runner` | |||
| ## 阶段 13:文档补全 | |||
| **目标**:让别人能跑、能排查、能扩展。 | |||
| **文件** | |||
| - Modify: `docs/testing/user-migration-e2e.md` | |||
| **任务** | |||
| - [ ] 写明测试架构 | |||
| - [ ] 写明覆盖范围 | |||
| - [ ] 写明本地运行命令 | |||
| - [ ] 写明失败产物位置 | |||
| - [ ] 写明常见失败与排查方式 | |||
| - [ ] 写明如何新增迁移场景 | |||
| **验收** | |||
| - 新同事可按文档独立跑通 | |||
| **建议提交** | |||
| - `docs(e2e): document user migration end-to-end tests` | |||
| ## 阶段 14:总体验证 | |||
| **目标**:确认新增 E2E 不破坏现有代码。 | |||
| **必须执行** | |||
| - [ ] `go test ./...` | |||
| - [ ] `go test -v ./test/e2e -count=1` | |||
| - [ ] `cd web && bun run build` | |||
| - [ ] `cd web && bunx playwright test` | |||
| **验收** | |||
| - 后端单测全绿 | |||
| - API E2E 全绿 | |||
| - 前端构建通过 | |||
| - UI smoke 全绿 | |||
| ## 阶段 15:可选 CI 接入 | |||
| **目标**:将 E2E 纳入自动化执行。 | |||
| **说明** | |||
| 当前仓库中尚未看到 `.github/workflows/`,本阶段默认后置,不作为本轮硬性前置条件。 | |||
| **任务** | |||
| - [ ] 新增 API E2E job | |||
| - [ ] 新增 UI smoke job | |||
| - [ ] 上传 `test-artifacts/user-migration-e2e/` | |||
| **验收** | |||
| - CI 可独立稳定执行,失败时能下载产物 | |||
| ## 开始前仍需要的最少步骤 | |||
| 如果要立即开始实施,正式开工前只需要做这些最小动作: | |||
| - [ ] 确认第一批只做 API E2E 基础设施 | |||
| - [ ] 确认第一版进程启动方式是 `go run .` | |||
| - [ ] 确认允许在迁移前端组件中补 `data-testid` | |||
| - [ ] 确认第一版先不接 CI | |||
| 确认完后,直接从**阶段 1**开始执行,不需要额外准备。 | |||
| ## 最终完成定义 | |||
| 只有同时满足以下条件,才算该 E2E 计划完成: | |||
| - `test/e2e` API E2E 可稳定运行 | |||
| - `web/e2e` UI smoke 可稳定运行 | |||
| - 一键执行脚本可用 | |||
| - 失败产物可保留 | |||
| - 文档完整 | |||
| - `go test ./...` 通过 | |||
| - `cd web && bun run build` 通过 | |||
| @@ -0,0 +1,176 @@ | |||
| # 用户迁移 E2E | |||
| ## 范围 | |||
| 当前已落地两层 E2E: | |||
| - API 黑盒 E2E:真实双节点、真实 HTTP、真实 SQLite、真实 `region_sync` | |||
| - UI Smoke E2E:Playwright 驱动真实浏览器访问 `/console/user` 的迁移中心 | |||
| 对应代码位置: | |||
| - `test/e2e/user_migration_test.go` | |||
| - `test/e2e/helpers/` | |||
| - `test/e2e/cmd/user_migration_ui_server/main.go` | |||
| - `web/playwright.config.ts` | |||
| - `web/e2e/` | |||
| - `scripts/run-user-migration-e2e.ps1` | |||
| ## 已覆盖用例 | |||
| ### API E2E | |||
| - `TestUserMigrationE2E_FullFlow_GrayScenarios` | |||
| - `TestUserMigrationE2E_IdempotentRetry` | |||
| - `TestUserMigrationE2E_ExecutionTimeDriftFails` | |||
| - `TestUserMigrationE2E_RescanPreservesHumanDecision` | |||
| - `TestUserMigrationE2E_ScanPagination` | |||
| - `TestUserMigrationE2E_RootPermission` | |||
| - `TestUserMigrationE2E_ResolveInvalidPayload` | |||
| - `TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning` | |||
| - `TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning` | |||
| - `TestUserMigrationE2E_VerifyQuotaMismatchDetected` | |||
| - `TestUserMigrationE2E_SkipNeverConvertsRemote` | |||
| ### UI Smoke E2E | |||
| - 非 root 管理员看不到“用户迁移”页签 | |||
| - root 可以进入迁移中心并创建批次 | |||
| - 扫描状态能通过页面轮询从 `scanning` 进入 `scanned` | |||
| - 冲突项可以在页面中执行人工决策 | |||
| - 执行前会出现冻结窗口确认 | |||
| - 执行完成后可以触发 verify 并看到结果表 | |||
| - 页面刷新后,批次列表和详情仍可重新加载 | |||
| ## 固定约定 | |||
| - CN 端口:`3100` | |||
| - OV 端口:`3101` | |||
| - CN session:`cn_session` | |||
| - OV session:`ov_session` | |||
| - Sync API Key:`e2e-sync-key` | |||
| - API E2E 运行目录:`test-artifacts/user-migration-e2e/runs/<test-name>/` | |||
| - UI E2E 运行目录:`test-artifacts/user-migration-e2e/runs/playwright-ui/` | |||
| ## 运行方式 | |||
| 只跑 API E2E: | |||
| ```powershell | |||
| go test -v ./test/e2e -count=1 | |||
| ``` | |||
| 只跑 UI E2E: | |||
| ```powershell | |||
| cd web | |||
| bunx playwright test | |||
| ``` | |||
| 通过统一脚本执行: | |||
| ```powershell | |||
| ./scripts/run-user-migration-e2e.ps1 -ApiOnly | |||
| ./scripts/run-user-migration-e2e.ps1 -UiOnly | |||
| ./scripts/run-user-migration-e2e.ps1 -All | |||
| ``` | |||
| ## UI E2E 依赖 | |||
| Playwright 当前使用系统 Edge: | |||
| - `web/playwright.config.ts` 中固定 `channel: 'msedge'` | |||
| - 不需要额外下载 Chromium 浏览器 | |||
| 首次在新机器上跑 UI E2E,需要安装 Playwright 的 `ffmpeg`,否则失败视频无法生成: | |||
| ```powershell | |||
| cd web | |||
| bunx playwright install ffmpeg | |||
| ``` | |||
| ## UI E2E 启动流程 | |||
| `bunx playwright test` 会自动执行以下流程: | |||
| 1. `global-setup.ts` 先清理 `3100/3101` 残留监听进程 | |||
| 2. 构建前端 `web/dist` | |||
| 3. 构建 `test/e2e/cmd/user_migration_ui_server` | |||
| 4. 启动 UI helper,helper 内部调用 `helpers.PrepareEnvironment(...)` | |||
| 5. helper 完成双节点准备后写入 `test-artifacts/user-migration-e2e/playwright/server-ready.json` | |||
| 6. `global-setup.ts` 读取 ready 标记,再登录 root,并生成两套 storage state | |||
| - `root-storage.json` | |||
| - `user-storage.json`(当前实际保存的是“非 root 管理员”会话,因为 `/console/user` 受 `AdminRoute` 保护) | |||
| 7. 测试结束后由 `global-teardown.ts` 回收 UI helper 进程树 | |||
| ## 产物位置 | |||
| ### API 产物 | |||
| 每个 API 用例的产物都在: | |||
| ```text | |||
| test-artifacts/user-migration-e2e/runs/<test-name>/ | |||
| ``` | |||
| 常见文件: | |||
| - `cn.db` | |||
| - `ov.db` | |||
| - `cn.log` | |||
| - `ov.log` | |||
| - `system-logs/cn/*` | |||
| - `system-logs/ov/*` | |||
| ### UI 产物 | |||
| Playwright 相关产物在: | |||
| ```text | |||
| test-artifacts/user-migration-e2e/playwright/ | |||
| ``` | |||
| 常见文件: | |||
| - `metadata.json` | |||
| - `server-ready.json` | |||
| - `ui-server.log` | |||
| - `auth/root-storage.json` | |||
| - `auth/user-storage.json` | |||
| - `html-report/` | |||
| - `test-output/<case>/trace.zip` | |||
| - `test-output/<case>/video.webm` | |||
| - `test-output/<case>/test-failed-1.png` | |||
| ## 排查建议 | |||
| 如果 API E2E 失败,优先看: | |||
| - `test-artifacts/user-migration-e2e/runs/<test-name>/cn.log` | |||
| - `test-artifacts/user-migration-e2e/runs/<test-name>/ov.log` | |||
| - `cn.db` 中的 `user_migration_batches` | |||
| - `cn.db` 中的 `user_migration_items` | |||
| - `ov.db` 中的 `users` | |||
| 如果 UI E2E 失败,优先看: | |||
| - `test-artifacts/user-migration-e2e/playwright/ui-server.log` | |||
| - `test-artifacts/user-migration-e2e/playwright/test-output/<case>/error-context.md` | |||
| - `trace.zip` | |||
| - 失败截图和视频 | |||
| ## 如何新增迁移场景 | |||
| 新增 API 迁移场景时,优先按下面顺序扩展: | |||
| 1. 在 `test/e2e/helpers/seed.go` 里补固定 seed 或新的冲突样本 | |||
| 2. 在 `test/e2e/helpers/assert.go` 里补通用断言或轮询逻辑 | |||
| 3. 在 `test/e2e/user_migration_test.go` 里新增独立测试,尽量复用 `prepareGrayScenarioBatch` 一类的局部 helper | |||
| 4. 如果是跨节点准备逻辑变化,优先改 `test/e2e/helpers/cluster.go` 或 `options.go` | |||
| 新增 UI smoke 场景时,优先按下面顺序扩展: | |||
| 1. 先判断是否需要新的稳定选择器;需要时只在迁移中心组件上补 `data-testid` | |||
| 2. 在 `web/e2e/user-migration.spec.ts` 中新增页面行为断言,不做 DB 最终值断言 | |||
| 3. 如果需要新的登录态或前置环境,改 `web/e2e/global-setup.ts` | |||
| 4. 若新增场景改变运行产物或依赖,再同步更新本文档 | |||
| @@ -104,6 +104,15 @@ func GetCustomOAuthProviderBySlug(slug string) (*CustomOAuthProvider, error) { | |||
| return &provider, nil | |||
| } | |||
| func GetCustomOAuthProvidersBySlugs(slugs []string) ([]*CustomOAuthProvider, error) { | |||
| var providers []*CustomOAuthProvider | |||
| if len(slugs) == 0 { | |||
| return providers, nil | |||
| } | |||
| err := DB.Where("slug IN ?", slugs).Order("id asc").Find(&providers).Error | |||
| return providers, err | |||
| } | |||
| // CreateCustomOAuthProvider creates a new custom OAuth provider | |||
| func CreateCustomOAuthProvider(provider *CustomOAuthProvider) error { | |||
| if err := validateCustomOAuthProvider(provider); err != nil { | |||
| @@ -286,6 +286,9 @@ func migrateDB() error { | |||
| &QuotaSyncLog{}, | |||
| &EmailQuotaRule{}, | |||
| &UserModelRateLimit{}, | |||
| &UserMigrationBatch{}, | |||
| &UserMigrationItem{}, | |||
| &MigrationQuotaGrant{}, | |||
| ) | |||
| if err != nil { | |||
| return err | |||
| @@ -350,6 +353,9 @@ func migrateDBFast() error { | |||
| {&PendingSyncRecord{}, "PendingSyncRecord"}, | |||
| {&QuotaSyncLog{}, "QuotaSyncLog"}, | |||
| {&EmailQuotaRule{}, "EmailQuotaRule"}, | |||
| {&UserMigrationBatch{}, "UserMigrationBatch"}, | |||
| {&UserMigrationItem{}, "UserMigrationItem"}, | |||
| {&MigrationQuotaGrant{}, "MigrationQuotaGrant"}, | |||
| } | |||
| // 动态计算migration数量,确保errChan缓冲区足够大 | |||
| errChan := make(chan error, len(migrations)) | |||
| @@ -0,0 +1,278 @@ | |||
| package model | |||
| import ( | |||
| "errors" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupRegionMigrationUserDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| require.NoError(t, db.AutoMigrate(&User{}, &CustomOAuthProvider{}, &UserOAuthBinding{})) | |||
| return db | |||
| } | |||
| func TestInsertImportedUserWithHashedPassword_PreservesHash(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| user := &User{ | |||
| Username: "ov-alice", | |||
| Password: "already-hashed-value", | |||
| Email: "alice@example.com", | |||
| DisplayName: "Alice", | |||
| Status: common.UserStatusEnabled, | |||
| Role: common.RoleCommonUser, | |||
| Group: "default", | |||
| Quota: 123, | |||
| AffCode: "A001", | |||
| CreatedAt: 1710000000, | |||
| } | |||
| require.NoError(t, InsertImportedUserWithHashedPassword(user)) | |||
| var saved User | |||
| require.NoError(t, db.Where("username = ?", "ov-alice").First(&saved).Error) | |||
| require.Equal(t, "already-hashed-value", saved.Password) | |||
| require.Greater(t, saved.Id, 0) | |||
| } | |||
| func TestInsertImportedUserWithHashedPassword_RejectsExplicitId(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| user := &User{ | |||
| Id: 10000001, | |||
| Username: "bad-import", | |||
| Password: "hash", | |||
| AffCode: "X001", | |||
| } | |||
| err := InsertImportedUserWithHashedPassword(user) | |||
| require.Error(t, err) | |||
| require.Contains(t, err.Error(), "Id must not be set") | |||
| } | |||
| func TestCopyOAuthBindingsIfMissing_Idempotent(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&CustomOAuthProvider{ | |||
| Id: 10, | |||
| Name: "GitHub Enterprise", | |||
| Slug: "gh-enterprise", | |||
| Enabled: true, | |||
| ClientId: "x", | |||
| AuthorizationEndpoint: "a", | |||
| TokenEndpoint: "t", | |||
| UserInfoEndpoint: "u", | |||
| }).Error) | |||
| require.NoError(t, db.Create(&User{Id: 1, Username: "cn-user", Password: "hash", AffCode: "AFF1"}).Error) | |||
| bindings := []*UserOAuthBinding{{ | |||
| ProviderId: 10, | |||
| ProviderUserId: "u-100", | |||
| }} | |||
| require.NoError(t, CopyOAuthBindingsIfMissing(1, bindings)) | |||
| require.NoError(t, CopyOAuthBindingsIfMissing(1, bindings)) | |||
| var count int64 | |||
| require.NoError(t, db.Model(&UserOAuthBinding{}).Where("user_id = ?", 1).Count(&count).Error) | |||
| require.EqualValues(t, 1, count) | |||
| } | |||
| func TestConvertUserToSynced_Idempotent(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000001, | |||
| Username: "ov-user", | |||
| Password: "hash", | |||
| AffCode: "AFF2", | |||
| Quota: 88, | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| require.NoError(t, ConvertUserToSynced(10000001, 2001, 88)) | |||
| require.NoError(t, ConvertUserToSynced(10000001, 2001, 88)) | |||
| var saved User | |||
| require.NoError(t, db.First(&saved, 10000001).Error) | |||
| require.Equal(t, common.UserSourceSynced, saved.Source) | |||
| require.Equal(t, 2001, saved.RemoteUserId) | |||
| } | |||
| func TestConvertUserToSynced_DifferentRemoteUserIdFails(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000002, | |||
| Username: "ov-user2", | |||
| Password: "hash", | |||
| AffCode: "AFF3", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| require.NoError(t, ConvertUserToSynced(10000002, 3001, 100)) | |||
| err := ConvertUserToSynced(10000002, 9999, 100) | |||
| require.Error(t, err) | |||
| require.ErrorIs(t, err, ErrSyncedUserRemoteUserIDImmutable) | |||
| } | |||
| func TestListLocalUsersForMigration_FiltersLocalOnly(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000001, | |||
| Username: "local-1", | |||
| Password: "h", | |||
| AffCode: "L1", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000002, | |||
| Username: "synced-1", | |||
| Password: "h", | |||
| AffCode: "S1", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 1, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000003, | |||
| Username: "root", | |||
| Password: "h", | |||
| AffCode: "R1", | |||
| Source: common.UserSourceLocal, | |||
| Role: common.RoleRootUser, | |||
| }).Error) | |||
| users, total, err := ListLocalUsersForMigration(0, 20) | |||
| require.NoError(t, err) | |||
| require.EqualValues(t, 1, total) | |||
| require.Len(t, users, 1) | |||
| require.Equal(t, "local-1", users[0].Username) | |||
| } | |||
| func TestQueryLocalUsersForMigration_FiltersByKeyword(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000011, | |||
| Username: "alice-local", | |||
| Password: "h", | |||
| Email: "alice@example.com", | |||
| AffCode: "AL1", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000012, | |||
| Username: "bob-local", | |||
| Password: "h", | |||
| Email: "bob@example.com", | |||
| AffCode: "BO1", | |||
| Source: common.UserSourceLocal, | |||
| }).Error) | |||
| users, total, err := QueryLocalUsersForMigration(0, 20, "alice") | |||
| require.NoError(t, err) | |||
| require.EqualValues(t, 1, total) | |||
| require.Len(t, users, 1) | |||
| require.Equal(t, "alice-local", users[0].Username) | |||
| } | |||
| func TestConvertUserToSynced_RejectsRootUser(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000010, | |||
| Username: "root", | |||
| Password: "hash", | |||
| AffCode: "ROOT1", | |||
| Source: common.UserSourceLocal, | |||
| Role: common.RoleRootUser, | |||
| }).Error) | |||
| err := ConvertUserToSynced(10000010, 2001, 88) | |||
| require.Error(t, err) | |||
| require.ErrorIs(t, err, ErrConvertRootUserToSynced) | |||
| var saved User | |||
| require.NoError(t, db.First(&saved, 10000010).Error) | |||
| require.Equal(t, common.UserSourceLocal, saved.Source) | |||
| require.Equal(t, 0, saved.RemoteUserId) | |||
| } | |||
| func TestConvertUserToSynced_SentinelErrorsRemainDistinct(t *testing.T) { | |||
| db := setupRegionMigrationUserDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 10000020, | |||
| Username: "already-synced", | |||
| Password: "hash", | |||
| AffCode: "SYNC20", | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: 3001, | |||
| }).Error) | |||
| err := ConvertUserToSynced(10000020, 9999, 100) | |||
| require.Error(t, err) | |||
| require.True(t, errors.Is(err, ErrSyncedUserRemoteUserIDImmutable)) | |||
| require.False(t, errors.Is(err, ErrConvertRootUserToSynced)) | |||
| } | |||
| @@ -348,13 +348,66 @@ func GetUserById(id int, selectAll bool) (*User, error) { | |||
| return &user, err | |||
| } | |||
| func getUserByStringColumn(column string, value string) (*User, error) { | |||
| if value == "" { | |||
| return nil, nil | |||
| } | |||
| var user User | |||
| result := DB.Where(column+" = ?", value).Limit(1).Find(&user) | |||
| if result.Error != nil { | |||
| return nil, result.Error | |||
| } | |||
| if result.RowsAffected == 0 { | |||
| return nil, nil | |||
| } | |||
| return &user, nil | |||
| } | |||
| func GetUserByUsername(username string) (*User, error) { | |||
| return getUserByStringColumn("username", username) | |||
| } | |||
| func GetUserByEmail(email string) (*User, error) { | |||
| return getUserByStringColumn("email", email) | |||
| } | |||
| func GetUserByGitHubId(githubId string) (*User, error) { | |||
| return getUserByStringColumn("github_id", githubId) | |||
| } | |||
| func GetUserByDiscordId(discordId string) (*User, error) { | |||
| return getUserByStringColumn("discord_id", discordId) | |||
| } | |||
| func GetUserByOidcId(oidcId string) (*User, error) { | |||
| return getUserByStringColumn("oidc_id", oidcId) | |||
| } | |||
| func GetUserByWeChatId(wechatId string) (*User, error) { | |||
| return getUserByStringColumn("wechat_id", wechatId) | |||
| } | |||
| func GetUserByTelegramId(telegramId string) (*User, error) { | |||
| return getUserByStringColumn("telegram_id", telegramId) | |||
| } | |||
| func GetUserByLinuxDOId(linuxDOId string) (*User, error) { | |||
| return getUserByStringColumn("linux_do_id", linuxDOId) | |||
| } | |||
| func GetUserIdByAffCode(affCode string) (int, error) { | |||
| if affCode == "" { | |||
| return 0, errors.New("affCode 为空!") | |||
| } | |||
| var user User | |||
| err := DB.Select("id").First(&user, "aff_code = ?", affCode).Error | |||
| return user.Id, err | |||
| result := DB.Select("id").Where("aff_code = ?", affCode).Limit(1).Find(&user) | |||
| if result.Error != nil { | |||
| return 0, result.Error | |||
| } | |||
| if result.RowsAffected == 0 { | |||
| return 0, gorm.ErrRecordNotFound | |||
| } | |||
| return user.Id, nil | |||
| } | |||
| func DeleteUserById(id int) (err error) { | |||
| @@ -521,6 +574,31 @@ func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error { | |||
| return nil | |||
| } | |||
| // InsertImportedUserWithHashedPassword 专用于迁移导入,直接写入已有密码哈希。 | |||
| func InsertImportedUserWithHashedPassword(user *User) error { | |||
| return InsertImportedUserWithHashedPasswordTx(DB, user) | |||
| } | |||
| func InsertImportedUserWithHashedPasswordTx(tx *gorm.DB, user *User) error { | |||
| if user == nil { | |||
| return errors.New("user is nil") | |||
| } | |||
| if user.Id != 0 { | |||
| return errors.New("InsertImportedUserWithHashedPassword: Id must not be set, let DB auto-assign") | |||
| } | |||
| if user.AffCode == "" { | |||
| user.AffCode = common.GetRandomString(4) | |||
| } | |||
| if user.CreatedAt == 0 { | |||
| user.CreatedAt = common.GetTimestamp() | |||
| } | |||
| if user.Setting == "" { | |||
| defaultSetting := dto.UserSetting{} | |||
| user.SetSetting(defaultSetting) | |||
| } | |||
| return tx.Create(user).Error | |||
| } | |||
| // FinalizeOAuthUserCreation performs post-transaction tasks for OAuth user creation. | |||
| // This should be called after the transaction commits successfully. | |||
| func (user *User) FinalizeOAuthUserCreation(inviterId int) { | |||
| @@ -1020,6 +1098,85 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) { | |||
| } | |||
| } | |||
| func ListLocalUsersForMigration(page, pageSize int) ([]*User, int64, error) { | |||
| return QueryLocalUsersForMigration(page, pageSize, "") | |||
| } | |||
| func QueryLocalUsersForMigration(page, pageSize int, keyword string) ([]*User, int64, error) { | |||
| if page < 0 { | |||
| page = 0 | |||
| } | |||
| if pageSize <= 0 { | |||
| pageSize = 20 | |||
| } | |||
| var users []*User | |||
| var total int64 | |||
| query := DB.Model(&User{}). | |||
| Where("source = ?", common.UserSourceLocal). | |||
| Where("role <> ?", common.RoleRootUser) | |||
| keyword = strings.TrimSpace(keyword) | |||
| if keyword != "" { | |||
| likeKeyword := "%" + strings.ToLower(keyword) + "%" | |||
| if id, err := strconv.Atoi(keyword); err == nil { | |||
| query = query.Where("id = ? OR lower(username) LIKE ? OR lower(email) LIKE ?", id, likeKeyword, likeKeyword) | |||
| } else { | |||
| query = query.Where("lower(username) LIKE ? OR lower(email) LIKE ?", likeKeyword, likeKeyword) | |||
| } | |||
| } | |||
| if err := query.Count(&total).Error; err != nil { | |||
| return nil, 0, err | |||
| } | |||
| err := query.Order("id asc").Offset(page * pageSize).Limit(pageSize).Find(&users).Error | |||
| return users, total, err | |||
| } | |||
| func HasOVSyncedCopyByRemoteId(cnUserId int) (bool, error) { | |||
| var count int64 | |||
| err := DB.Model(&User{}). | |||
| Where("remote_user_id = ? AND source = ?", cnUserId, common.UserSourceSynced). | |||
| Count(&count).Error | |||
| return count > 0, err | |||
| } | |||
| var ( | |||
| ErrConvertRootUserToSynced = errors.New("user.convert_root_to_synced_denied") | |||
| ErrSyncedUserRemoteUserIDImmutable = errors.New("user.synced_remote_user_id_immutable") | |||
| ) | |||
| func ConvertUserToSynced(userId, remoteUserId, syncedQuota int) error { | |||
| var user User | |||
| if err := DB.Where("id = ?", userId).First(&user).Error; err != nil { | |||
| return err | |||
| } | |||
| if user.Role == common.RoleRootUser { | |||
| return ErrConvertRootUserToSynced | |||
| } | |||
| now := common.GetTimestamp() | |||
| if user.Source == common.UserSourceSynced { | |||
| if user.RemoteUserId != remoteUserId { | |||
| return fmt.Errorf("%w: user %d is already synced to remote_user_id=%d, cannot change to %d", | |||
| ErrSyncedUserRemoteUserIDImmutable, | |||
| userId, user.RemoteUserId, remoteUserId) | |||
| } | |||
| if user.SyncedQuota == syncedQuota { | |||
| return nil | |||
| } | |||
| return DB.Model(&User{}).Where("id = ?", userId).Updates(map[string]any{ | |||
| "synced_quota": syncedQuota, | |||
| "last_sync_at": now, | |||
| }).Error | |||
| } | |||
| return DB.Model(&User{}).Where("id = ?", userId).Updates(map[string]any{ | |||
| "source": common.UserSourceSynced, | |||
| "remote_user_id": remoteUserId, | |||
| "synced_quota": syncedQuota, | |||
| "last_sync_at": now, | |||
| }).Error | |||
| } | |||
| // GetSyncedUsers 获取所有同步用户 | |||
| func GetSyncedUsers() []User { | |||
| var users []User | |||
| @@ -0,0 +1,377 @@ | |||
| package model | |||
| import ( | |||
| "fmt" | |||
| "slices" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "gorm.io/gorm" | |||
| "gorm.io/gorm/clause" | |||
| ) | |||
| const ( | |||
| UserMigrationBatchStatusDraft = "draft" | |||
| UserMigrationBatchStatusScanning = "scanning" | |||
| UserMigrationBatchStatusScanned = "scanned" | |||
| UserMigrationBatchStatusReady = "ready" | |||
| UserMigrationBatchStatusRunning = "running" | |||
| UserMigrationBatchStatusCompleted = "completed" | |||
| UserMigrationBatchStatusFailed = "failed" | |||
| UserMigrationBatchStatusCancelled = "cancelled" | |||
| UserMigrationItemStatusPendingScan = "pending_scan" | |||
| UserMigrationItemStatusConflict = "conflict" | |||
| UserMigrationItemStatusReady = "ready" | |||
| UserMigrationItemStatusMigrating = "migrating" | |||
| UserMigrationItemStatusMigrated = "migrated" | |||
| UserMigrationItemStatusSkipped = "skipped" | |||
| UserMigrationItemStatusFailed = "failed" | |||
| UserMigrationStrategyCreateNew = "create_new" | |||
| UserMigrationStrategyMergeExisting = "merge_into_existing" | |||
| UserMigrationStrategySkip = "skip" | |||
| ) | |||
| type UserMigrationBatch struct { | |||
| Id int `json:"id" gorm:"primaryKey"` | |||
| Name string `json:"name" gorm:"type:varchar(128);not null"` | |||
| SourceRegion string `json:"source_region" gorm:"type:varchar(32);not null"` | |||
| TargetRegion string `json:"target_region" gorm:"type:varchar(32);not null"` | |||
| SelectionMode string `json:"selection_mode" gorm:"type:varchar(32);not null;default:'all'"` | |||
| SelectionPayload string `json:"selection_payload" gorm:"type:text"` | |||
| RequestedUserCount int `json:"requested_user_count" gorm:"default:0"` | |||
| MatchedUserCount int `json:"matched_user_count" gorm:"default:0"` | |||
| ExcludedUserCount int `json:"excluded_user_count" gorm:"default:0"` | |||
| SelectionSummary string `json:"selection_summary" gorm:"type:text"` | |||
| Status string `json:"status" gorm:"type:varchar(32);not null;default:'draft';index"` | |||
| TotalUsers int `json:"total_users" gorm:"default:0"` | |||
| ConflictUsers int `json:"conflict_users" gorm:"default:0"` | |||
| MigratedUsers int `json:"migrated_users" gorm:"default:0"` | |||
| FailedUsers int `json:"failed_users" gorm:"default:0"` | |||
| OperatorId int `json:"operator_id" gorm:"index"` | |||
| CreatedAt int64 `json:"created_at" gorm:"bigint"` | |||
| UpdatedAt int64 `json:"updated_at" gorm:"bigint"` | |||
| } | |||
| type UserMigrationItem struct { | |||
| Id int `json:"id" gorm:"primaryKey"` | |||
| BatchId int `json:"batch_id" gorm:"index:idx_batch_source_user,unique"` | |||
| SourceUserId int `json:"source_user_id" gorm:"index:idx_batch_source_user,unique"` | |||
| TargetUserId int `json:"target_user_id" gorm:"default:0;index"` | |||
| SourceUsername string `json:"source_username" gorm:"type:varchar(128);not null"` | |||
| SourceEmail string `json:"source_email" gorm:"type:varchar(255);default:''"` | |||
| SourceQuota int `json:"source_quota" gorm:"default:0"` | |||
| ConflictFlags string `json:"conflict_flags" gorm:"type:text"` | |||
| ResolutionStrategy string `json:"resolution_strategy" gorm:"type:varchar(32);default:''"` | |||
| Status string `json:"status" gorm:"type:varchar(32);not null;default:'pending_scan';index"` | |||
| ErrorMessage string `json:"error_message" gorm:"type:text"` | |||
| Snapshot string `json:"snapshot" gorm:"type:text"` | |||
| MigrationToken string `json:"migration_token" gorm:"type:varchar(64);default:'';index"` | |||
| MigratedAt int64 `json:"migrated_at" gorm:"bigint;default:0"` | |||
| CreatedAt int64 `json:"created_at" gorm:"bigint"` | |||
| UpdatedAt int64 `json:"updated_at" gorm:"bigint"` | |||
| } | |||
| type MigrationQuotaGrant struct { | |||
| Id int `json:"id" gorm:"primaryKey"` | |||
| MigrationToken string `json:"migration_token" gorm:"type:varchar(64);not null;uniqueIndex"` | |||
| CreatedAt int64 `json:"created_at" gorm:"bigint"` | |||
| } | |||
| type ActiveMigrationUserConflict struct { | |||
| SourceUserID int `json:"source_user_id"` | |||
| BatchID int `json:"batch_id"` | |||
| BatchStatus string `json:"batch_status"` | |||
| } | |||
| func (b *UserMigrationBatch) BeforeCreate(tx *gorm.DB) error { | |||
| now := common.GetTimestamp() | |||
| if b.Status == "" { | |||
| b.Status = UserMigrationBatchStatusDraft | |||
| } | |||
| if b.SelectionMode == "" { | |||
| b.SelectionMode = "all" | |||
| } | |||
| b.CreatedAt = now | |||
| b.UpdatedAt = now | |||
| return nil | |||
| } | |||
| func (b *UserMigrationBatch) BeforeUpdate(tx *gorm.DB) error { | |||
| b.UpdatedAt = common.GetTimestamp() | |||
| return nil | |||
| } | |||
| func (i *UserMigrationItem) BeforeCreate(tx *gorm.DB) error { | |||
| now := common.GetTimestamp() | |||
| if i.Status == "" { | |||
| i.Status = UserMigrationItemStatusPendingScan | |||
| } | |||
| i.CreatedAt = now | |||
| i.UpdatedAt = now | |||
| return nil | |||
| } | |||
| func (i *UserMigrationItem) BeforeUpdate(tx *gorm.DB) error { | |||
| i.UpdatedAt = common.GetTimestamp() | |||
| return nil | |||
| } | |||
| func (g *MigrationQuotaGrant) BeforeCreate(tx *gorm.DB) error { | |||
| g.CreatedAt = common.GetTimestamp() | |||
| return nil | |||
| } | |||
| func CreateUserMigrationBatch(batch *UserMigrationBatch) error { | |||
| return DB.Create(batch).Error | |||
| } | |||
| func CancelUserMigrationBatch(batchId int) (bool, error) { | |||
| var batch UserMigrationBatch | |||
| if err := DB.First(&batch, batchId).Error; err != nil { | |||
| return false, err | |||
| } | |||
| switch batch.Status { | |||
| case UserMigrationBatchStatusCancelled: | |||
| return true, nil | |||
| case UserMigrationBatchStatusDraft, | |||
| UserMigrationBatchStatusScanned, | |||
| UserMigrationBatchStatusReady, | |||
| UserMigrationBatchStatusFailed: | |||
| return true, DB.Model(&UserMigrationBatch{}). | |||
| Where("id = ?", batchId). | |||
| Update("status", UserMigrationBatchStatusCancelled).Error | |||
| default: | |||
| return false, nil | |||
| } | |||
| } | |||
| func FindActiveMigrationUserConflicts(sourceUserIDs []int) ([]ActiveMigrationUserConflict, error) { | |||
| if len(sourceUserIDs) == 0 { | |||
| return nil, nil | |||
| } | |||
| activeStatuses := []string{ | |||
| UserMigrationBatchStatusDraft, | |||
| UserMigrationBatchStatusScanning, | |||
| UserMigrationBatchStatusScanned, | |||
| UserMigrationBatchStatusReady, | |||
| UserMigrationBatchStatusRunning, | |||
| UserMigrationBatchStatusFailed, | |||
| } | |||
| type activeBatchSelection struct { | |||
| ID int | |||
| Status string | |||
| SelectionMode string | |||
| SelectionPayload string | |||
| } | |||
| conflictMap := make(map[string]ActiveMigrationUserConflict) | |||
| addConflict := func(conflict ActiveMigrationUserConflict) { | |||
| key := fmt.Sprintf("%d:%d", conflict.SourceUserID, conflict.BatchID) | |||
| if _, exists := conflictMap[key]; exists { | |||
| return | |||
| } | |||
| conflictMap[key] = conflict | |||
| } | |||
| var itemConflicts []ActiveMigrationUserConflict | |||
| err := DB.Table("user_migration_items AS umi"). | |||
| Select("umi.source_user_id, umb.id AS batch_id, umb.status AS batch_status"). | |||
| Joins("JOIN user_migration_batches AS umb ON umb.id = umi.batch_id"). | |||
| Where("umi.source_user_id IN ?", sourceUserIDs). | |||
| Where("umb.status IN ?", activeStatuses). | |||
| Order("umi.source_user_id ASC, umb.id ASC"). | |||
| Scan(&itemConflicts).Error | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| for _, conflict := range itemConflicts { | |||
| addConflict(conflict) | |||
| } | |||
| var activeBatches []activeBatchSelection | |||
| if err := DB.Model(&UserMigrationBatch{}). | |||
| Select("id, status, selection_mode, selection_payload"). | |||
| Where("status IN ?", activeStatuses). | |||
| Where("selection_mode = ?", "explicit_ids"). | |||
| Find(&activeBatches).Error; err != nil { | |||
| return nil, err | |||
| } | |||
| sourceIDSet := make(map[int]struct{}, len(sourceUserIDs)) | |||
| for _, id := range sourceUserIDs { | |||
| sourceIDSet[id] = struct{}{} | |||
| } | |||
| for _, batch := range activeBatches { | |||
| if batch.SelectionPayload == "" { | |||
| continue | |||
| } | |||
| var payload struct { | |||
| SourceUserIDs []int `json:"source_user_ids"` | |||
| } | |||
| if err := common.UnmarshalJsonStr(batch.SelectionPayload, &payload); err != nil { | |||
| return nil, err | |||
| } | |||
| for _, sourceUserID := range payload.SourceUserIDs { | |||
| if _, ok := sourceIDSet[sourceUserID]; !ok { | |||
| continue | |||
| } | |||
| addConflict(ActiveMigrationUserConflict{ | |||
| SourceUserID: sourceUserID, | |||
| BatchID: batch.ID, | |||
| BatchStatus: batch.Status, | |||
| }) | |||
| } | |||
| } | |||
| conflicts := make([]ActiveMigrationUserConflict, 0, len(conflictMap)) | |||
| for _, conflict := range conflictMap { | |||
| conflicts = append(conflicts, conflict) | |||
| } | |||
| slices.SortFunc(conflicts, func(a, b ActiveMigrationUserConflict) int { | |||
| if a.SourceUserID != b.SourceUserID { | |||
| return a.SourceUserID - b.SourceUserID | |||
| } | |||
| return a.BatchID - b.BatchID | |||
| }) | |||
| return conflicts, nil | |||
| } | |||
| func UpsertUserMigrationItem(item *UserMigrationItem) error { | |||
| var existing UserMigrationItem | |||
| result := DB.Where("batch_id = ? AND source_user_id = ?", item.BatchId, item.SourceUserId).Limit(1).Find(&existing) | |||
| if result.Error != nil { | |||
| return result.Error | |||
| } | |||
| if result.RowsAffected > 0 { | |||
| updates := map[string]any{ | |||
| "source_username": item.SourceUsername, | |||
| "source_email": item.SourceEmail, | |||
| "source_quota": item.SourceQuota, | |||
| "conflict_flags": item.ConflictFlags, | |||
| "status": item.Status, | |||
| "snapshot": item.Snapshot, | |||
| } | |||
| if existing.ResolutionStrategy == "" && item.ResolutionStrategy != "" { | |||
| updates["resolution_strategy"] = item.ResolutionStrategy | |||
| } | |||
| if existing.TargetUserId == 0 && item.TargetUserId != 0 { | |||
| updates["target_user_id"] = item.TargetUserId | |||
| } | |||
| item.Id = existing.Id | |||
| return DB.Model(&existing).Updates(updates).Error | |||
| } | |||
| return DB.Create(item).Error | |||
| } | |||
| func ListUserMigrationItemsByBatch(batchId int) ([]*UserMigrationItem, error) { | |||
| var items []*UserMigrationItem | |||
| err := DB.Where("batch_id = ?", batchId).Order("id asc").Find(&items).Error | |||
| return items, err | |||
| } | |||
| func ListExecutableUserMigrationItems(batchId int) ([]*UserMigrationItem, error) { | |||
| var items []*UserMigrationItem | |||
| err := DB.Where("batch_id = ? AND status IN ?", batchId, []string{ | |||
| UserMigrationItemStatusReady, | |||
| UserMigrationItemStatusMigrating, | |||
| UserMigrationItemStatusFailed, | |||
| }).Order("id asc").Find(&items).Error | |||
| return items, err | |||
| } | |||
| func MarkUserMigrationItemFailed(id int, errMsg string) error { | |||
| return DB.Model(&UserMigrationItem{}).Where("id = ?", id).Updates(map[string]any{ | |||
| "status": UserMigrationItemStatusFailed, | |||
| "error_message": errMsg, | |||
| }).Error | |||
| } | |||
| func MarkUserMigrationItemSkipped(id int) error { | |||
| return DB.Model(&UserMigrationItem{}).Where("id = ?", id). | |||
| Update("status", UserMigrationItemStatusSkipped).Error | |||
| } | |||
| func RefreshUserMigrationBatchStats(batchId int) error { | |||
| var total, conflicts, pendingScan int64 | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ?", batchId).Count(&total) | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusConflict).Count(&conflicts) | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusPendingScan).Count(&pendingScan) | |||
| status := UserMigrationBatchStatusScanned | |||
| if conflicts == 0 && pendingScan == 0 && total > 0 { | |||
| status = UserMigrationBatchStatusReady | |||
| } | |||
| return DB.Model(&UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ | |||
| "total_users": int(total), | |||
| "conflict_users": int(conflicts), | |||
| "status": status, | |||
| }).Error | |||
| } | |||
| func RefreshUserMigrationBatchExecutionStats(batchId int) error { | |||
| var migrated, failed, pending, migrating int64 | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrated).Count(&migrated) | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusFailed).Count(&failed) | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status IN ?", batchId, []string{ | |||
| UserMigrationItemStatusReady, | |||
| UserMigrationItemStatusMigrating, | |||
| }).Count(&pending) | |||
| DB.Model(&UserMigrationItem{}).Where("batch_id = ? AND status = ?", batchId, UserMigrationItemStatusMigrating).Count(&migrating) | |||
| status := UserMigrationBatchStatusCompleted | |||
| if failed > 0 { | |||
| status = UserMigrationBatchStatusFailed | |||
| } | |||
| if pending > 0 { | |||
| status = UserMigrationBatchStatusRunning | |||
| } | |||
| if pending == migrating && migrating > 0 { | |||
| status = UserMigrationBatchStatusFailed | |||
| } | |||
| return DB.Model(&UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ | |||
| "migrated_users": int(migrated), | |||
| "failed_users": int(failed), | |||
| "status": status, | |||
| }).Error | |||
| } | |||
| func TryLockBatchForExecution(batchId int) (bool, error) { | |||
| result := DB.Model(&UserMigrationBatch{}). | |||
| Where("id = ? AND status IN ?", batchId, []string{ | |||
| UserMigrationBatchStatusReady, | |||
| UserMigrationBatchStatusFailed, | |||
| }). | |||
| Update("status", UserMigrationBatchStatusRunning) | |||
| return result.RowsAffected == 1, result.Error | |||
| } | |||
| func TryLockBatchForScan(batchId int) (bool, error) { | |||
| result := DB.Model(&UserMigrationBatch{}). | |||
| Where("id = ? AND status IN ?", batchId, []string{ | |||
| UserMigrationBatchStatusDraft, | |||
| UserMigrationBatchStatusScanned, | |||
| UserMigrationBatchStatusReady, | |||
| }). | |||
| Update("status", UserMigrationBatchStatusScanning) | |||
| return result.RowsAffected == 1, result.Error | |||
| } | |||
| func AtomicGrantQuotaIfNotExists(tx *gorm.DB, userId, delta int, token string) (bool, error) { | |||
| result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&MigrationQuotaGrant{MigrationToken: token}) | |||
| if result.Error != nil { | |||
| return false, result.Error | |||
| } | |||
| if result.RowsAffected == 0 { | |||
| return true, nil | |||
| } | |||
| return false, tx.Model(&User{}).Where("id = ?", userId). | |||
| Update("quota", gorm.Expr("quota + ?", delta)).Error | |||
| } | |||
| @@ -0,0 +1,285 @@ | |||
| package model | |||
| import ( | |||
| "encoding/json" | |||
| "testing" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupUserMigrationTestDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| require.NoError(t, db.AutoMigrate( | |||
| &User{}, | |||
| &CustomOAuthProvider{}, | |||
| &UserOAuthBinding{}, | |||
| &UserMigrationBatch{}, | |||
| &UserMigrationItem{}, | |||
| &MigrationQuotaGrant{}, | |||
| )) | |||
| return db | |||
| } | |||
| func TestUserMigrationModels_AutoMigrate(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| require.True(t, db.Migrator().HasTable(&UserMigrationBatch{})) | |||
| require.True(t, db.Migrator().HasTable(&UserMigrationItem{})) | |||
| require.True(t, db.Migrator().HasTable(&MigrationQuotaGrant{})) | |||
| } | |||
| func TestCreateUserMigrationBatch_DefaultStatus(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| batch := &UserMigrationBatch{ | |||
| Name: "ov-to-cn-wave-1", | |||
| SourceRegion: "overseas", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| } | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| require.Equal(t, UserMigrationBatchStatusDraft, batch.Status) | |||
| require.Equal(t, "all", batch.SelectionMode) | |||
| require.Greater(t, batch.Id, 0) | |||
| } | |||
| func TestCreateUserMigrationBatch_StoresExplicitIDsSelection(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| payload := map[string]any{"source_user_ids": []int{1001, 1002, 1003}} | |||
| payloadJSON, err := json.Marshal(payload) | |||
| require.NoError(t, err) | |||
| batch := &UserMigrationBatch{ | |||
| Name: "partial-wave", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| SelectionMode: "explicit_ids", | |||
| SelectionPayload: string(payloadJSON), | |||
| RequestedUserCount: 3, | |||
| OperatorId: 1, | |||
| } | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| var saved UserMigrationBatch | |||
| require.NoError(t, DB.First(&saved, batch.Id).Error) | |||
| require.Equal(t, "explicit_ids", saved.SelectionMode) | |||
| require.Equal(t, string(payloadJSON), saved.SelectionPayload) | |||
| require.Equal(t, 3, saved.RequestedUserCount) | |||
| require.Equal(t, 0, saved.MatchedUserCount) | |||
| require.Equal(t, 0, saved.ExcludedUserCount) | |||
| } | |||
| func TestUpsertMigrationItem_PreservesHumanSetStrategy(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| batch := &UserMigrationBatch{ | |||
| Name: "b1", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| } | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| first := &UserMigrationItem{ | |||
| BatchId: batch.Id, | |||
| SourceUserId: 10000001, | |||
| SourceUsername: "alice", | |||
| SourceEmail: "alice@example.com", | |||
| SourceQuota: 12345, | |||
| ResolutionStrategy: "", | |||
| Status: UserMigrationItemStatusConflict, | |||
| } | |||
| require.NoError(t, UpsertUserMigrationItem(first)) | |||
| require.NoError(t, DB.Model(&UserMigrationItem{}). | |||
| Where("batch_id = ? AND source_user_id = ?", batch.Id, 10000001). | |||
| Update("resolution_strategy", UserMigrationStrategyMergeExisting).Error) | |||
| second := &UserMigrationItem{ | |||
| BatchId: batch.Id, | |||
| SourceUserId: 10000001, | |||
| SourceUsername: "alice", | |||
| SourceEmail: "alice@example.com", | |||
| SourceQuota: 99999, | |||
| ResolutionStrategy: UserMigrationStrategyCreateNew, | |||
| Status: UserMigrationItemStatusReady, | |||
| } | |||
| require.NoError(t, UpsertUserMigrationItem(second)) | |||
| items, err := ListUserMigrationItemsByBatch(batch.Id) | |||
| require.NoError(t, err) | |||
| require.Len(t, items, 1) | |||
| require.Equal(t, UserMigrationStrategyMergeExisting, items[0].ResolutionStrategy) | |||
| require.Equal(t, UserMigrationItemStatusReady, items[0].Status) | |||
| require.Equal(t, 99999, items[0].SourceQuota) | |||
| } | |||
| func TestAtomicGrantQuotaIfNotExists_Idempotent(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&User{ | |||
| Id: 1, | |||
| Username: "cn-user", | |||
| Password: "hash", | |||
| AffCode: "AFF1", | |||
| Quota: 500, | |||
| }).Error) | |||
| var alreadyDone bool | |||
| err := db.Transaction(func(tx *gorm.DB) error { | |||
| var innerErr error | |||
| alreadyDone, innerErr = AtomicGrantQuotaIfNotExists(tx, 1, 200, "token-abc") | |||
| return innerErr | |||
| }) | |||
| require.NoError(t, err) | |||
| require.False(t, alreadyDone) | |||
| var user User | |||
| require.NoError(t, db.First(&user, 1).Error) | |||
| require.Equal(t, 700, user.Quota) | |||
| err = db.Transaction(func(tx *gorm.DB) error { | |||
| var innerErr error | |||
| alreadyDone, innerErr = AtomicGrantQuotaIfNotExists(tx, 1, 200, "token-abc") | |||
| return innerErr | |||
| }) | |||
| require.NoError(t, err) | |||
| require.True(t, alreadyDone) | |||
| require.NoError(t, db.First(&user, 1).Error) | |||
| require.Equal(t, 700, user.Quota) | |||
| } | |||
| func TestRefreshBatchStats_ReadyOnlyWhenNoPendingScan(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| batch := &UserMigrationBatch{Name: "stats-test", SourceRegion: "ov", TargetRegion: "cn", OperatorId: 1} | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| require.NoError(t, DB.Create(&UserMigrationItem{ | |||
| BatchId: batch.Id, | |||
| SourceUserId: 1, | |||
| SourceUsername: "u1", | |||
| Status: UserMigrationItemStatusReady, | |||
| }).Error) | |||
| require.NoError(t, DB.Create(&UserMigrationItem{ | |||
| BatchId: batch.Id, | |||
| SourceUserId: 2, | |||
| SourceUsername: "u2", | |||
| Status: UserMigrationItemStatusPendingScan, | |||
| }).Error) | |||
| require.NoError(t, RefreshUserMigrationBatchStats(batch.Id)) | |||
| var refreshed UserMigrationBatch | |||
| require.NoError(t, DB.First(&refreshed, batch.Id).Error) | |||
| require.Equal(t, UserMigrationBatchStatusScanned, refreshed.Status) | |||
| } | |||
| func TestRefreshUserMigrationBatchExecutionStats_MigratingLeakHandled(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| batch := &UserMigrationBatch{ | |||
| Name: "migrating-leak", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| Status: UserMigrationBatchStatusRunning, | |||
| } | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| require.NoError(t, DB.Create(&UserMigrationItem{ | |||
| BatchId: batch.Id, | |||
| SourceUserId: 101, | |||
| SourceUsername: "stuck-user", | |||
| Status: UserMigrationItemStatusMigrating, | |||
| }).Error) | |||
| require.NoError(t, RefreshUserMigrationBatchExecutionStats(batch.Id)) | |||
| var refreshed UserMigrationBatch | |||
| require.NoError(t, DB.First(&refreshed, batch.Id).Error) | |||
| require.Equal(t, UserMigrationBatchStatusFailed, refreshed.Status) | |||
| require.Equal(t, 0, refreshed.MigratedUsers) | |||
| require.Equal(t, 0, refreshed.FailedUsers) | |||
| } | |||
| func TestCancelUserMigrationBatch_ReleasesBatch(t *testing.T) { | |||
| db := setupUserMigrationTestDB(t) | |||
| orig := DB | |||
| DB = db | |||
| defer func() { | |||
| DB = orig | |||
| }() | |||
| batch := &UserMigrationBatch{ | |||
| Name: "cancel-batch", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| SelectionMode: "explicit_ids", | |||
| SelectionPayload: `{"source_user_ids":[1001,1002]}`, | |||
| OperatorId: 1, | |||
| Status: UserMigrationBatchStatusScanned, | |||
| } | |||
| require.NoError(t, CreateUserMigrationBatch(batch)) | |||
| conflicts, err := FindActiveMigrationUserConflicts([]int{1001, 1002}) | |||
| require.NoError(t, err) | |||
| require.Len(t, conflicts, 2) | |||
| cancelled, err := CancelUserMigrationBatch(batch.Id) | |||
| require.NoError(t, err) | |||
| require.True(t, cancelled) | |||
| var refreshed UserMigrationBatch | |||
| require.NoError(t, DB.First(&refreshed, batch.Id).Error) | |||
| require.Equal(t, UserMigrationBatchStatusCancelled, refreshed.Status) | |||
| conflicts, err = FindActiveMigrationUserConflicts([]int{1001, 1002}) | |||
| require.NoError(t, err) | |||
| require.Len(t, conflicts, 0) | |||
| } | |||
| @@ -10,9 +10,9 @@ import ( | |||
| // UserOAuthBinding stores the binding relationship between users and custom OAuth providers | |||
| type UserOAuthBinding struct { | |||
| Id int `json:"id" gorm:"primaryKey"` | |||
| UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider | |||
| ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID | |||
| ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider | |||
| UserId int `json:"user_id" gorm:"not null;uniqueIndex:ux_user_provider"` // User ID - one binding per user per provider | |||
| ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex:ux_user_provider;uniqueIndex:ux_provider_userid"` // Custom OAuth provider ID | |||
| ProviderUserId string `json:"provider_user_id" gorm:"type:varchar(256);not null;uniqueIndex:ux_provider_userid"` // User ID from OAuth provider - one OAuth account per provider | |||
| CreatedAt time.Time `json:"created_at"` | |||
| } | |||
| @@ -40,15 +40,21 @@ func GetUserOAuthBinding(userId, providerId int) (*UserOAuthBinding, error) { | |||
| // GetUserByOAuthBinding finds a user by provider ID and provider user ID | |||
| func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) { | |||
| var binding UserOAuthBinding | |||
| err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).First(&binding).Error | |||
| if err != nil { | |||
| return nil, err | |||
| result := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, providerUserId).Limit(1).Find(&binding) | |||
| if result.Error != nil { | |||
| return nil, result.Error | |||
| } | |||
| if result.RowsAffected == 0 { | |||
| return nil, gorm.ErrRecordNotFound | |||
| } | |||
| var user User | |||
| err = DB.First(&user, binding.UserId).Error | |||
| if err != nil { | |||
| return nil, err | |||
| userResult := DB.Where("id = ?", binding.UserId).Limit(1).Find(&user) | |||
| if userResult.Error != nil { | |||
| return nil, userResult.Error | |||
| } | |||
| if userResult.RowsAffected == 0 { | |||
| return nil, gorm.ErrRecordNotFound | |||
| } | |||
| return &user, nil | |||
| } | |||
| @@ -104,6 +110,36 @@ func CreateUserOAuthBindingWithTx(tx *gorm.DB, binding *UserOAuthBinding) error | |||
| return tx.Create(binding).Error | |||
| } | |||
| func CopyOAuthBindingsIfMissing(userId int, bindings []*UserOAuthBinding) error { | |||
| return CopyOAuthBindingsIfMissingTx(DB, userId, bindings) | |||
| } | |||
| func CopyOAuthBindingsIfMissingTx(tx *gorm.DB, userId int, bindings []*UserOAuthBinding) error { | |||
| for _, binding := range bindings { | |||
| if binding == nil { | |||
| continue | |||
| } | |||
| var existing UserOAuthBinding | |||
| result := tx.Where("user_id = ? AND provider_id = ?", userId, binding.ProviderId).Limit(1).Find(&existing) | |||
| if result.Error != nil { | |||
| return result.Error | |||
| } | |||
| if result.RowsAffected > 0 { | |||
| continue | |||
| } | |||
| newBinding := &UserOAuthBinding{ | |||
| UserId: userId, | |||
| ProviderId: binding.ProviderId, | |||
| ProviderUserId: binding.ProviderUserId, | |||
| } | |||
| if err := CreateUserOAuthBindingWithTx(tx, newBinding); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| // UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account) | |||
| func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error { | |||
| // Check if the new provider user ID is already taken by another user | |||
| @@ -405,5 +405,30 @@ func SetApiRouter(router *gin.Engine) { | |||
| syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) | |||
| syncRoute.GET("/config", controller.GetSyncConfig) | |||
| } | |||
| migrationRoute := apiRouter.Group("/internal/migration") | |||
| migrationRoute.Use(middleware.SyncAuth()) | |||
| { | |||
| migrationRoute.GET("/users/check-synced-copy", controller.CheckSyncedCopy) | |||
| migrationRoute.GET("/users", controller.ListMigrationUsers) | |||
| migrationRoute.POST("/users/query", controller.QueryMigrationUsers) | |||
| migrationRoute.GET("/users/:id", controller.GetMigrationUser) | |||
| migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced) | |||
| } | |||
| userMigrationRoute := apiRouter.Group("/user-migrations") | |||
| userMigrationRoute.Use(middleware.RootAuth()) | |||
| { | |||
| userMigrationRoute.GET("/candidate-users", controller.ListMigrationCandidateUsers) | |||
| userMigrationRoute.POST("/batches", controller.CreateUserMigrationBatch) | |||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | |||
| userMigrationRoute.GET("/batches/:id", controller.GetUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/scan", controller.ScanUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/retry", controller.RetryUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/verify", controller.VerifyUserMigrationBatch) | |||
| userMigrationRoute.POST("/items/:id/resolve", controller.ResolveUserMigrationItem) | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,168 @@ | |||
| package router | |||
| import ( | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/controller" | |||
| "github.com/QuantumNous/new-api/middleware" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/gin-contrib/sessions" | |||
| "github.com/gin-contrib/sessions/cookie" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func setupUserMigrationRouterDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, err := db.DB() | |||
| require.NoError(t, err) | |||
| sqlDB.SetMaxOpenConns(1) | |||
| require.NoError(t, db.AutoMigrate( | |||
| &model.User{}, | |||
| &model.CustomOAuthProvider{}, | |||
| &model.UserOAuthBinding{}, | |||
| &model.UserMigrationBatch{}, | |||
| &model.UserMigrationItem{}, | |||
| &model.MigrationQuotaGrant{}, | |||
| )) | |||
| return db | |||
| } | |||
| func setupUserMigrationRootRouter() *gin.Engine { | |||
| gin.SetMode(gin.TestMode) | |||
| r := gin.New() | |||
| store := cookie.NewStore([]byte("test-session-secret")) | |||
| store.Options(sessions.Options{ | |||
| Path: "/", | |||
| MaxAge: 3600, | |||
| HttpOnly: true, | |||
| Secure: false, | |||
| }) | |||
| r.Use(sessions.Sessions("session", store)) | |||
| userMigrationRoute := r.Group("/api/user-migrations") | |||
| userMigrationRoute.Use(func(c *gin.Context) { | |||
| session := sessions.Default(c) | |||
| session.Set("username", "root") | |||
| session.Set("role", common.RoleRootUser) | |||
| session.Set("id", 1) | |||
| session.Set("status", common.UserStatusEnabled) | |||
| session.Save() | |||
| c.Request.Header.Set("New-Api-User", "1") | |||
| c.Next() | |||
| }, middleware.RootAuth()) | |||
| { | |||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | |||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||
| } | |||
| return r | |||
| } | |||
| func setupUserMigrationAdminRouter() *gin.Engine { | |||
| gin.SetMode(gin.TestMode) | |||
| r := gin.New() | |||
| store := cookie.NewStore([]byte("test-session-secret")) | |||
| store.Options(sessions.Options{ | |||
| Path: "/", | |||
| MaxAge: 3600, | |||
| HttpOnly: true, | |||
| Secure: false, | |||
| }) | |||
| r.Use(sessions.Sessions("session", store)) | |||
| userMigrationRoute := r.Group("/api/user-migrations") | |||
| userMigrationRoute.Use(func(c *gin.Context) { | |||
| session := sessions.Default(c) | |||
| session.Set("username", "admin") | |||
| session.Set("role", common.RoleAdminUser) | |||
| session.Set("id", 2) | |||
| session.Set("status", common.UserStatusEnabled) | |||
| session.Save() | |||
| c.Request.Header.Set("New-Api-User", "2") | |||
| c.Next() | |||
| }, middleware.RootAuth()) | |||
| { | |||
| userMigrationRoute.GET("/batches", controller.ListUserMigrationBatches) | |||
| userMigrationRoute.POST("/batches/:id/execute", controller.ExecuteUserMigrationBatch) | |||
| userMigrationRoute.POST("/batches/:id/cancel", controller.CancelUserMigrationBatch) | |||
| } | |||
| return r | |||
| } | |||
| func TestUserMigrationRoutes_RootCanAccess(t *testing.T) { | |||
| db := setupUserMigrationRouterDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| require.NoError(t, db.Create(&model.UserMigrationBatch{ | |||
| Id: 1, | |||
| Name: "router-batch", | |||
| SourceRegion: "ov", | |||
| TargetRegion: "cn", | |||
| OperatorId: 1, | |||
| Status: model.UserMigrationBatchStatusDraft, | |||
| }).Error) | |||
| router := setupUserMigrationRootRouter() | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) | |||
| w = httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.NotEqual(t, http.StatusUnauthorized, w.Code) | |||
| req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||
| w = httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.NotEqual(t, http.StatusUnauthorized, w.Code) | |||
| } | |||
| func TestUserMigrationRoutes_AdminForbidden(t *testing.T) { | |||
| db := setupUserMigrationRouterDB(t) | |||
| orig := model.DB | |||
| model.DB = db | |||
| defer func() { | |||
| model.DB = orig | |||
| }() | |||
| router := setupUserMigrationAdminRouter() | |||
| req := httptest.NewRequest(http.MethodGet, "/api/user-migrations/batches", nil) | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| require.Contains(t, w.Body.String(), "success\":false") | |||
| req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/execute", nil) | |||
| w = httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| require.Contains(t, w.Body.String(), "success\":false") | |||
| req = httptest.NewRequest(http.MethodPost, "/api/user-migrations/batches/1/cancel", nil) | |||
| w = httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| require.Equal(t, http.StatusOK, w.Code) | |||
| require.Contains(t, w.Body.String(), "success\":false") | |||
| } | |||
| @@ -0,0 +1,63 @@ | |||
| param( | |||
| [switch]$ApiOnly, | |||
| [switch]$UiOnly, | |||
| [switch]$All | |||
| ) | |||
| Set-StrictMode -Version Latest | |||
| $ErrorActionPreference = 'Stop' | |||
| $selectedModes = @(@($ApiOnly, $UiOnly, $All) | Where-Object { $_ }) | |||
| if ($selectedModes.Count -gt 1) { | |||
| throw 'Cannot combine -ApiOnly, -UiOnly, and -All.' | |||
| } | |||
| if (-not $ApiOnly -and -not $UiOnly -and -not $All) { | |||
| $ApiOnly = $true | |||
| } | |||
| $repoRoot = Split-Path -Parent $PSScriptRoot | |||
| $artifactRoot = Join-Path $repoRoot 'test-artifacts/user-migration-e2e' | |||
| $runsDir = Join-Path $artifactRoot 'runs' | |||
| if ($ApiOnly -or $All) { | |||
| if (Test-Path $runsDir) { | |||
| Remove-Item -Recurse -Force $runsDir | |||
| } | |||
| } | |||
| Push-Location $repoRoot | |||
| try { | |||
| if ($ApiOnly -or $All) { | |||
| Write-Host '[user-migration-e2e] running API E2E...' | |||
| go test -v ./test/e2e -count=1 | |||
| } | |||
| if ($All) { | |||
| Write-Host '[user-migration-e2e] running full Go test suite...' | |||
| go test ./... | |||
| Write-Host '[user-migration-e2e] building frontend...' | |||
| Push-Location (Join-Path $repoRoot 'web') | |||
| try { | |||
| bun run build | |||
| } | |||
| finally { | |||
| Pop-Location | |||
| } | |||
| } | |||
| if ($UiOnly -or $All) { | |||
| Write-Host '[user-migration-e2e] running Playwright UI E2E...' | |||
| Push-Location (Join-Path $repoRoot 'web') | |||
| try { | |||
| bunx playwright test | |||
| } | |||
| finally { | |||
| Pop-Location | |||
| } | |||
| } | |||
| } | |||
| finally { | |||
| Pop-Location | |||
| } | |||
| @@ -0,0 +1,163 @@ | |||
| package user_migration | |||
| import ( | |||
| "bytes" | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "net/url" | |||
| "strings" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| ) | |||
| type migrationClient interface { | |||
| ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) | |||
| QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) | |||
| GetUser(userId int) (*RemoteUserSnapshot, error) | |||
| ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error | |||
| HasSyncedCopy(cnUserId int) (bool, error) | |||
| } | |||
| type Client struct { | |||
| endpoint string | |||
| apiKey string | |||
| http *http.Client | |||
| } | |||
| func NewClient(endpoint, apiKey string) *Client { | |||
| return &Client{ | |||
| endpoint: strings.TrimRight(endpoint, "/"), | |||
| apiKey: apiKey, | |||
| http: &http.Client{Timeout: 30 * time.Second}, | |||
| } | |||
| } | |||
| func (c *Client) doRequest(method, path string, body any) ([]byte, error) { | |||
| var bodyReader io.Reader | |||
| if body != nil { | |||
| data, err := common.Marshal(body) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("marshal request: %w", err) | |||
| } | |||
| bodyReader = bytes.NewReader(data) | |||
| } | |||
| req, err := http.NewRequest(method, c.endpoint+path, bodyReader) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| req.Header.Set("X-Sync-API-Key", c.apiKey) | |||
| req.Header.Set("X-Sync-Node", "cn-migration") | |||
| if body != nil { | |||
| req.Header.Set("Content-Type", "application/json") | |||
| } | |||
| resp, err := c.http.Do(req) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| defer resp.Body.Close() | |||
| data, err := io.ReadAll(resp.Body) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| if resp.StatusCode != http.StatusOK { | |||
| return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(data)) | |||
| } | |||
| return data, nil | |||
| } | |||
| func (c *Client) ListUsers(page, pageSize int, keyword string) (*ListRemoteUsersResponse, error) { | |||
| path := fmt.Sprintf("/api/internal/migration/users?page=%d&page_size=%d", page, pageSize) | |||
| if keyword != "" { | |||
| path += "&keyword=" + url.QueryEscape(keyword) | |||
| } | |||
| data, err := c.doRequest(http.MethodGet, path, nil) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| var resp ListRemoteUsersResponse | |||
| if err := common.Unmarshal(data, &resp); err != nil { | |||
| return nil, err | |||
| } | |||
| return &resp, nil | |||
| } | |||
| func (c *Client) QueryUsers(page, pageSize int, sourceUserIDs []int) (*QueryRemoteUsersResponse, error) { | |||
| data, err := c.doRequest(http.MethodPost, "/api/internal/migration/users/query", &QueryRemoteUsersRequest{ | |||
| Page: page, | |||
| PageSize: pageSize, | |||
| SelectionMode: SelectionModeExplicitIDs, | |||
| SourceUserIDs: sourceUserIDs, | |||
| }) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| var resp QueryRemoteUsersResponse | |||
| if err := common.Unmarshal(data, &resp); err != nil { | |||
| return nil, err | |||
| } | |||
| return &resp, nil | |||
| } | |||
| func (c *Client) GetUser(userId int) (*RemoteUserSnapshot, error) { | |||
| data, err := c.doRequest(http.MethodGet, | |||
| fmt.Sprintf("/api/internal/migration/users/%d", userId), nil) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| var resp struct { | |||
| Success bool `json:"success"` | |||
| Data RemoteUserSnapshot `json:"data"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| if err := common.Unmarshal(data, &resp); err != nil { | |||
| return nil, err | |||
| } | |||
| if !resp.Success { | |||
| return nil, fmt.Errorf("get user failed: %s", resp.Error) | |||
| } | |||
| return &resp.Data, nil | |||
| } | |||
| func (c *Client) ConvertToSynced(userId int, req *ConvertRemoteUserRequest) error { | |||
| data, err := c.doRequest(http.MethodPost, | |||
| fmt.Sprintf("/api/internal/migration/users/%d/convert-to-synced", userId), req) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| var resp struct { | |||
| Success bool `json:"success"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| if err := common.Unmarshal(data, &resp); err != nil { | |||
| return err | |||
| } | |||
| if !resp.Success { | |||
| return fmt.Errorf("convert-to-synced failed: %s", resp.Error) | |||
| } | |||
| return nil | |||
| } | |||
| func (c *Client) HasSyncedCopy(cnUserId int) (bool, error) { | |||
| data, err := c.doRequest(http.MethodGet, | |||
| fmt.Sprintf("/api/internal/migration/users/check-synced-copy?cn_user_id=%d", cnUserId), nil) | |||
| if err != nil { | |||
| return false, err | |||
| } | |||
| var resp struct { | |||
| Success bool `json:"success"` | |||
| HasCopy bool `json:"has_copy"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| if err := common.Unmarshal(data, &resp); err != nil { | |||
| return false, err | |||
| } | |||
| if !resp.Success { | |||
| return false, fmt.Errorf("check-synced-copy failed: %s", resp.Error) | |||
| } | |||
| return resp.HasCopy, nil | |||
| } | |||
| @@ -0,0 +1,302 @@ | |||
| package user_migration | |||
| import ( | |||
| "fmt" | |||
| "strings" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "gorm.io/gorm" | |||
| ) | |||
| func (s *Service) ExecuteBatch(batchId int) error { | |||
| items, err := model.ListExecutableUserMigrationItems(batchId) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| providerSlugMap, err := buildProviderSlugMap() | |||
| if err != nil { | |||
| return err | |||
| } | |||
| for _, item := range items { | |||
| if item.Status == model.UserMigrationItemStatusMigrated { | |||
| continue | |||
| } | |||
| if err := s.executeItem(item, providerSlugMap); err != nil { | |||
| _ = model.MarkUserMigrationItemFailed(item.Id, err.Error()) | |||
| } | |||
| } | |||
| return model.RefreshUserMigrationBatchExecutionStats(batchId) | |||
| } | |||
| func (s *Service) executeItem(item *model.UserMigrationItem, providerSlugMap map[string]int) error { | |||
| switch item.ResolutionStrategy { | |||
| case model.UserMigrationStrategyCreateNew: | |||
| return s.executeCreateNew(item, providerSlugMap) | |||
| case model.UserMigrationStrategyMergeExisting: | |||
| return s.executeMergeExisting(item, providerSlugMap) | |||
| case model.UserMigrationStrategySkip: | |||
| return model.MarkUserMigrationItemSkipped(item.Id) | |||
| default: | |||
| return fmt.Errorf("unsupported strategy: %q", item.ResolutionStrategy) | |||
| } | |||
| } | |||
| func (s *Service) executeCreateNew(item *model.UserMigrationItem, providerSlugMap map[string]int) error { | |||
| if item.TargetUserId != 0 { | |||
| snapshot, err := s.client.GetUser(item.SourceUserId) | |||
| if err != nil { | |||
| return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) | |||
| } | |||
| if err := s.revalidateCreateNew(item, snapshot, providerSlugMap); err != nil { | |||
| return err | |||
| } | |||
| if err := markItemMigrating(item.Id); err != nil { | |||
| return err | |||
| } | |||
| cnUser, err := model.GetUserById(item.TargetUserId, true) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| return s.ensureRemoteConverted(item, cnUser.Quota) | |||
| } | |||
| snapshot, err := s.client.GetUser(item.SourceUserId) | |||
| if err != nil { | |||
| return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) | |||
| } | |||
| if err := s.revalidateCreateNew(item, snapshot, providerSlugMap); err != nil { | |||
| return err | |||
| } | |||
| if err := markItemMigrating(item.Id); err != nil { | |||
| return err | |||
| } | |||
| affCode := snapshot.AffCode | |||
| if affCode != "" { | |||
| if _, err := model.GetUserIdByAffCode(affCode); err == nil { | |||
| affCode = common.GetRandomString(4) | |||
| } | |||
| } | |||
| cnUser := &model.User{ | |||
| Username: snapshot.Username, | |||
| Password: snapshot.Password, | |||
| Email: snapshot.Email, | |||
| DisplayName: snapshot.DisplayName, | |||
| Status: snapshot.Status, | |||
| Role: snapshot.Role, | |||
| Group: snapshot.Group, | |||
| Quota: snapshot.Quota, | |||
| AffCode: affCode, | |||
| CreatedAt: snapshot.CreatedAt, | |||
| Setting: snapshot.Setting, | |||
| GitHubId: snapshot.GitHubId, | |||
| DiscordId: snapshot.DiscordId, | |||
| OidcId: snapshot.OidcId, | |||
| WeChatId: snapshot.WeChatId, | |||
| TelegramId: snapshot.TelegramId, | |||
| LinuxDOId: snapshot.LinuxDOId, | |||
| } | |||
| err = model.DB.Transaction(func(tx *gorm.DB) error { | |||
| if err := model.InsertImportedUserWithHashedPasswordTx(tx, cnUser); err != nil { | |||
| if isUniqueConstraintErr(err) { | |||
| return fmt.Errorf("execution-time identity drift detected, rescan required: %w", err) | |||
| } | |||
| return fmt.Errorf("create CN user: %w", err) | |||
| } | |||
| if err := copyMappedBindingsTx(tx, cnUser.Id, snapshot.OAuthBindings); err != nil { | |||
| return err | |||
| } | |||
| if err := tx.Model(&model.UserMigrationItem{}).Where("id = ?", item.Id). | |||
| Update("target_user_id", cnUser.Id).Error; err != nil { | |||
| return err | |||
| } | |||
| item.TargetUserId = cnUser.Id | |||
| return nil | |||
| }) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| return s.ensureRemoteConverted(item, cnUser.Quota) | |||
| } | |||
| func (s *Service) executeMergeExisting(item *model.UserMigrationItem, providerSlugMap map[string]int) error { | |||
| snapshot, err := s.client.GetUser(item.SourceUserId) | |||
| if err != nil { | |||
| return fmt.Errorf("get remote user %d: %w", item.SourceUserId, err) | |||
| } | |||
| if err := s.revalidateMergeExisting(item, snapshot, providerSlugMap); err != nil { | |||
| return err | |||
| } | |||
| if err := markItemMigrating(item.Id); err != nil { | |||
| return err | |||
| } | |||
| finalQuota := 0 | |||
| err = model.DB.Transaction(func(tx *gorm.DB) error { | |||
| _, err := model.AtomicGrantQuotaIfNotExists(tx, item.TargetUserId, snapshot.Quota, item.MigrationToken) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| var cnUser model.User | |||
| if err := tx.Select("quota").First(&cnUser, item.TargetUserId).Error; err != nil { | |||
| return err | |||
| } | |||
| finalQuota = cnUser.Quota | |||
| return copyMappedBindingsTx(tx, item.TargetUserId, snapshot.OAuthBindings) | |||
| }) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| return s.ensureRemoteConverted(item, finalQuota) | |||
| } | |||
| func copyMappedBindingsTx(tx *gorm.DB, userId int, bindings []RemoteOAuthBinding) error { | |||
| if len(bindings) == 0 { | |||
| return nil | |||
| } | |||
| slugs := make([]string, 0, len(bindings)) | |||
| for _, binding := range bindings { | |||
| slugs = append(slugs, binding.ProviderSlug) | |||
| } | |||
| providers, err := model.GetCustomOAuthProvidersBySlugs(slugs) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| slugToID := make(map[string]int, len(providers)) | |||
| for _, provider := range providers { | |||
| slugToID[provider.Slug] = provider.Id | |||
| } | |||
| localBindings := make([]*model.UserOAuthBinding, 0, len(bindings)) | |||
| for _, binding := range bindings { | |||
| localID, ok := slugToID[binding.ProviderSlug] | |||
| if !ok { | |||
| continue | |||
| } | |||
| localBindings = append(localBindings, &model.UserOAuthBinding{ | |||
| ProviderId: localID, | |||
| ProviderUserId: binding.ProviderUserId, | |||
| }) | |||
| } | |||
| return model.CopyOAuthBindingsIfMissingTx(tx, userId, localBindings) | |||
| } | |||
| func (s *Service) ensureRemoteConverted(item *model.UserMigrationItem, syncedQuota int) error { | |||
| remoteUser, err := s.client.GetUser(item.SourceUserId) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| if remoteUser.Source != common.UserSourceSynced || remoteUser.RemoteUserId != item.TargetUserId { | |||
| if err := s.client.ConvertToSynced(item.SourceUserId, &ConvertRemoteUserRequest{ | |||
| RemoteUserId: item.TargetUserId, | |||
| SyncedQuota: syncedQuota, | |||
| }); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| return markItemMigrated(item.Id) | |||
| } | |||
| func (s *Service) revalidateCreateNew(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) error { | |||
| if snapshot.Source == common.UserSourceSynced { | |||
| if item.TargetUserId != 0 && snapshot.RemoteUserId == item.TargetUserId { | |||
| return nil | |||
| } | |||
| return fmt.Errorf("execution-time identity drift detected, remote user %d is already synced, rescan required", item.SourceUserId) | |||
| } | |||
| result, err := s.analyzeExecutionSnapshot(snapshot, providerSlugMap) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| if item.TargetUserId == 0 { | |||
| if result.Status != model.UserMigrationItemStatusReady || | |||
| result.Strategy != model.UserMigrationStrategyCreateNew || | |||
| result.TargetCNUserID != 0 { | |||
| return fmt.Errorf("execution-time identity drift detected, rescan required") | |||
| } | |||
| return nil | |||
| } | |||
| if result.Status != model.UserMigrationItemStatusReady { | |||
| return fmt.Errorf("execution-time identity drift detected, rescan required") | |||
| } | |||
| if result.Strategy == model.UserMigrationStrategyCreateNew && result.TargetCNUserID == 0 { | |||
| return nil | |||
| } | |||
| if result.Strategy == model.UserMigrationStrategyMergeExisting && result.TargetCNUserID == item.TargetUserId { | |||
| return nil | |||
| } | |||
| return fmt.Errorf("execution-time identity drift detected, rescan required") | |||
| } | |||
| func (s *Service) revalidateMergeExisting(item *model.UserMigrationItem, snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) error { | |||
| if snapshot.Source == common.UserSourceSynced { | |||
| if snapshot.RemoteUserId == item.TargetUserId { | |||
| return nil | |||
| } | |||
| return fmt.Errorf("execution-time identity drift detected, remote user %d is already synced to CN user %d", item.SourceUserId, snapshot.RemoteUserId) | |||
| } | |||
| result, err := s.analyzeExecutionSnapshot(snapshot, providerSlugMap) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| if result.Status != model.UserMigrationItemStatusReady || | |||
| result.Strategy != model.UserMigrationStrategyMergeExisting || | |||
| result.TargetCNUserID != item.TargetUserId { | |||
| return fmt.Errorf("execution-time merge target drift detected, rescan required") | |||
| } | |||
| return nil | |||
| } | |||
| func (s *Service) analyzeExecutionSnapshot(snapshot *RemoteUserSnapshot, providerSlugMap map[string]int) (*conflictResult, error) { | |||
| result, err := analyzeConflicts(snapshot, providerSlugMap, s.client) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("revalidate conflicts for user %d: %w", snapshot.Id, err) | |||
| } | |||
| return result, nil | |||
| } | |||
| func markItemMigrating(itemID int) error { | |||
| return model.DB.Model(&model.UserMigrationItem{}).Where("id = ?", itemID).Updates(map[string]any{ | |||
| "status": model.UserMigrationItemStatusMigrating, | |||
| "error_message": "", | |||
| }).Error | |||
| } | |||
| func markItemMigrated(itemID int) error { | |||
| return model.DB.Model(&model.UserMigrationItem{}).Where("id = ?", itemID).Updates(map[string]any{ | |||
| "status": model.UserMigrationItemStatusMigrated, | |||
| "migrated_at": timeNow(), | |||
| "error_message": "", | |||
| }).Error | |||
| } | |||
| func isUniqueConstraintErr(err error) bool { | |||
| if err == nil { | |||
| return false | |||
| } | |||
| message := err.Error() | |||
| return strings.Contains(message, "UNIQUE constraint failed") || | |||
| strings.Contains(message, "Duplicate entry") || | |||
| strings.Contains(strings.ToLower(message), "duplicate key value") | |||
| } | |||
| func timeNow() int64 { | |||
| return common.GetTimestamp() | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| package user_migration | |||
| import "github.com/QuantumNous/new-api/model" | |||
| func buildProviderSlugMap() (map[string]int, error) { | |||
| providers, err := model.GetEnabledCustomOAuthProviders() | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| result := make(map[string]int, len(providers)) | |||
| for _, provider := range providers { | |||
| result[provider.Slug] = provider.Id | |||
| } | |||
| return result, nil | |||
| } | |||
| @@ -0,0 +1,226 @@ | |||
| package user_migration | |||
| import ( | |||
| "errors" | |||
| "fmt" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "gorm.io/gorm" | |||
| ) | |||
| const scanPageSize = 100 | |||
| type Service struct { | |||
| client migrationClient | |||
| } | |||
| func NewService(client migrationClient) *Service { | |||
| return &Service{client: client} | |||
| } | |||
| func (s *Service) ScanBatch(batchId int) error { | |||
| providerSlugMap, err := buildProviderSlugMap() | |||
| if err != nil { | |||
| return err | |||
| } | |||
| var batch model.UserMigrationBatch | |||
| if err := model.DB.First(&batch, batchId).Error; err != nil { | |||
| return err | |||
| } | |||
| var selectionPayload SelectionPayload | |||
| if batch.SelectionPayload != "" { | |||
| if err := common.UnmarshalJsonStr(batch.SelectionPayload, &selectionPayload); err != nil { | |||
| return fmt.Errorf("parse selection payload: %w", err) | |||
| } | |||
| } | |||
| page := 0 | |||
| var latestSummary *SelectionSummary | |||
| for { | |||
| 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 respData { | |||
| result, err := analyzeConflicts(&remoteUser, providerSlugMap, s.client) | |||
| if err != nil { | |||
| return fmt.Errorf("analyze conflicts for user %d: %w", remoteUser.Id, err) | |||
| } | |||
| flagsJSON, err := common.Marshal(result.Flags) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| snapshotJSON, err := common.Marshal(remoteUser) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| item := &model.UserMigrationItem{ | |||
| BatchId: batchId, | |||
| SourceUserId: remoteUser.Id, | |||
| SourceUsername: remoteUser.Username, | |||
| SourceEmail: remoteUser.Email, | |||
| SourceQuota: remoteUser.Quota, | |||
| ConflictFlags: string(flagsJSON), | |||
| ResolutionStrategy: result.Strategy, | |||
| Status: result.Status, | |||
| Snapshot: string(snapshotJSON), | |||
| MigrationToken: fmt.Sprintf("batch-%d-user-%d", batchId, remoteUser.Id), | |||
| TargetUserId: result.TargetCNUserID, | |||
| } | |||
| if err := model.UpsertUserMigrationItem(item); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| if len(respData) < scanPageSize || total <= (page+1)*scanPageSize { | |||
| break | |||
| } | |||
| page++ | |||
| } | |||
| if latestSummary != nil { | |||
| summaryJSON, err := common.Marshal(latestSummary) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| if err := model.DB.Model(&model.UserMigrationBatch{}).Where("id = ?", batchId).Updates(map[string]any{ | |||
| "matched_user_count": latestSummary.Matched, | |||
| "excluded_user_count": len(latestSummary.Excluded), | |||
| "selection_summary": string(summaryJSON), | |||
| }).Error; err != nil { | |||
| return err | |||
| } | |||
| } | |||
| return model.RefreshUserMigrationBatchStats(batchId) | |||
| } | |||
| type conflictResult struct { | |||
| Flags []string | |||
| Strategy string | |||
| Status string | |||
| TargetCNUserID int | |||
| } | |||
| func analyzeConflicts(remote *RemoteUserSnapshot, providerSlugMap map[string]int, client migrationClient) (*conflictResult, error) { | |||
| result := &conflictResult{} | |||
| var mergeCandidate *model.User | |||
| hardConflict := false | |||
| assignMergeCandidate := func(flag string, user *model.User) { | |||
| if user == nil { | |||
| return | |||
| } | |||
| result.Flags = append(result.Flags, flag) | |||
| if mergeCandidate == nil { | |||
| mergeCandidate = user | |||
| return | |||
| } | |||
| if mergeCandidate.Id != user.Id { | |||
| hardConflict = true | |||
| result.Flags = append(result.Flags, "multiple_merge_targets") | |||
| } | |||
| } | |||
| if user, _ := model.GetUserByUsername(remote.Username); user != nil { | |||
| result.Flags = append(result.Flags, "username") | |||
| hardConflict = true | |||
| } | |||
| if remote.Email != "" { | |||
| user, err := model.GetUserByEmail(remote.Email) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| assignMergeCandidate("email", user) | |||
| } | |||
| type oauthField struct { | |||
| value string | |||
| flag string | |||
| query func(string) (*model.User, error) | |||
| } | |||
| fields := []oauthField{ | |||
| {value: remote.GitHubId, flag: "github_id", query: model.GetUserByGitHubId}, | |||
| {value: remote.DiscordId, flag: "discord_id", query: model.GetUserByDiscordId}, | |||
| {value: remote.OidcId, flag: "oidc_id", query: model.GetUserByOidcId}, | |||
| {value: remote.WeChatId, flag: "wechat_id", query: model.GetUserByWeChatId}, | |||
| {value: remote.TelegramId, flag: "telegram_id", query: model.GetUserByTelegramId}, | |||
| {value: remote.LinuxDOId, flag: "linux_do_id", query: model.GetUserByLinuxDOId}, | |||
| } | |||
| for _, field := range fields { | |||
| if field.value == "" { | |||
| continue | |||
| } | |||
| user, err := field.query(field.value) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| assignMergeCandidate(field.flag, user) | |||
| } | |||
| for _, binding := range remote.OAuthBindings { | |||
| localProviderID, ok := providerSlugMap[binding.ProviderSlug] | |||
| if !ok { | |||
| continue | |||
| } | |||
| user, err := model.GetUserByOAuthBinding(localProviderID, binding.ProviderUserId) | |||
| if errors.Is(err, gorm.ErrRecordNotFound) { | |||
| continue | |||
| } | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| assignMergeCandidate("oauth:"+binding.ProviderSlug, user) | |||
| } | |||
| if remote.AffCode != "" { | |||
| if _, err := model.GetUserIdByAffCode(remote.AffCode); err == nil { | |||
| result.Flags = append(result.Flags, "aff_code") | |||
| } | |||
| } | |||
| if hardConflict { | |||
| result.Status = model.UserMigrationItemStatusConflict | |||
| return result, nil | |||
| } | |||
| if mergeCandidate != nil { | |||
| hasCopy, err := client.HasSyncedCopy(mergeCandidate.Id) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("check synced copy for CN user %d: %w", mergeCandidate.Id, err) | |||
| } | |||
| if hasCopy { | |||
| result.Flags = append(result.Flags, "cn_already_synced_to_ov") | |||
| result.Status = model.UserMigrationItemStatusConflict | |||
| return result, nil | |||
| } | |||
| result.Status = model.UserMigrationItemStatusReady | |||
| result.Strategy = model.UserMigrationStrategyMergeExisting | |||
| result.TargetCNUserID = mergeCandidate.Id | |||
| return result, nil | |||
| } | |||
| result.Status = model.UserMigrationItemStatusReady | |||
| result.Strategy = model.UserMigrationStrategyCreateNew | |||
| return result, nil | |||
| } | |||
| @@ -0,0 +1,78 @@ | |||
| package user_migration | |||
| type RemoteOAuthBinding struct { | |||
| ProviderSlug string `json:"provider_slug"` | |||
| ProviderUserId string `json:"provider_user_id"` | |||
| } | |||
| const ( | |||
| SelectionModeAll = "all" | |||
| SelectionModeExplicitIDs = "explicit_ids" | |||
| ) | |||
| type SelectionPayload struct { | |||
| SourceUserIDs []int `json:"source_user_ids"` | |||
| } | |||
| type SelectionExcluded struct { | |||
| UserID int `json:"user_id"` | |||
| Reason string `json:"reason"` | |||
| } | |||
| type SelectionSummary struct { | |||
| Requested int `json:"requested"` | |||
| Matched int `json:"matched"` | |||
| Excluded []SelectionExcluded `json:"excluded"` | |||
| } | |||
| type RemoteUserSnapshot struct { | |||
| Id int `json:"id"` | |||
| Username string `json:"username"` | |||
| Password string `json:"password"` | |||
| Email string `json:"email"` | |||
| DisplayName string `json:"display_name"` | |||
| Status int `json:"status"` | |||
| Role int `json:"role"` | |||
| Group string `json:"group"` | |||
| Quota int `json:"quota"` | |||
| AffCode string `json:"aff_code"` | |||
| CreatedAt int64 `json:"created_at"` | |||
| Setting string `json:"setting"` | |||
| GitHubId string `json:"github_id"` | |||
| DiscordId string `json:"discord_id"` | |||
| OidcId string `json:"oidc_id"` | |||
| WeChatId string `json:"wechat_id"` | |||
| TelegramId string `json:"telegram_id"` | |||
| LinuxDOId string `json:"linux_do_id"` | |||
| OAuthBindings []RemoteOAuthBinding `json:"oauth_bindings"` | |||
| Source string `json:"source"` | |||
| RemoteUserId int `json:"remote_user_id"` | |||
| SyncedQuota int `json:"synced_quota"` | |||
| } | |||
| type ListRemoteUsersResponse struct { | |||
| Success bool `json:"success"` | |||
| Data []RemoteUserSnapshot `json:"data"` | |||
| Total int `json:"total"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| type QueryRemoteUsersRequest struct { | |||
| Page int `json:"page"` | |||
| PageSize int `json:"page_size"` | |||
| SelectionMode string `json:"selection_mode"` | |||
| SourceUserIDs []int `json:"source_user_ids"` | |||
| } | |||
| type QueryRemoteUsersResponse struct { | |||
| Success bool `json:"success"` | |||
| Data []RemoteUserSnapshot `json:"data"` | |||
| Total int `json:"total"` | |||
| SelectionSummary *SelectionSummary `json:"selection_summary,omitempty"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| type ConvertRemoteUserRequest struct { | |||
| RemoteUserId int `json:"remote_user_id"` | |||
| SyncedQuota int `json:"synced_quota"` | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| package user_migration | |||
| import ( | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| ) | |||
| type VerifyResult struct { | |||
| TargetUserExists bool `json:"target_user_exists"` | |||
| RemoteConverted bool `json:"remote_converted"` | |||
| QuotaMatched bool `json:"quota_matched"` | |||
| } | |||
| func (s *Service) VerifyItem(item *model.UserMigrationItem) (*VerifyResult, error) { | |||
| targetUser, err := model.GetUserById(item.TargetUserId, true) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| remoteUser, err := s.client.GetUser(item.SourceUserId) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| remoteConverted := remoteUser.Source == common.UserSourceSynced && | |||
| remoteUser.RemoteUserId == item.TargetUserId | |||
| return &VerifyResult{ | |||
| TargetUserExists: targetUser != nil, | |||
| RemoteConverted: remoteConverted, | |||
| QuotaMatched: targetUser != nil && remoteConverted && remoteUser.SyncedQuota >= item.SourceQuota, | |||
| }, nil | |||
| } | |||
| @@ -0,0 +1,79 @@ | |||
| package main | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "os" | |||
| "os/signal" | |||
| "path/filepath" | |||
| "syscall" | |||
| "github.com/QuantumNous/new-api/common" | |||
| helpers "github.com/QuantumNous/new-api/test/e2e/helpers" | |||
| ) | |||
| func main() { | |||
| if err := run(); err != nil { | |||
| fmt.Fprintln(os.Stderr, err) | |||
| os.Exit(1) | |||
| } | |||
| } | |||
| func run() error { | |||
| repoRoot, err := os.Getwd() | |||
| if err != nil { | |||
| return fmt.Errorf("get working directory: %w", err) | |||
| } | |||
| artifactRoot := filepath.Join(repoRoot, "test-artifacts", "user-migration-e2e") | |||
| if err := os.MkdirAll(artifactRoot, 0o755); err != nil { | |||
| return fmt.Errorf("create artifact root: %w", err) | |||
| } | |||
| binaryPath, err := helpers.BuildBinary(repoRoot, artifactRoot) | |||
| if err != nil { | |||
| return fmt.Errorf("build api binary: %w", err) | |||
| } | |||
| env, err := helpers.PrepareEnvironment(repoRoot, artifactRoot, binaryPath, "playwright-ui") | |||
| if err != nil { | |||
| return fmt.Errorf("prepare playwright environment: %w", err) | |||
| } | |||
| defer func() { | |||
| _ = env.Close() | |||
| }() | |||
| playwrightArtifactRoot := filepath.Join(artifactRoot, "playwright") | |||
| if err := os.MkdirAll(playwrightArtifactRoot, 0o755); err != nil { | |||
| return fmt.Errorf("create playwright artifact root: %w", err) | |||
| } | |||
| readyPayload, err := common.Marshal(map[string]any{ | |||
| "run_dir": env.RunDir, | |||
| "base_url": env.Cluster.CN.BaseURL, | |||
| "ov_base_url": env.Cluster.OV.BaseURL, | |||
| }) | |||
| if err != nil { | |||
| return fmt.Errorf("marshal ready payload: %w", err) | |||
| } | |||
| if err := os.WriteFile( | |||
| filepath.Join(playwrightArtifactRoot, "server-ready.json"), | |||
| readyPayload, | |||
| 0o644, | |||
| ); err != nil { | |||
| return fmt.Errorf("write ready marker: %w", err) | |||
| } | |||
| fmt.Printf( | |||
| "user migration ui e2e ready: cn=%s ov=%s run_dir=%s\n", | |||
| env.Cluster.CN.BaseURL, | |||
| env.Cluster.OV.BaseURL, | |||
| env.RunDir, | |||
| ) | |||
| ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |||
| defer stop() | |||
| <-ctx.Done() | |||
| return nil | |||
| } | |||
| @@ -0,0 +1,215 @@ | |||
| package helpers | |||
| import ( | |||
| "fmt" | |||
| "net/http" | |||
| "testing" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/model" | |||
| userMigration "github.com/QuantumNous/new-api/service/user_migration" | |||
| "github.com/stretchr/testify/require" | |||
| ) | |||
| type BatchDetail struct { | |||
| Batch model.UserMigrationBatch | |||
| Items []model.UserMigrationItem | |||
| Total int64 | |||
| } | |||
| type batchDetailResponse struct { | |||
| Success bool `json:"success"` | |||
| Data model.UserMigrationBatch `json:"data"` | |||
| Items []model.UserMigrationItem `json:"items"` | |||
| Total int64 `json:"total"` | |||
| } | |||
| type VerifyEntry struct { | |||
| SourceUserID int `json:"source_user_id"` | |||
| TargetUserID int `json:"target_user_id"` | |||
| Result *userMigration.VerifyResult `json:"result,omitempty"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| type verifyResponse struct { | |||
| Success bool `json:"success"` | |||
| Data []VerifyEntry `json:"data"` | |||
| } | |||
| type remoteUserResponse struct { | |||
| Success bool `json:"success"` | |||
| Data userMigration.RemoteUserSnapshot `json:"data"` | |||
| } | |||
| func GetBatchDetail(client *APIClient, batchID int) (*BatchDetail, error) { | |||
| var resp batchDetailResponse | |||
| if err := client.GetJSON(fmt.Sprintf("/api/user-migrations/batches/%d?page=0&page_size=100", batchID), http.StatusOK, &resp); err != nil { | |||
| return nil, err | |||
| } | |||
| if !resp.Success { | |||
| return nil, fmt.Errorf("get batch detail %d failed", batchID) | |||
| } | |||
| return &BatchDetail{ | |||
| Batch: resp.Data, | |||
| Items: resp.Items, | |||
| Total: resp.Total, | |||
| }, nil | |||
| } | |||
| func WaitBatchStatus(client *APIClient, batchID int, expectedStatus string, timeout time.Duration) (*BatchDetail, error) { | |||
| deadline := time.Now().Add(timeout) | |||
| lastStatus := "" | |||
| for time.Now().Before(deadline) { | |||
| detail, err := GetBatchDetail(client, batchID) | |||
| if err == nil { | |||
| lastStatus = detail.Batch.Status | |||
| if detail.Batch.Status == expectedStatus { | |||
| return detail, nil | |||
| } | |||
| } | |||
| time.Sleep(250 * time.Millisecond) | |||
| } | |||
| return nil, fmt.Errorf("batch %d did not reach status %q within %s, last status=%q", batchID, expectedStatus, timeout, lastStatus) | |||
| } | |||
| func VerifyBatch(client *APIClient, batchID int) ([]VerifyEntry, error) { | |||
| var resp verifyResponse | |||
| if err := client.PostJSON(fmt.Sprintf("/api/user-migrations/batches/%d/verify", batchID), map[string]any{}, http.StatusOK, &resp); err != nil { | |||
| return nil, err | |||
| } | |||
| if !resp.Success { | |||
| return nil, fmt.Errorf("verify batch %d failed", batchID) | |||
| } | |||
| return resp.Data, nil | |||
| } | |||
| func FindItemBySourceUserID(items []model.UserMigrationItem, sourceUserID int) *model.UserMigrationItem { | |||
| for i := range items { | |||
| if items[i].SourceUserId == sourceUserID { | |||
| return &items[i] | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| func GetRemoteUserSnapshot(baseURL string, userID int) (*userMigration.RemoteUserSnapshot, error) { | |||
| client, err := NewAPIClient(baseURL) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| var resp remoteUserResponse | |||
| if err := client.DoJSONWithHeaders( | |||
| http.MethodGet, | |||
| fmt.Sprintf("/api/internal/migration/users/%d", userID), | |||
| nil, | |||
| http.StatusOK, | |||
| map[string]string{ | |||
| "X-Sync-API-Key": DefaultSyncAPIKey, | |||
| "X-Sync-Node": "e2e-assert", | |||
| }, | |||
| &resp, | |||
| ); err != nil { | |||
| return nil, err | |||
| } | |||
| if !resp.Success { | |||
| return nil, fmt.Errorf("get remote user snapshot %d failed", userID) | |||
| } | |||
| return &resp.Data, nil | |||
| } | |||
| func AssertCNUserExists(t *testing.T, dbPath, username string) *model.User { | |||
| t.Helper() | |||
| db, err := OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, CloseSQLite(db)) | |||
| }() | |||
| var user model.User | |||
| require.NoError(t, db.Where("username = ?", username).First(&user).Error) | |||
| return &user | |||
| } | |||
| func LoadCNUserByEmail(t *testing.T, dbPath, email string) *model.User { | |||
| t.Helper() | |||
| db, err := OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, CloseSQLite(db)) | |||
| }() | |||
| var user model.User | |||
| require.NoError(t, db.Where("email = ?", email).First(&user).Error) | |||
| return &user | |||
| } | |||
| func AssertOVUserSynced(t *testing.T, ovBaseURL string, userID, targetUserID, syncedQuota int) { | |||
| t.Helper() | |||
| snapshot, err := GetRemoteUserSnapshot(ovBaseURL, userID) | |||
| require.NoError(t, err) | |||
| require.Equal(t, "synced", snapshot.Source) | |||
| require.Equal(t, targetUserID, snapshot.RemoteUserId) | |||
| require.Equal(t, syncedQuota, snapshot.SyncedQuota) | |||
| } | |||
| func AssertQuota(t *testing.T, dbPath string, userID, quota int) { | |||
| t.Helper() | |||
| db, err := OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, CloseSQLite(db)) | |||
| }() | |||
| var user model.User | |||
| require.NoError(t, db.Select("id", "quota").First(&user, userID).Error) | |||
| require.Equal(t, quota, user.Quota) | |||
| } | |||
| func AssertNoDuplicateQuotaGrant(t *testing.T, dbPath, migrationToken string) { | |||
| t.Helper() | |||
| db, err := OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, CloseSQLite(db)) | |||
| }() | |||
| var count int64 | |||
| require.NoError(t, db.Model(&model.MigrationQuotaGrant{}).Where("migration_token = ?", migrationToken).Count(&count).Error) | |||
| require.EqualValues(t, 1, count) | |||
| } | |||
| func CountUsersByUsername(t *testing.T, dbPath, username string) int64 { | |||
| t.Helper() | |||
| db, err := OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, CloseSQLite(db)) | |||
| }() | |||
| var count int64 | |||
| require.NoError(t, db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error) | |||
| return count | |||
| } | |||
| func CountUsersByEmail(t *testing.T, dbPath, email string) int64 { | |||
| t.Helper() | |||
| db, err := OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, CloseSQLite(db)) | |||
| }() | |||
| var count int64 | |||
| require.NoError(t, db.Model(&model.User{}).Where("email = ?", email).Count(&count).Error) | |||
| return count | |||
| } | |||
| @@ -0,0 +1,75 @@ | |||
| package helpers | |||
| import "fmt" | |||
| type setupStatusResponse struct { | |||
| Success bool `json:"success"` | |||
| Data struct { | |||
| Status bool `json:"status"` | |||
| RootInit bool `json:"root_init"` | |||
| } `json:"data"` | |||
| Message string `json:"message,omitempty"` | |||
| } | |||
| type setupResponse struct { | |||
| Success bool `json:"success"` | |||
| Message string `json:"message,omitempty"` | |||
| } | |||
| type loginResponse struct { | |||
| Success bool `json:"success"` | |||
| Data struct { | |||
| ID int `json:"id"` | |||
| } `json:"data"` | |||
| Message string `json:"message,omitempty"` | |||
| } | |||
| func SetupRoot(client *APIClient, username, password string) error { | |||
| var setupStatus setupStatusResponse | |||
| if err := client.GetJSON("/api/setup", 200, &setupStatus); err != nil { | |||
| return err | |||
| } | |||
| if setupStatus.Data.Status { | |||
| return nil | |||
| } | |||
| var resp setupResponse | |||
| if err := client.PostJSON("/api/setup", map[string]any{ | |||
| "username": username, | |||
| "password": password, | |||
| "confirmPassword": password, | |||
| "SelfUseModeEnabled": false, | |||
| "DemoSiteEnabled": false, | |||
| }, 200, &resp); err != nil { | |||
| return err | |||
| } | |||
| if !resp.Success { | |||
| return fmt.Errorf("setup root failed: %s", resp.Message) | |||
| } | |||
| return nil | |||
| } | |||
| func Login(client *APIClient, username, password string) error { | |||
| var resp loginResponse | |||
| if err := client.PostJSON("/api/user/login", map[string]any{ | |||
| "username": username, | |||
| "password": password, | |||
| }, 200, &resp); err != nil { | |||
| return err | |||
| } | |||
| if !resp.Success { | |||
| return fmt.Errorf("login failed: %s", resp.Message) | |||
| } | |||
| if resp.Data.ID == 0 { | |||
| return fmt.Errorf("login succeeded but user id is empty") | |||
| } | |||
| client.UserID = resp.Data.ID | |||
| return nil | |||
| } | |||
| func SetupAndLoginRoot(client *APIClient, username, password string) error { | |||
| if err := SetupRoot(client, username, password); err != nil { | |||
| return err | |||
| } | |||
| return Login(client, username, password) | |||
| } | |||
| @@ -0,0 +1,397 @@ | |||
| package helpers | |||
| import ( | |||
| "errors" | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "os" | |||
| "os/exec" | |||
| "path/filepath" | |||
| "runtime" | |||
| "strings" | |||
| "time" | |||
| ) | |||
| const ( | |||
| NodeTypeMaster = "master" | |||
| NodeTypeSlave = "slave" | |||
| DefaultCNPort = 3100 | |||
| DefaultOVPort = 3101 | |||
| DefaultSessionSecret = "user-migration-e2e-session-secret" | |||
| DefaultCNDBFileName = "cn.db" | |||
| DefaultOVDBFileName = "ov.db" | |||
| DefaultCNLogFileName = "cn.log" | |||
| DefaultOVLogFileName = "ov.log" | |||
| DefaultCNSessionName = "cn_session" | |||
| DefaultOVSessionName = "ov_session" | |||
| DefaultSyncAPIKey = "e2e-sync-key" | |||
| DefaultRootUsername = "root" | |||
| DefaultRootPassword = "Password123!" | |||
| defaultNodeReadyTimout = 45 * time.Second | |||
| ) | |||
| type NodeProcess struct { | |||
| Name string | |||
| Port int | |||
| BaseURL string | |||
| DBPath string | |||
| SessionName string | |||
| NodeType string | |||
| LogPath string | |||
| cmd *exec.Cmd | |||
| logFile *os.File | |||
| waitCh chan error | |||
| } | |||
| type Cluster struct { | |||
| RepoRoot string | |||
| RunDir string | |||
| BinaryPath string | |||
| CNDBPath string | |||
| OVDBPath string | |||
| CN *NodeProcess | |||
| OV *NodeProcess | |||
| } | |||
| type Environment struct { | |||
| RepoRoot string | |||
| ArtifactRoot string | |||
| RunDir string | |||
| BinaryPath string | |||
| CNDBPath string | |||
| OVDBPath string | |||
| Cluster *Cluster | |||
| CNClient *APIClient | |||
| OVClient *APIClient | |||
| } | |||
| func BuildBinary(repoRoot, artifactRoot string) (string, error) { | |||
| binDir := filepath.Join(artifactRoot, "bin") | |||
| if err := os.MkdirAll(binDir, 0o755); err != nil { | |||
| return "", fmt.Errorf("create binary dir: %w", err) | |||
| } | |||
| binaryName := "new-api-e2e" | |||
| if runtime.GOOS == "windows" { | |||
| binaryName += ".exe" | |||
| } | |||
| binaryPath := filepath.Join(binDir, binaryName) | |||
| cmd := exec.Command("go", "build", "-o", binaryPath, ".") | |||
| cmd.Dir = repoRoot | |||
| output, err := cmd.CombinedOutput() | |||
| if err != nil { | |||
| return "", fmt.Errorf("build e2e binary: %w\n%s", err, string(output)) | |||
| } | |||
| return binaryPath, nil | |||
| } | |||
| func PrepareEnvironment(repoRoot, artifactRoot, binaryPath, scenarioName string) (*Environment, error) { | |||
| runDir := filepath.Join(artifactRoot, "runs", sanitizeName(scenarioName)) | |||
| if err := os.RemoveAll(runDir); err != nil { | |||
| return nil, fmt.Errorf("reset run dir: %w", err) | |||
| } | |||
| if err := os.MkdirAll(runDir, 0o755); err != nil { | |||
| return nil, fmt.Errorf("create run dir: %w", err) | |||
| } | |||
| cluster := NewCluster(repoRoot, runDir, binaryPath) | |||
| if err := cluster.Start(NodeTypeMaster, NodeTypeMaster); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, err | |||
| } | |||
| cnClient, err := NewAPIClient(cluster.CN.BaseURL) | |||
| if err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, err | |||
| } | |||
| ovClient, err := NewAPIClient(cluster.OV.BaseURL) | |||
| if err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, err | |||
| } | |||
| if err := SetupRoot(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("setup CN root: %w", err) | |||
| } | |||
| if err := SetupRoot(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("setup OV root: %w", err) | |||
| } | |||
| if err := cluster.Stop(); err != nil { | |||
| return nil, fmt.Errorf("stop bootstrap cluster: %w", err) | |||
| } | |||
| if err := SeedCNData(cluster.CNDBPath); err != nil { | |||
| return nil, fmt.Errorf("seed CN data: %w", err) | |||
| } | |||
| if err := SeedOVData(cluster.OVDBPath); err != nil { | |||
| return nil, fmt.Errorf("seed OV data: %w", err) | |||
| } | |||
| if err := cluster.Start(NodeTypeMaster, NodeTypeSlave); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, err | |||
| } | |||
| cnClient, err = NewAPIClient(cluster.CN.BaseURL) | |||
| if err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, err | |||
| } | |||
| ovClient, err = NewAPIClient(cluster.OV.BaseURL) | |||
| if err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, err | |||
| } | |||
| if err := Login(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("login CN root: %w", err) | |||
| } | |||
| if err := Login(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("login OV root: %w", err) | |||
| } | |||
| if err := ConfigureCNRegionSync(cnClient, cluster.CN.BaseURL, cluster.OV.BaseURL, DefaultSyncAPIKey); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("configure CN region sync: %w", err) | |||
| } | |||
| if err := ConfigureOVRegionSync(ovClient, cluster.CN.BaseURL, DefaultSyncAPIKey); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("configure OV region sync: %w", err) | |||
| } | |||
| if err := WaitOptionApplied(cluster.OV.BaseURL, DefaultSyncAPIKey, 5, 20*time.Second); err != nil { | |||
| _ = cluster.Stop() | |||
| return nil, fmt.Errorf("wait OV migration API ready: %w", err) | |||
| } | |||
| return &Environment{ | |||
| RepoRoot: repoRoot, | |||
| ArtifactRoot: artifactRoot, | |||
| RunDir: runDir, | |||
| BinaryPath: binaryPath, | |||
| CNDBPath: cluster.CNDBPath, | |||
| OVDBPath: cluster.OVDBPath, | |||
| Cluster: cluster, | |||
| CNClient: cnClient, | |||
| OVClient: ovClient, | |||
| }, nil | |||
| } | |||
| func (e *Environment) Close() error { | |||
| if e == nil || e.Cluster == nil { | |||
| return nil | |||
| } | |||
| return e.Cluster.Stop() | |||
| } | |||
| func NewCluster(repoRoot, runDir, binaryPath string) *Cluster { | |||
| return &Cluster{ | |||
| RepoRoot: repoRoot, | |||
| RunDir: runDir, | |||
| BinaryPath: binaryPath, | |||
| CNDBPath: filepath.Join(runDir, DefaultCNDBFileName), | |||
| OVDBPath: filepath.Join(runDir, DefaultOVDBFileName), | |||
| } | |||
| } | |||
| func (c *Cluster) Start(cnNodeType, ovNodeType string) error { | |||
| if err := os.MkdirAll(c.RunDir, 0o755); err != nil { | |||
| return fmt.Errorf("create run dir: %w", err) | |||
| } | |||
| cn, err := c.startNode("cn", DefaultCNPort, c.CNDBPath, DefaultCNSessionName, cnNodeType, DefaultCNLogFileName) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| c.CN = cn | |||
| ov, err := c.startNode("ov", DefaultOVPort, c.OVDBPath, DefaultOVSessionName, ovNodeType, DefaultOVLogFileName) | |||
| if err != nil { | |||
| _ = c.CN.Stop() | |||
| c.CN = nil | |||
| return err | |||
| } | |||
| c.OV = ov | |||
| if err := c.CN.WaitReady(defaultNodeReadyTimout); err != nil { | |||
| _ = c.Stop() | |||
| return err | |||
| } | |||
| if err := c.OV.WaitReady(defaultNodeReadyTimout); err != nil { | |||
| _ = c.Stop() | |||
| return err | |||
| } | |||
| return nil | |||
| } | |||
| func (c *Cluster) Stop() error { | |||
| var errs []string | |||
| if c.OV != nil { | |||
| if err := c.OV.Stop(); err != nil { | |||
| errs = append(errs, err.Error()) | |||
| } | |||
| c.OV = nil | |||
| } | |||
| if c.CN != nil { | |||
| if err := c.CN.Stop(); err != nil { | |||
| errs = append(errs, err.Error()) | |||
| } | |||
| c.CN = nil | |||
| } | |||
| if len(errs) == 0 { | |||
| return nil | |||
| } | |||
| return errors.New(strings.Join(errs, "; ")) | |||
| } | |||
| func (c *Cluster) startNode(name string, port int, dbPath, sessionName, nodeType, logFileName string) (*NodeProcess, error) { | |||
| logPath := filepath.Join(c.RunDir, logFileName) | |||
| logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("open %s log file: %w", name, err) | |||
| } | |||
| systemLogDir := filepath.Join(c.RunDir, "system-logs", name) | |||
| if err := os.MkdirAll(systemLogDir, 0o755); err != nil { | |||
| _ = logFile.Close() | |||
| return nil, fmt.Errorf("create %s system log dir: %w", name, err) | |||
| } | |||
| cmd := exec.Command(c.BinaryPath, "--log-dir", systemLogDir) | |||
| cmd.Dir = c.RepoRoot | |||
| cmd.Env = mergeEnv(os.Environ(), map[string]string{ | |||
| "PORT": fmt.Sprintf("%d", port), | |||
| "SQLITE_PATH": dbPath, | |||
| "SESSION_SECRET": DefaultSessionSecret, | |||
| "SESSION_NAME": sessionName, | |||
| "SESSION_SECURE": "false", | |||
| "SESSION_SAMESITE": "strict", | |||
| "GLOBAL_API_RATE_LIMIT_ENABLE": "false", | |||
| "GLOBAL_WEB_RATE_LIMIT_ENABLE": "false", | |||
| "CRITICAL_RATE_LIMIT_ENABLE": "false", | |||
| "GIN_MODE": "release", | |||
| "NODE_TYPE": nodeType, | |||
| }) | |||
| cmd.Stdout = logFile | |||
| cmd.Stderr = logFile | |||
| waitCh := make(chan error, 1) | |||
| if err := cmd.Start(); err != nil { | |||
| _ = logFile.Close() | |||
| return nil, fmt.Errorf("start %s node: %w", name, err) | |||
| } | |||
| go func() { | |||
| waitCh <- cmd.Wait() | |||
| }() | |||
| return &NodeProcess{ | |||
| Name: name, | |||
| Port: port, | |||
| BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port), | |||
| DBPath: dbPath, | |||
| SessionName: sessionName, | |||
| NodeType: nodeType, | |||
| LogPath: logPath, | |||
| cmd: cmd, | |||
| logFile: logFile, | |||
| waitCh: waitCh, | |||
| }, nil | |||
| } | |||
| func (n *NodeProcess) WaitReady(timeout time.Duration) error { | |||
| client := &http.Client{Timeout: 1 * time.Second} | |||
| deadline := time.Now().Add(timeout) | |||
| for time.Now().Before(deadline) { | |||
| select { | |||
| case err := <-n.waitCh: | |||
| if err == nil { | |||
| return fmt.Errorf("%s node exited before ready; see %s", n.Name, n.LogPath) | |||
| } | |||
| return fmt.Errorf("%s node exited before ready: %w; see %s", n.Name, err, n.LogPath) | |||
| default: | |||
| } | |||
| resp, err := client.Get(n.BaseURL + "/api/status") | |||
| if err == nil { | |||
| _, _ = io.Copy(io.Discard, resp.Body) | |||
| resp.Body.Close() | |||
| if resp.StatusCode == http.StatusOK { | |||
| return nil | |||
| } | |||
| } | |||
| time.Sleep(250 * time.Millisecond) | |||
| } | |||
| return fmt.Errorf("%s node did not become ready within %s; see %s", n.Name, timeout, n.LogPath) | |||
| } | |||
| func (n *NodeProcess) Stop() error { | |||
| if n == nil { | |||
| return nil | |||
| } | |||
| var errs []string | |||
| killRequested := false | |||
| if n.cmd != nil && n.cmd.Process != nil { | |||
| if err := n.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { | |||
| errs = append(errs, fmt.Sprintf("kill %s node: %v", n.Name, err)) | |||
| } else { | |||
| killRequested = true | |||
| } | |||
| select { | |||
| case err := <-n.waitCh: | |||
| if err != nil && !killRequested && !strings.Contains(strings.ToLower(err.Error()), "signal: killed") { | |||
| errs = append(errs, fmt.Sprintf("wait %s node: %v", n.Name, err)) | |||
| } | |||
| case <-time.After(5 * time.Second): | |||
| errs = append(errs, fmt.Sprintf("wait %s node timeout", n.Name)) | |||
| } | |||
| } | |||
| if n.logFile != nil { | |||
| if err := n.logFile.Close(); err != nil { | |||
| errs = append(errs, fmt.Sprintf("close %s log: %v", n.Name, err)) | |||
| } | |||
| n.logFile = nil | |||
| } | |||
| n.cmd = nil | |||
| if len(errs) == 0 { | |||
| return nil | |||
| } | |||
| return errors.New(strings.Join(errs, "; ")) | |||
| } | |||
| func sanitizeName(name string) string { | |||
| replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") | |||
| return replacer.Replace(name) | |||
| } | |||
| func mergeEnv(base []string, overrides map[string]string) []string { | |||
| filtered := make([]string, 0, len(base)+len(overrides)) | |||
| for _, entry := range base { | |||
| parts := strings.SplitN(entry, "=", 2) | |||
| if len(parts) != 2 { | |||
| continue | |||
| } | |||
| if _, ok := overrides[parts[0]]; ok { | |||
| continue | |||
| } | |||
| filtered = append(filtered, entry) | |||
| } | |||
| for key, value := range overrides { | |||
| filtered = append(filtered, key+"="+value) | |||
| } | |||
| return filtered | |||
| } | |||
| @@ -0,0 +1,119 @@ | |||
| package helpers | |||
| import ( | |||
| "bytes" | |||
| "encoding/json" | |||
| "fmt" | |||
| "io" | |||
| "net/http" | |||
| "net/http/cookiejar" | |||
| "strings" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| ) | |||
| type APIClient struct { | |||
| BaseURL string | |||
| UserID int | |||
| client *http.Client | |||
| } | |||
| func NewAPIClient(baseURL string) (*APIClient, error) { | |||
| jar, err := cookiejar.New(nil) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("create cookie jar: %w", err) | |||
| } | |||
| return &APIClient{ | |||
| BaseURL: strings.TrimRight(baseURL, "/"), | |||
| client: &http.Client{ | |||
| Jar: jar, | |||
| Timeout: 15 * time.Second, | |||
| }, | |||
| }, nil | |||
| } | |||
| func (c *APIClient) DoJSON(method, path string, requestBody any, expectedStatus int, responseBody any) error { | |||
| return c.DoJSONWithHeaders(method, path, requestBody, expectedStatus, nil, responseBody) | |||
| } | |||
| func (c *APIClient) DoJSONWithHeaders(method, path string, requestBody any, expectedStatus int, headers map[string]string, responseBody any) error { | |||
| var bodyReader io.Reader | |||
| var requestDump string | |||
| if requestBody != nil { | |||
| data, err := common.Marshal(requestBody) | |||
| if err != nil { | |||
| return fmt.Errorf("marshal request body for %s %s: %w", method, path, err) | |||
| } | |||
| bodyReader = bytes.NewReader(data) | |||
| requestDump = string(data) | |||
| } | |||
| req, err := http.NewRequest(method, c.BaseURL+path, bodyReader) | |||
| if err != nil { | |||
| return fmt.Errorf("create request %s %s: %w", method, path, err) | |||
| } | |||
| req.Header.Set("Accept", "application/json") | |||
| if requestBody != nil { | |||
| req.Header.Set("Content-Type", "application/json") | |||
| } | |||
| if c.UserID != 0 { | |||
| req.Header.Set("New-Api-User", fmt.Sprintf("%d", c.UserID)) | |||
| } | |||
| for key, value := range headers { | |||
| req.Header.Set(key, value) | |||
| } | |||
| resp, err := c.client.Do(req) | |||
| if err != nil { | |||
| return fmt.Errorf("do request %s %s: %w", method, path, err) | |||
| } | |||
| defer resp.Body.Close() | |||
| respData, err := io.ReadAll(resp.Body) | |||
| if err != nil { | |||
| return fmt.Errorf("read response %s %s: %w", method, path, err) | |||
| } | |||
| if resp.StatusCode != expectedStatus { | |||
| return fmt.Errorf( | |||
| "%s %s: expected status %d, got %d, request=%s, response=%s", | |||
| method, path, expectedStatus, resp.StatusCode, requestDump, string(respData), | |||
| ) | |||
| } | |||
| if responseBody == nil || len(respData) == 0 { | |||
| return nil | |||
| } | |||
| if err := common.Unmarshal(respData, responseBody); err != nil { | |||
| return fmt.Errorf( | |||
| "unmarshal response for %s %s: %w, raw=%s", | |||
| method, path, err, string(respData), | |||
| ) | |||
| } | |||
| return nil | |||
| } | |||
| func (c *APIClient) GetJSON(path string, expectedStatus int, responseBody any) error { | |||
| return c.DoJSON(http.MethodGet, path, nil, expectedStatus, responseBody) | |||
| } | |||
| func (c *APIClient) PostJSON(path string, requestBody any, expectedStatus int, responseBody any) error { | |||
| return c.DoJSON(http.MethodPost, path, requestBody, expectedStatus, responseBody) | |||
| } | |||
| func (c *APIClient) PutJSON(path string, requestBody any, expectedStatus int, responseBody any) error { | |||
| return c.DoJSON(http.MethodPut, path, requestBody, expectedStatus, responseBody) | |||
| } | |||
| func (c *APIClient) DeleteJSON(path string, expectedStatus int, responseBody any) error { | |||
| return c.DoJSON(http.MethodDelete, path, nil, expectedStatus, responseBody) | |||
| } | |||
| type Envelope struct { | |||
| Success bool `json:"success"` | |||
| Message string `json:"message,omitempty"` | |||
| Error string `json:"error,omitempty"` | |||
| Data json.RawMessage `json:"data,omitempty"` | |||
| } | |||
| @@ -0,0 +1,115 @@ | |||
| package helpers | |||
| import ( | |||
| "fmt" | |||
| "net/http" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| ) | |||
| type optionResponse struct { | |||
| Success bool `json:"success"` | |||
| Message string `json:"message,omitempty"` | |||
| } | |||
| type internalListUsersResponse struct { | |||
| Success bool `json:"success"` | |||
| Total int `json:"total"` | |||
| } | |||
| func SetOption(client *APIClient, key string, value any) error { | |||
| var resp optionResponse | |||
| if err := client.PutJSON("/api/option/", map[string]any{ | |||
| "key": key, | |||
| "value": value, | |||
| }, http.StatusOK, &resp); err != nil { | |||
| return err | |||
| } | |||
| if !resp.Success { | |||
| return fmt.Errorf("set option %s failed: %s", key, resp.Message) | |||
| } | |||
| return nil | |||
| } | |||
| func ConfigureCNRegionSync(client *APIClient, cnBaseURL, ovBaseURL, apiKey string) error { | |||
| slaveEndpoints, err := common.Marshal([]string{ovBaseURL}) | |||
| if err != nil { | |||
| return fmt.Errorf("marshal slave endpoints: %w", err) | |||
| } | |||
| options := []struct { | |||
| Key string | |||
| Value any | |||
| }{ | |||
| {Key: "region_sync.enabled", Value: true}, | |||
| {Key: "region_sync.region_id", Value: "cn"}, | |||
| {Key: "region_sync.is_master", Value: true}, | |||
| {Key: "region_sync.master_endpoint", Value: cnBaseURL}, | |||
| {Key: "region_sync.slave_endpoints", Value: string(slaveEndpoints)}, | |||
| {Key: "region_sync.sync_api_key", Value: apiKey}, | |||
| } | |||
| for _, option := range options { | |||
| if err := SetOption(client, option.Key, option.Value); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| func ConfigureOVRegionSync(client *APIClient, cnBaseURL, apiKey string) error { | |||
| emptyEndpoints, err := common.Marshal([]string{}) | |||
| if err != nil { | |||
| return fmt.Errorf("marshal empty slave endpoints: %w", err) | |||
| } | |||
| options := []struct { | |||
| Key string | |||
| Value any | |||
| }{ | |||
| {Key: "region_sync.enabled", Value: true}, | |||
| {Key: "region_sync.region_id", Value: "ov"}, | |||
| {Key: "region_sync.is_master", Value: false}, | |||
| {Key: "region_sync.master_endpoint", Value: cnBaseURL}, | |||
| {Key: "region_sync.slave_endpoints", Value: string(emptyEndpoints)}, | |||
| {Key: "region_sync.sync_api_key", Value: apiKey}, | |||
| } | |||
| for _, option := range options { | |||
| if err := SetOption(client, option.Key, option.Value); err != nil { | |||
| return err | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| func WaitOptionApplied(baseURL, apiKey string, expectedTotal int, timeout time.Duration) error { | |||
| return WaitInternalMigrationReady(baseURL, apiKey, expectedTotal, timeout) | |||
| } | |||
| func WaitInternalMigrationReady(baseURL, apiKey string, expectedTotal int, timeout time.Duration) error { | |||
| client, err := NewAPIClient(baseURL) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| deadline := time.Now().Add(timeout) | |||
| for time.Now().Before(deadline) { | |||
| var resp internalListUsersResponse | |||
| err := client.DoJSONWithHeaders( | |||
| http.MethodGet, | |||
| "/api/internal/migration/users?page=0&page_size=1", | |||
| nil, | |||
| http.StatusOK, | |||
| map[string]string{ | |||
| "X-Sync-API-Key": apiKey, | |||
| "X-Sync-Node": "e2e-ready-check", | |||
| }, | |||
| &resp, | |||
| ) | |||
| if err == nil && resp.Success && resp.Total == expectedTotal { | |||
| return nil | |||
| } | |||
| time.Sleep(250 * time.Millisecond) | |||
| } | |||
| return fmt.Errorf("internal migration API at %s not ready within %s", baseURL, timeout) | |||
| } | |||
| @@ -0,0 +1,304 @@ | |||
| package helpers | |||
| import ( | |||
| "fmt" | |||
| "os" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/glebarez/sqlite" | |||
| "gorm.io/gorm" | |||
| ) | |||
| const ( | |||
| CNMergeTargetUserID = 11 | |||
| CNSyncedCopyOwnerUserID = 12 | |||
| CNTakenUsernameUserID = 13 | |||
| CNSoftAffOwnerUserID = 14 | |||
| OVCreateUserID = 10000001 | |||
| OVMergeUserID = 10000002 | |||
| OVHardSyncedUserID = 10000003 | |||
| OVUsernameConflictUserID = 10000004 | |||
| OVSoftAffUserID = 10000005 | |||
| OVSyncedCopyUserID = 10000100 | |||
| UsernameCNMergeTarget = "cn-merge-target" | |||
| UsernameCNSyncedCopyOwner = "cn-synced-copy-owner" | |||
| UsernameCNTakenUsername = "taken-username" | |||
| UsernameCNSoftAffOwner = "aff-owner" | |||
| UsernameOVCreate = "ov-create" | |||
| UsernameOVMerge = "ov-merge" | |||
| UsernameOVHardSynced = "ov-hard-synced" | |||
| UsernameOVUsernameConflict = "taken-username" | |||
| UsernameOVSoftAff = "ov-aff-soft" | |||
| UsernameOVSyncedCopy = "ov-existing-synced-copy" | |||
| EmailCNMergeTarget = "merge@example.com" | |||
| EmailCNSyncedCopyOwner = "synced@example.com" | |||
| EmailCNTakenUsername = "taken@example.com" | |||
| EmailCNSoftAffOwner = "soft-owner@example.com" | |||
| EmailOVCreate = "create@example.com" | |||
| EmailOVMerge = "merge@example.com" | |||
| EmailOVHardSynced = "synced@example.com" | |||
| EmailOVUsernameConflict = "username-conflict@example.com" | |||
| EmailOVSoftAff = "soft@example.com" | |||
| EmailOVSyncedCopy = "copy@example.com" | |||
| AffCodeCNMergeTarget = "CNM1" | |||
| AffCodeCNSyncedCopyOwner = "CNH3" | |||
| AffCodeCNTakenUsername = "CNT1" | |||
| AffCodeCNSoftAffOwner = "SOFT1" | |||
| AffCodeOVCreate = "OVC1" | |||
| AffCodeOVMerge = "OVM2" | |||
| AffCodeOVHardSynced = "OVH3" | |||
| AffCodeOVUsername = "OVU4" | |||
| AffCodeOVSyncedCopy = "OVS1" | |||
| ) | |||
| func ResetDB(path string) error { | |||
| if err := os.Remove(path); err != nil && !os.IsNotExist(err) { | |||
| return err | |||
| } | |||
| return nil | |||
| } | |||
| func OpenSQLite(path string) (*gorm.DB, error) { | |||
| db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| sqlDB, err := db.DB() | |||
| if err != nil { | |||
| return nil, err | |||
| } | |||
| sqlDB.SetMaxOpenConns(1) | |||
| return db, nil | |||
| } | |||
| func CloseSQLite(db *gorm.DB) error { | |||
| if db == nil { | |||
| return nil | |||
| } | |||
| sqlDB, err := db.DB() | |||
| if err != nil { | |||
| return err | |||
| } | |||
| return sqlDB.Close() | |||
| } | |||
| func SeedCNData(dbPath string) error { | |||
| db, err := OpenSQLite(dbPath) | |||
| if err != nil { | |||
| return fmt.Errorf("open CN sqlite: %w", err) | |||
| } | |||
| defer func() { | |||
| _ = CloseSQLite(db) | |||
| }() | |||
| passwordHash, err := common.Password2Hash("fixture-password") | |||
| if err != nil { | |||
| return fmt.Errorf("hash fixture password: %w", err) | |||
| } | |||
| now := common.GetTimestamp() | |||
| users := []*model.User{ | |||
| { | |||
| Id: CNMergeTargetUserID, | |||
| Username: UsernameCNMergeTarget, | |||
| Password: passwordHash, | |||
| DisplayName: "CN Merge Target", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailCNMergeTarget, | |||
| Group: "default", | |||
| Quota: 500, | |||
| AffCode: AffCodeCNMergeTarget, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: CNSyncedCopyOwnerUserID, | |||
| Username: UsernameCNSyncedCopyOwner, | |||
| Password: passwordHash, | |||
| DisplayName: "CN Synced Copy Owner", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailCNSyncedCopyOwner, | |||
| Group: "default", | |||
| Quota: 300, | |||
| AffCode: AffCodeCNSyncedCopyOwner, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: CNTakenUsernameUserID, | |||
| Username: UsernameCNTakenUsername, | |||
| Password: passwordHash, | |||
| DisplayName: "CN Username Conflict", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailCNTakenUsername, | |||
| Group: "default", | |||
| Quota: 50, | |||
| AffCode: AffCodeCNTakenUsername, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: CNSoftAffOwnerUserID, | |||
| Username: UsernameCNSoftAffOwner, | |||
| Password: passwordHash, | |||
| DisplayName: "CN Soft Aff Owner", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailCNSoftAffOwner, | |||
| Group: "default", | |||
| Quota: 80, | |||
| AffCode: AffCodeCNSoftAffOwner, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| } | |||
| for _, user := range users { | |||
| if err := db.Create(user).Error; err != nil { | |||
| return fmt.Errorf("seed CN user %s: %w", user.Username, err) | |||
| } | |||
| } | |||
| return nil | |||
| } | |||
| func SeedOVData(dbPath string) error { | |||
| db, err := OpenSQLite(dbPath) | |||
| if err != nil { | |||
| return fmt.Errorf("open OV sqlite: %w", err) | |||
| } | |||
| defer func() { | |||
| _ = CloseSQLite(db) | |||
| }() | |||
| passwordHash, err := common.Password2Hash("fixture-password") | |||
| if err != nil { | |||
| return fmt.Errorf("hash fixture password: %w", err) | |||
| } | |||
| now := common.GetTimestamp() | |||
| users := []*model.User{ | |||
| { | |||
| Id: OVCreateUserID, | |||
| Username: UsernameOVCreate, | |||
| Password: passwordHash, | |||
| DisplayName: "OV Create", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailOVCreate, | |||
| Group: "default", | |||
| Quota: 100, | |||
| AffCode: AffCodeOVCreate, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: OVMergeUserID, | |||
| Username: UsernameOVMerge, | |||
| Password: passwordHash, | |||
| DisplayName: "OV Merge", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailOVMerge, | |||
| Group: "default", | |||
| Quota: 200, | |||
| AffCode: AffCodeOVMerge, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: OVHardSyncedUserID, | |||
| Username: UsernameOVHardSynced, | |||
| Password: passwordHash, | |||
| DisplayName: "OV Hard Synced", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailOVHardSynced, | |||
| Group: "default", | |||
| Quota: 300, | |||
| AffCode: AffCodeOVHardSynced, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: OVUsernameConflictUserID, | |||
| Username: UsernameOVUsernameConflict, | |||
| Password: passwordHash, | |||
| DisplayName: "OV Username Conflict", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailOVUsernameConflict, | |||
| Group: "default", | |||
| Quota: 120, | |||
| AffCode: AffCodeOVUsername, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| { | |||
| Id: OVSoftAffUserID, | |||
| Username: UsernameOVSoftAff, | |||
| Password: passwordHash, | |||
| DisplayName: "OV Soft Aff", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailOVSoftAff, | |||
| Group: "default", | |||
| Quota: 150, | |||
| AffCode: AffCodeCNSoftAffOwner, | |||
| Source: common.UserSourceLocal, | |||
| CreatedAt: now, | |||
| }, | |||
| } | |||
| for _, user := range users { | |||
| if err := db.Create(user).Error; err != nil { | |||
| return fmt.Errorf("seed OV user %s: %w", user.Username, err) | |||
| } | |||
| } | |||
| return SeedOVSyncedCopy(dbPath) | |||
| } | |||
| func SeedOVSyncedCopy(dbPath string) error { | |||
| db, err := OpenSQLite(dbPath) | |||
| if err != nil { | |||
| return fmt.Errorf("open OV sqlite for synced copy: %w", err) | |||
| } | |||
| defer func() { | |||
| _ = CloseSQLite(db) | |||
| }() | |||
| passwordHash, err := common.Password2Hash("fixture-password") | |||
| if err != nil { | |||
| return fmt.Errorf("hash fixture password for synced copy: %w", err) | |||
| } | |||
| now := common.GetTimestamp() | |||
| user := &model.User{ | |||
| Id: OVSyncedCopyUserID, | |||
| Username: UsernameOVSyncedCopy, | |||
| Password: passwordHash, | |||
| DisplayName: "OV Existing Synced Copy", | |||
| Role: common.RoleCommonUser, | |||
| Status: common.UserStatusEnabled, | |||
| Email: EmailOVSyncedCopy, | |||
| Group: "default", | |||
| Quota: 0, | |||
| AffCode: AffCodeOVSyncedCopy, | |||
| Source: common.UserSourceSynced, | |||
| RemoteUserId: CNSyncedCopyOwnerUserID, | |||
| SyncedQuota: 300, | |||
| LastSyncAt: now, | |||
| CreatedAt: now, | |||
| } | |||
| if err := db.Create(user).Error; err != nil { | |||
| return fmt.Errorf("seed OV synced copy %s: %w", user.Username, err) | |||
| } | |||
| return nil | |||
| } | |||
| @@ -0,0 +1,800 @@ | |||
| package e2e | |||
| import ( | |||
| "fmt" | |||
| "os" | |||
| "path/filepath" | |||
| "testing" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| user_migration "github.com/QuantumNous/new-api/service/user_migration" | |||
| helpers "github.com/QuantumNous/new-api/test/e2e/helpers" | |||
| "github.com/stretchr/testify/require" | |||
| ) | |||
| var ( | |||
| repoRoot string | |||
| artifactRoot string | |||
| binaryPath string | |||
| ) | |||
| type simpleResponse struct { | |||
| Success bool `json:"success"` | |||
| Message string `json:"message,omitempty"` | |||
| Error string `json:"error,omitempty"` | |||
| } | |||
| type createBatchResponse struct { | |||
| Success bool `json:"success"` | |||
| Data model.UserMigrationBatch `json:"data"` | |||
| } | |||
| type adminUserResponse struct { | |||
| Success bool `json:"success"` | |||
| Data model.User `json:"data"` | |||
| } | |||
| type batchPageResponse struct { | |||
| Success bool `json:"success"` | |||
| Data model.UserMigrationBatch `json:"data"` | |||
| Items []model.UserMigrationItem `json:"items"` | |||
| Total int64 `json:"total"` | |||
| } | |||
| type preparedBatch struct { | |||
| BatchID int | |||
| Detail *helpers.BatchDetail | |||
| Items map[int]*model.UserMigrationItem | |||
| } | |||
| type executedBatch struct { | |||
| Prepared *preparedBatch | |||
| Detail *helpers.BatchDetail | |||
| CreatedUser *model.User | |||
| SoftAffUser *model.User | |||
| VerifyResult map[int]helpers.VerifyEntry | |||
| } | |||
| func TestMain(m *testing.M) { | |||
| var err error | |||
| wd, err := os.Getwd() | |||
| if err != nil { | |||
| fmt.Fprintln(os.Stderr, err) | |||
| os.Exit(1) | |||
| } | |||
| repoRoot = filepath.Clean(filepath.Join(wd, "..", "..")) | |||
| artifactRoot = filepath.Join(repoRoot, "test-artifacts", "user-migration-e2e") | |||
| if err := os.MkdirAll(artifactRoot, 0o755); err != nil { | |||
| fmt.Fprintln(os.Stderr, err) | |||
| os.Exit(1) | |||
| } | |||
| binaryPath, err = helpers.BuildBinary(repoRoot, artifactRoot) | |||
| if err != nil { | |||
| fmt.Fprintln(os.Stderr, err) | |||
| os.Exit(1) | |||
| } | |||
| os.Exit(m.Run()) | |||
| } | |||
| func TestUserMigrationE2E_FullFlow_GrayScenarios(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| result := runFullFlow(t, env) | |||
| require.Equal(t, model.UserMigrationBatchStatusCompleted, result.Detail.Batch.Status) | |||
| require.Equal(t, 3, result.Detail.Batch.MigratedUsers) | |||
| require.Equal(t, 0, result.Detail.Batch.FailedUsers) | |||
| require.Len(t, result.VerifyResult, 3) | |||
| } | |||
| func TestUserMigrationE2E_IdempotentRetry(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| result := runFullFlow(t, env) | |||
| forceRetryableState(t, env.CNDBPath, result.Prepared.BatchID, []int{ | |||
| result.Prepared.Items[helpers.OVCreateUserID].Id, | |||
| result.Prepared.Items[helpers.OVMergeUserID].Id, | |||
| result.Prepared.Items[helpers.OVSoftAffUserID].Id, | |||
| }) | |||
| var retryResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/retry", result.Prepared.BatchID), | |||
| nil, | |||
| 202, | |||
| &retryResp, | |||
| )) | |||
| require.True(t, retryResp.Success) | |||
| retried, err := helpers.WaitBatchStatus(env.CNClient, result.Prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second) | |||
| require.NoError(t, err) | |||
| retriedItems := indexItemsBySourceUserID(retried.Items) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVCreateUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVMergeUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, retriedItems[helpers.OVSoftAffUserID].Status) | |||
| require.EqualValues(t, 1, helpers.CountUsersByUsername(t, env.CNDBPath, helpers.UsernameOVCreate)) | |||
| require.EqualValues(t, 1, helpers.CountUsersByUsername(t, env.CNDBPath, helpers.UsernameOVSoftAff)) | |||
| helpers.AssertQuota(t, env.CNDBPath, helpers.CNMergeTargetUserID, 700) | |||
| helpers.AssertNoDuplicateQuotaGrant(t, env.CNDBPath, result.Prepared.Items[helpers.OVMergeUserID].MigrationToken) | |||
| verifyEntries, err := helpers.VerifyBatch(env.CNClient, result.Prepared.BatchID) | |||
| require.NoError(t, err) | |||
| require.Len(t, verifyEntries, 3) | |||
| for _, entry := range verifyEntries { | |||
| require.Empty(t, entry.Error) | |||
| require.NotNil(t, entry.Result) | |||
| require.True(t, entry.Result.TargetUserExists) | |||
| require.True(t, entry.Result.RemoteConverted) | |||
| require.True(t, entry.Result.QuotaMatched) | |||
| } | |||
| } | |||
| func TestUserMigrationE2E_ExecutionTimeDriftFails(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| prepared := prepareGrayScenarioBatch(t, env) | |||
| renameOVUser(t, env, helpers.OVCreateUserID, helpers.UsernameCNMergeTarget) | |||
| var executeResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), | |||
| nil, | |||
| 202, | |||
| &executeResp, | |||
| )) | |||
| require.True(t, executeResp.Success) | |||
| failedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusFailed, 25*time.Second) | |||
| require.NoError(t, err) | |||
| items := indexItemsBySourceUserID(failedDetail.Items) | |||
| require.Equal(t, model.UserMigrationItemStatusFailed, items[helpers.OVCreateUserID].Status) | |||
| require.Contains(t, items[helpers.OVCreateUserID].ErrorMessage, "drift") | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVMergeUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVSoftAffUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVHardSyncedUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVUsernameConflictUserID].Status) | |||
| require.EqualValues(t, 0, helpers.CountUsersByEmail(t, env.CNDBPath, helpers.EmailOVCreate)) | |||
| driftedRemote, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVCreateUserID) | |||
| require.NoError(t, err) | |||
| require.NotEqual(t, common.UserSourceSynced, driftedRemote.Source) | |||
| } | |||
| func TestUserMigrationE2E_RescanPreservesHumanDecision(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| prepared := prepareGrayScenarioBatch(t, env) | |||
| var scanResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/scan", prepared.BatchID), | |||
| nil, | |||
| 202, | |||
| &scanResp, | |||
| )) | |||
| require.True(t, scanResp.Success) | |||
| rescannedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusScanned, 20*time.Second) | |||
| require.NoError(t, err) | |||
| items := indexItemsBySourceUserID(rescannedDetail.Items) | |||
| require.Equal(t, model.UserMigrationStrategySkip, items[helpers.OVHardSyncedUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusConflict, items[helpers.OVHardSyncedUserID].Status) | |||
| require.Equal(t, model.UserMigrationStrategySkip, items[helpers.OVUsernameConflictUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusConflict, items[helpers.OVUsernameConflictUserID].Status) | |||
| require.Equal(t, model.UserMigrationStrategyMergeExisting, items[helpers.OVMergeUserID].ResolutionStrategy) | |||
| require.Equal(t, helpers.CNMergeTargetUserID, items[helpers.OVMergeUserID].TargetUserId) | |||
| } | |||
| func TestUserMigrationE2E_ScanPagination(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| createManagedUsers(t, env.OVClient, "ov-page", 97, common.RoleCommonUser) | |||
| batchID := createBatch(t, env, "pagination") | |||
| 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.UserMigrationBatchStatusScanned, 60*time.Second) | |||
| require.NoError(t, err) | |||
| require.EqualValues(t, 102, detail.Total) | |||
| require.Equal(t, 102, detail.Batch.TotalUsers) | |||
| page0 := getBatchPage(t, env.CNClient, batchID, 0, 100) | |||
| require.Len(t, page0.Items, 100) | |||
| page1 := getBatchPage(t, env.CNClient, batchID, 1, 100) | |||
| require.Len(t, page1.Items, 2) | |||
| require.EqualValues(t, 102, page1.Total) | |||
| } | |||
| func TestUserMigrationE2E_RootPermission(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| adminClient := createAndLoginManagedUser(t, env.Cluster.CN.BaseURL, env.CNClient, "migration-admin", helpers.DefaultRootPassword, common.RoleAdminUser) | |||
| var listResp simpleResponse | |||
| require.NoError(t, adminClient.GetJSON("/api/user-migrations/batches", 200, &listResp)) | |||
| require.False(t, listResp.Success) | |||
| require.NotEmpty(t, listResp.Message) | |||
| var createResp simpleResponse | |||
| require.NoError(t, adminClient.PostJSON("/api/user-migrations/batches", map[string]any{ | |||
| "name": "no-access", | |||
| "source_region": "ov", | |||
| "target_region": "cn", | |||
| }, 200, &createResp)) | |||
| require.False(t, createResp.Success) | |||
| require.NotEmpty(t, createResp.Message) | |||
| } | |||
| func TestUserMigrationE2E_ResolveInvalidPayload(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| scanned := scanGrayScenarioBatch(t, env, "resolve-invalid") | |||
| conflictItem := scanned.Items[helpers.OVHardSyncedUserID] | |||
| var invalidStrategyResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/items/%d/resolve", conflictItem.Id), | |||
| map[string]any{ | |||
| "resolution_strategy": "invalid", | |||
| }, | |||
| 400, | |||
| &invalidStrategyResp, | |||
| )) | |||
| require.False(t, invalidStrategyResp.Success) | |||
| require.Contains(t, invalidStrategyResp.Error, "invalid resolution_strategy") | |||
| var mergeWithoutTargetResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/items/%d/resolve", conflictItem.Id), | |||
| map[string]any{ | |||
| "resolution_strategy": model.UserMigrationStrategyMergeExisting, | |||
| }, | |||
| 400, | |||
| &mergeWithoutTargetResp, | |||
| )) | |||
| require.False(t, mergeWithoutTargetResp.Success) | |||
| require.Contains(t, mergeWithoutTargetResp.Error, "target_user_id") | |||
| } | |||
| func TestUserMigrationE2E_ScanConflict_WhenAlreadyScanning(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| batchID := createBatch(t, env, "scan-conflict") | |||
| setBatchStatus(t, env.CNDBPath, batchID, model.UserMigrationBatchStatusScanning) | |||
| var resp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), | |||
| nil, | |||
| 409, | |||
| &resp, | |||
| )) | |||
| require.False(t, resp.Success) | |||
| require.Contains(t, resp.Error, "already being scanned") | |||
| } | |||
| func TestUserMigrationE2E_ExecuteConflict_WhenAlreadyRunning(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| prepared := prepareGrayScenarioBatch(t, env) | |||
| setBatchStatus(t, env.CNDBPath, prepared.BatchID, model.UserMigrationBatchStatusRunning) | |||
| var resp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), | |||
| nil, | |||
| 409, | |||
| &resp, | |||
| )) | |||
| require.False(t, resp.Success) | |||
| require.Contains(t, resp.Error, "already running") | |||
| } | |||
| func TestUserMigrationE2E_VerifyQuotaMismatchDetected(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| result := runFullFlow(t, env) | |||
| convertRemoteUserToSynced(t, env.Cluster.OV.BaseURL, helpers.OVMergeUserID, helpers.CNMergeTargetUserID, 199) | |||
| verifyEntries, err := helpers.VerifyBatch(env.CNClient, result.Prepared.BatchID) | |||
| require.NoError(t, err) | |||
| verifyBySource := make(map[int]helpers.VerifyEntry, len(verifyEntries)) | |||
| for _, entry := range verifyEntries { | |||
| verifyBySource[entry.SourceUserID] = entry | |||
| } | |||
| require.NotNil(t, verifyBySource[helpers.OVMergeUserID].Result) | |||
| require.False(t, verifyBySource[helpers.OVMergeUserID].Result.QuotaMatched) | |||
| require.True(t, verifyBySource[helpers.OVCreateUserID].Result.QuotaMatched) | |||
| require.True(t, verifyBySource[helpers.OVSoftAffUserID].Result.QuotaMatched) | |||
| } | |||
| func TestUserMigrationE2E_SkipNeverConvertsRemote(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| prepared := prepareGrayScenarioBatch(t, env) | |||
| resolveItem(t, env, prepared.Items[helpers.OVCreateUserID].Id, model.UserMigrationStrategySkip, 0) | |||
| readyDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusReady, 10*time.Second) | |||
| require.NoError(t, err) | |||
| var executeResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), | |||
| nil, | |||
| 202, | |||
| &executeResp, | |||
| )) | |||
| require.True(t, executeResp.Success) | |||
| completedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second) | |||
| require.NoError(t, err) | |||
| items := indexItemsBySourceUserID(completedDetail.Items) | |||
| require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVCreateUserID].Status) | |||
| require.EqualValues(t, 0, helpers.CountUsersByEmail(t, env.CNDBPath, helpers.EmailOVCreate)) | |||
| remoteUser, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVCreateUserID) | |||
| require.NoError(t, err) | |||
| require.NotEqual(t, common.UserSourceSynced, remoteUser.Source) | |||
| require.Equal(t, readyDetail.Items[0].BatchId, items[helpers.OVCreateUserID].BatchId) | |||
| } | |||
| func TestUserMigrationE2E_ExplicitIDs_OnlySelectedUsersScanned(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| batchID := createBatchWithSelection(t, env, "explicit-selected", []int{ | |||
| helpers.OVCreateUserID, | |||
| helpers.OVMergeUserID, | |||
| }) | |||
| var scanResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), | |||
| nil, | |||
| 202, | |||
| &scanResp, | |||
| )) | |||
| require.True(t, scanResp.Success) | |||
| detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusReady, 20*time.Second) | |||
| require.NoError(t, err) | |||
| require.EqualValues(t, 2, detail.Total) | |||
| require.Equal(t, 2, detail.Batch.TotalUsers) | |||
| require.Equal(t, user_migration.SelectionModeExplicitIDs, detail.Batch.SelectionMode) | |||
| require.Equal(t, 2, detail.Batch.RequestedUserCount) | |||
| items := indexItemsBySourceUserID(detail.Items) | |||
| require.Len(t, items, 2) | |||
| require.Contains(t, items, helpers.OVCreateUserID) | |||
| require.Contains(t, items, helpers.OVMergeUserID) | |||
| require.NotContains(t, items, helpers.OVHardSyncedUserID) | |||
| } | |||
| func TestUserMigrationE2E_ExplicitIDs_SelectionSummaryRecorded(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| batchID := createBatchWithSelection(t, env, "explicit-summary", []int{ | |||
| helpers.OVCreateUserID, | |||
| helpers.OVSyncedCopyUserID, | |||
| 1, | |||
| 19999999, | |||
| }) | |||
| var scanResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/scan", batchID), | |||
| nil, | |||
| 202, | |||
| &scanResp, | |||
| )) | |||
| require.True(t, scanResp.Success) | |||
| detail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusReady, 20*time.Second) | |||
| require.NoError(t, err) | |||
| require.Equal(t, 4, detail.Batch.RequestedUserCount) | |||
| require.Equal(t, 1, detail.Batch.MatchedUserCount) | |||
| require.Equal(t, 3, detail.Batch.ExcludedUserCount) | |||
| require.EqualValues(t, 1, detail.Total) | |||
| var summary user_migration.SelectionSummary | |||
| require.NoError(t, common.UnmarshalJsonStr(detail.Batch.SelectionSummary, &summary)) | |||
| require.Equal(t, 4, summary.Requested) | |||
| require.Equal(t, 1, summary.Matched) | |||
| require.Len(t, summary.Excluded, 3) | |||
| reasonsByID := make(map[int]string, len(summary.Excluded)) | |||
| for _, item := range summary.Excluded { | |||
| reasonsByID[item.UserID] = item.Reason | |||
| } | |||
| require.Equal(t, "already_synced", reasonsByID[helpers.OVSyncedCopyUserID]) | |||
| require.Equal(t, "root_user_not_migratable", reasonsByID[1]) | |||
| require.Equal(t, "not_found", reasonsByID[19999999]) | |||
| } | |||
| func TestUserMigrationE2E_ExplicitIDs_ActiveBatchOverlapRejected(t *testing.T) { | |||
| env := newEnvironment(t) | |||
| firstBatchID := createBatchWithSelection(t, env, "explicit-overlap-first", []int{ | |||
| helpers.OVCreateUserID, | |||
| }) | |||
| require.NotZero(t, firstBatchID) | |||
| var resp struct { | |||
| Success bool `json:"success"` | |||
| Error string `json:"error"` | |||
| Conflicts []struct { | |||
| SourceUserID int `json:"source_user_id"` | |||
| BatchID int `json:"batch_id"` | |||
| BatchStatus string `json:"batch_status"` | |||
| } `json:"conflicts"` | |||
| } | |||
| require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", map[string]any{ | |||
| "name": "explicit-overlap-second", | |||
| "source_region": "ov", | |||
| "target_region": "cn", | |||
| "selection_mode": "explicit_ids", | |||
| "source_user_ids": []int{helpers.OVCreateUserID, helpers.OVMergeUserID}, | |||
| }, 409, &resp)) | |||
| require.False(t, resp.Success) | |||
| require.Equal(t, "some selected users already exist in active migration batches", resp.Error) | |||
| require.Len(t, resp.Conflicts, 1) | |||
| require.Equal(t, helpers.OVCreateUserID, resp.Conflicts[0].SourceUserID) | |||
| require.Equal(t, firstBatchID, resp.Conflicts[0].BatchID) | |||
| require.Equal(t, model.UserMigrationBatchStatusDraft, resp.Conflicts[0].BatchStatus) | |||
| } | |||
| func newEnvironment(t *testing.T) *helpers.Environment { | |||
| t.Helper() | |||
| env, err := helpers.PrepareEnvironment(repoRoot, artifactRoot, binaryPath, t.Name()) | |||
| require.NoError(t, err) | |||
| t.Cleanup(func() { | |||
| require.NoError(t, env.Close()) | |||
| }) | |||
| return env | |||
| } | |||
| func runFullFlow(t *testing.T, env *helpers.Environment) *executedBatch { | |||
| t.Helper() | |||
| prepared := prepareGrayScenarioBatch(t, env) | |||
| var executeResp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d/execute", prepared.BatchID), | |||
| nil, | |||
| 202, | |||
| &executeResp, | |||
| )) | |||
| require.True(t, executeResp.Success) | |||
| completedDetail, err := helpers.WaitBatchStatus(env.CNClient, prepared.BatchID, model.UserMigrationBatchStatusCompleted, 25*time.Second) | |||
| require.NoError(t, err) | |||
| require.EqualValues(t, 5, completedDetail.Total) | |||
| items := indexItemsBySourceUserID(completedDetail.Items) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVCreateUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVMergeUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusMigrated, items[helpers.OVSoftAffUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVHardSyncedUserID].Status) | |||
| require.Equal(t, model.UserMigrationItemStatusSkipped, items[helpers.OVUsernameConflictUserID].Status) | |||
| createdUser := helpers.AssertCNUserExists(t, env.CNDBPath, helpers.UsernameOVCreate) | |||
| require.Equal(t, 100, createdUser.Quota) | |||
| helpers.AssertQuota(t, env.CNDBPath, helpers.CNMergeTargetUserID, 700) | |||
| softAffUser := helpers.AssertCNUserExists(t, env.CNDBPath, helpers.UsernameOVSoftAff) | |||
| require.Equal(t, 150, softAffUser.Quota) | |||
| require.NotEqual(t, helpers.AffCodeCNSoftAffOwner, softAffUser.AffCode) | |||
| helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVCreateUserID, createdUser.Id, 100) | |||
| helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVMergeUserID, helpers.CNMergeTargetUserID, 700) | |||
| helpers.AssertOVUserSynced(t, env.Cluster.OV.BaseURL, helpers.OVSoftAffUserID, softAffUser.Id, 150) | |||
| hardSnapshot, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVHardSyncedUserID) | |||
| require.NoError(t, err) | |||
| require.NotEqual(t, common.UserSourceSynced, hardSnapshot.Source) | |||
| usernameSnapshot, err := helpers.GetRemoteUserSnapshot(env.Cluster.OV.BaseURL, helpers.OVUsernameConflictUserID) | |||
| require.NoError(t, err) | |||
| require.NotEqual(t, common.UserSourceSynced, usernameSnapshot.Source) | |||
| verifyEntries, err := helpers.VerifyBatch(env.CNClient, prepared.BatchID) | |||
| require.NoError(t, err) | |||
| require.Len(t, verifyEntries, 3) | |||
| verifyBySource := make(map[int]helpers.VerifyEntry, len(verifyEntries)) | |||
| for _, entry := range verifyEntries { | |||
| require.Empty(t, entry.Error) | |||
| require.NotNil(t, entry.Result) | |||
| require.True(t, entry.Result.TargetUserExists) | |||
| require.True(t, entry.Result.RemoteConverted) | |||
| require.True(t, entry.Result.QuotaMatched) | |||
| verifyBySource[entry.SourceUserID] = entry | |||
| } | |||
| return &executedBatch{ | |||
| Prepared: prepared, | |||
| Detail: completedDetail, | |||
| CreatedUser: createdUser, | |||
| SoftAffUser: softAffUser, | |||
| VerifyResult: verifyBySource, | |||
| } | |||
| } | |||
| func prepareGrayScenarioBatch(t *testing.T, env *helpers.Environment) *preparedBatch { | |||
| t.Helper() | |||
| scanned := scanGrayScenarioBatch(t, env, "gray-scenarios") | |||
| scannedItems := scanned.Items | |||
| require.Equal(t, model.UserMigrationStrategyCreateNew, scannedItems[helpers.OVCreateUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVCreateUserID].Status) | |||
| require.Equal(t, model.UserMigrationStrategyMergeExisting, scannedItems[helpers.OVMergeUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVMergeUserID].Status) | |||
| require.Equal(t, helpers.CNMergeTargetUserID, scannedItems[helpers.OVMergeUserID].TargetUserId) | |||
| require.Equal(t, model.UserMigrationItemStatusConflict, scannedItems[helpers.OVHardSyncedUserID].Status) | |||
| require.Contains(t, decodeFlags(t, scannedItems[helpers.OVHardSyncedUserID].ConflictFlags), "cn_already_synced_to_ov") | |||
| require.Equal(t, model.UserMigrationItemStatusConflict, scannedItems[helpers.OVUsernameConflictUserID].Status) | |||
| require.Contains(t, decodeFlags(t, scannedItems[helpers.OVUsernameConflictUserID].ConflictFlags), "username") | |||
| require.Equal(t, model.UserMigrationStrategyCreateNew, scannedItems[helpers.OVSoftAffUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusReady, scannedItems[helpers.OVSoftAffUserID].Status) | |||
| require.Contains(t, decodeFlags(t, scannedItems[helpers.OVSoftAffUserID].ConflictFlags), "aff_code") | |||
| resolveItem(t, env, scannedItems[helpers.OVHardSyncedUserID].Id, model.UserMigrationStrategySkip, 0) | |||
| resolveItem(t, env, scannedItems[helpers.OVUsernameConflictUserID].Id, model.UserMigrationStrategySkip, 0) | |||
| readyDetail, err := helpers.WaitBatchStatus(env.CNClient, scanned.BatchID, model.UserMigrationBatchStatusReady, 10*time.Second) | |||
| require.NoError(t, err) | |||
| readyItems := indexItemsBySourceUserID(readyDetail.Items) | |||
| require.Equal(t, model.UserMigrationStrategySkip, readyItems[helpers.OVHardSyncedUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusReady, readyItems[helpers.OVHardSyncedUserID].Status) | |||
| require.Equal(t, model.UserMigrationStrategySkip, readyItems[helpers.OVUsernameConflictUserID].ResolutionStrategy) | |||
| require.Equal(t, model.UserMigrationItemStatusReady, readyItems[helpers.OVUsernameConflictUserID].Status) | |||
| return &preparedBatch{ | |||
| BatchID: scanned.BatchID, | |||
| Detail: readyDetail, | |||
| Items: readyItems, | |||
| } | |||
| } | |||
| func scanGrayScenarioBatch(t *testing.T, env *helpers.Environment, name string) *preparedBatch { | |||
| t.Helper() | |||
| batchID := createBatch(t, env, name) | |||
| 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) | |||
| scannedDetail, err := helpers.WaitBatchStatus(env.CNClient, batchID, model.UserMigrationBatchStatusScanned, 20*time.Second) | |||
| require.NoError(t, err) | |||
| require.EqualValues(t, 5, scannedDetail.Total) | |||
| scannedItems := indexItemsBySourceUserID(scannedDetail.Items) | |||
| require.Len(t, scannedItems, 5) | |||
| return &preparedBatch{ | |||
| BatchID: batchID, | |||
| Detail: scannedDetail, | |||
| Items: scannedItems, | |||
| } | |||
| } | |||
| func createBatch(t *testing.T, env *helpers.Environment, name string) int { | |||
| t.Helper() | |||
| return createBatchWithSelection(t, env, name, nil) | |||
| } | |||
| func createBatchWithSelection(t *testing.T, env *helpers.Environment, name string, sourceUserIDs []int) int { | |||
| t.Helper() | |||
| payload := map[string]any{ | |||
| "name": name, | |||
| "source_region": "ov", | |||
| "target_region": "cn", | |||
| } | |||
| if len(sourceUserIDs) > 0 { | |||
| payload["selection_mode"] = "explicit_ids" | |||
| payload["source_user_ids"] = sourceUserIDs | |||
| } | |||
| var resp createBatchResponse | |||
| require.NoError(t, env.CNClient.PostJSON("/api/user-migrations/batches", payload, 200, &resp)) | |||
| require.True(t, resp.Success) | |||
| require.NotZero(t, resp.Data.Id) | |||
| return resp.Data.Id | |||
| } | |||
| func resolveItem(t *testing.T, env *helpers.Environment, itemID int, strategy string, targetUserID int) { | |||
| t.Helper() | |||
| var resp simpleResponse | |||
| require.NoError(t, env.CNClient.PostJSON( | |||
| fmt.Sprintf("/api/user-migrations/items/%d/resolve", itemID), | |||
| map[string]any{ | |||
| "resolution_strategy": strategy, | |||
| "target_user_id": targetUserID, | |||
| }, | |||
| 200, | |||
| &resp, | |||
| )) | |||
| require.True(t, resp.Success) | |||
| } | |||
| func renameOVUser(t *testing.T, env *helpers.Environment, userID int, newUsername string) { | |||
| t.Helper() | |||
| var getResp adminUserResponse | |||
| require.NoError(t, env.OVClient.GetJSON(fmt.Sprintf("/api/user/%d", userID), 200, &getResp)) | |||
| require.True(t, getResp.Success) | |||
| var updateResp simpleResponse | |||
| require.NoError(t, env.OVClient.PutJSON("/api/user", map[string]any{ | |||
| "id": getResp.Data.Id, | |||
| "username": newUsername, | |||
| "display_name": getResp.Data.DisplayName, | |||
| "group": getResp.Data.Group, | |||
| "quota": getResp.Data.Quota, | |||
| "remark": getResp.Data.Remark, | |||
| "capture_relay": getResp.Data.CaptureRelay, | |||
| }, 200, &updateResp)) | |||
| require.True(t, updateResp.Success) | |||
| } | |||
| func createAndLoginManagedUser(t *testing.T, baseURL string, rootClient *helpers.APIClient, username, password string, role int) *helpers.APIClient { | |||
| t.Helper() | |||
| var resp simpleResponse | |||
| require.NoError(t, rootClient.PostJSON("/api/user/", map[string]any{ | |||
| "username": username, | |||
| "password": password, | |||
| "display_name": username, | |||
| "role": role, | |||
| }, 200, &resp)) | |||
| require.True(t, resp.Success) | |||
| client, err := helpers.NewAPIClient(baseURL) | |||
| require.NoError(t, err) | |||
| require.NoError(t, helpers.Login(client, username, password)) | |||
| return client | |||
| } | |||
| func createManagedUsers(t *testing.T, client *helpers.APIClient, prefix string, count int, role int) { | |||
| t.Helper() | |||
| for i := 0; i < count; i++ { | |||
| username := prefix | |||
| email := fmt.Sprintf("%s@example.com", prefix) | |||
| displayName := prefix | |||
| if count > 1 { | |||
| username = fmt.Sprintf("%s-%03d", prefix, i) | |||
| email = fmt.Sprintf("%s-%03d@example.com", prefix, i) | |||
| displayName = fmt.Sprintf("%s %03d", prefix, i) | |||
| } | |||
| var resp simpleResponse | |||
| require.NoError(t, client.PostJSON("/api/user/", map[string]any{ | |||
| "username": username, | |||
| "password": helpers.DefaultRootPassword, | |||
| "display_name": displayName, | |||
| "email": email, | |||
| "role": role, | |||
| }, 200, &resp)) | |||
| require.True(t, resp.Success) | |||
| } | |||
| } | |||
| func forceRetryableState(t *testing.T, dbPath string, batchID int, itemIDs []int) { | |||
| t.Helper() | |||
| db, err := helpers.OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, helpers.CloseSQLite(db)) | |||
| }() | |||
| require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID). | |||
| Update("status", model.UserMigrationBatchStatusFailed).Error) | |||
| require.NoError(t, db.Model(&model.UserMigrationItem{}).Where("id IN ?", itemIDs). | |||
| Updates(map[string]any{ | |||
| "status": model.UserMigrationItemStatusFailed, | |||
| "error_message": "forced retry for e2e", | |||
| }).Error) | |||
| } | |||
| func setBatchStatus(t *testing.T, dbPath string, batchID int, status string) { | |||
| t.Helper() | |||
| db, err := helpers.OpenSQLite(dbPath) | |||
| require.NoError(t, err) | |||
| defer func() { | |||
| require.NoError(t, helpers.CloseSQLite(db)) | |||
| }() | |||
| require.NoError(t, db.Model(&model.UserMigrationBatch{}).Where("id = ?", batchID).Update("status", status).Error) | |||
| } | |||
| func getBatchPage(t *testing.T, client *helpers.APIClient, batchID, page, pageSize int) *batchPageResponse { | |||
| t.Helper() | |||
| var resp batchPageResponse | |||
| require.NoError(t, client.GetJSON( | |||
| fmt.Sprintf("/api/user-migrations/batches/%d?page=%d&page_size=%d", batchID, page, pageSize), | |||
| 200, | |||
| &resp, | |||
| )) | |||
| require.True(t, resp.Success) | |||
| return &resp | |||
| } | |||
| func convertRemoteUserToSynced(t *testing.T, ovBaseURL string, sourceUserID, remoteUserID, syncedQuota int) { | |||
| t.Helper() | |||
| client, err := helpers.NewAPIClient(ovBaseURL) | |||
| require.NoError(t, err) | |||
| var resp simpleResponse | |||
| require.NoError(t, client.DoJSONWithHeaders( | |||
| "POST", | |||
| fmt.Sprintf("/api/internal/migration/users/%d/convert-to-synced", sourceUserID), | |||
| map[string]any{ | |||
| "remote_user_id": remoteUserID, | |||
| "synced_quota": syncedQuota, | |||
| }, | |||
| 200, | |||
| map[string]string{ | |||
| "X-Sync-API-Key": helpers.DefaultSyncAPIKey, | |||
| "X-Sync-Node": "e2e-test", | |||
| }, | |||
| &resp, | |||
| )) | |||
| require.True(t, resp.Success) | |||
| } | |||
| func indexItemsBySourceUserID(items []model.UserMigrationItem) map[int]*model.UserMigrationItem { | |||
| indexed := make(map[int]*model.UserMigrationItem, len(items)) | |||
| for i := range items { | |||
| indexed[items[i].SourceUserId] = &items[i] | |||
| } | |||
| return indexed | |||
| } | |||
| func decodeFlags(t *testing.T, flagsJSON string) []string { | |||
| t.Helper() | |||
| var flags []string | |||
| require.NoError(t, common.UnmarshalJsonStr(flagsJSON, &flags)) | |||
| return flags | |||
| } | |||
| @@ -0,0 +1,60 @@ | |||
| import { expect, test as base } from '@playwright/test'; | |||
| import { readFile } from 'node:fs/promises'; | |||
| import path from 'node:path'; | |||
| import { fileURLToPath } from 'node:url'; | |||
| const currentDir = path.dirname(fileURLToPath(import.meta.url)); | |||
| export const webRoot = path.resolve(currentDir, '..', '..'); | |||
| export const repoRoot = path.resolve(webRoot, '..'); | |||
| export const artifactRoot = path.join( | |||
| repoRoot, | |||
| 'test-artifacts', | |||
| 'user-migration-e2e', | |||
| ); | |||
| export const playwrightArtifactRoot = path.join(artifactRoot, 'playwright'); | |||
| export const authDir = path.join(playwrightArtifactRoot, 'auth'); | |||
| export const metadataPath = path.join(playwrightArtifactRoot, 'metadata.json'); | |||
| export const readyMarkerPath = path.join( | |||
| playwrightArtifactRoot, | |||
| 'server-ready.json', | |||
| ); | |||
| export const serverLogPath = path.join(playwrightArtifactRoot, 'ui-server.log'); | |||
| export const serverBinaryDir = path.join(playwrightArtifactRoot, 'bin'); | |||
| export const serverBinaryPath = path.join( | |||
| serverBinaryDir, | |||
| process.platform === 'win32' | |||
| ? 'user-migration-ui-server.exe' | |||
| : 'user-migration-ui-server', | |||
| ); | |||
| export const rootStorageStatePath = path.join(authDir, 'root-storage.json'); | |||
| export const userStorageStatePath = path.join(authDir, 'user-storage.json'); | |||
| export const adminStorageStatePath = userStorageStatePath; | |||
| export const clusterRunDir = path.join(artifactRoot, 'runs', 'playwright-ui'); | |||
| export const cnBaseURL = 'http://127.0.0.1:3100'; | |||
| export const ovBaseURL = 'http://127.0.0.1:3101'; | |||
| export const rootUsername = 'root'; | |||
| export const rootPassword = 'Password123!'; | |||
| export const adminUsername = 'playwright-admin'; | |||
| export const adminPassword = 'Password123!'; | |||
| export type ClusterMetadata = { | |||
| serverPid: number; | |||
| serverBinaryPath: string; | |||
| serverLogPath: string; | |||
| runDir: string; | |||
| baseURL: string; | |||
| ovBaseURL: string; | |||
| }; | |||
| export async function readClusterMetadata(): Promise<ClusterMetadata> { | |||
| const raw = await readFile(metadataPath, 'utf8'); | |||
| return JSON.parse(raw) as ClusterMetadata; | |||
| } | |||
| export const test = base.extend<{ cluster: ClusterMetadata }>({ | |||
| cluster: async ({}, use) => { | |||
| await use(await readClusterMetadata()); | |||
| }, | |||
| }); | |||
| export { expect }; | |||
| @@ -0,0 +1,306 @@ | |||
| import { request } from '@playwright/test'; | |||
| import { spawn } from 'node:child_process'; | |||
| import { access, open, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; | |||
| import { setTimeout as delay } from 'node:timers/promises'; | |||
| import { | |||
| adminPassword, | |||
| adminStorageStatePath, | |||
| adminUsername, | |||
| authDir, | |||
| clusterRunDir, | |||
| cnBaseURL, | |||
| metadataPath, | |||
| ovBaseURL, | |||
| playwrightArtifactRoot, | |||
| readyMarkerPath, | |||
| repoRoot, | |||
| rootPassword, | |||
| rootStorageStatePath, | |||
| rootUsername, | |||
| serverBinaryDir, | |||
| serverBinaryPath, | |||
| serverLogPath, | |||
| webRoot, | |||
| } from './fixtures/cluster'; | |||
| type LoginResponse = { | |||
| success: boolean; | |||
| message?: string; | |||
| data?: { | |||
| id: number; | |||
| username: string; | |||
| display_name: string; | |||
| role: number; | |||
| status: number; | |||
| group: string; | |||
| }; | |||
| }; | |||
| type SimpleResponse = { | |||
| success: boolean; | |||
| message?: string; | |||
| error?: string; | |||
| }; | |||
| async function runCommand(command: string, args: string[], cwd: string) { | |||
| await new Promise<void>((resolve, reject) => { | |||
| const child = spawn(command, args, { | |||
| cwd, | |||
| stdio: ['ignore', 'pipe', 'pipe'], | |||
| shell: false, | |||
| }); | |||
| let stdout = ''; | |||
| let stderr = ''; | |||
| child.stdout.on('data', (chunk) => { | |||
| stdout += chunk.toString(); | |||
| }); | |||
| child.stderr.on('data', (chunk) => { | |||
| stderr += chunk.toString(); | |||
| }); | |||
| child.on('error', reject); | |||
| child.on('close', (code) => { | |||
| if (code === 0) { | |||
| resolve(); | |||
| return; | |||
| } | |||
| reject( | |||
| new Error( | |||
| `${command} ${args.join(' ')} failed with code ${code}\n${stdout}${stderr}`, | |||
| ), | |||
| ); | |||
| }); | |||
| }); | |||
| } | |||
| async function killPreviousServer() { | |||
| try { | |||
| const raw = await readFile(metadataPath, 'utf8'); | |||
| const metadata = JSON.parse(raw) as { serverPid?: number }; | |||
| if (!metadata.serverPid) { | |||
| return; | |||
| } | |||
| await killProcessTree(metadata.serverPid); | |||
| } catch { | |||
| // ignore stale or missing metadata | |||
| } | |||
| } | |||
| async function killProcessTree(pid: number) { | |||
| if (process.platform === 'win32') { | |||
| await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot); | |||
| return; | |||
| } | |||
| process.kill(pid, 'SIGTERM'); | |||
| } | |||
| async function cleanupListenerPorts() { | |||
| if (process.platform !== 'win32') { | |||
| return; | |||
| } | |||
| await runCommand( | |||
| 'powershell', | |||
| [ | |||
| '-NoProfile', | |||
| '-Command', | |||
| "$ports = 3100,3101; " + | |||
| "Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | " + | |||
| "Where-Object { $ports -contains $_.LocalPort } | " + | |||
| 'Select-Object -ExpandProperty OwningProcess -Unique | ' + | |||
| 'ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }', | |||
| ], | |||
| repoRoot, | |||
| ); | |||
| } | |||
| async function waitForServerReady() { | |||
| const deadline = Date.now() + 180_000; | |||
| while (Date.now() < deadline) { | |||
| try { | |||
| await access(readyMarkerPath); | |||
| const response = await fetch(`${cnBaseURL}/api/status`, { | |||
| headers: { 'Cache-Control': 'no-store' }, | |||
| }); | |||
| if (response.ok) { | |||
| return; | |||
| } | |||
| } catch { | |||
| // retry | |||
| } | |||
| await delay(500); | |||
| } | |||
| throw new Error( | |||
| `user migration UI server did not become ready in time, see ${serverLogPath}`, | |||
| ); | |||
| } | |||
| async function login(username: string, password: string) { | |||
| const api = await request.newContext({ | |||
| baseURL: cnBaseURL, | |||
| extraHTTPHeaders: { | |||
| 'Cache-Control': 'no-store', | |||
| }, | |||
| }); | |||
| const response = await api.post('/api/user/login', { | |||
| data: { | |||
| username, | |||
| password, | |||
| }, | |||
| }); | |||
| if (!response.ok()) { | |||
| throw new Error(`login request for ${username} failed: ${response.status()}`); | |||
| } | |||
| const payload = (await response.json()) as LoginResponse; | |||
| if (!payload.success || !payload.data) { | |||
| throw new Error(`login failed for ${username}: ${payload.message ?? 'unknown error'}`); | |||
| } | |||
| return { api, user: payload.data }; | |||
| } | |||
| async function loginWithRetry( | |||
| username: string, | |||
| password: string, | |||
| timeoutMs = 60_000, | |||
| ) { | |||
| const deadline = Date.now() + timeoutMs; | |||
| let lastError = `login failed for ${username}`; | |||
| while (Date.now() < deadline) { | |||
| try { | |||
| return await login(username, password); | |||
| } catch (error) { | |||
| lastError = error instanceof Error ? error.message : String(error); | |||
| await delay(500); | |||
| } | |||
| } | |||
| throw new Error(lastError); | |||
| } | |||
| async function writeStorageState( | |||
| api: Awaited<ReturnType<typeof request.newContext>>, | |||
| user: LoginResponse['data'], | |||
| outputPath: string, | |||
| ) { | |||
| const state = await api.storageState(); | |||
| await writeFile( | |||
| outputPath, | |||
| JSON.stringify( | |||
| { | |||
| cookies: state.cookies, | |||
| origins: [ | |||
| { | |||
| origin: cnBaseURL, | |||
| localStorage: [ | |||
| { | |||
| name: 'user', | |||
| value: JSON.stringify(user), | |||
| }, | |||
| ], | |||
| }, | |||
| ], | |||
| }, | |||
| null, | |||
| 2, | |||
| ), | |||
| 'utf8', | |||
| ); | |||
| } | |||
| export default async function globalSetup() { | |||
| await killPreviousServer(); | |||
| await cleanupListenerPorts(); | |||
| await rm(playwrightArtifactRoot, { recursive: true, force: true }); | |||
| await mkdir(authDir, { recursive: true }); | |||
| await mkdir(serverBinaryDir, { recursive: true }); | |||
| await runCommand('bun', ['run', 'build'], webRoot); | |||
| await runCommand( | |||
| 'go', | |||
| ['build', '-o', serverBinaryPath, './test/e2e/cmd/user_migration_ui_server'], | |||
| repoRoot, | |||
| ); | |||
| const logFile = await open(serverLogPath, 'a'); | |||
| const child = spawn(serverBinaryPath, [], { | |||
| cwd: repoRoot, | |||
| stdio: ['ignore', logFile.fd, logFile.fd], | |||
| }); | |||
| await logFile.close(); | |||
| await writeFile( | |||
| metadataPath, | |||
| JSON.stringify( | |||
| { | |||
| serverPid: child.pid, | |||
| serverBinaryPath, | |||
| serverLogPath, | |||
| runDir: clusterRunDir, | |||
| baseURL: cnBaseURL, | |||
| ovBaseURL, | |||
| }, | |||
| null, | |||
| 2, | |||
| ), | |||
| 'utf8', | |||
| ); | |||
| await waitForServerReady(); | |||
| const rootSession = await loginWithRetry(rootUsername, rootPassword); | |||
| try { | |||
| const createAdminResponse = await rootSession.api.post('/api/user/', { | |||
| headers: { | |||
| 'New-API-User': String(rootSession.user.id), | |||
| }, | |||
| data: { | |||
| username: adminUsername, | |||
| password: adminPassword, | |||
| display_name: 'Playwright Admin', | |||
| role: 10, | |||
| }, | |||
| }); | |||
| if (!createAdminResponse.ok()) { | |||
| throw new Error( | |||
| `create admin request failed: ${createAdminResponse.status()} ${createAdminResponse.statusText()}`, | |||
| ); | |||
| } | |||
| const createAdminPayload = (await createAdminResponse.json()) as SimpleResponse; | |||
| const createAdminMessage = | |||
| createAdminPayload.message ?? createAdminPayload.error ?? 'unknown error'; | |||
| const adminAlreadyExists = | |||
| createAdminMessage.includes('UNIQUE constraint failed: users.username') || | |||
| createAdminMessage.includes('Duplicate entry'); | |||
| if (!createAdminPayload.success && !adminAlreadyExists) { | |||
| throw new Error( | |||
| `create admin failed: ${createAdminMessage}`, | |||
| ); | |||
| } | |||
| await writeStorageState( | |||
| rootSession.api, | |||
| rootSession.user, | |||
| rootStorageStatePath, | |||
| ); | |||
| } finally { | |||
| await rootSession.api.dispose(); | |||
| } | |||
| const adminSession = await loginWithRetry(adminUsername, adminPassword, 20_000); | |||
| try { | |||
| await writeStorageState( | |||
| adminSession.api, | |||
| adminSession.user, | |||
| adminStorageStatePath, | |||
| ); | |||
| } finally { | |||
| await adminSession.api.dispose(); | |||
| } | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| import { rm } from 'node:fs/promises'; | |||
| import { metadataPath, playwrightArtifactRoot, repoRoot } from './fixtures/cluster'; | |||
| import { readClusterMetadata } from './fixtures/cluster'; | |||
| import { spawn } from 'node:child_process'; | |||
| async function runCommand(command: string, args: string[], cwd: string) { | |||
| await new Promise<void>((resolve, reject) => { | |||
| const child = spawn(command, args, { | |||
| cwd, | |||
| stdio: 'ignore', | |||
| shell: false, | |||
| }); | |||
| child.on('error', reject); | |||
| child.on('close', (code) => { | |||
| if (code === 0) { | |||
| resolve(); | |||
| return; | |||
| } | |||
| reject(new Error(`${command} ${args.join(' ')} failed with code ${code}`)); | |||
| }); | |||
| }); | |||
| } | |||
| async function killProcessTree(pid: number) { | |||
| if (process.platform === 'win32') { | |||
| await runCommand('taskkill', ['/PID', String(pid), '/T', '/F'], repoRoot); | |||
| return; | |||
| } | |||
| process.kill(pid, 'SIGTERM'); | |||
| } | |||
| export default async function globalTeardown() { | |||
| try { | |||
| const metadata = await readClusterMetadata(); | |||
| await killProcessTree(metadata.serverPid); | |||
| } catch { | |||
| // ignore missing metadata or already-exited processes | |||
| } | |||
| await rm(metadataPath, { force: true }); | |||
| await rm(playwrightArtifactRoot, { recursive: false, force: false }).catch(() => { | |||
| // keep artifacts when directory is not empty | |||
| }); | |||
| } | |||
| @@ -0,0 +1,235 @@ | |||
| import type { Page } from '@playwright/test'; | |||
| import { | |||
| adminStorageStatePath, | |||
| expect, | |||
| rootStorageStatePath, | |||
| test, | |||
| } from './fixtures/cluster'; | |||
| function nextBatchName(prefix: string) { | |||
| return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`; | |||
| } | |||
| async function gotoUserPage(page: Page) { | |||
| await page.goto('/console/user'); | |||
| await expect(page.getByTestId('user-page')).toBeVisible(); | |||
| } | |||
| async function openMigrationTab(page: Page) { | |||
| const migrationTab = page.getByTestId('user-migration-tab'); | |||
| await expect(migrationTab).toBeVisible(); | |||
| await migrationTab.click(); | |||
| await expect(page.getByTestId('user-migration-center')).toBeVisible(); | |||
| } | |||
| 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; | |||
| } | |||
| await row.locator('.semi-checkbox').first().click(); | |||
| } | |||
| async function createBatch( | |||
| page: Page, | |||
| batchName: string, | |||
| selectedUserIds: number[] = [], | |||
| ) { | |||
| await page.getByTestId('migration-create-batch-button').click(); | |||
| await expect( | |||
| page.getByTestId('migration-selection-mode-group'), | |||
| ).toBeVisible(); | |||
| if (selectedUserIds.length > 0) { | |||
| await page.getByTestId('migration-selection-mode-explicit').click(); | |||
| await expect(page.getByTestId('migration-candidate-table')).toBeVisible(); | |||
| for (const userId of selectedUserIds) { | |||
| await selectCandidateUser(page, userId); | |||
| } | |||
| } | |||
| await page.getByTestId('migration-create-batch-submit').click(); | |||
| const nameField = page.getByTestId('migration-batch-name-field'); | |||
| await expect(nameField).toBeVisible(); | |||
| await nameField.fill(batchName); | |||
| await page.getByTestId('migration-create-batch-submit').click(); | |||
| await expect(page.getByTestId('migration-batch-table')).toContainText( | |||
| batchName, | |||
| ); | |||
| await page.getByTestId('migration-batch-table').getByText(batchName).click(); | |||
| await expect(page.getByTestId('migration-execution-panel')).toBeVisible(); | |||
| await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | |||
| } | |||
| test.describe('User Migration Access', () => { | |||
| test.use({ storageState: adminStorageStatePath }); | |||
| test('non-root admin cannot see migration tab', async ({ page }) => { | |||
| await gotoUserPage(page); | |||
| await expect(page.getByTestId('user-migration-tab')).toHaveCount(0); | |||
| }); | |||
| }); | |||
| test.describe.serial('User Migration Smoke', () => { | |||
| test.use({ storageState: rootStorageStatePath }); | |||
| test('root can open migration center and create a batch', async ({ | |||
| page, | |||
| }) => { | |||
| const batchName = nextBatchName('ui-create'); | |||
| await gotoUserPage(page); | |||
| await openMigrationTab(page); | |||
| await createBatch(page, batchName); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'draft', | |||
| ); | |||
| }); | |||
| 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 ({ | |||
| page, | |||
| }) => { | |||
| const batchName = nextBatchName('ui-flow'); | |||
| await gotoUserPage(page); | |||
| await openMigrationTab(page); | |||
| await createBatch(page, batchName); | |||
| await page.getByTestId('migration-scan-button').click(); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| /scanning|scanned/, | |||
| ); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'scanned', | |||
| { | |||
| timeout: 25_000, | |||
| }, | |||
| ); | |||
| await expect(page.getByTestId('migration-conflict-table')).toBeVisible(); | |||
| await page.getByTestId('migration-resolve-skip-10000003').click(); | |||
| await page.getByTestId('migration-resolve-skip-10000004').click(); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'ready', | |||
| ); | |||
| await page.getByTestId('migration-execute-button').click(); | |||
| await expect(page.getByTestId('migration-execute-confirm')).toBeVisible(); | |||
| await page.getByTestId('migration-execute-confirm').click(); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| /running|completed/, | |||
| ); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'completed', | |||
| { | |||
| timeout: 25_000, | |||
| }, | |||
| ); | |||
| 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'); | |||
| }); | |||
| test('page reload keeps batch list and detail loading available', async ({ | |||
| page, | |||
| }) => { | |||
| const batchName = nextBatchName('ui-refresh'); | |||
| await gotoUserPage(page); | |||
| await openMigrationTab(page); | |||
| await createBatch(page, batchName); | |||
| await page.getByTestId('migration-scan-button').click(); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'scanned', | |||
| { | |||
| timeout: 25_000, | |||
| }, | |||
| ); | |||
| await page.reload(); | |||
| await gotoUserPage(page); | |||
| await openMigrationTab(page); | |||
| await expect(page.getByTestId('migration-batch-table')).toContainText( | |||
| batchName, | |||
| ); | |||
| await page | |||
| .getByTestId('migration-batch-table') | |||
| .getByText(batchName) | |||
| .click(); | |||
| await expect(page.getByTestId('migration-batch-detail')).toBeVisible(); | |||
| await expect(page.getByTestId('migration-batch-status')).toHaveText( | |||
| 'scanned', | |||
| ); | |||
| }); | |||
| }); | |||
| @@ -76,6 +76,7 @@ | |||
| }, | |||
| "devDependencies": { | |||
| "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", | |||
| "@playwright/test": "^1.60.0", | |||
| "@so1ve/prettier-config": "^3.1.0", | |||
| "@vitejs/plugin-react": "^4.2.1", | |||
| "autoprefixer": "^10.4.21", | |||
| @@ -0,0 +1,35 @@ | |||
| import { defineConfig } from '@playwright/test'; | |||
| import path from 'node:path'; | |||
| import { cnBaseURL, playwrightArtifactRoot } from './e2e/fixtures/cluster'; | |||
| export default defineConfig({ | |||
| testDir: './e2e', | |||
| testMatch: '**/*.spec.ts', | |||
| fullyParallel: false, | |||
| workers: 1, | |||
| timeout: 60_000, | |||
| expect: { | |||
| timeout: 15_000, | |||
| }, | |||
| outputDir: path.join(playwrightArtifactRoot, 'test-output'), | |||
| reporter: [ | |||
| ['list'], | |||
| [ | |||
| 'html', | |||
| { | |||
| outputFolder: path.join(playwrightArtifactRoot, 'html-report'), | |||
| open: 'never', | |||
| }, | |||
| ], | |||
| ], | |||
| globalSetup: './e2e/global-setup.ts', | |||
| globalTeardown: './e2e/global-teardown.ts', | |||
| use: { | |||
| baseURL: cnBaseURL, | |||
| channel: 'msedge', | |||
| headless: true, | |||
| screenshot: 'only-on-failure', | |||
| trace: 'retain-on-failure', | |||
| video: 'retain-on-failure', | |||
| }, | |||
| }); | |||
| @@ -0,0 +1,140 @@ | |||
| import React from 'react'; | |||
| import { Table, Tag, Typography } from '@douyinfe/semi-ui'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| function renderFlags(text) { | |||
| try { | |||
| const value = JSON.parse(text || '[]'); | |||
| return Array.isArray(value) && value.length > 0 ? value.join(', ') : '-'; | |||
| } catch { | |||
| return text || '-'; | |||
| } | |||
| } | |||
| const statusColorMap = { | |||
| conflict: 'orange', | |||
| ready: 'green', | |||
| migrating: 'blue', | |||
| migrated: 'green', | |||
| skipped: 'grey', | |||
| failed: 'red', | |||
| }; | |||
| export default function BatchDetail({ | |||
| selectedBatch, | |||
| items, | |||
| itemsTotal, | |||
| itemsPage, | |||
| itemsPageSize, | |||
| fetchItems, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const selectionSummary = (() => { | |||
| try { | |||
| return selectedBatch?.selection_summary | |||
| ? JSON.parse(selectedBatch.selection_summary) | |||
| : null; | |||
| } catch { | |||
| return null; | |||
| } | |||
| })(); | |||
| const columns = [ | |||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | |||
| { title: t('用户名'), dataIndex: 'source_username' }, | |||
| { | |||
| title: t('邮箱'), | |||
| dataIndex: 'source_email', | |||
| render: (value) => value || '-', | |||
| }, | |||
| { title: t('海外额度'), dataIndex: 'source_quota' }, | |||
| { | |||
| title: t('冲突类型'), | |||
| dataIndex: 'conflict_flags', | |||
| render: renderFlags, | |||
| }, | |||
| { | |||
| title: t('处理策略'), | |||
| dataIndex: 'resolution_strategy', | |||
| render: (value) => value || '-', | |||
| }, | |||
| { | |||
| title: t('状态'), | |||
| dataIndex: 'status', | |||
| render: (value) => <Tag color={statusColorMap[value] || 'grey'}>{value}</Tag>, | |||
| }, | |||
| { | |||
| title: t('目标国内账号'), | |||
| dataIndex: 'target_user_id', | |||
| render: (value) => value || '-', | |||
| }, | |||
| { | |||
| title: t('错误信息'), | |||
| dataIndex: 'error_message', | |||
| render: (value) => value || '-', | |||
| }, | |||
| ]; | |||
| return ( | |||
| <div style={{ width: '100%' }} data-testid='migration-batch-detail'> | |||
| <Typography.Title | |||
| heading={5} | |||
| style={{ marginBottom: 12 }} | |||
| data-testid='migration-batch-detail-title' | |||
| > | |||
| {t('批次详情')} #{selectedBatch.id} | |||
| </Typography.Title> | |||
| {selectedBatch?.selection_mode === 'explicit_ids' && ( | |||
| <div style={{ marginBottom: 12 }} data-testid='migration-selection-summary'> | |||
| <Typography.Text> | |||
| {t('已选 {{count}} 人', { | |||
| count: selectedBatch.requested_user_count || 0, | |||
| })} | |||
| </Typography.Text> | |||
| <br /> | |||
| <Typography.Text> | |||
| {t('命中 {{count}} 人', { | |||
| count: selectedBatch.matched_user_count || 0, | |||
| })} | |||
| </Typography.Text> | |||
| <br /> | |||
| <Typography.Text> | |||
| {t('排除 {{count}} 人', { | |||
| count: selectedBatch.excluded_user_count || 0, | |||
| })} | |||
| </Typography.Text> | |||
| {selectionSummary?.excluded?.length > 0 && ( | |||
| <> | |||
| <br /> | |||
| <Typography.Text type='secondary'> | |||
| {selectionSummary.excluded | |||
| .map((item) => `${item.user_id}:${item.reason}`) | |||
| .join(', ')} | |||
| </Typography.Text> | |||
| </> | |||
| )} | |||
| </div> | |||
| )} | |||
| <Table | |||
| rowKey='id' | |||
| columns={columns} | |||
| dataSource={items} | |||
| size='small' | |||
| data-testid='migration-batch-detail-table' | |||
| pagination={{ | |||
| currentPage: itemsPage + 1, | |||
| pageSize: itemsPageSize, | |||
| total: itemsTotal, | |||
| pageSizeOpts: [20, 50, 100], | |||
| showSizeChanger: true, | |||
| onPageChange: (page) => | |||
| fetchItems(selectedBatch.id, page - 1, itemsPageSize), | |||
| onPageSizeChange: (size) => fetchItems(selectedBatch.id, 0, size), | |||
| }} | |||
| /> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,396 @@ | |||
| import React, { useEffect, useMemo, useRef, useState } from 'react'; | |||
| import { | |||
| Button, | |||
| Form, | |||
| Input, | |||
| Modal, | |||
| Radio, | |||
| RadioGroup, | |||
| Space, | |||
| Table, | |||
| Tag, | |||
| Typography, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { IconPlus, IconRefresh } from '@douyinfe/semi-icons'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { timestamp2string } from '../../../../helpers'; | |||
| const statusColorMap = { | |||
| draft: 'grey', | |||
| scanning: 'blue', | |||
| scanned: 'light-blue', | |||
| ready: 'green', | |||
| running: 'cyan', | |||
| completed: 'green', | |||
| failed: 'red', | |||
| cancelled: 'grey', | |||
| }; | |||
| export default function BatchList({ | |||
| batches, | |||
| selectedBatch, | |||
| createBatch, | |||
| refresh, | |||
| selectBatch, | |||
| clearSelectedBatch, | |||
| candidateUsers, | |||
| candidateTotal, | |||
| candidatePage, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| selectedCandidateIds, | |||
| fetchCandidateUsers, | |||
| syncCandidateSelections, | |||
| removeCandidateSelection, | |||
| clearCandidateSelection, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const [visible, setVisible] = useState(false); | |||
| const [step, setStep] = useState(0); | |||
| const [selectionMode, setSelectionMode] = useState('all'); | |||
| const formApiRef = useRef(null); | |||
| useEffect(() => { | |||
| if (visible && step === 0 && selectionMode === 'explicit_ids') { | |||
| void fetchCandidateUsers(0, candidatePageSize, candidateKeyword); | |||
| } | |||
| }, [ | |||
| visible, | |||
| step, | |||
| selectionMode, | |||
| fetchCandidateUsers, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| ]); | |||
| const columns = [ | |||
| { title: t('批次名称'), dataIndex: 'name' }, | |||
| { | |||
| title: t('迁移范围'), | |||
| dataIndex: 'requested_user_count', | |||
| render: (_, record) => | |||
| record.selection_mode === 'explicit_ids' | |||
| ? t('指定 {{count}} 人', { count: record.requested_user_count || 0 }) | |||
| : t('全部'), | |||
| }, | |||
| { | |||
| title: t('状态'), | |||
| dataIndex: 'status', | |||
| render: (status) => ( | |||
| <Tag color={statusColorMap[status] || 'grey'}>{status}</Tag> | |||
| ), | |||
| }, | |||
| { title: t('总用户数'), dataIndex: 'total_users' }, | |||
| { title: t('冲突数'), dataIndex: 'conflict_users' }, | |||
| { title: t('已迁移'), dataIndex: 'migrated_users' }, | |||
| { title: t('失败数'), dataIndex: 'failed_users' }, | |||
| { | |||
| title: t('创建时间'), | |||
| dataIndex: 'created_at', | |||
| render: (value) => (value ? timestamp2string(value) : '-'), | |||
| }, | |||
| ]; | |||
| const candidateColumns = [ | |||
| { title: t('海外用户ID'), dataIndex: 'id' }, | |||
| { title: t('用户名'), dataIndex: 'username' }, | |||
| { | |||
| title: t('邮箱'), | |||
| dataIndex: 'email', | |||
| render: (value) => value || '-', | |||
| }, | |||
| { title: t('海外额度'), dataIndex: 'quota' }, | |||
| { title: t('分组'), dataIndex: 'group' }, | |||
| ]; | |||
| const selectedCandidateSummary = useMemo( | |||
| () => | |||
| `${selectedCandidateIds.slice(0, 10).join(', ')}${ | |||
| selectedCandidateIds.length > 10 ? ' ...' : '' | |||
| }`, | |||
| [selectedCandidateIds], | |||
| ); | |||
| const closeModal = () => { | |||
| setVisible(false); | |||
| setStep(0); | |||
| setSelectionMode('all'); | |||
| clearCandidateSelection(); | |||
| }; | |||
| const handleCreate = async () => { | |||
| if (step === 0) { | |||
| if ( | |||
| selectionMode === 'explicit_ids' && | |||
| selectedCandidateIds.length === 0 | |||
| ) { | |||
| return; | |||
| } | |||
| setStep(1); | |||
| return; | |||
| } | |||
| const values = await formApiRef.current.validate(); | |||
| const success = await createBatch({ | |||
| ...values, | |||
| selection_mode: selectionMode, | |||
| source_user_ids: | |||
| selectionMode === 'explicit_ids' ? selectedCandidateIds : [], | |||
| }); | |||
| if (success) { | |||
| closeModal(); | |||
| } | |||
| }; | |||
| return ( | |||
| <div style={{ width: '100%' }} data-testid='migration-batch-list'> | |||
| <Space style={{ marginBottom: 12 }}> | |||
| <Typography.Title | |||
| heading={5} | |||
| style={{ margin: 0 }} | |||
| data-testid='migration-batch-list-title' | |||
| > | |||
| {t('迁移批次')} | |||
| </Typography.Title> | |||
| <Button | |||
| icon={<IconPlus />} | |||
| onClick={() => setVisible(true)} | |||
| data-testid='migration-create-batch-button' | |||
| > | |||
| {t('创建批次')} | |||
| </Button> | |||
| <Button | |||
| icon={<IconRefresh />} | |||
| onClick={refresh} | |||
| data-testid='migration-refresh-batches-button' | |||
| > | |||
| {t('刷新')} | |||
| </Button> | |||
| {selectedBatch && ( | |||
| <Button | |||
| onClick={clearSelectedBatch} | |||
| data-testid='migration-clear-batch-selection-button' | |||
| > | |||
| {t('清空选择')} | |||
| </Button> | |||
| )} | |||
| </Space> | |||
| <Table | |||
| rowKey='id' | |||
| columns={columns} | |||
| dataSource={batches} | |||
| pagination={false} | |||
| size='small' | |||
| data-testid='migration-batch-table' | |||
| rowSelection={{ | |||
| type: 'radio', | |||
| selectedRowKeys: selectedBatch ? [selectedBatch.id] : [], | |||
| onChange: (_, rows) => { | |||
| if (rows[0]) { | |||
| void selectBatch(rows[0]); | |||
| return; | |||
| } | |||
| clearSelectedBatch(); | |||
| }, | |||
| }} | |||
| onRow={(record) => ({ | |||
| onClick: () => { | |||
| if (selectedBatch?.id === record.id) { | |||
| clearSelectedBatch(); | |||
| return; | |||
| } | |||
| void selectBatch(record); | |||
| }, | |||
| })} | |||
| empty={t('暂无数据')} | |||
| /> | |||
| <Modal | |||
| visible={visible} | |||
| title={step === 0 ? t('候选用户') : t('创建批次')} | |||
| onCancel={closeModal} | |||
| onOk={handleCreate} | |||
| okText={step === 0 ? t('下一步') : t('创建批次')} | |||
| okButtonProps={{ | |||
| 'data-testid': 'migration-create-batch-submit', | |||
| disabled: | |||
| step === 0 && | |||
| selectionMode === 'explicit_ids' && | |||
| selectedCandidateIds.length === 0, | |||
| }} | |||
| cancelButtonProps={{ 'data-testid': 'migration-create-batch-cancel' }} | |||
| data-testid='migration-create-batch-modal' | |||
| width={step === 0 ? 960 : 640} | |||
| > | |||
| {step === 0 ? ( | |||
| <Space vertical style={{ width: '100%' }}> | |||
| <Typography.Text>{t('迁移范围')}</Typography.Text> | |||
| <RadioGroup | |||
| type='button' | |||
| buttonSize='middle' | |||
| value={selectionMode} | |||
| onChange={(event) => { | |||
| const nextValue = event?.target?.value || 'all'; | |||
| setSelectionMode(nextValue); | |||
| if (nextValue === 'explicit_ids') { | |||
| void fetchCandidateUsers( | |||
| 0, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| ); | |||
| } | |||
| }} | |||
| data-testid='migration-selection-mode-group' | |||
| > | |||
| <Radio value='all' data-testid='migration-selection-mode-all'> | |||
| {t('全部')} | |||
| </Radio> | |||
| <Radio | |||
| value='explicit_ids' | |||
| data-testid='migration-selection-mode-explicit' | |||
| > | |||
| {t('指定用户')} | |||
| </Radio> | |||
| </RadioGroup> | |||
| {selectionMode === 'explicit_ids' && ( | |||
| <> | |||
| <Input | |||
| value={candidateKeyword} | |||
| onChange={(value) => | |||
| void fetchCandidateUsers(0, candidatePageSize, value) | |||
| } | |||
| placeholder={t('按用户ID / 用户名 / 邮箱搜索')} | |||
| data-testid='migration-candidate-search-input' | |||
| /> | |||
| <Space> | |||
| <Typography.Text type='secondary'> | |||
| <span data-testid='migration-candidate-selected-count'> | |||
| {t('已选 {{count}} 人', { | |||
| count: selectedCandidateIds.length, | |||
| })} | |||
| </span> | |||
| </Typography.Text> | |||
| <Button | |||
| theme='borderless' | |||
| size='small' | |||
| disabled={selectedCandidateIds.length === 0} | |||
| onClick={clearCandidateSelection} | |||
| data-testid='migration-candidate-clear-selection-button' | |||
| > | |||
| {t('清空选择')} | |||
| </Button> | |||
| </Space> | |||
| {selectedCandidateIds.length > 0 && ( | |||
| <Space | |||
| wrap | |||
| data-testid='migration-candidate-selected-summary' | |||
| > | |||
| {selectedCandidateIds.map((id) => ( | |||
| <Tag | |||
| key={id} | |||
| closable | |||
| onClose={() => removeCandidateSelection(id)} | |||
| > | |||
| {id} | |||
| </Tag> | |||
| ))} | |||
| </Space> | |||
| )} | |||
| <Table | |||
| rowKey='id' | |||
| columns={candidateColumns} | |||
| dataSource={candidateUsers} | |||
| size='small' | |||
| data-testid='migration-candidate-table' | |||
| pagination={{ | |||
| currentPage: candidatePage + 1, | |||
| pageSize: candidatePageSize, | |||
| total: candidateTotal, | |||
| pageSizeOpts: [20, 50, 100], | |||
| showSizeChanger: true, | |||
| onPageChange: (page) => | |||
| void fetchCandidateUsers( | |||
| page - 1, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| ), | |||
| onPageSizeChange: (size) => | |||
| void fetchCandidateUsers(0, size, candidateKeyword), | |||
| }} | |||
| rowSelection={{ | |||
| type: 'checkbox', | |||
| selectedRowKeys: selectedCandidateIds, | |||
| onChange: (keys) => | |||
| syncCandidateSelections( | |||
| candidateUsers.map((user) => user.id), | |||
| keys, | |||
| ), | |||
| }} | |||
| onRow={(record) => ({ | |||
| 'data-testid': `migration-candidate-row-${record.id}`, | |||
| })} | |||
| /> | |||
| </> | |||
| )} | |||
| </Space> | |||
| ) : ( | |||
| <Form | |||
| getFormApi={(api) => { | |||
| formApiRef.current = api; | |||
| }} | |||
| initValues={{ source_region: 'ov', target_region: 'cn' }} | |||
| > | |||
| <Typography.Text | |||
| type='secondary' | |||
| data-testid='migration-selection-range' | |||
| > | |||
| {selectionMode === 'explicit_ids' | |||
| ? t('指定 {{count}} 人', { | |||
| count: selectedCandidateIds.length, | |||
| }) | |||
| : t('全部')} | |||
| </Typography.Text> | |||
| {selectionMode === 'explicit_ids' && | |||
| selectedCandidateIds.length > 0 && ( | |||
| <Typography.Paragraph | |||
| type='secondary' | |||
| data-testid='migration-selected-candidate-summary' | |||
| > | |||
| {selectedCandidateSummary} | |||
| </Typography.Paragraph> | |||
| )} | |||
| <Button | |||
| theme='borderless' | |||
| style={{ paddingLeft: 0 }} | |||
| onClick={() => setStep(0)} | |||
| data-testid='migration-back-to-candidates-button' | |||
| > | |||
| {selectionMode === 'explicit_ids' | |||
| ? t('返回选择用户') | |||
| : t('返回设置范围')} | |||
| </Button> | |||
| <Form.Input | |||
| field='name' | |||
| label={t('批次名称')} | |||
| rules={[{ required: true, message: t('请输入批次名称') }]} | |||
| data-testid='migration-batch-name-field' | |||
| /> | |||
| <Form.Input | |||
| field='source_region' | |||
| label={t('源区域')} | |||
| rules={[{ required: true, message: t('请输入源区域') }]} | |||
| data-testid='migration-batch-source-region-field' | |||
| /> | |||
| <Form.Input | |||
| field='target_region' | |||
| label={t('目标区域')} | |||
| rules={[{ required: true, message: t('请输入目标区域') }]} | |||
| data-testid='migration-batch-target-region-field' | |||
| /> | |||
| </Form> | |||
| )} | |||
| </Modal> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,99 @@ | |||
| import React, { useMemo, useState } from 'react'; | |||
| import { Button, InputNumber, Space, Table, Typography } from '@douyinfe/semi-ui'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| function formatFlags(text) { | |||
| try { | |||
| const value = JSON.parse(text || '[]'); | |||
| return Array.isArray(value) && value.length > 0 ? value.join(', ') : '-'; | |||
| } catch { | |||
| return text || '-'; | |||
| } | |||
| } | |||
| export default function ConflictResolver({ items, resolveItem }) { | |||
| const { t } = useTranslation(); | |||
| const [targets, setTargets] = useState({}); | |||
| const conflictItems = useMemo( | |||
| () => items.filter((item) => item.status === 'conflict'), | |||
| [items], | |||
| ); | |||
| const columns = [ | |||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | |||
| { title: t('用户名'), dataIndex: 'source_username' }, | |||
| { title: t('邮箱'), dataIndex: 'source_email', render: (value) => value || '-' }, | |||
| { | |||
| title: t('冲突类型'), | |||
| dataIndex: 'conflict_flags', | |||
| render: formatFlags, | |||
| }, | |||
| { | |||
| title: t('目标国内账号'), | |||
| render: (_, record) => ( | |||
| <InputNumber | |||
| min={1} | |||
| value={targets[record.id] ?? record.target_user_id ?? 0} | |||
| onChange={(value) => { | |||
| setTargets((prev) => ({ ...prev, [record.id]: value || 0 })); | |||
| }} | |||
| placeholder={t('请输入目标国内用户 ID')} | |||
| data-testid={`migration-resolve-target-${record.source_user_id}`} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('操作'), | |||
| render: (_, record) => ( | |||
| <Space> | |||
| <Button | |||
| size='small' | |||
| onClick={() => resolveItem(record.id, 'create_new')} | |||
| data-testid={`migration-resolve-create-${record.source_user_id}`} | |||
| > | |||
| {t('新建国内主账号')} | |||
| </Button> | |||
| <Button | |||
| size='small' | |||
| onClick={() => | |||
| resolveItem( | |||
| record.id, | |||
| 'merge_into_existing', | |||
| targets[record.id] || record.target_user_id || 0, | |||
| ) | |||
| } | |||
| data-testid={`migration-resolve-merge-${record.source_user_id}`} | |||
| > | |||
| {t('合并到现有国内账号')} | |||
| </Button> | |||
| <Button | |||
| size='small' | |||
| type='danger' | |||
| onClick={() => resolveItem(record.id, 'skip')} | |||
| data-testid={`migration-resolve-skip-${record.source_user_id}`} | |||
| > | |||
| {t('跳过此用户')} | |||
| </Button> | |||
| </Space> | |||
| ), | |||
| }, | |||
| ]; | |||
| return ( | |||
| <div style={{ width: '100%' }} data-testid='migration-conflict-resolver'> | |||
| <Typography.Title heading={5} style={{ marginBottom: 12 }}> | |||
| {t('冲突处理')} | |||
| </Typography.Title> | |||
| <Table | |||
| rowKey='id' | |||
| columns={columns} | |||
| dataSource={conflictItems} | |||
| pagination={false} | |||
| size='small' | |||
| empty={t('暂无数据')} | |||
| data-testid='migration-conflict-table' | |||
| /> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,126 @@ | |||
| import React from 'react'; | |||
| import { | |||
| Button, | |||
| Popconfirm, | |||
| Space, | |||
| Table, | |||
| Tag, | |||
| Typography, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| const runningStatusMap = { | |||
| scanning: 'blue', | |||
| running: 'cyan', | |||
| }; | |||
| export default function ExecutionPanel({ | |||
| selectedBatch, | |||
| scanBatch, | |||
| executeBatch, | |||
| retryBatch, | |||
| cancelBatch, | |||
| verifyBatch, | |||
| verifyResults, | |||
| }) { | |||
| const { t } = useTranslation(); | |||
| const canCancel = [ | |||
| 'draft', | |||
| 'scanned', | |||
| 'ready', | |||
| 'failed', | |||
| 'cancelled', | |||
| ].includes(selectedBatch.status); | |||
| const verifyColumns = [ | |||
| { title: t('海外用户ID'), dataIndex: 'source_user_id' }, | |||
| { title: t('目标国内账号'), dataIndex: 'target_user_id' }, | |||
| { | |||
| title: t('状态'), | |||
| render: (_, record) => ( | |||
| <Tag color={record.error ? 'red' : 'green'}> | |||
| {record.error ? t('失败数') : t('验证')} | |||
| </Tag> | |||
| ), | |||
| }, | |||
| { | |||
| title: t('错误信息'), | |||
| render: (_, record) => record.error || '-', | |||
| }, | |||
| ]; | |||
| return ( | |||
| <div style={{ width: '100%' }} data-testid='migration-execution-panel'> | |||
| <Space style={{ marginBottom: 12 }}> | |||
| <Typography.Title heading={5} style={{ margin: 0 }}> | |||
| {t('执行与验证')} | |||
| </Typography.Title> | |||
| <Tag | |||
| color={runningStatusMap[selectedBatch.status] || 'grey'} | |||
| data-testid='migration-batch-status' | |||
| > | |||
| {selectedBatch.status} | |||
| </Tag> | |||
| <Button | |||
| onClick={() => scanBatch(selectedBatch.id)} | |||
| data-testid='migration-scan-button' | |||
| > | |||
| {t('预检查')} | |||
| </Button> | |||
| <Popconfirm | |||
| title={t('执行前请确认已进入冻结窗口')} | |||
| onConfirm={() => executeBatch(selectedBatch.id)} | |||
| okButtonProps={{ 'data-testid': 'migration-execute-confirm' }} | |||
| cancelButtonProps={{ 'data-testid': 'migration-execute-cancel' }} | |||
| > | |||
| <Button | |||
| theme='solid' | |||
| type='primary' | |||
| data-testid='migration-execute-button' | |||
| > | |||
| {t('开始迁移')} | |||
| </Button> | |||
| </Popconfirm> | |||
| <Button | |||
| onClick={() => retryBatch(selectedBatch.id)} | |||
| data-testid='migration-retry-button' | |||
| > | |||
| {t('重试失败项')} | |||
| </Button> | |||
| <Popconfirm | |||
| title={t('确认删除该批次?')} | |||
| content={t('这是软删除,只会将批次标记为 cancelled')} | |||
| onConfirm={() => cancelBatch(selectedBatch.id)} | |||
| okButtonProps={{ 'data-testid': 'migration-cancel-confirm' }} | |||
| cancelButtonProps={{ 'data-testid': 'migration-cancel-abort' }} | |||
| > | |||
| <Button | |||
| type='danger' | |||
| disabled={!canCancel} | |||
| data-testid='migration-cancel-button' | |||
| > | |||
| {t('删除批次')} | |||
| </Button> | |||
| </Popconfirm> | |||
| <Button | |||
| onClick={() => verifyBatch(selectedBatch.id)} | |||
| data-testid='migration-verify-button' | |||
| > | |||
| {t('迁移后验证')} | |||
| </Button> | |||
| </Space> | |||
| {verifyResults.length > 0 && ( | |||
| <Table | |||
| rowKey={(record) => | |||
| `${record.source_user_id}-${record.target_user_id}` | |||
| } | |||
| columns={verifyColumns} | |||
| dataSource={verifyResults} | |||
| pagination={false} | |||
| size='small' | |||
| data-testid='migration-verify-results-table' | |||
| /> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| import React from 'react'; | |||
| import { Space, Spin } from '@douyinfe/semi-ui'; | |||
| import { useUserMigrationData } from '../../../../hooks/users/useUserMigrationData'; | |||
| import BatchList from './BatchList'; | |||
| import BatchDetail from './BatchDetail'; | |||
| import ConflictResolver from './ConflictResolver'; | |||
| import ExecutionPanel from './ExecutionPanel'; | |||
| export default function UserMigrationCenter() { | |||
| const data = useUserMigrationData(); | |||
| return ( | |||
| <Spin spinning={data.loading}> | |||
| <Space | |||
| vertical | |||
| align='start' | |||
| style={{ width: '100%' }} | |||
| spacing='medium' | |||
| data-testid='user-migration-center' | |||
| > | |||
| <BatchList {...data} /> | |||
| {data.selectedBatch && ( | |||
| <> | |||
| <ExecutionPanel {...data} /> | |||
| <ConflictResolver {...data} /> | |||
| <BatchDetail {...data} /> | |||
| </> | |||
| )} | |||
| </Space> | |||
| </Spin> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,360 @@ | |||
| import { useCallback, useEffect, useRef, useState } from 'react'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import { API, showError, showSuccess } from '../../helpers'; | |||
| const POLL_INTERVAL_MS = 3000; | |||
| const RUNNING_STATUSES = new Set(['running', 'scanning']); | |||
| export function useUserMigrationData() { | |||
| const { t } = useTranslation(); | |||
| const [batches, setBatches] = useState([]); | |||
| const [selectedBatch, setSelectedBatch] = useState(null); | |||
| const [candidateUsers, setCandidateUsers] = useState([]); | |||
| const [candidateTotal, setCandidateTotal] = useState(0); | |||
| const [candidatePage, setCandidatePage] = useState(0); | |||
| const [candidatePageSize, setCandidatePageSize] = useState(50); | |||
| const [candidateKeyword, setCandidateKeyword] = useState(''); | |||
| const [selectedCandidateIds, setSelectedCandidateIds] = useState([]); | |||
| const [items, setItems] = useState([]); | |||
| const [itemsTotal, setItemsTotal] = useState(0); | |||
| const [itemsPage, setItemsPage] = useState(0); | |||
| const [itemsPageSize, setItemsPageSize] = useState(50); | |||
| const [loading, setLoading] = useState(false); | |||
| const [verifyResults, setVerifyResults] = useState([]); | |||
| const pollTimerRef = useRef(null); | |||
| const selectedBatchIdRef = useRef(null); | |||
| const itemsPageRef = useRef(0); | |||
| const itemsPageSizeRef = useRef(50); | |||
| useEffect(() => { | |||
| selectedBatchIdRef.current = selectedBatch?.id || null; | |||
| }, [selectedBatch]); | |||
| useEffect(() => { | |||
| itemsPageRef.current = itemsPage; | |||
| }, [itemsPage]); | |||
| useEffect(() => { | |||
| itemsPageSizeRef.current = itemsPageSize; | |||
| }, [itemsPageSize]); | |||
| const stopPolling = useCallback(() => { | |||
| if (pollTimerRef.current) { | |||
| clearInterval(pollTimerRef.current); | |||
| pollTimerRef.current = null; | |||
| } | |||
| }, []); | |||
| const fetchBatches = useCallback(async () => { | |||
| try { | |||
| const res = await API.get('/api/user-migrations/batches'); | |||
| if (!res.data.success) { | |||
| showError(res.data.error || t('获取批次失败')); | |||
| return []; | |||
| } | |||
| const data = res.data.data || []; | |||
| setBatches(data); | |||
| if (selectedBatchIdRef.current) { | |||
| const current = | |||
| data.find((batch) => batch.id === selectedBatchIdRef.current) || null; | |||
| setSelectedBatch(current); | |||
| } | |||
| return data; | |||
| } catch (error) { | |||
| showError(error.message); | |||
| return []; | |||
| } | |||
| }, [t]); | |||
| const fetchItems = useCallback( | |||
| async (batchId, page = 0, pageSize = 50) => { | |||
| if (!batchId) { | |||
| return; | |||
| } | |||
| try { | |||
| const res = await API.get( | |||
| `/api/user-migrations/batches/${batchId}?page=${page}&page_size=${pageSize}`, | |||
| ); | |||
| if (!res.data.success) { | |||
| showError(res.data.error || t('获取批次详情失败')); | |||
| 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 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), | |||
| ); | |||
| if (hasRunning && !pollTimerRef.current) { | |||
| pollTimerRef.current = setInterval(() => { | |||
| void refresh(true); | |||
| }, POLL_INTERVAL_MS); | |||
| } | |||
| if (!hasRunning) { | |||
| stopPolling(); | |||
| } | |||
| if (!silent) { | |||
| setLoading(false); | |||
| } | |||
| }, | |||
| [fetchBatches, fetchItems, stopPolling], | |||
| ); | |||
| useEffect(() => { | |||
| void refresh(); | |||
| return () => stopPolling(); | |||
| }, [refresh, stopPolling]); | |||
| const selectBatch = useCallback( | |||
| async (batch) => { | |||
| setVerifyResults([]); | |||
| setSelectedBatch(batch); | |||
| await fetchItems(batch.id, 0, itemsPageSizeRef.current); | |||
| }, | |||
| [fetchItems], | |||
| ); | |||
| const createBatch = async (payload) => { | |||
| try { | |||
| const res = await API.post('/api/user-migrations/batches', payload); | |||
| 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 scanBatch = async (batchId) => { | |||
| try { | |||
| const res = await API.post( | |||
| `/api/user-migrations/batches/${batchId}/scan`, | |||
| ); | |||
| 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 resolveItem = async (itemId, strategy, targetUserId = 0) => { | |||
| try { | |||
| const res = await API.post( | |||
| `/api/user-migrations/items/${itemId}/resolve`, | |||
| { | |||
| resolution_strategy: strategy, | |||
| target_user_id: targetUserId, | |||
| }, | |||
| ); | |||
| if (!res.data.success) { | |||
| showError(res.data.error || t('更新策略失败')); | |||
| return false; | |||
| } | |||
| await refresh(); | |||
| return true; | |||
| } catch (error) { | |||
| showError(error.message); | |||
| return false; | |||
| } | |||
| }; | |||
| const executeBatch = async (batchId) => { | |||
| try { | |||
| const res = await API.post( | |||
| `/api/user-migrations/batches/${batchId}/execute`, | |||
| ); | |||
| 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 retryBatch = async (batchId) => { | |||
| try { | |||
| const res = await API.post( | |||
| `/api/user-migrations/batches/${batchId}/retry`, | |||
| ); | |||
| 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 cancelBatch = async (batchId) => { | |||
| try { | |||
| const res = await API.post( | |||
| `/api/user-migrations/batches/${batchId}/cancel`, | |||
| ); | |||
| if (!res.data.success) { | |||
| showError(res.data.error || t('删除批次失败')); | |||
| return false; | |||
| } | |||
| showSuccess(t('批次已删除')); | |||
| await refresh(); | |||
| return true; | |||
| } catch (error) { | |||
| showError(error.message); | |||
| return false; | |||
| } | |||
| }; | |||
| const verifyBatch = async (batchId) => { | |||
| try { | |||
| const res = await API.post( | |||
| `/api/user-migrations/batches/${batchId}/verify`, | |||
| ); | |||
| if (!res.data.success) { | |||
| showError(res.data.error || t('验证失败')); | |||
| return []; | |||
| } | |||
| const results = res.data.data || []; | |||
| setVerifyResults(results); | |||
| showSuccess(t('验证完成')); | |||
| return results; | |||
| } catch (error) { | |||
| showError(error.message); | |||
| return []; | |||
| } | |||
| }; | |||
| return { | |||
| batches, | |||
| selectedBatch, | |||
| candidateUsers, | |||
| candidateTotal, | |||
| candidatePage, | |||
| candidatePageSize, | |||
| candidateKeyword, | |||
| selectedCandidateIds, | |||
| items, | |||
| itemsTotal, | |||
| itemsPage, | |||
| itemsPageSize, | |||
| loading, | |||
| verifyResults, | |||
| refresh, | |||
| selectBatch, | |||
| clearSelectedBatch, | |||
| fetchCandidateUsers, | |||
| syncCandidateSelections, | |||
| removeCandidateSelection, | |||
| clearCandidateSelection, | |||
| fetchItems, | |||
| createBatch, | |||
| scanBatch, | |||
| resolveItem, | |||
| executeBatch, | |||
| retryBatch, | |||
| cancelBatch, | |||
| verifyBatch, | |||
| t, | |||
| }; | |||
| } | |||
| @@ -168,19 +168,23 @@ export default function ModelRatioNotSetEditor(props) { | |||
| }; | |||
| try { | |||
| // 数据转换 - 只处理已修改的模型 | |||
| // ??????????????????????????????????????? | |||
| models.forEach((model) => { | |||
| // 只有当用户设置了值时才更新 | |||
| delete output.ModelPrice[model.name]; | |||
| delete output.ModelRatio[model.name]; | |||
| delete output.CompletionRatio[model.name]; | |||
| if (model.price !== '') { | |||
| // 如果价格不为空,则转换为浮点数,忽略倍率参数 | |||
| output.ModelPrice[model.name] = parseFloat(model.price); | |||
| } else { | |||
| if (model.ratio !== '') | |||
| if (model.ratio !== '') { | |||
| output.ModelRatio[model.name] = parseFloat(model.ratio); | |||
| if (model.completionRatio !== '') | |||
| } | |||
| if (model.completionRatio !== '') { | |||
| output.CompletionRatio[model.name] = parseFloat( | |||
| model.completionRatio, | |||
| ); | |||
| } | |||
| } | |||
| // 高级倍率字段(0 是有效值) | |||
| @@ -22,18 +22,28 @@ import { Tabs, TabPane } from '@douyinfe/semi-ui'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import UsersTable from '../../components/table/users'; | |||
| import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | |||
| import UserMigrationCenter from '../../components/table/users/migration'; | |||
| import { isRoot } from '../../helpers'; | |||
| const User = () => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <div className='mt-[60px] px-2'> | |||
| <Tabs type='line'> | |||
| <div className='mt-[60px] px-2' data-testid='user-page'> | |||
| <Tabs type='line' data-testid='user-tabs'> | |||
| <TabPane tab={t('用户列表')} itemKey='users'> | |||
| <UsersTable /> | |||
| </TabPane> | |||
| <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | |||
| <SettingsEmailQuotaRule /> | |||
| </TabPane> | |||
| {isRoot() && ( | |||
| <TabPane | |||
| tab={<span data-testid='user-migration-tab'>{t('用户迁移')}</span>} | |||
| itemKey='user-migration' | |||
| > | |||
| <UserMigrationCenter /> | |||
| </TabPane> | |||
| )} | |||
| </Tabs> | |||
| </div> | |||
| ); | |||