diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 10256311..d4e3eb03 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -86,7 +86,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) concurrencyCache := repository.NewConcurrencyCache(client) concurrencyService := service.NewConcurrencyService(concurrencyCache) - accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService) + crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository) + accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) proxyHandler := admin.NewProxyHandler(adminService) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 662fdd60..89855616 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -34,10 +34,20 @@ type AccountHandler struct { accountUsageService *service.AccountUsageService accountTestService *service.AccountTestService concurrencyService *service.ConcurrencyService + crsSyncService *service.CRSSyncService } // NewAccountHandler creates a new admin account handler -func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler { +func NewAccountHandler( + adminService service.AdminService, + oauthService *service.OAuthService, + openaiOAuthService *service.OpenAIOAuthService, + rateLimitService *service.RateLimitService, + accountUsageService *service.AccountUsageService, + accountTestService *service.AccountTestService, + concurrencyService *service.ConcurrencyService, + crsSyncService *service.CRSSyncService, +) *AccountHandler { return &AccountHandler{ adminService: adminService, oauthService: oauthService, @@ -46,6 +56,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service. accountUsageService: accountUsageService, accountTestService: accountTestService, concurrencyService: concurrencyService, + crsSyncService: crsSyncService, } } @@ -224,6 +235,13 @@ type TestAccountRequest struct { ModelID string `json:"model_id"` } +type SyncFromCRSRequest struct { + BaseURL string `json:"base_url" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + SyncProxies *bool `json:"sync_proxies"` +} + // Test handles testing account connectivity with SSE streaming // POST /api/v1/admin/accounts/:id/test func (h *AccountHandler) Test(c *gin.Context) { @@ -244,6 +262,35 @@ func (h *AccountHandler) Test(c *gin.Context) { } } +// SyncFromCRS handles syncing accounts from claude-relay-service (CRS) +// POST /api/v1/admin/accounts/sync/crs +func (h *AccountHandler) SyncFromCRS(c *gin.Context) { + var req SyncFromCRSRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + // Default to syncing proxies (can be disabled by explicitly setting false) + syncProxies := true + if req.SyncProxies != nil { + syncProxies = *req.SyncProxies + } + + result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{ + BaseURL: req.BaseURL, + Username: req.Username, + Password: req.Password, + SyncProxies: syncProxies, + }) + if err != nil { + response.BadRequest(c, "Sync failed: "+err.Error()) + return + } + + response.Success(c, result) +} + // Refresh handles refreshing account credentials // POST /api/v1/admin/accounts/:id/refresh func (h *AccountHandler) Refresh(c *gin.Context) { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index dd9b42b1..26deaf7b 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -2,6 +2,7 @@ package repository import ( "context" + "errors" "github.com/Wei-Shaw/sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "time" @@ -39,6 +40,22 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Accou return &account, nil } +func (r *AccountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error) { + if crsAccountID == "" { + return nil, nil + } + + var account model.Account + err := r.db.WithContext(ctx).Where("extra->>'crs_account_id' = ?", crsAccountID).First(&account).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &account, nil +} + func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error { return r.db.WithContext(ctx).Save(account).Error } diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index a3df5f06..128ab36a 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep accounts.GET("", h.Admin.Account.List) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) + accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.POST("/:id/test", h.Admin.Account.Test) diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go new file mode 100644 index 00000000..590a28d8 --- /dev/null +++ b/backend/internal/service/crs_sync_service.go @@ -0,0 +1,808 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/model" + "github.com/Wei-Shaw/sub2api/internal/service/ports" +) + +type CRSSyncService struct { + accountRepo ports.AccountRepository + proxyRepo ports.ProxyRepository +} + +func NewCRSSyncService(accountRepo ports.AccountRepository, proxyRepo ports.ProxyRepository) *CRSSyncService { + return &CRSSyncService{ + accountRepo: accountRepo, + proxyRepo: proxyRepo, + } +} + +type SyncFromCRSInput struct { + BaseURL string + Username string + Password string + SyncProxies bool +} + +type SyncFromCRSItemResult struct { + CRSAccountID string `json:"crs_account_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Action string `json:"action"` // created/updated/failed/skipped + Error string `json:"error,omitempty"` +} + +type SyncFromCRSResult struct { + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Items []SyncFromCRSItemResult `json:"items"` +} + +type crsLoginResponse struct { + Success bool `json:"success"` + Token string `json:"token"` + Message string `json:"message"` + Error string `json:"error"` + Username string `json:"username"` +} + +type crsExportResponse struct { + Success bool `json:"success"` + Error string `json:"error"` + Message string `json:"message"` + Data struct { + ExportedAt string `json:"exportedAt"` + ClaudeAccounts []crsClaudeAccount `json:"claudeAccounts"` + ClaudeConsoleAccounts []crsConsoleAccount `json:"claudeConsoleAccounts"` + OpenAIOAuthAccounts []crsOpenAIOAuthAccount `json:"openaiOAuthAccounts"` + OpenAIResponsesAccounts []crsOpenAIResponsesAccount `json:"openaiResponsesAccounts"` + } `json:"data"` +} + +type crsProxy struct { + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` +} + +type crsClaudeAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + AuthType string `json:"authType"` // oauth/setup-token + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +type crsConsoleAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + MaxConcurrentTasks int `json:"maxConcurrentTasks"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +type crsOpenAIResponsesAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +type crsOpenAIOAuthAccount struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + AuthType string `json:"authType"` // oauth + IsActive bool `json:"isActive"` + Schedulable bool `json:"schedulable"` + Priority int `json:"priority"` + Status string `json:"status"` + Proxy *crsProxy `json:"proxy"` + Credentials map[string]any `json:"credentials"` +} + +func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) { + baseURL, err := normalizeBaseURL(input.BaseURL) + if err != nil { + return nil, err + } + if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" { + return nil, errors.New("username and password are required") + } + + client := &http.Client{Timeout: 20 * time.Second} + + adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password) + if err != nil { + return nil, err + } + + exported, err := crsExportAccounts(ctx, client, baseURL, adminToken) + if err != nil { + return nil, err + } + + now := time.Now().UTC().Format(time.RFC3339) + + result := &SyncFromCRSResult{ + Items: make( + []SyncFromCRSItemResult, + 0, + len(exported.Data.ClaudeAccounts)+len(exported.Data.ClaudeConsoleAccounts)+len(exported.Data.OpenAIOAuthAccounts)+len(exported.Data.OpenAIResponsesAccounts), + ), + } + + var proxies []model.Proxy + if input.SyncProxies { + proxies, _ = s.proxyRepo.ListActive(ctx) + } + + // Claude OAuth / Setup Token -> sub2api anthropic oauth/setup-token + for _, src := range exported.Data.ClaudeAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + targetType := strings.TrimSpace(src.AuthType) + if targetType == "" { + targetType = "oauth" + } + if targetType != model.AccountTypeOAuth && targetType != model.AccountTypeSetupToken { + item.Action = "skipped" + item.Error = "unsupported authType: " + targetType + result.Skipped++ + result.Items = append(result.Items, item) + continue + } + + accessToken, _ := src.Credentials["access_token"].(string) + if strings.TrimSpace(accessToken) == "" { + item.Action = "failed" + item.Error = "missing access_token" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name)) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformAnthropic, + Type: targetType, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + // Update existing + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformAnthropic + existing.Type = targetType + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // Claude Console API Key -> sub2api anthropic apikey + for _, src := range exported.Data.ClaudeConsoleAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + apiKey, _ := src.Credentials["api_key"].(string) + if strings.TrimSpace(apiKey) == "" { + item.Action = "failed" + item.Error = "missing api_key" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy(ctx, input.SyncProxies, &proxies, src.Proxy, fmt.Sprintf("crs-%s", src.Name)) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + priority := clampPriority(src.Priority) + concurrency := 3 + if src.MaxConcurrentTasks > 0 { + concurrency = src.MaxConcurrentTasks + } + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformAnthropic, + Type: model.AccountTypeApiKey, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformAnthropic + existing.Type = model.AccountTypeApiKey + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // OpenAI OAuth -> sub2api openai oauth + for _, src := range exported.Data.OpenAIOAuthAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + accessToken, _ := src.Credentials["access_token"].(string) + if strings.TrimSpace(accessToken) == "" { + item.Action = "failed" + item.Error = "missing access_token" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + proxyID, err := s.mapOrCreateProxy( + ctx, + input.SyncProxies, + &proxies, + src.Proxy, + fmt.Sprintf("crs-%s", src.Name), + ) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + // Normalize token_type + if v, ok := credentials["token_type"].(string); !ok || strings.TrimSpace(v) == "" { + credentials["token_type"] = "Bearer" + } + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformOpenAI, + Type: model.AccountTypeOAuth, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformOpenAI + existing.Type = model.AccountTypeOAuth + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + // OpenAI Responses API Key -> sub2api openai apikey + for _, src := range exported.Data.OpenAIResponsesAccounts { + item := SyncFromCRSItemResult{ + CRSAccountID: src.ID, + Kind: src.Kind, + Name: src.Name, + } + + apiKey, _ := src.Credentials["api_key"].(string) + if strings.TrimSpace(apiKey) == "" { + item.Action = "failed" + item.Error = "missing api_key" + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if baseURL, ok := src.Credentials["base_url"].(string); !ok || strings.TrimSpace(baseURL) == "" { + src.Credentials["base_url"] = "https://api.openai.com" + } + + proxyID, err := s.mapOrCreateProxy( + ctx, + input.SyncProxies, + &proxies, + src.Proxy, + fmt.Sprintf("crs-%s", src.Name), + ) + if err != nil { + item.Action = "failed" + item.Error = "proxy sync failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + credentials := sanitizeCredentialsMap(src.Credentials) + priority := clampPriority(src.Priority) + concurrency := 3 + status := mapCRSStatus(src.IsActive, src.Status) + + extra := map[string]any{ + "crs_account_id": src.ID, + "crs_kind": src.Kind, + "crs_synced_at": now, + } + + existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) + if err != nil { + item.Action = "failed" + item.Error = "db lookup failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + if existing == nil { + account := &model.Account{ + Name: defaultName(src.Name, src.ID), + Platform: model.PlatformOpenAI, + Type: model.AccountTypeApiKey, + Credentials: model.JSONB(credentials), + Extra: model.JSONB(extra), + ProxyID: proxyID, + Concurrency: concurrency, + Priority: priority, + Status: status, + Schedulable: src.Schedulable, + } + if err := s.accountRepo.Create(ctx, account); err != nil { + item.Action = "failed" + item.Error = "create failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + item.Action = "created" + result.Created++ + result.Items = append(result.Items, item) + continue + } + + if existing.Extra == nil { + existing.Extra = make(model.JSONB) + } + for k, v := range extra { + existing.Extra[k] = v + } + existing.Name = defaultName(src.Name, src.ID) + existing.Platform = model.PlatformOpenAI + existing.Type = model.AccountTypeApiKey + existing.Credentials = model.JSONB(credentials) + existing.ProxyID = proxyID + existing.Concurrency = concurrency + existing.Priority = priority + existing.Status = status + existing.Schedulable = src.Schedulable + + if err := s.accountRepo.Update(ctx, existing); err != nil { + item.Action = "failed" + item.Error = "update failed: " + err.Error() + result.Failed++ + result.Items = append(result.Items, item) + continue + } + + item.Action = "updated" + result.Updated++ + result.Items = append(result.Items, item) + } + + return result, nil +} + +func (s *CRSSyncService) mapOrCreateProxy(ctx context.Context, enabled bool, cached *[]model.Proxy, src *crsProxy, defaultName string) (*int64, error) { + if !enabled || src == nil { + return nil, nil + } + protocol := strings.ToLower(strings.TrimSpace(src.Protocol)) + switch protocol { + case "socks": + protocol = "socks5" + case "socks5h": + protocol = "socks5" + } + host := strings.TrimSpace(src.Host) + port := src.Port + username := strings.TrimSpace(src.Username) + password := strings.TrimSpace(src.Password) + + if protocol == "" || host == "" || port <= 0 { + return nil, nil + } + if protocol != "http" && protocol != "https" && protocol != "socks5" { + return nil, nil + } + + // Find existing proxy (active only). + for _, p := range *cached { + if strings.EqualFold(p.Protocol, protocol) && + p.Host == host && + p.Port == port && + p.Username == username && + p.Password == password { + id := p.ID + return &id, nil + } + } + + // Create new proxy + proxy := &model.Proxy{ + Name: defaultProxyName(defaultName, protocol, host, port), + Protocol: protocol, + Host: host, + Port: port, + Username: username, + Password: password, + Status: model.StatusActive, + } + if err := s.proxyRepo.Create(ctx, proxy); err != nil { + return nil, err + } + + *cached = append(*cached, *proxy) + id := proxy.ID + return &id, nil +} + +func defaultProxyName(base, protocol, host string, port int) string { + base = strings.TrimSpace(base) + if base == "" { + base = "crs" + } + return fmt.Sprintf("%s (%s://%s:%d)", base, protocol, host, port) +} + +func defaultName(name, id string) string { + if strings.TrimSpace(name) != "" { + return strings.TrimSpace(name) + } + return "CRS " + id +} + +func clampPriority(priority int) int { + if priority < 1 || priority > 100 { + return 50 + } + return priority +} + +func sanitizeCredentialsMap(input map[string]any) map[string]any { + if input == nil { + return map[string]any{} + } + out := make(map[string]any, len(input)) + for k, v := range input { + // Avoid nil values to keep JSONB cleaner + if v != nil { + out[k] = v + } + } + return out +} + +func mapCRSStatus(isActive bool, status string) string { + if !isActive { + return "inactive" + } + if strings.EqualFold(strings.TrimSpace(status), "error") { + return "error" + } + return "active" +} + +func normalizeBaseURL(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("base_url is required") + } + u, err := url.Parse(trimmed) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid base_url: %s", trimmed) + } + u.Path = strings.TrimRight(u.Path, "/") + return strings.TrimRight(u.String(), "/"), nil +} + +func crsLogin(ctx context.Context, client *http.Client, baseURL, username, password string) (string, error) { + payload := map[string]any{ + "username": username, + "password": password, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/web/auth/login", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("crs login failed: status=%d body=%s", resp.StatusCode, string(raw)) + } + + var parsed crsLoginResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("crs login parse failed: %w", err) + } + if !parsed.Success || strings.TrimSpace(parsed.Token) == "" { + msg := parsed.Message + if msg == "" { + msg = parsed.Error + } + if msg == "" { + msg = "unknown error" + } + return "", errors.New("crs login failed: " + msg) + } + return parsed.Token, nil +} + +func crsExportAccounts(ctx context.Context, client *http.Client, baseURL, adminToken string) (*crsExportResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/admin/sync/export-accounts?include_secrets=true", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+adminToken) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 5<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("crs export failed: status=%d body=%s", resp.StatusCode, string(raw)) + } + + var parsed crsExportResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("crs export parse failed: %w", err) + } + if !parsed.Success { + msg := parsed.Message + if msg == "" { + msg = parsed.Error + } + if msg == "" { + msg = "unknown error" + } + return nil, errors.New("crs export failed: " + msg) + } + return &parsed, nil +} diff --git a/backend/internal/service/ports/account.go b/backend/internal/service/ports/account.go index 2cc1b9fd..73be0005 100644 --- a/backend/internal/service/ports/account.go +++ b/backend/internal/service/ports/account.go @@ -11,6 +11,9 @@ import ( type AccountRepository interface { Create(ctx context.Context, account *model.Account) error GetByID(ctx context.Context, id int64) (*model.Account, error) + // GetByCRSAccountID finds an account previously synced from CRS. + // Returns (nil, nil) if not found. + GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error) Update(ctx context.Context, account *model.Account) error Delete(ctx context.Context, id int64) error diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index f1c18c04..998eaee6 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -75,6 +75,7 @@ var ProviderSet = wire.NewSet( NewSubscriptionService, NewConcurrencyService, NewIdentityService, + NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService, diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 92bb7cd8..6f5dd8f3 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -244,6 +244,28 @@ export async function getAvailableModels(id: number): Promise { return data; } +export async function syncFromCrs(params: { + base_url: string; + username: string; + password: string; + sync_proxies?: boolean; +}): Promise<{ + created: number; + updated: number; + skipped: number; + failed: number; + items: Array<{ + crs_account_id: string; + kind: string; + name: string; + action: string; + error?: string; + }>; +}> { + const { data } = await apiClient.post('/admin/accounts/sync/crs', params); + return data; +} + export const accountsAPI = { list, getById, @@ -263,6 +285,7 @@ export const accountsAPI = { generateAuthUrl, exchangeCode, batchCreate, + syncFromCrs, }; export default accountsAPI; diff --git a/frontend/src/components/account/SyncFromCrsModal.vue b/frontend/src/components/account/SyncFromCrsModal.vue new file mode 100644 index 00000000..5cb9b00b --- /dev/null +++ b/frontend/src/components/account/SyncFromCrsModal.vue @@ -0,0 +1,165 @@ + + + + diff --git a/frontend/src/components/account/index.ts b/frontend/src/components/account/index.ts index 254c6720..e0463d02 100644 --- a/frontend/src/components/account/index.ts +++ b/frontend/src/components/account/index.ts @@ -8,3 +8,4 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue' export { default as AccountStatsModal } from './AccountStatsModal.vue' export { default as AccountTestModal } from './AccountTestModal.vue' export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue' +export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 203868e9..03427645 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -682,6 +682,25 @@ export default { title: 'Account Management', description: 'Manage AI platform accounts and credentials', createAccount: 'Create Account', + syncFromCrs: 'Sync from CRS', + syncFromCrsTitle: 'Sync Accounts from CRS', + syncFromCrsDesc: + 'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).', + crsBaseUrl: 'CRS Base URL', + crsBaseUrlPlaceholder: 'e.g. http://127.0.0.1:3000', + crsUsername: 'Username', + crsPassword: 'Password', + syncProxies: 'Also sync proxies (match by host/port/auth or create)', + syncNow: 'Sync Now', + syncing: 'Syncing...', + syncMissingFields: 'Please fill base URL, username and password', + syncResult: 'Sync Result', + syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}', + syncErrors: 'Errors / Skipped Details', + syncCompleted: 'Sync completed: created {created}, updated {updated}', + syncCompletedWithErrors: + 'Sync completed with errors: failed {failed} (created {created}, updated {updated})', + syncFailed: 'Sync failed', editAccount: 'Edit Account', deleteAccount: 'Delete Account', searchAccounts: 'Search accounts...', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4c688365..b406f709 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -780,6 +780,23 @@ export default { title: '账号管理', description: '管理 AI 平台账号和 Cookie', createAccount: '添加账号', + syncFromCrs: '从 CRS 同步', + syncFromCrsTitle: '从 CRS 同步账号', + syncFromCrsDesc: '将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。', + crsBaseUrl: 'CRS 服务地址', + crsBaseUrlPlaceholder: '例如:http://127.0.0.1:3000', + crsUsername: '用户名', + crsPassword: '密码', + syncProxies: '同时同步代理(按 host/port/账号匹配或自动创建)', + syncNow: '开始同步', + syncing: '同步中...', + syncMissingFields: '请填写服务地址、用户名和密码', + syncResult: '同步结果', + syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}', + syncErrors: '错误/跳过详情', + syncCompleted: '同步完成:创建 {created},更新 {updated}', + syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})', + syncFailed: '同步失败', editAccount: '编辑账号', deleteAccount: '删除账号', deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index b9903a38..5980b379 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -16,6 +16,15 @@ +