From 102b15ad41f8972834532cc81ed5eb3a890cff24 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 3 Jun 2026 11:49:21 +0800 Subject: [PATCH] feat(user-migration): add internal migration endpoints and client --- controller/internal_user_migration.go | 124 +++++++++++++++ controller/internal_user_migration_test.go | 172 +++++++++++++++++++++ router/api-router.go | 9 ++ service/user_migration/client.go | 141 +++++++++++++++++ service/user_migration/types.go | 42 +++++ 5 files changed, 488 insertions(+) create mode 100644 controller/internal_user_migration.go create mode 100644 controller/internal_user_migration_test.go create mode 100644 service/user_migration/client.go create mode 100644 service/user_migration/types.go diff --git a/controller/internal_user_migration.go b/controller/internal_user_migration.go new file mode 100644 index 0000000..f86c4ea --- /dev/null +++ b/controller/internal_user_migration.go @@ -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 +} diff --git a/controller/internal_user_migration_test.go b/controller/internal_user_migration_test.go new file mode 100644 index 0000000..b089083 --- /dev/null +++ b/controller/internal_user_migration_test.go @@ -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)) +} diff --git a/router/api-router.go b/router/api-router.go index ab70558..0abe810 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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) + } } } diff --git a/service/user_migration/client.go b/service/user_migration/client.go new file mode 100644 index 0000000..1d1b67b --- /dev/null +++ b/service/user_migration/client.go @@ -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 +} diff --git a/service/user_migration/types.go b/service/user_migration/types.go new file mode 100644 index 0000000..1605a22 --- /dev/null +++ b/service/user_migration/types.go @@ -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"` +}