# 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) | common.ApiError(c, err) | ||||
| return | 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") | myRole := c.GetInt("role") | ||||
| if myRole <= originUser.Role && myRole != common.RoleRootUser { | 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 | 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 | // CreateCustomOAuthProvider creates a new custom OAuth provider | ||||
| func CreateCustomOAuthProvider(provider *CustomOAuthProvider) error { | func CreateCustomOAuthProvider(provider *CustomOAuthProvider) error { | ||||
| if err := validateCustomOAuthProvider(provider); err != nil { | if err := validateCustomOAuthProvider(provider); err != nil { | ||||
| @@ -286,6 +286,9 @@ func migrateDB() error { | |||||
| &QuotaSyncLog{}, | &QuotaSyncLog{}, | ||||
| &EmailQuotaRule{}, | &EmailQuotaRule{}, | ||||
| &UserModelRateLimit{}, | &UserModelRateLimit{}, | ||||
| &UserMigrationBatch{}, | |||||
| &UserMigrationItem{}, | |||||
| &MigrationQuotaGrant{}, | |||||
| ) | ) | ||||
| if err != nil { | if err != nil { | ||||
| return err | return err | ||||
| @@ -350,6 +353,9 @@ func migrateDBFast() error { | |||||
| {&PendingSyncRecord{}, "PendingSyncRecord"}, | {&PendingSyncRecord{}, "PendingSyncRecord"}, | ||||
| {&QuotaSyncLog{}, "QuotaSyncLog"}, | {&QuotaSyncLog{}, "QuotaSyncLog"}, | ||||
| {&EmailQuotaRule{}, "EmailQuotaRule"}, | {&EmailQuotaRule{}, "EmailQuotaRule"}, | ||||
| {&UserMigrationBatch{}, "UserMigrationBatch"}, | |||||
| {&UserMigrationItem{}, "UserMigrationItem"}, | |||||
| {&MigrationQuotaGrant{}, "MigrationQuotaGrant"}, | |||||
| } | } | ||||
| // 动态计算migration数量,确保errChan缓冲区足够大 | // 动态计算migration数量,确保errChan缓冲区足够大 | ||||
| errChan := make(chan error, len(migrations)) | 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 | 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) { | func GetUserIdByAffCode(affCode string) (int, error) { | ||||
| if affCode == "" { | if affCode == "" { | ||||
| return 0, errors.New("affCode 为空!") | return 0, errors.New("affCode 为空!") | ||||
| } | } | ||||
| var user User | 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) { | func DeleteUserById(id int) (err error) { | ||||
| @@ -521,6 +574,31 @@ func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error { | |||||
| return nil | 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. | // FinalizeOAuthUserCreation performs post-transaction tasks for OAuth user creation. | ||||
| // This should be called after the transaction commits successfully. | // This should be called after the transaction commits successfully. | ||||
| func (user *User) FinalizeOAuthUserCreation(inviterId int) { | 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 获取所有同步用户 | // GetSyncedUsers 获取所有同步用户 | ||||
| func GetSyncedUsers() []User { | func GetSyncedUsers() []User { | ||||
| var users []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 | // UserOAuthBinding stores the binding relationship between users and custom OAuth providers | ||||
| type UserOAuthBinding struct { | type UserOAuthBinding struct { | ||||
| Id int `json:"id" gorm:"primaryKey"` | 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"` | 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 | // GetUserByOAuthBinding finds a user by provider ID and provider user ID | ||||
| func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) { | func GetUserByOAuthBinding(providerId int, providerUserId string) (*User, error) { | ||||
| var binding UserOAuthBinding | 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 | 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 | return &user, nil | ||||
| } | } | ||||
| @@ -104,6 +110,36 @@ func CreateUserOAuthBindingWithTx(tx *gorm.DB, binding *UserOAuthBinding) error | |||||
| return tx.Create(binding).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) | // UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account) | ||||
| func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error { | func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error { | ||||
| // Check if the new provider user ID is already taken by another user | // 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.POST("/quota/batch-deduct", controller.BatchDeductQuota) | ||||
| syncRoute.GET("/config", controller.GetSyncConfig) | 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": { | "devDependencies": { | ||||
| "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", | "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", | ||||
| "@playwright/test": "^1.60.0", | |||||
| "@so1ve/prettier-config": "^3.1.0", | "@so1ve/prettier-config": "^3.1.0", | ||||
| "@vitejs/plugin-react": "^4.2.1", | "@vitejs/plugin-react": "^4.2.1", | ||||
| "autoprefixer": "^10.4.21", | "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 { | try { | ||||
| // 数据转换 - 只处理已修改的模型 | |||||
| // ??????????????????????????????????????? | |||||
| models.forEach((model) => { | models.forEach((model) => { | ||||
| // 只有当用户设置了值时才更新 | |||||
| delete output.ModelPrice[model.name]; | |||||
| delete output.ModelRatio[model.name]; | |||||
| delete output.CompletionRatio[model.name]; | |||||
| if (model.price !== '') { | if (model.price !== '') { | ||||
| // 如果价格不为空,则转换为浮点数,忽略倍率参数 | |||||
| output.ModelPrice[model.name] = parseFloat(model.price); | output.ModelPrice[model.name] = parseFloat(model.price); | ||||
| } else { | } else { | ||||
| if (model.ratio !== '') | |||||
| if (model.ratio !== '') { | |||||
| output.ModelRatio[model.name] = parseFloat(model.ratio); | output.ModelRatio[model.name] = parseFloat(model.ratio); | ||||
| if (model.completionRatio !== '') | |||||
| } | |||||
| if (model.completionRatio !== '') { | |||||
| output.CompletionRatio[model.name] = parseFloat( | output.CompletionRatio[model.name] = parseFloat( | ||||
| model.completionRatio, | model.completionRatio, | ||||
| ); | ); | ||||
| } | |||||
| } | } | ||||
| // 高级倍率字段(0 是有效值) | // 高级倍率字段(0 是有效值) | ||||
| @@ -22,18 +22,28 @@ import { Tabs, TabPane } from '@douyinfe/semi-ui'; | |||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import UsersTable from '../../components/table/users'; | import UsersTable from '../../components/table/users'; | ||||
| import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | import SettingsEmailQuotaRule from '../Setting/Operation/SettingsEmailQuotaRule'; | ||||
| import UserMigrationCenter from '../../components/table/users/migration'; | |||||
| import { isRoot } from '../../helpers'; | |||||
| const User = () => { | const User = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| return ( | 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'> | <TabPane tab={t('用户列表')} itemKey='users'> | ||||
| <UsersTable /> | <UsersTable /> | ||||
| </TabPane> | </TabPane> | ||||
| <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | <TabPane tab={t('邮箱后缀额度规则')} itemKey='email-quota'> | ||||
| <SettingsEmailQuotaRule /> | <SettingsEmailQuotaRule /> | ||||
| </TabPane> | </TabPane> | ||||
| {isRoot() && ( | |||||
| <TabPane | |||||
| tab={<span data-testid='user-migration-tab'>{t('用户迁移')}</span>} | |||||
| itemKey='user-migration' | |||||
| > | |||||
| <UserMigrationCenter /> | |||||
| </TabPane> | |||||
| )} | |||||
| </Tabs> | </Tabs> | ||||
| </div> | </div> | ||||
| ); | ); | ||||