| @@ -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.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"` | |||
| } | |||