Kaynağa Gözat

feat(user-migration): add internal migration endpoints and client

master
fengsilin 1 hafta önce
ebeveyn
işleme
102b15ad41
5 değiştirilmiş dosya ile 488 ekleme ve 0 silme
  1. +124
    -0
      controller/internal_user_migration.go
  2. +172
    -0
      controller/internal_user_migration_test.go
  3. +9
    -0
      router/api-router.go
  4. +141
    -0
      service/user_migration/client.go
  5. +42
    -0
      service/user_migration/types.go

+ 124
- 0
controller/internal_user_migration.go Dosyayı Görüntüle

@@ -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
}

+ 172
- 0
controller/internal_user_migration_test.go Dosyayı Görüntüle

@@ -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))
}

+ 9
- 0
router/api-router.go Dosyayı Görüntüle

@@ -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)
}
} }
} }

+ 141
- 0
service/user_migration/client.go Dosyayı Görüntüle

@@ -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
}

+ 42
- 0
service/user_migration/types.go Dosyayı Görüntüle

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

Yükleniyor…
İptal
Kaydet