| @@ -0,0 +1,124 @@ | |||||
| package controller | |||||
| import ( | |||||
| "net/http" | |||||
| "strconv" | |||||
| "strings" | |||||
| "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")) | |||||
| if pageSize <= 0 || pageSize > 200 { | |||||
| pageSize = 50 | |||||
| } | |||||
| users, total, err := model.ListLocalUsersForMigration(page, pageSize) | |||||
| 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 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 | |||||
| } | |||||
| 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 strings.Contains(err.Error(), "already synced") || strings.Contains(err.Error(), "cannot change") { | |||||
| 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, | |||||
| } | |||||
| 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,172 @@ | |||||
| package controller | |||||
| import ( | |||||
| "encoding/json" | |||||
| "net/http" | |||||
| "net/http/httptest" | |||||
| "strings" | |||||
| "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 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) | |||||
| 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 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 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)) | |||||
| } | |||||
| @@ -405,5 +405,14 @@ 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.GET("/users/:id", controller.GetMigrationUser) | |||||
| migrationRoute.POST("/users/:id/convert-to-synced", controller.ConvertMigrationUserToSynced) | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,141 @@ | |||||
| package user_migration | |||||
| import ( | |||||
| "bytes" | |||||
| "fmt" | |||||
| "io" | |||||
| "net/http" | |||||
| "strings" | |||||
| "time" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| ) | |||||
| type migrationClient interface { | |||||
| ListUsers(page, pageSize int) (*ListRemoteUsersResponse, 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) (*ListRemoteUsersResponse, error) { | |||||
| data, err := c.doRequest(http.MethodGet, | |||||
| fmt.Sprintf("/api/internal/migration/users?page=%d&page_size=%d", page, pageSize), 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) 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,42 @@ | |||||
| package user_migration | |||||
| type RemoteOAuthBinding struct { | |||||
| ProviderSlug string `json:"provider_slug"` | |||||
| ProviderUserId string `json:"provider_user_id"` | |||||
| } | |||||
| 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"` | |||||
| } | |||||
| type ListRemoteUsersResponse struct { | |||||
| Success bool `json:"success"` | |||||
| Data []RemoteUserSnapshot `json:"data"` | |||||
| Total int `json:"total"` | |||||
| Error string `json:"error,omitempty"` | |||||
| } | |||||
| type ConvertRemoteUserRequest struct { | |||||
| RemoteUserId int `json:"remote_user_id"` | |||||
| SyncedQuota int `json:"synced_quota"` | |||||
| } | |||||