sub2api/backend/internal/service/windsurf_services.go
win de048fad25 chore(wip): save Windsurf/Antigravity/ops customizations before upstream merge
WIP commit保存以下定制工作以便后续合并 upstream v0.1.124-125:
- Windsurf: tier access service, NLU extractor, cold threshold, Google login
- Antigravity: client/oauth 调整
- Ops: log stream handler/broadcaster/middleware, OpsLogStreamView
- Frontend: WindsurfLoginModal Google, GoogleIcon, AccountsView, sidebar/router/i18n
2026-05-09 00:41:19 +08:00

486 lines
13 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
)
type WindsurfLSService struct {
cfg config.WindsurfConfig
connector windsurf.LSConnector
}
func NewWindsurfLSService(cfg config.WindsurfConfig, pool *windsurf.LSPool) *WindsurfLSService {
var connector windsurf.LSConnector
switch cfg.LSMode {
case "docker":
connector = windsurf.NewCompatDockerConnector(
cfg.Docker.Host,
cfg.Docker.Port,
windsurf.DockerDiscoveryConfig{
DefaultCSRFToken: cfg.Docker.CSRFToken,
ProbeInterval: cfg.Docker.ProbeInterval,
ProbeTimeout: cfg.Docker.ProbeTimeout,
DiscoverInterval: cfg.Docker.DiscoverInterval,
},
)
case "embedded":
connector = windsurf.NewEmbeddedConnector(pool)
case "external":
port := 0
if cfg.External.BaseURL != "" {
port = 443
}
connector = windsurf.NewExternalConnector(
cfg.External.BaseURL,
port,
cfg.External.CSRFToken,
)
default:
connector = windsurf.NewDockerConnector(
cfg.Docker.Host,
cfg.Docker.Port,
cfg.Docker.CSRFToken,
)
slog.Warn("windsurf_ls_unknown_mode", "mode", cfg.LSMode, "fallback", "docker")
}
return &WindsurfLSService{
cfg: cfg,
connector: connector,
}
}
func (s *WindsurfLSService) Connector() windsurf.LSConnector {
return s.connector
}
func (s *WindsurfLSService) Acquire(ctx context.Context, proxyURL string) (*windsurf.LSLease, error) {
return s.connector.Acquire(ctx, proxyURL)
}
func (s *WindsurfLSService) AcquireByBinding(binding WindsurfLSBinding) (*windsurf.LSLease, error) {
if binding.ContainerID == "" && binding.ContainerName == "" {
return s.connector.Acquire(context.Background(), "")
}
if dc, ok := s.connector.(*windsurf.DockerDiscoveryConnector); ok {
id := binding.ContainerID
if id == "" {
id = binding.ContainerName
}
return dc.AcquireByID(id)
}
return s.connector.Acquire(context.Background(), "")
}
func (s *WindsurfLSService) Health(ctx context.Context) error {
return s.connector.Health(ctx)
}
func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus {
return s.connector.Status()
}
// Stop terminates LS resources owned by this service (embedded LS processes,
// docker discovery goroutines). Safe to call on a nil receiver.
func (s *WindsurfLSService) Stop() {
if s == nil || s.connector == nil {
return
}
s.connector.Shutdown()
}
type WindsurfAuthService struct {
cfg config.WindsurfConfig
authClient *windsurf.AuthClient
accountRepo AccountRepository
proxyRepo ProxyRepository
adminSvc AdminService
}
func NewWindsurfAuthService(
cfg config.WindsurfConfig,
accountRepo AccountRepository,
proxyRepo ProxyRepository,
adminSvc AdminService,
) *WindsurfAuthService {
authClient := &windsurf.AuthClient{
Auth1BaseURL: cfg.Auth1BaseURL,
SeatServiceBaseURL: cfg.SeatServiceBaseURL,
CodeiumRegisterURL: cfg.CodeiumRegisterURL,
FirebaseAPIKey: cfg.FirebaseAPIKey,
RequestTimeout: cfg.RequestTimeout,
}
return &WindsurfAuthService{
cfg: cfg,
authClient: authClient,
accountRepo: accountRepo,
proxyRepo: proxyRepo,
adminSvc: adminSvc,
}
}
type WindsurfLoginInput struct {
Email string
Password string
Name string
Notes *string
ProxyID *int64
GroupIDs []int64
Concurrency int
Priority int
ProbeAfter bool
LSInstanceID string
}
type WindsurfLoginOutput struct {
AccountID int64 `json:"account_id"`
Email string `json:"email"`
Tier string `json:"tier"`
AuthMethod string `json:"auth_method"`
APIKeyPresent bool `json:"api_key_present"`
RefreshTokenPresent bool `json:"refresh_token_present"`
}
func (s *WindsurfAuthService) Login(ctx context.Context, input *WindsurfLoginInput) (*WindsurfLoginOutput, error) {
existing, err := s.accountRepo.FindByCredentialField(ctx, domain.PlatformWindsurf, "email", input.Email)
if err != nil {
return nil, fmt.Errorf("check existing account: %w", err)
}
if len(existing) > 0 {
return nil, fmt.Errorf("windsurf account with email %s already exists (account_id=%d)", input.Email, existing[0].ID)
}
proxyURL := ""
if input.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
if err != nil {
return nil, fmt.Errorf("get proxy: %w", err)
}
proxyURL = proxy.URL()
}
result, err := s.authClient.Login(ctx, input.Email, input.Password, proxyURL)
if err != nil {
return nil, err
}
creds := WindsurfCredentials{
Email: input.Email,
APIKey: result.APIKey,
RefreshToken: result.RefreshToken,
IDToken: result.IDToken,
SessionToken: result.SessionToken,
Auth1Token: result.Auth1Token,
AuthMethod: result.AuthMethod,
APIServerURL: result.APIServerURL,
RegisteredAt: time.Now().Format(time.RFC3339),
}
expiresAt := time.Now().Add(50 * time.Minute)
creds.ExpiresAt = expiresAt.Format(time.RFC3339)
credMap := StoreWindsurfCredentials(creds)
extra := WindsurfExtra{
Profile: WindsurfProfileSnapshot{
TierSource: "login",
},
Refresh: WindsurfRefreshState{},
}
if input.LSInstanceID != "" {
extra.LSBinding = WindsurfLSBinding{
ContainerID: input.LSInstanceID,
}
}
extraMap := StoreWindsurfExtra(extra)
name := input.Name
if name == "" {
if result.Name != "" {
name = result.Name
} else {
name = input.Email
}
}
concurrency := input.Concurrency
if concurrency <= 0 {
concurrency = 1
}
createInput := &CreateAccountInput{
Name: name,
Notes: input.Notes,
Platform: domain.PlatformWindsurf,
Type: domain.AccountTypeWindsurfSession,
Credentials: credMap,
Extra: extraMap,
ProxyID: input.ProxyID,
Concurrency: concurrency,
Priority: input.Priority,
GroupIDs: input.GroupIDs,
}
account, err := s.adminSvc.CreateAccount(ctx, createInput)
if err != nil {
return nil, fmt.Errorf("create account: %w", err)
}
return &WindsurfLoginOutput{
AccountID: account.ID,
Email: input.Email,
Tier: "unknown",
AuthMethod: result.AuthMethod,
APIKeyPresent: result.APIKey != "",
RefreshTokenPresent: result.RefreshToken != "",
}, nil
}
type WindsurfTokenLoginInput struct {
Token string
Email string
Name string
Notes *string
ProxyID *int64
GroupIDs []int64
Concurrency int
Priority int
ProbeAfter bool
LSInstanceID string
}
// TokenLogin registers a Windsurf account by exchanging a token obtained from
// https://windsurf.com/show-auth-token (after the user signed in on
// windsurf.com via Google / GitHub / email) with Codeium's register_user
// endpoint. Because the OAuth round-trip happens entirely on windsurf.com,
// no Firebase Referer-restricted requests originate from our own domain —
// this is the only flow that works for self-hosted deployments.
func (s *WindsurfAuthService) TokenLogin(ctx context.Context, input *WindsurfTokenLoginInput) (*WindsurfLoginOutput, error) {
if input.Token == "" {
return nil, infraerrors.BadRequest("WINDSURF_TOKEN_REQUIRED", "token required")
}
if input.Email != "" {
existing, err := s.accountRepo.FindByCredentialField(ctx, domain.PlatformWindsurf, "email", input.Email)
if err != nil {
return nil, fmt.Errorf("check existing account: %w", err)
}
if len(existing) > 0 {
return nil, infraerrors.Conflict(
"WINDSURF_ACCOUNT_EMAIL_EXISTS",
"windsurf account with this email already exists",
)
}
}
proxyURL := ""
if input.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
if err != nil {
return nil, fmt.Errorf("get proxy: %w", err)
}
proxyURL = proxy.URL()
}
reg, err := s.authClient.RegisterWithCodeiumDefault(ctx, input.Token, proxyURL)
if err != nil {
return nil, fmt.Errorf("codeium register (token): %w", err)
}
emailForRecord := input.Email
if emailForRecord == "" {
emailForRecord = reg.Name // best-effort label when caller didn't supply one
}
creds := WindsurfCredentials{
Email: emailForRecord,
APIKey: reg.APIKey,
AuthMethod: "token",
APIServerURL: reg.APIServerURL,
RegisteredAt: time.Now().Format(time.RFC3339),
}
credMap := StoreWindsurfCredentials(creds)
extra := WindsurfExtra{
Profile: WindsurfProfileSnapshot{TierSource: "login"},
Refresh: WindsurfRefreshState{},
}
if input.LSInstanceID != "" {
extra.LSBinding = WindsurfLSBinding{ContainerID: input.LSInstanceID}
}
extraMap := StoreWindsurfExtra(extra)
name := input.Name
if name == "" {
name = reg.Name
}
if name == "" {
name = emailForRecord
}
if name == "" {
name = "Windsurf Account"
}
concurrency := input.Concurrency
if concurrency <= 0 {
concurrency = 1
}
createInput := &CreateAccountInput{
Name: name,
Notes: input.Notes,
Platform: domain.PlatformWindsurf,
Type: domain.AccountTypeWindsurfSession,
Credentials: credMap,
Extra: extraMap,
ProxyID: input.ProxyID,
Concurrency: concurrency,
Priority: input.Priority,
GroupIDs: input.GroupIDs,
}
account, err := s.adminSvc.CreateAccount(ctx, createInput)
if err != nil {
return nil, fmt.Errorf("create account: %w", err)
}
return &WindsurfLoginOutput{
AccountID: account.ID,
Email: emailForRecord,
Tier: "unknown",
AuthMethod: "token",
APIKeyPresent: reg.APIKey != "",
RefreshTokenPresent: false,
}, nil
}
func (s *WindsurfAuthService) BatchLogin(ctx context.Context, items []string, proxyID *int64, groupIDs []int64, concurrency, priority int, probeAfter bool) ([]WindsurfBatchResult, error) {
results := make([]WindsurfBatchResult, 0, len(items))
for _, item := range items {
email, password, err := parseEmailPassword(item)
if err != nil {
results = append(results, WindsurfBatchResult{
Email: item,
Success: false,
Error: err.Error(),
})
continue
}
input := &WindsurfLoginInput{
Email: email,
Password: password,
ProxyID: proxyID,
GroupIDs: groupIDs,
Concurrency: concurrency,
Priority: priority,
ProbeAfter: probeAfter,
}
output, loginErr := s.Login(ctx, input)
if loginErr != nil {
results = append(results, WindsurfBatchResult{
Email: email,
Success: false,
Error: loginErr.Error(),
})
continue
}
results = append(results, WindsurfBatchResult{
Email: email,
Success: true,
AccountID: output.AccountID,
Output: output,
})
}
return results, nil
}
type WindsurfBatchResult struct {
Email string `json:"email"`
Success bool `json:"success"`
AccountID int64 `json:"account_id,omitempty"`
Output *WindsurfLoginOutput `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
func parseEmailPassword(item string) (string, string, error) {
sep := "----"
idx := -1
for i := 0; i <= len(item)-len(sep); i++ {
if item[i:i+len(sep)] == sep {
idx = i
break
}
}
if idx < 0 {
return "", "", fmt.Errorf("invalid format: expected email----password")
}
email := item[:idx]
password := item[idx+len(sep):]
if email == "" || password == "" {
return "", "", fmt.Errorf("email and password cannot be empty")
}
return email, password, nil
}
func (s *WindsurfAuthService) RefreshToken(ctx context.Context, accountID int64) error {
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
return err
}
if account.Platform != domain.PlatformWindsurf {
return fmt.Errorf("account %d is not a windsurf account", accountID)
}
creds := LoadWindsurfCredentials(account.Credentials)
proxyURL := ""
if account.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
if err == nil {
proxyURL = proxy.URL()
}
}
if creds.AuthMethod == "firebase" && creds.RefreshToken != "" {
refreshResult, err := s.authClient.RefreshFirebaseToken(ctx, creds.RefreshToken, proxyURL)
if err != nil {
return fmt.Errorf("firebase refresh: %w", err)
}
creds.IDToken = refreshResult.IDToken
creds.RefreshToken = refreshResult.RefreshToken
creds.ExpiresAt = time.Now().Add(time.Duration(refreshResult.ExpiresIn) * time.Second).Format(time.RFC3339)
creds.LastRefreshAt = time.Now().Format(time.RFC3339)
regResult, err := s.authClient.ReRegisterWithCodeium(ctx, refreshResult.IDToken, proxyURL)
if err != nil {
slog.Warn("windsurf_reregister_failed", "account_id", accountID, "error", err)
} else {
creds.APIKey = regResult.APIKey
creds.LastReregisterAt = time.Now().Format(time.RFC3339)
}
} else if creds.AuthMethod == "auth1" {
// Auth1 tokens don't use Firebase refresh; re-login would be needed
return fmt.Errorf("auth1 accounts require re-login for token refresh")
} else {
return fmt.Errorf("unknown auth method: %s", creds.AuthMethod)
}
credMap := StoreWindsurfCredentials(creds)
account.Credentials = credMap
return s.accountRepo.Update(ctx, account)
}