From 4a56afb101ebb1472ad8aa7ac47bf8b55b37c0b8 Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:59:12 +0800 Subject: [PATCH] feat: enhance account identity synchronization and web account settings management. --- backend/internal/app/application.go | 3 +- .../application/account/provider_links.go | 76 ++++++ .../account/provider_links_test.go | 172 +++++++++++++ .../application/account/quota_refresh_test.go | 48 +++- .../internal/application/account/service.go | 16 ++ .../account/web_account_settings.go | 47 +++- .../account/web_account_settings_test.go | 71 +++++- .../account/web_console_sync_test.go | 3 + .../application/accountsync/service.go | 13 + .../application/accountsync/service_test.go | 29 +++ .../internal/application/gateway/selector.go | 92 ++++--- .../application/gateway/selector_plan.go | 101 ++++++-- .../application/gateway/selector_test.go | 22 +- backend/internal/domain/account/account.go | 22 +- backend/internal/infra/egress/manager.go | 80 ++++-- backend/internal/infra/egress/manager_test.go | 77 ++++++ backend/internal/infra/egress/trace.go | 14 ++ .../persistence/relational/account_links.go | 217 ++++++++++++++++ .../relational/account_links_test.go | 236 ++++++++++++++++++ .../relational/account_repository.go | 213 ++++++++++++++-- .../infra/persistence/relational/mapping.go | 10 +- .../infra/persistence/relational/models.go | 23 +- .../relational/postgres_integration_test.go | 71 ++++++ .../infra/persistence/relational/schema.go | 4 + .../relational/schema_account_identity.go | 53 ++++ .../persistence/relational/web_nsfw_test.go | 46 +++- .../infra/provider/browserheaders/chromium.go | 66 +++++ .../internal/infra/provider/cli/adapter.go | 10 +- backend/internal/infra/provider/cli/video.go | 4 +- .../provider/console/account_identity.go | 21 ++ .../infra/provider/console/adapter.go | 1 + .../infra/provider/console/console_test.go | 36 +++ .../infra/provider/console/headers.go | 57 +---- backend/internal/infra/provider/provider.go | 22 ++ .../infra/provider/sessionidentity/session.go | 145 +++++++++++ .../infra/provider/web/account_identity.go | 25 ++ .../provider/web/account_identity_test.go | 73 ++++++ backend/internal/repository/account.go | 4 + .../transport/http/account/handler.go | 97 +++---- .../transport/http/account/handler_test.go | 21 +- .../features/accounts/account-name-cell.tsx | 133 ++++++++++ .../src/features/accounts/accounts-api.ts | 15 +- .../src/features/accounts/accounts-page.tsx | 59 +---- .../features/accounts/web-account-scripts.tsx | 8 +- frontend/src/shared/i18n/index.ts | 12 +- 45 files changed, 2259 insertions(+), 309 deletions(-) create mode 100644 backend/internal/application/account/provider_links.go create mode 100644 backend/internal/application/account/provider_links_test.go create mode 100644 backend/internal/infra/persistence/relational/account_links.go create mode 100644 backend/internal/infra/persistence/relational/account_links_test.go create mode 100644 backend/internal/infra/persistence/relational/schema_account_identity.go create mode 100644 backend/internal/infra/provider/browserheaders/chromium.go create mode 100644 backend/internal/infra/provider/console/account_identity.go create mode 100644 backend/internal/infra/provider/sessionidentity/session.go create mode 100644 backend/internal/infra/provider/web/account_identity.go create mode 100644 backend/internal/infra/provider/web/account_identity_test.go create mode 100644 frontend/src/features/accounts/account-name-cell.tsx diff --git a/backend/internal/app/application.go b/backend/internal/app/application.go index 6fd5dcd7f..3128e418d 100644 --- a/backend/internal/app/application.go +++ b/backend/internal/app/application.go @@ -330,7 +330,8 @@ func webProviderConfig(cfg config.Config) webprovider.Config { func consoleProviderConfig(cfg config.Config) consoleprovider.Config { return consoleprovider.Config{ - BaseURL: cfg.Provider.Console.BaseURL, TimeoutSeconds: int(cfg.Provider.Console.ChatTimeout.Value().Seconds()), + BaseURL: cfg.Provider.Console.BaseURL, SessionBaseURL: cfg.Provider.Web.BaseURL, + TimeoutSeconds: int(cfg.Provider.Console.ChatTimeout.Value().Seconds()), } } diff --git a/backend/internal/application/account/provider_links.go b/backend/internal/application/account/provider_links.go new file mode 100644 index 000000000..2118da27e --- /dev/null +++ b/backend/internal/application/account/provider_links.go @@ -0,0 +1,76 @@ +package account + +import ( + "context" + "fmt" + "strings" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +type providerLinkRepository interface { + ReconcileProviderLinks(ctx context.Context, accountID uint64) error + UpdateIdentityMetadata(ctx context.Context, accountID uint64, email, userID, teamID string) error +} + +// SyncAccountIdentity 尽力补充 Web/Console 的稳定上游身份,并据此建立高可信弱关联。 +// 该操作不修改账号凭据、健康、额度或路由状态。 +func (s *Service) SyncAccountIdentity(ctx context.Context, id uint64) error { + _, err, _ := s.identitySyncs.Do(fmt.Sprintf("%d", id), func() (any, error) { + return nil, s.syncAccountIdentity(ctx, id) + }) + return err +} + +func (s *Service) syncAccountIdentity(ctx context.Context, id uint64) error { + links, ok := s.accounts.(providerLinkRepository) + if !ok { + return nil + } + value, err := s.accounts.Get(ctx, id) + if err != nil { + return mapRepositoryError(err) + } + if value.Provider != accountdomain.ProviderWeb && value.Provider != accountdomain.ProviderConsole { + return nil + } + // Session 身份只需成功补充一次;已有 user_id 或 email 时仅做本地关联协调, + // 不再重复访问上游。没有任何身份数据的失败账号会在后续同步时重试。 + if strings.TrimSpace(value.UserID) != "" || strings.TrimSpace(value.Email) != "" { + return mapRepositoryError(links.ReconcileProviderLinks(ctx, id)) + } + if s.providers == nil { + return fmt.Errorf("Provider 注册表未初始化") + } + adapter, ok := s.providers.AccountIdentity(value.Provider) + if !ok { + return nil + } + identity, err := adapter.SyncAccountIdentity(ctx, value) + if err != nil { + return err + } + if len(identity.Email) > 255 || len(identity.UserID) > 255 || len(identity.TeamID) > 255 { + return fmt.Errorf("Grok Web Session 身份字段超过安全上限") + } + if err := links.UpdateIdentityMetadata(ctx, id, identity.Email, identity.UserID, identity.TeamID); err != nil { + return mapRepositoryError(err) + } + return mapRepositoryError(links.ReconcileProviderLinks(ctx, id)) +} + +func (s *Service) reconcileProviderLinksBestEffort(ctx context.Context, id uint64) { + links, ok := s.accounts.(providerLinkRepository) + if !ok { + return + } + if err := links.ReconcileProviderLinks(ctx, id); err != nil { + s.logger.Warn("account_provider_link_reconcile_failed", "account_id", id, "error", err) + } +} + +func (s *Service) syncAccountIdentityBestEffort(ctx context.Context, id uint64) { + if err := s.SyncAccountIdentity(ctx, id); err != nil { + s.logger.Warn("account_identity_sync_failed", "account_id", id, "error", err) + } +} diff --git a/backend/internal/application/account/provider_links_test.go b/backend/internal/application/account/provider_links_test.go new file mode 100644 index 000000000..ae94b27b7 --- /dev/null +++ b/backend/internal/application/account/provider_links_test.go @@ -0,0 +1,172 @@ +package account + +import ( + "context" + "errors" + "path/filepath" + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/persistence/relational" + "github.com/chenyme/grok2api/backend/internal/infra/provider" +) + +func TestSyncAccountIdentityLinksUniqueBuildWithoutSharingState(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: 7, MaxConcurrent: 3, + }) + if err != nil { + t.Fatal(err) + } + build, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderBuild, AuthType: accountdomain.AuthTypeOAuth, Name: "build", SourceKey: "build", UserID: "user-1", + EncryptedAccessToken: "encrypted", Enabled: false, AuthStatus: accountdomain.AuthStatusReauthRequired, Priority: 1, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + build.Enabled = false + build, err = repo.Update(ctx, build) + if err != nil { + t.Fatal(err) + } + adapter.identity = provider.AccountIdentity{UserID: "user-1", Email: "user@example.com"} + if err := service.SyncAccountIdentity(ctx, web.ID); err != nil { + t.Fatal(err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + build, err = repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + if web.UserID != "user-1" || web.Email != "user@example.com" || len(web.LinkedAccounts) != 1 || web.LinkedAccounts[0].ID != build.ID { + t.Fatalf("web = %#v", web) + } + if !web.Enabled || web.AuthStatus != accountdomain.AuthStatusActive || web.Priority != 7 || web.MaxConcurrent != 3 { + t.Fatalf("web operational state changed: %#v", web) + } + if build.Enabled || build.AuthStatus != accountdomain.AuthStatusReauthRequired { + t.Fatalf("build operational state changed: %#v", build) + } + if err := service.SyncAccountIdentity(ctx, web.ID); err != nil { + t.Fatal(err) + } + if adapter.identityCalls != 1 { + t.Fatalf("identity calls = %d", adapter.identityCalls) + } +} + +func TestSyncAccountIdentityFailureDoesNotInvalidateAccount(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", SourceKey: "sso:" + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: 1, MaxConcurrent: 8, + }) + if err != nil { + t.Fatal(err) + } + adapter.identityErr = provider.ErrUnauthorized + if err := service.SyncAccountIdentity(ctx, web.ID); !errors.Is(err, provider.ErrUnauthorized) { + t.Fatalf("err = %v", err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + if web.AuthStatus != accountdomain.AuthStatusActive || !web.Enabled || web.FailureCount != 0 { + t.Fatalf("identity failure changed account state: %#v", web) + } +} + +func TestSyncAccountIdentityDoesNotRepeatWhenEmailIsKnown(t *testing.T) { + t.Parallel() + ctx := context.Background() + service, repo, adapter := newWebAccountSettingsTestService(t) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", Email: "known@example.com", + SourceKey: "sso:" + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter.identity = provider.AccountIdentity{UserID: "stable-user", Email: "known@example.com"} + if err := service.SyncAccountIdentity(ctx, web.ID); err != nil { + t.Fatal(err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + if web.UserID != "" || web.Email != "known@example.com" || adapter.identityCalls != 0 { + t.Fatalf("identity was fetched again: user_id=%q email=%q calls=%d", web.UserID, web.Email, adapter.identityCalls) + } +} + +func TestSyncConsoleAccountIdentityLinksUniqueWebAccountOnce(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "console-identity.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := relational.NewAccountRepository(database) + web, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, Name: "web", + SourceKey: "sso:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + UserID: "same-user", EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + console, _, err := repo.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, Name: "console", + SourceKey: "console-sso:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: accountdomain.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + adapter := &consoleIdentityAdapterStub{identity: provider.AccountIdentity{UserID: "same-user", Email: "same@example.com"}} + service := NewService(repo, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + if err := service.SyncAccountIdentity(ctx, console.ID); err != nil { + t.Fatal(err) + } + if err := service.SyncAccountIdentity(ctx, console.ID); err != nil { + t.Fatal(err) + } + console, err = repo.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if adapter.calls != 1 || console.UserID != "same-user" || len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID { + t.Fatalf("calls=%d console=%#v", adapter.calls, console) + } +} + +type consoleIdentityAdapterStub struct { + identity provider.AccountIdentity + calls int +} + +func (*consoleIdentityAdapterStub) Provider() accountdomain.Provider { + return accountdomain.ProviderConsole +} + +func (a *consoleIdentityAdapterStub) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) { + a.calls++ + return a.identity, nil +} diff --git a/backend/internal/application/account/quota_refresh_test.go b/backend/internal/application/account/quota_refresh_test.go index 8cd5ba131..c7e4119de 100644 --- a/backend/internal/application/account/quota_refresh_test.go +++ b/backend/internal/application/account/quota_refresh_test.go @@ -94,6 +94,44 @@ func TestRefreshQuotaModeDoesNotTriggerFullProviderSyncForAutoTier(t *testing.T) } } +func TestRefreshQuotaFetchesWebIdentityOnlyUntilDataExists(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "quota-identity.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accounts := relational.NewAccountRepository(database) + credential, _, err := accounts.UpsertByIdentity(ctx, accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + Name: "web-identity", SourceKey: "web-identity", EncryptedAccessToken: "encrypted", + Enabled: true, AuthStatus: accountdomain.AuthStatusActive, WebTier: accountdomain.WebTierAuto, + }) + if err != nil { + t.Fatal(err) + } + adapter := "aCountingAdapter{} + service := NewService(accounts, nil, nil, nil, provider.NewRegistry(adapter), nil, nil) + for range 2 { + if _, err := service.RefreshQuota(ctx, credential.ID); err != nil { + t.Fatal(err) + } + } + if adapter.fullCalls.Load() != 2 || adapter.identityCalls.Load() != 1 { + t.Fatalf("quota calls=%d identity calls=%d", adapter.fullCalls.Load(), adapter.identityCalls.Load()) + } + stored, err := accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if stored.Email != "identity@example.com" { + t.Fatalf("email = %q", stored.Email) + } +} + type deniedQuotaRefreshLock struct{} func (deniedQuotaRefreshLock) Acquire(context.Context, string, time.Duration) (func(), bool, error) { @@ -101,8 +139,9 @@ func (deniedQuotaRefreshLock) Acquire(context.Context, string, time.Duration) (f } type quotaCountingAdapter struct { - modeCalls atomic.Int64 - fullCalls atomic.Int64 + modeCalls atomic.Int64 + fullCalls atomic.Int64 + identityCalls atomic.Int64 } func (a *quotaCountingAdapter) Provider() accountdomain.Provider { return accountdomain.ProviderWeb } @@ -119,6 +158,11 @@ func (a *quotaCountingAdapter) SyncQuota(context.Context, accountdomain.Credenti return provider.QuotaSnapshot{}, nil } +func (a *quotaCountingAdapter) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) { + a.identityCalls.Add(1) + return provider.AccountIdentity{Email: "identity@example.com"}, nil +} + func (a *quotaCountingAdapter) SyncQuotaMode(_ context.Context, credential accountdomain.Credential, mode string) (accountdomain.QuotaWindow, error) { a.modeCalls.Add(1) now := time.Now().UTC() diff --git a/backend/internal/application/account/service.go b/backend/internal/application/account/service.go index ada46c360..d6d3ff597 100644 --- a/backend/internal/application/account/service.go +++ b/backend/internal/application/account/service.go @@ -252,6 +252,7 @@ type Service struct { refreshes singleflight.Group billingSyncs singleflight.Group quotaSyncs singleflight.Group + identitySyncs singleflight.Group refreshMu sync.Mutex lastRefreshAt map[uint64]time.Time quotaRefreshMu sync.Mutex @@ -697,6 +698,7 @@ func (s *Service) PollDeviceLogin(ctx context.Context, sessionID string) (View, if err != nil { return View{}, err } + s.reconcileProviderLinksBestEffort(ctx, value.ID) _ = s.deviceSessions.Delete(ctx, sessionID) return s.Get(ctx, value.ID) } @@ -824,6 +826,7 @@ func (s *Service) persistImportedSeeds(ctx context.Context, seeds []provider.Cre } for _, value := range stored { result.AccountIDs = append(result.AccountIDs, value.ID) + s.reconcileProviderLinksBestEffort(ctx, value.ID) if observer != nil { if err := observer(value.ID); err != nil { return ImportResult{}, err @@ -1897,6 +1900,16 @@ func (s *Service) refreshQuota(ctx context.Context, id uint64) ([]accountdomain. } } } + // 身份补全是非关键操作:只在额度落库和恢复任务调度完成后执行, + // 并沿用调用方取消语义,不能反向影响额度同步结果。 + if (value.Provider == accountdomain.ProviderWeb || value.Provider == accountdomain.ProviderConsole) && ctx.Err() == nil { + if strings.TrimSpace(value.UserID) == "" && strings.TrimSpace(value.Email) == "" { + s.syncAccountIdentityBestEffort(ctx, id) + } else { + // 已有 Session 身份时只做本地增量关联,不再访问上游。 + s.reconcileProviderLinksBestEffort(ctx, id) + } + } return snapshot.Windows, nil } @@ -2374,6 +2387,9 @@ func (s *Service) credentialFromSeed(seed provider.CredentialSeed) (accountdomai authType = definition.Credential.AuthType } value := accountdomain.Credential{Provider: providerValue, AuthType: authType, WebTier: seed.WebTier, Name: seed.Name, Email: seed.Email, UserID: seed.UserID, TeamID: seed.TeamID, SourceKey: sourceKey, OIDCClientID: seed.OIDCClientID, EncryptedAccessToken: accessEncrypted, EncryptedRefreshToken: refreshEncrypted, EncryptedCloudflareCookie: cloudflareEncrypted, ExpiresAt: seed.ExpiresAt, Enabled: true, AuthStatus: accountdomain.AuthStatusActive, Priority: accountdomain.DefaultPriority, MaxConcurrent: accountdomain.DefaultMaxConcurrent, MinimumRemaining: accountdomain.DefaultMinimumRemaining} + if providerValue == accountdomain.ProviderWeb && strings.TrimSpace(seed.AccessToken) != "" { + value.EgressIdentity = "sso_" + security.HashToken(seed.AccessToken)[:32] + } return value, nil } diff --git a/backend/internal/application/account/web_account_settings.go b/backend/internal/application/account/web_account_settings.go index 01ffcd7b5..552a5c785 100644 --- a/backend/internal/application/account/web_account_settings.go +++ b/backend/internal/application/account/web_account_settings.go @@ -85,12 +85,19 @@ func (s *Service) runWebAccountScript(ctx context.Context, id uint64, options We if err != nil { return err } + options = pendingWebAccountScriptOptions(credential, options) + if !options.AcceptTerms && !options.SetBirthDate && !options.EnableNSFW { + return nil + } if options.AcceptTerms { if err := s.runWebAccountSetting(ctx, credential, "接受服务协议", func() error { return adapter.AcceptTerms(ctx, credential) }); err != nil { return err } + if err := s.recordWebAccountState(ctx, credential.ID, "服务协议", s.accounts.MarkWebTermsAccepted); err != nil { + return err + } } if options.SetBirthDate { if err := s.setRandomWebBirthDate(ctx, credential, adapter); err != nil { @@ -103,16 +110,30 @@ func (s *Service) runWebAccountScript(ctx context.Context, id uint64, options We }); err != nil { return err } - writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), webAccountStateWriteTimeout) - err := s.accounts.MarkWebNSFWEnabled(writeCtx, credential.ID, s.now().UTC()) - cancel() - if err != nil { - return fmt.Errorf("记录 NSFW 状态: %w", mapRepositoryError(err)) + if err := s.recordWebAccountState(ctx, credential.ID, "NSFW", s.accounts.MarkWebNSFWEnabled); err != nil { + return err } } return nil } +func pendingWebAccountScriptOptions(credential accountdomain.Credential, options WebAccountScriptOptions) WebAccountScriptOptions { + if credential.WebTermsAcceptedAt != nil { + options.AcceptTerms = false + } + birthDateRecorded := credential.WebBirthDateSetAt != nil || credential.WebNSFWEnabledAt != nil + if birthDateRecorded { + options.SetBirthDate = false + } + if credential.WebNSFWEnabledAt != nil { + options.EnableNSFW = false + } + if options.EnableNSFW && !birthDateRecorded { + options.SetBirthDate = true + } + return options +} + func (s *Service) setRandomWebBirthDate(ctx context.Context, credential accountdomain.Credential, adapter provider.WebAccountSettingsAdapter) error { birthDate, err := randomWebBirthDate(s.now().In(time.Local), cryptorand.Reader) if err != nil { @@ -121,10 +142,20 @@ func (s *Service) setRandomWebBirthDate(ctx context.Context, credential accountd err = s.runWebAccountSetting(ctx, credential, "设置生日", func() error { return adapter.SetBirthDate(ctx, credential, birthDate) }) - if errors.Is(err, provider.ErrBirthDateAlreadySet) { - return nil + if err != nil && !errors.Is(err, provider.ErrBirthDateAlreadySet) { + return err } - return err + return s.recordWebAccountState(ctx, credential.ID, "生日", s.accounts.MarkWebBirthDateSet) +} + +func (s *Service) recordWebAccountState(ctx context.Context, id uint64, state string, mark func(context.Context, uint64, time.Time) error) error { + writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), webAccountStateWriteTimeout) + err := mark(writeCtx, id, s.now().UTC()) + cancel() + if err != nil { + return fmt.Errorf("记录%s状态: %w", state, mapRepositoryError(err)) + } + return nil } func (s *Service) webAccountSettings(ctx context.Context, id uint64) (accountdomain.Credential, provider.WebAccountSettingsAdapter, error) { diff --git a/backend/internal/application/account/web_account_settings_test.go b/backend/internal/application/account/web_account_settings_test.go index 6a8caf6f1..6e6a16ddd 100644 --- a/backend/internal/application/account/web_account_settings_test.go +++ b/backend/internal/application/account/web_account_settings_test.go @@ -47,6 +47,25 @@ func TestWebAccountSettingsAreWebOnlyAndGenerateBirthDate(t *testing.T) { if adapter.terms != 1 || adapter.birthDate.Before(earliest) || adapter.birthDate.After(latest) || adapter.nsfw != 1 { t.Fatalf("adapter terms=%d birth=%v nsfw=%d", adapter.terms, adapter.birthDate, adapter.nsfw) } + updatedWeb, err := repo.Get(ctx, webAccount.ID) + if err != nil { + t.Fatal(err) + } + if updatedWeb.WebTermsAcceptedAt == nil || updatedWeb.WebBirthDateSetAt == nil || updatedWeb.WebNSFWEnabledAt == nil || !updatedWeb.WebTermsAcceptedAt.Equal(service.now()) || !updatedWeb.WebBirthDateSetAt.Equal(service.now()) || !updatedWeb.WebNSFWEnabledAt.Equal(service.now()) { + t.Fatalf("profile markers terms=%v birth=%v nsfw=%v", updatedWeb.WebTermsAcceptedAt, updatedWeb.WebBirthDateSetAt, updatedWeb.WebNSFWEnabledAt) + } + if err := service.AcceptWebTerms(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if err := service.SetWebBirthDate(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if err := service.EnableWebNSFW(ctx, webAccount.ID); err != nil { + t.Fatal(err) + } + if adapter.terms != 1 || adapter.birthCalls != 1 || adapter.nsfw != 1 { + t.Fatalf("recorded steps repeated: terms=%d birth=%d nsfw=%d", adapter.terms, adapter.birthCalls, adapter.nsfw) + } if err := service.AcceptWebTerms(ctx, buildAccount.ID); !errors.Is(err, ErrUnsupported) { t.Fatalf("build err = %v", err) } @@ -70,6 +89,30 @@ func TestRandomWebBirthDateUsesInclusiveAgeRange(t *testing.T) { } } +func TestPendingWebAccountScriptOptionsSkipsRecordedSteps(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + all := WebAccountScriptOptions{AcceptTerms: true, SetBirthDate: true, EnableNSFW: true} + tests := []struct { + name string + credential accountdomain.Credential + want WebAccountScriptOptions + }{ + {name: "none recorded", want: all}, + {name: "terms recorded", credential: accountdomain.Credential{WebTermsAcceptedAt: &now}, want: WebAccountScriptOptions{SetBirthDate: true, EnableNSFW: true}}, + {name: "birth recorded", credential: accountdomain.Credential{WebBirthDateSetAt: &now}, want: WebAccountScriptOptions{AcceptTerms: true, EnableNSFW: true}}, + {name: "nsfw implies birth", credential: accountdomain.Credential{WebNSFWEnabledAt: &now}, want: WebAccountScriptOptions{AcceptTerms: true}}, + {name: "all recorded", credential: accountdomain.Credential{WebTermsAcceptedAt: &now, WebBirthDateSetAt: &now, WebNSFWEnabledAt: &now}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := pendingWebAccountScriptOptions(test.credential, all); got != test.want { + t.Fatalf("options = %#v, want %#v", got, test.want) + } + }) + } +} + func TestWebBirthDateRangeHandlesLeapDay(t *testing.T) { t.Parallel() earliest, latest := webBirthDateRange(time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC)) @@ -137,14 +180,25 @@ func newWebAccountSettingsTestService(t *testing.T) (*Service, *relational.Accou } type webAccountSettingsAdapterStub struct { - mu sync.Mutex - terms int - birthDate time.Time - nsfw int - err error - calls map[uint64][]string - failures map[uint64]map[string]error - afterCall func(string) + mu sync.Mutex + terms int + birthDate time.Time + birthCalls int + nsfw int + err error + calls map[uint64][]string + failures map[uint64]map[string]error + afterCall func(string) + identity provider.AccountIdentity + identityErr error + identityCalls int +} + +func (a *webAccountSettingsAdapterStub) SyncAccountIdentity(context.Context, accountdomain.Credential) (provider.AccountIdentity, error) { + a.mu.Lock() + a.identityCalls++ + a.mu.Unlock() + return a.identity, a.identityErr } func (*webAccountSettingsAdapterStub) Provider() accountdomain.Provider { @@ -162,6 +216,7 @@ func (a *webAccountSettingsAdapterStub) SetBirthDate(_ context.Context, credenti a.mu.Lock() defer a.mu.Unlock() a.birthDate = value + a.birthCalls++ return a.recordLocked(credential.ID, "setBirthDate") } diff --git a/backend/internal/application/account/web_console_sync_test.go b/backend/internal/application/account/web_console_sync_test.go index 1594fd610..b3e81432d 100644 --- a/backend/internal/application/account/web_console_sync_test.go +++ b/backend/internal/application/account/web_console_sync_test.go @@ -145,6 +145,9 @@ func TestSyncWebAccountsToConsoleIsIdempotentAndPreservesBuildLink(t *testing.T) if updatedWeb.LinkedAccountID != buildAccount.ID || updatedWeb.LinkedProvider != accountdomain.ProviderBuild { t.Fatalf("updated web account = %#v", updatedWeb) } + if len(updatedWeb.LinkedAccounts) != 2 || updatedWeb.LinkedAccounts[0].Provider != accountdomain.ProviderBuild || updatedWeb.LinkedAccounts[1].Provider != accountdomain.ProviderConsole || updatedWeb.LinkedAccounts[1].ID != consoleAccount.ID { + t.Fatalf("updated Web links = %#v", updatedWeb.LinkedAccounts) + } _, total, err := accounts.List(ctx, repository.AccountListQuery{ Page: repository.PageQuery{Limit: 10}, Filter: repository.AccountListFilter{Provider: string(accountdomain.ProviderConsole)}, }) diff --git a/backend/internal/application/accountsync/service.go b/backend/internal/application/accountsync/service.go index 690e591b6..0afe07d31 100644 --- a/backend/internal/application/accountsync/service.go +++ b/backend/internal/application/accountsync/service.go @@ -45,6 +45,10 @@ type quotaSynchronizer interface { RefreshQuota(ctx context.Context, accountID uint64) ([]accountdomain.QuotaWindow, error) } +type identitySynchronizer interface { + SyncAccountIdentity(ctx context.Context, accountID uint64) error +} + // Service 对新接入账号执行一次性额度与模型补齐,并限制批量同步并发。 type Service struct { logger *slog.Logger @@ -204,6 +208,15 @@ func (s *Service) syncAccount(ctx context.Context, accountID uint64) error { if !ok { return fmt.Errorf("Provider %s 未注册生命周期策略", view.Credential.Provider) } + if view.Credential.Provider == accountdomain.ProviderWeb || view.Credential.Provider == accountdomain.ProviderConsole { + if identity, ok := s.accounts.(identitySynchronizer); ok { + operationCtx, cancel := context.WithTimeout(ctx, operationTimeout) + if err := identity.SyncAccountIdentity(operationCtx, accountID); err != nil { + s.logger.Warn("account_initial_identity_sync_failed", "account_id", accountID, "error", err) + } + cancel() + } + } if definition.Quota == provider.QuotaRemoteWindow || definition.Quota == provider.QuotaLocalWindow { hasQuota, quotaErr := s.quota.HasQuotaWindows(ctx, accountID) if quotaErr != nil { diff --git a/backend/internal/application/accountsync/service_test.go b/backend/internal/application/accountsync/service_test.go index 532174424..8076ff01f 100644 --- a/backend/internal/application/accountsync/service_test.go +++ b/backend/internal/application/accountsync/service_test.go @@ -34,6 +34,17 @@ type accountReaderStub struct { quota provider.QuotaKind } +type identityAccountReaderStub struct { + accountReaderStub + err error + calls int +} + +func (s *identityAccountReaderStub) SyncAccountIdentity(context.Context, uint64) error { + s.calls++ + return s.err +} + func (s accountReaderStub) Get(context.Context, uint64) (accountapp.View, error) { return accountapp.View{Credential: accountdomain.Credential{Provider: s.provider}}, nil } @@ -201,6 +212,24 @@ func TestSyncAccountUsesQuotaForConsoleProvider(t *testing.T) { } } +func TestSyncAccountIgnoresBestEffortSSOIdentityFailure(t *testing.T) { + for _, providerValue := range []accountdomain.Provider{accountdomain.ProviderWeb, accountdomain.ProviderConsole} { + t.Run(string(providerValue), func(t *testing.T) { + reader := &identityAccountReaderStub{accountReaderStub: accountReaderStub{provider: providerValue}, err: errors.New("session unavailable")} + quota := "aStub{} + models := &modelStub{hasSnapshot: true} + service := NewService(slog.Default(), reader, &billingStub{}, quota, models) + + if err := service.syncAccount(context.Background(), 10); err != nil { + t.Fatal(err) + } + if reader.calls != 1 || quota.syncs != 1 { + t.Fatalf("identity calls=%d quota syncs=%d", reader.calls, quota.syncs) + } + }) + } +} + func TestSyncAccountUsesDeclaredQuotaPolicyInsteadOfProviderName(t *testing.T) { billing := &billingStub{} quota := "aStub{} diff --git a/backend/internal/application/gateway/selector.go b/backend/internal/application/gateway/selector.go index 120989fea..c973d0de4 100644 --- a/backend/internal/application/gateway/selector.go +++ b/backend/internal/application/gateway/selector.go @@ -11,6 +11,7 @@ import ( "time" "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/pkg/resultcache" "github.com/chenyme/grok2api/backend/internal/repository" "golang.org/x/sync/singleflight" ) @@ -27,6 +28,8 @@ type accountLease struct { const quotaProbeLease = 5 * time.Minute const successPersistInterval = 30 * time.Second const candidateCacheTTL = time.Second +const concurrencySnapshotTTL = 25 * time.Millisecond +const maxConcurrencySnapshots = 256 const modelAccessDeniedCooldown = 5 * time.Minute @@ -87,22 +90,23 @@ func (l *accountLease) Release() { // Selector 实现可替换的 balanced 账号选择策略。 type Selector struct { - accounts repository.AccountRepository - concurrency repository.ConcurrencyLimiter - sticky repository.StickySessionRepository - stickyTTL time.Duration - cooldownBase time.Duration - cooldownMax time.Duration - capacityWait time.Duration - preferFreeBuild bool - mu sync.Mutex - leaseWakeMu sync.Mutex - leaseWake chan struct{} - lastSelectedAt map[uint64]time.Time - lastSuccessAt map[uint64]time.Time - candidates map[candidateCacheKey]candidateSnapshot - candidateLoads singleflight.Group - tierOrders interface { + accounts repository.AccountRepository + concurrency repository.ConcurrencyLimiter + sticky repository.StickySessionRepository + stickyTTL time.Duration + cooldownBase time.Duration + cooldownMax time.Duration + capacityWait time.Duration + preferFreeBuild bool + mu sync.Mutex + leaseWakeMu sync.Mutex + leaseWake chan struct{} + lastSelectedAt map[uint64]time.Time + lastSuccessAt map[uint64]time.Time + candidates map[candidateCacheKey]candidateSnapshot + candidateLoads singleflight.Group + concurrencySnapshots *resultcache.Cache[[32]byte, map[string]int] + tierOrders interface { TierOrder(account.Provider, string) []account.WebTier } } @@ -114,7 +118,7 @@ func NewSelector(accounts repository.AccountRepository, concurrency repository.C if len(capacityWait) > 0 && capacityWait[0] > 0 { wait = capacityWait[0] } - return &Selector{accounts: accounts, concurrency: concurrency, sticky: sticky, tierOrders: tierOrders, stickyTTL: stickyTTL, cooldownBase: cooldownBase, cooldownMax: cooldownMax, capacityWait: wait, leaseWake: make(chan struct{}), lastSelectedAt: make(map[uint64]time.Time), lastSuccessAt: make(map[uint64]time.Time), candidates: make(map[candidateCacheKey]candidateSnapshot)} + return &Selector{accounts: accounts, concurrency: concurrency, sticky: sticky, tierOrders: tierOrders, stickyTTL: stickyTTL, cooldownBase: cooldownBase, cooldownMax: cooldownMax, capacityWait: wait, leaseWake: make(chan struct{}), lastSelectedAt: make(map[uint64]time.Time), lastSuccessAt: make(map[uint64]time.Time), candidates: make(map[candidateCacheKey]candidateSnapshot), concurrencySnapshots: resultcache.New[[32]byte, map[string]int](maxConcurrencySnapshots, concurrencySnapshotTTL)} } func (s *Selector) UpdateConfig(stickyTTL, cooldownBase, cooldownMax time.Duration, capacityWait ...time.Duration) { @@ -148,15 +152,16 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr if err != nil { return nil, err } - normalCandidates := make([]account.RoutingCandidate, 0, len(values)) - probeCandidates := make([]account.RoutingCandidate, 0, len(values)) + // 仅保留候选下标,避免每个请求复制包含凭据、计费和额度结构的完整账号切片。 + normalCandidates := make([]int, 0, len(values)) + probeCandidates := make([]int, 0, len(values)) supportedCandidates := 0 consideredCandidates := 0 coolingCandidates := 0 modelCoolingCandidates := 0 quotaCandidates := 0 var earliestRetry time.Time - for _, candidate := range values { + for index, candidate := range values { value := candidate.Credential if excluded[value.ID] || value.AuthStatus != account.AuthStatusActive { continue @@ -179,7 +184,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr quotaRecovery := candidate.QuotaRecovery if quotaRecovery != nil && quotaRecovery.Status != account.QuotaRecoveryStatusActive { if allowQuotaProbe && quotaRecovery.NextProbeAt != nil && !now.Before(*quotaRecovery.NextProbeAt) { - probeCandidates = append(probeCandidates, candidate) + probeCandidates = append(probeCandidates, index) } else { quotaCandidates++ if quotaRecovery.NextProbeAt != nil { @@ -199,7 +204,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr } continue } - normalCandidates = append(normalCandidates, candidate) + normalCandidates = append(normalCandidates, index) } if len(normalCandidates) == 0 && len(probeCandidates) == 0 { reason := SelectionNoAccounts @@ -216,7 +221,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr return nil, &SelectionUnavailableError{Reason: reason, RetryAfter: retryDelay(now, earliestRetry)} } if len(probeCandidates) > 0 { - plan, err := s.planCandidates(ctx, probeCandidates, now, s.resolveTierOrder(provider, upstreamModel)) + plan, err := s.planCandidateIndexes(ctx, values, probeCandidates, now, s.resolveTierOrder(provider, upstreamModel)) if err != nil { return nil, err } @@ -249,7 +254,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr return nil, fmt.Errorf("读取会话粘滞状态: %w", err) } if ok { - candidate, eligible := routingCandidateByID(normalCandidates, stickyID) + candidate, eligible := routingCandidateByID(values, normalCandidates, stickyID) if eligible { stickyTTL, _, _, _ := s.routingConfig() boundID, bindErr := s.sticky.Bind(ctx, stickyKey, stickyID, now, now.Add(stickyTTL)) @@ -257,7 +262,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr return nil, fmt.Errorf("刷新会话粘滞状态: %w", bindErr) } if boundID != stickyID { - candidate, eligible = routingCandidateByID(normalCandidates, boundID) + candidate, eligible = routingCandidateByID(values, normalCandidates, boundID) stickyID = boundID } if eligible { @@ -278,7 +283,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr // 粘性账号仅因并发满载而暂时不可用时,先等待该账号;超时后允许本次请求临时借用 // 其他账号,但不覆盖原绑定,避免并行请求让活跃会话在账号池中来回抖动。 if saturatedStickyID != 0 { - plan, err := s.planCandidates(ctx, normalCandidates, time.Now().UTC(), s.resolveTierOrder(provider, upstreamModel)) + plan, err := s.planCandidateIndexes(ctx, values, normalCandidates, time.Now().UTC(), s.resolveTierOrder(provider, upstreamModel)) if err != nil { return nil, err } @@ -303,7 +308,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr waitDeadline := time.Now().Add(capacityWait) for { currentTime := time.Now().UTC() - plan, err := s.planCandidates(ctx, normalCandidates, currentTime, s.resolveTierOrder(provider, upstreamModel)) + plan, err := s.planCandidateIndexes(ctx, values, normalCandidates, currentTime, s.resolveTierOrder(provider, upstreamModel)) if err != nil { return nil, err } @@ -323,7 +328,7 @@ func (s *Selector) Acquire(ctx context.Context, provider account.Provider, upstr return nil, fmt.Errorf("写入会话粘滞状态: %w", bindErr) } if boundID != candidate.Credential.ID { - if boundCandidate, eligible := routingCandidateByID(normalCandidates, boundID); eligible { + if boundCandidate, eligible := routingCandidateByID(values, normalCandidates, boundID); eligible { boundLease, boundErr := s.acquirePinnedCapacity(ctx, boundCandidate.Credential) if boundErr == nil { lease.Release() @@ -368,8 +373,9 @@ func stickySessionKey(value string) string { return hex.EncodeToString(digest[:]) } -func routingCandidateByID(values []account.RoutingCandidate, accountID uint64) (account.RoutingCandidate, bool) { - for _, candidate := range values { +func routingCandidateByID(values []account.RoutingCandidate, indexes []int, accountID uint64) (account.RoutingCandidate, bool) { + for _, index := range indexes { + candidate := values[index] if candidate.Credential.ID == accountID { return candidate, true } @@ -567,17 +573,25 @@ func (s *Selector) ConsumeQuota(provider account.Provider, accountID uint64, mod if key.provider != provider { continue } + var next []account.RoutingCandidate for index := range snapshot.values { - candidate := &snapshot.values[index] + candidate := snapshot.values[index] if candidate.Credential.ID != accountID || candidate.QuotaWindow == nil || candidate.QuotaWindow.Mode != mode { continue } - window := *candidate.QuotaWindow + if next == nil { + next = append([]account.RoutingCandidate(nil), snapshot.values...) + } + window := *next[index].QuotaWindow window.Remaining = max(0, window.Remaining-amount) window.UpdatedAt = time.Now().UTC() - candidate.QuotaWindow = &window + next[index].QuotaWindow = &window + break + } + if next != nil { + snapshot.values = next + s.candidates[key] = snapshot } - s.candidates[key] = snapshot } } @@ -606,9 +620,8 @@ func (s *Selector) loadCandidates(ctx context.Context, provider account.Provider key := candidateCacheKey{provider: provider, upstreamModel: upstreamModel, quotaMode: quotaMode} s.mu.Lock() if snapshot, ok := s.candidates[key]; ok && now.Before(snapshot.expiresAt) { - values := append([]account.RoutingCandidate(nil), snapshot.values...) s.mu.Unlock() - return values, nil + return snapshot.values, nil } s.mu.Unlock() loadKey := string(provider) + "\x00" + upstreamModel + "\x00" + quotaMode @@ -616,9 +629,8 @@ func (s *Selector) loadCandidates(ctx context.Context, provider account.Provider checkTime := time.Now().UTC() s.mu.Lock() if snapshot, ok := s.candidates[key]; ok && checkTime.Before(snapshot.expiresAt) { - values := append([]account.RoutingCandidate(nil), snapshot.values...) s.mu.Unlock() - return values, nil + return snapshot.values, nil } s.mu.Unlock() values, err := s.accounts.ListRoutingCandidates(ctx, provider, upstreamModel, quotaMode) @@ -626,14 +638,14 @@ func (s *Selector) loadCandidates(ctx context.Context, provider account.Provider return nil, err } s.mu.Lock() - s.candidates[key] = candidateSnapshot{values: append([]account.RoutingCandidate(nil), values...), expiresAt: checkTime.Add(candidateCacheTTL)} + s.candidates[key] = candidateSnapshot{values: values, expiresAt: checkTime.Add(candidateCacheTTL)} s.mu.Unlock() return values, nil }) if err != nil { return nil, err } - return append([]account.RoutingCandidate(nil), loaded.([]account.RoutingCandidate)...), nil + return loaded.([]account.RoutingCandidate), nil } func (s *Selector) invalidateCandidates(provider account.Provider) { diff --git a/backend/internal/application/gateway/selector_plan.go b/backend/internal/application/gateway/selector_plan.go index 1a50ac2cb..358f3a7b8 100644 --- a/backend/internal/application/gateway/selector_plan.go +++ b/backend/internal/application/gateway/selector_plan.go @@ -3,6 +3,7 @@ package gateway import ( "container/heap" "context" + "crypto/sha256" "fmt" "strconv" "time" @@ -91,45 +92,51 @@ func candidateScoreBetter(values []account.RoutingCandidate, leftScore, rightSco // planCandidates 批量读取动态并发状态,并以 O(n) 建堆生成保持原比较规则的候选计划。 func (s *Selector) planCandidates(ctx context.Context, values []account.RoutingCandidate, now time.Time, tierOrder []account.WebTier) (*candidatePlan, error) { - keys := make([]string, len(values)) - for index, candidate := range values { - keys[index] = accountConcurrencyKey(candidate.Credential.ID) - } - var concurrencySnapshot map[string]int - batchReader, batched := s.concurrency.(repository.ConcurrencySnapshotReader) - if batched { - var err error - concurrencySnapshot, err = batchReader.CurrentMany(ctx, keys) - if err != nil { - return nil, fmt.Errorf("批量读取账号并发租约: %w", err) - } + return s.planCandidateIndexes(ctx, values, nil, now, tierOrder) +} + +// planCandidateIndexes 在不可变候选快照上按下标规划,避免过滤阶段复制完整账号结构。 +// indexes 为 nil 时表示使用 values 的全部元素。 +func (s *Selector) planCandidateIndexes(ctx context.Context, values []account.RoutingCandidate, indexes []int, now time.Time, tierOrder []account.WebTier) (*candidatePlan, error) { + length := len(indexes) + if indexes == nil { + length = len(values) } - inFlight := make([]int, len(values)) - for index := range values { - if batched { - inFlight[index] = concurrencySnapshot[keys[index]] - continue - } - current, err := s.concurrency.Current(ctx, keys[index]) - if err != nil { - return nil, fmt.Errorf("读取账号并发租约: %w", err) + keys := make([]string, length) + for position := range length { + index := position + if indexes != nil { + index = indexes[position] } - inFlight[index] = current + keys[position] = accountConcurrencyKey(values[index].Credential.ID) + } + concurrencySnapshot, err := s.loadConcurrencySnapshot(ctx, keys) + if err != nil { + return nil, err + } + inFlight := make([]int, length) + for position := range length { + inFlight[position] = concurrencySnapshot[keys[position]] } s.mu.Lock() - scores := make([]candidateScore, len(values)) - for index, candidate := range values { + scores := make([]candidateScore, length) + for position := range length { + index := position + if indexes != nil { + index = indexes[position] + } + candidate := values[index] score := candidateScore{ index: index, tier: tierOrderRank(tierOrder, candidate.Credential.WebTier), preferFreeBuild: s.preferFreeBuild && candidate.IsKnownFreeBuild(), - inFlight: inFlight[index], lastSelected: s.lastSelectedAt[candidate.Credential.ID], + inFlight: inFlight[position], lastSelected: s.lastSelectedAt[candidate.Credential.ID], } if candidate.Billing != nil { score.remaining = candidate.Billing.Remaining() score.billingFresh = now.Sub(candidate.Billing.SyncedAt) <= 30*time.Minute } - scores[index] = score + scores[position] = score } s.mu.Unlock() @@ -138,6 +145,48 @@ func (s *Selector) planCandidates(ctx context.Context, values []account.RoutingC return plan, nil } +// loadConcurrencySnapshot 在极短窗口内合并相同候选池的并发快照读取。 +// 快照只参与排序,最终容量仍由原子 Acquire 校验,因此陈旧快照不会突破账号并发上限。 +func (s *Selector) loadConcurrencySnapshot(ctx context.Context, keys []string) (map[string]int, error) { + cacheKey := concurrencySnapshotKey(keys) + load := func() (map[string]int, error) { + values := make(map[string]int, len(keys)) + if batchReader, ok := s.concurrency.(repository.ConcurrencySnapshotReader); ok { + var err error + values, err = batchReader.CurrentMany(ctx, keys) + if err != nil { + return nil, fmt.Errorf("批量读取账号并发租约: %w", err) + } + } else { + for _, key := range keys { + current, err := s.concurrency.Current(ctx, key) + if err != nil { + return nil, fmt.Errorf("读取账号并发租约: %w", err) + } + values[key] = current + } + } + return values, nil + } + // 仅测试中的手工 Selector 可能没有初始化缓存,保持最小兼容回退。 + if s.concurrencySnapshots == nil { + return load() + } + return s.concurrencySnapshots.Load(ctx, cacheKey, time.Now(), load) +} + +func concurrencySnapshotKey(keys []string) [32]byte { + hash := sha256.New() + separator := []byte{0} + for _, key := range keys { + _, _ = hash.Write([]byte(key)) + _, _ = hash.Write(separator) + } + var result [32]byte + copy(result[:], hash.Sum(nil)) + return result +} + func accountConcurrencyKey(accountID uint64) string { return "account:" + strconv.FormatUint(accountID, 10) } diff --git a/backend/internal/application/gateway/selector_test.go b/backend/internal/application/gateway/selector_test.go index 8dbef0bba..25f33a241 100644 --- a/backend/internal/application/gateway/selector_test.go +++ b/backend/internal/application/gateway/selector_test.go @@ -100,8 +100,7 @@ func BenchmarkSelectorCandidatePlanning(b *testing.B) { b.ResetTimer() b.RunParallel(func(parallel *testing.PB) { for parallel.Next() { - values := append([]account.RoutingCandidate(nil), candidates...) - plan, err := selector.planCandidates(ctx, values, now, nil) + plan, err := selector.planCandidates(ctx, candidates, now, nil) if err != nil { b.Fatal(err) } @@ -389,7 +388,7 @@ func TestStickySessionKeyIsFixedLengthAndStable(t *testing.T) { func TestSelectorUsesBatchConcurrencySnapshot(t *testing.T) { limiter := &batchConcurrencyLimiter{values: map[string]int{"account:1": 2, "account:2": 1}} - selector := &Selector{concurrency: limiter, lastSelectedAt: make(map[uint64]time.Time)} + selector := NewSelector(nil, limiter, nil, nil, time.Hour, time.Second, time.Minute) values := []account.RoutingCandidate{ {Credential: account.Credential{ID: 1, Priority: 1}}, {Credential: account.Credential{ID: 2, Priority: 1}}, @@ -402,6 +401,19 @@ func TestSelectorUsesBatchConcurrencySnapshot(t *testing.T) { if limiter.batchCalls != 1 || limiter.currentCalls != 0 || !ok || first.Credential.ID != 2 { t.Fatalf("batchCalls=%d currentCalls=%d values=%#v", limiter.batchCalls, limiter.currentCalls, values) } + if _, err := selector.planCandidates(context.Background(), values, time.Now().UTC(), nil); err != nil { + t.Fatal(err) + } + if limiter.batchCalls != 1 { + t.Fatalf("short snapshot cache made %d batch reads", limiter.batchCalls) + } + time.Sleep(concurrencySnapshotTTL + 5*time.Millisecond) + if _, err := selector.planCandidates(context.Background(), values, time.Now().UTC(), nil); err != nil { + t.Fatal(err) + } + if limiter.batchCalls != 2 { + t.Fatalf("expired snapshot cache made %d batch reads", limiter.batchCalls) + } } func TestSelectorPreferFreeBuildHotReloadAndSaturationFallback(t *testing.T) { @@ -517,11 +529,15 @@ func TestSelectorConsumesOnlyMatchingQuotaSnapshot(t *testing.T) { Credential: account.Credential{ID: 7}, QuotaWindow: &account.QuotaWindow{AccountID: 7, Mode: "fast", Remaining: 10}, }}}, }} + original := selector.candidates[key].values selector.ConsumeQuota(account.ProviderWeb, 7, "fast", 3) window := selector.candidates[key].values[0].QuotaWindow if window == nil || window.Remaining != 7 { t.Fatalf("quota window = %#v", window) } + if original[0].QuotaWindow == nil || original[0].QuotaWindow.Remaining != 10 { + t.Fatalf("published snapshot was mutated: %#v", original[0].QuotaWindow) + } } func TestSelectorWaitsBrieflyForAccountCapacity(t *testing.T) { diff --git a/backend/internal/domain/account/account.go b/backend/internal/domain/account/account.go index d117abea4..54b3777cb 100644 --- a/backend/internal/domain/account/account.go +++ b/backend/internal/domain/account/account.go @@ -16,6 +16,16 @@ const ( ProviderConsole Provider = "grok_console" ) +// LinkedAccount 表示同一上游用户在另一 Provider 下的弱关联账号。 +// 关联只用于出口身份与管理端展示,不共享额度、健康或路由状态。 +type LinkedAccount struct { + ID uint64 + Provider Provider + Name string + Email string + UserID string +} + var providers = [...]Provider{ProviderBuild, ProviderWeb, ProviderConsole} // Providers 返回按产品展示和后台维护顺序排列的稳定 Provider 集合。 @@ -129,12 +139,22 @@ type Credential struct { ObservedModelAt *time.Time WebTier WebTier WebTierSyncedAt *time.Time + // EgressIdentity 是不含凭据和个人信息的稳定出口身份。 + // 关联到同一 Web 账号的 Build/Console 只共享该值,不共享任何运行状态。 + EgressIdentity string // WebNSFWEnabledAt 记录 Grok Web 上游首次确认 NSFW 已成功开启的时间。 // 普通导入、额度同步和凭据更新不得清除。 - WebNSFWEnabledAt *time.Time + WebNSFWEnabledAt *time.Time + // WebTermsAcceptedAt 记录 Grok Web 上游首次确认服务协议已接受的时间。 + // 关联渠道只共享该展示状态,不获得修改 Web 资料的能力。 + WebTermsAcceptedAt *time.Time + // WebBirthDateSetAt 记录 Grok Web 上游首次确认生日已设置的时间。 + // 该字段用于避免批量脚本重复请求不可修改的生日接口。 + WebBirthDateSetAt *time.Time LinkedAccountID uint64 LinkedAccountName string LinkedProvider Provider + LinkedAccounts []LinkedAccount // BuildAPIFallback 仅记录 grok_build 曾因当次 Build 403 成功回退到 XAI。 // 它不参与路由;每个新请求仍先走 Build,只有当次严格 403 才可尝试 XAI。 // token refresh / SSO 转换 / 普通 upsert / 重启不得清除。 diff --git a/backend/internal/infra/egress/manager.go b/backend/internal/infra/egress/manager.go index 407963be8..da33c6b77 100644 --- a/backend/internal/infra/egress/manager.go +++ b/backend/internal/infra/egress/manager.go @@ -24,6 +24,9 @@ import ( const DefaultUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" const nodeSnapshotTTL = time.Second const stickyProxyRetryLimit = 2 +const clientCacheIdleTTL = 30 * time.Minute +const clientCacheCleanupInterval = time.Minute +const maxCachedClients = 4096 type Lease struct { NodeID uint64 @@ -57,18 +60,20 @@ func (l *Lease) Release() { } type Manager struct { - repository repository.EgressRepository - cipher *security.Cipher - mu sync.Mutex - clients map[clientCacheKey]cachedClient - inflight map[uint64]int - nodes map[domain.Scope]cachedNodeSnapshot - nodeLoads singleflight.Group + repository repository.EgressRepository + cipher *security.Cipher + mu sync.Mutex + clients map[clientCacheKey]cachedClient + inflight map[uint64]int + nodes map[domain.Scope]cachedNodeSnapshot + nodeLoads singleflight.Group + lastClientCleanup time.Time } type cachedClient struct { - client requestClient - browser *browserClient + client requestClient + browser *browserClient + lastUsed time.Time } type clientCacheKey struct { @@ -94,7 +99,10 @@ func (m *Manager) Acquire(ctx context.Context, scope domain.Scope, affinity stri // AcquireCredential binds the outbound proxy identity to one persisted // Provider credential. Resin templates use this identity as their Account. func (m *Manager) AcquireCredential(ctx context.Context, scope domain.Scope, credential accountdomain.Credential) (*Lease, error) { - identity := string(credential.Provider) + "_" + strconv.FormatUint(credential.ID, 10) + identity := strings.TrimSpace(credential.EgressIdentity) + if identity == "" { + identity = string(credential.Provider) + "_" + strconv.FormatUint(credential.ID, 10) + } credentialCookies := "" if scope != domain.ScopeBuild && strings.TrimSpace(credential.EncryptedCloudflareCookie) != "" { cookies, decryptErr := m.cipher.Decrypt(credential.EncryptedCloudflareCookie) @@ -108,7 +116,7 @@ func (m *Manager) AcquireCredential(ctx context.Context, scope domain.Scope, cre // otherwise the proxy rotates the IP while the clearance remains bound to // the other lease. The digest is non-reversible and is only used as a proxy // template account label. - if credential.AuthType == accountdomain.AuthTypeSSO && strings.TrimSpace(credential.EncryptedAccessToken) != "" { + if strings.TrimSpace(credential.EgressIdentity) == "" && credential.AuthType == accountdomain.AuthTypeSSO && strings.TrimSpace(credential.EncryptedAccessToken) != "" { token, decryptErr := m.cipher.Decrypt(credential.EncryptedAccessToken) if decryptErr != nil { return nil, decryptErr @@ -335,9 +343,13 @@ func (m *Manager) clientFor(id uint64, scope domain.Scope, proxyURL, userAgent, cacheScope = domain.ScopeWeb } key := clientCacheKey{nodeID: id, scope: cacheScope, fingerprint: fingerprint} + now := time.Now() m.mu.Lock() defer m.mu.Unlock() + m.cleanupClientCacheLocked(now) if cached, ok := m.clients[key]; ok { + cached.lastUsed = now + m.clients[key] = cached return cached, nil } var value cachedClient @@ -355,6 +367,7 @@ func (m *Manager) clientFor(id uint64, scope domain.Scope, proxyURL, userAgent, value.client = client value.browser = client } + value.lastUsed = now // 固定代理同节点出现新指纹说明配置已更新,旧连接池应淘汰。 // 账号模板代理的指纹会随 Resin Account 变化,必须并存才能维持各账号的粘性连接池。 // 直连节点统一使用 ID 0,不同 Provider 的传输必须并存,避免 Build 与 Web 互相重建客户端。 @@ -363,16 +376,53 @@ func (m *Manager) clientFor(id uint64, scope domain.Scope, proxyURL, userAgent, if previousKey.nodeID != id { continue } - if previous.client != nil { - previous.client.CloseIdleConnections() - } - delete(m.clients, previousKey) + m.evictClientLocked(previousKey, previous) } } + m.ensureClientCacheCapacityLocked() m.clients[key] = value return value, nil } +func (m *Manager) cleanupClientCacheLocked(now time.Time) { + if m.clients == nil { + m.clients = make(map[clientCacheKey]cachedClient) + } + if !m.lastClientCleanup.IsZero() && now.Sub(m.lastClientCleanup) < clientCacheCleanupInterval { + return + } + m.lastClientCleanup = now + for key, value := range m.clients { + if !value.lastUsed.IsZero() && now.Sub(value.lastUsed) >= clientCacheIdleTTL { + m.evictClientLocked(key, value) + } + } +} + +func (m *Manager) ensureClientCacheCapacityLocked() { + for len(m.clients) >= maxCachedClients { + var oldestKey clientCacheKey + var oldest cachedClient + found := false + for key, value := range m.clients { + if !found || value.lastUsed.Before(oldest.lastUsed) { + oldestKey, oldest, found = key, value, true + } + } + if !found { + break + } + m.evictClientLocked(oldestKey, oldest) + } +} + +func (m *Manager) evictClientLocked(key clientCacheKey, value cachedClient) { + if value.client != nil { + value.client.CloseIdleConnections() + } + delete(m.clients, key) +} + func (m *Manager) Feedback(ctx context.Context, nodeID uint64, status int, transportErr error) { m.FeedbackForScope(ctx, domain.ScopeWeb, nodeID, status, transportErr) } diff --git a/backend/internal/infra/egress/manager_test.go b/backend/internal/infra/egress/manager_test.go index f9e832a7b..14ba84efb 100644 --- a/backend/internal/infra/egress/manager_test.go +++ b/backend/internal/infra/egress/manager_test.go @@ -24,6 +24,38 @@ func TestDirectFallbackRebuildsClientAfterAntiBotRejection(t *testing.T) { } } +func TestClientCacheEvictsIdleEntriesAndEnforcesCapacity(t *testing.T) { + now := time.Now() + idleClient := &scriptedRequestClient{} + freshClient := &scriptedRequestClient{} + idleKey := clientCacheKey{nodeID: 1, scope: domain.ScopeWeb, fingerprint: "idle"} + freshKey := clientCacheKey{nodeID: 1, scope: domain.ScopeWeb, fingerprint: "fresh"} + manager := &Manager{clients: map[clientCacheKey]cachedClient{ + idleKey: {client: idleClient, lastUsed: now.Add(-clientCacheIdleTTL)}, + freshKey: {client: freshClient, lastUsed: now}, + }} + manager.cleanupClientCacheLocked(now) + if _, exists := manager.clients[idleKey]; exists || idleClient.closedIdle != 1 { + t.Fatalf("idle client exists=%v closed=%d", exists, idleClient.closedIdle) + } + if _, exists := manager.clients[freshKey]; !exists || freshClient.closedIdle != 0 { + t.Fatalf("fresh client exists=%v closed=%d", exists, freshClient.closedIdle) + } + + oldestClient := &scriptedRequestClient{} + oldestKey := clientCacheKey{nodeID: 2, scope: domain.ScopeBuild, fingerprint: "oldest"} + manager.clients = make(map[clientCacheKey]cachedClient, maxCachedClients) + manager.clients[oldestKey] = cachedClient{client: oldestClient, lastUsed: now.Add(-time.Hour)} + for index := 1; index < maxCachedClients; index++ { + key := clientCacheKey{nodeID: uint64(index + 2), scope: domain.ScopeBuild, fingerprint: "cached"} + manager.clients[key] = cachedClient{lastUsed: now} + } + manager.ensureClientCacheCapacityLocked() + if len(manager.clients) != maxCachedClients-1 || oldestClient.closedIdle != 1 { + t.Fatalf("cache size=%d oldest closed=%d", len(manager.clients), oldestClient.closedIdle) + } +} + func TestDirectBuildAndWebClientsDoNotEvictEachOther(t *testing.T) { cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") if err != nil { @@ -248,6 +280,51 @@ func TestAcquireCredentialRendersResinAccountAndOverridesNodeCookie(t *testing.T } } +func TestLinkedProvidersSharePersistedResinIdentity(t *testing.T) { + cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + if err != nil { + t.Fatal(err) + } + proxyURL, err := cipher.Encrypt("socks5h://Default.{account}:token@resin:2260") + if err != nil { + t.Fatal(err) + } + firstToken, _ := cipher.Encrypt("first-sso") + rotatedToken, _ := cipher.Encrypt("rotated-sso") + manager := NewManager(egressRepositoryTestStub{nodes: []domain.Node{ + {ID: 1, Name: "web", Scope: domain.ScopeWeb, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + {ID: 2, Name: "build", Scope: domain.ScopeBuild, Enabled: true, Health: 1, EncryptedProxyURL: proxyURL}, + }}, cipher) + const identity = "sso_persisted_identity" + web, err := manager.AcquireCredential(context.Background(), domain.ScopeWeb, accountdomain.Credential{ + ID: 11, Provider: accountdomain.ProviderWeb, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: firstToken, EgressIdentity: identity, + }) + if err != nil { + t.Fatal(err) + } + defer web.Release() + console, err := manager.AcquireCredential(context.Background(), domain.ScopeConsole, accountdomain.Credential{ + ID: 22, Provider: accountdomain.ProviderConsole, AuthType: accountdomain.AuthTypeSSO, + EncryptedAccessToken: rotatedToken, EgressIdentity: identity, + }) + if err != nil { + t.Fatal(err) + } + defer console.Release() + buildCtx := WithCredential(context.Background(), accountdomain.Credential{ID: 33, Provider: accountdomain.ProviderBuild, EgressIdentity: identity}) + build, configured, err := manager.AcquireIfConfigured(buildCtx, domain.ScopeBuild, AccountFromContext(buildCtx)) + if err != nil || !configured { + t.Fatalf("build configured=%v err=%v", configured, err) + } + defer build.Release() + for name, proxy := range map[string]string{"web": web.ProxyURL, "console": console.ProxyURL, "build": build.ProxyURL} { + if !strings.Contains(proxy, "Default."+identity+":") { + t.Fatalf("%s proxy = %q", name, proxy) + } + } +} + func TestConsoleFallsBackToWebAndSharesSSOResinIdentity(t *testing.T) { cipher, err := security.NewCipher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") if err != nil { diff --git a/backend/internal/infra/egress/trace.go b/backend/internal/infra/egress/trace.go index aaf68e089..a1652263a 100644 --- a/backend/internal/infra/egress/trace.go +++ b/backend/internal/infra/egress/trace.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" domain "github.com/chenyme/grok2api/backend/internal/domain/egress" ) @@ -37,6 +38,19 @@ func WithAccount(ctx context.Context, provider string, accountID uint64) context return WithAccountIdentity(ctx, strings.TrimSpace(provider)+"_"+fmt.Sprintf("%d", accountID)) } +// WithCredential 将弱关联账号的稳定出口身份传给 Build 传输;未关联账号保持原有 Provider+ID 身份。 +func WithCredential(ctx context.Context, credential accountdomain.Credential) context.Context { + identity := strings.TrimSpace(credential.EgressIdentity) + if identity == "" { + provider := credential.Provider + if provider == "" { + provider = accountdomain.ProviderBuild + } + return WithAccount(ctx, string(provider), credential.ID) + } + return WithAccountIdentity(ctx, identity) +} + // WithAccountIdentity attaches the stable, non-sensitive identity used by // account-bound proxy templates such as Resin. Providers that represent the // same upstream login (for example Web and Console sharing one SSO token) can diff --git a/backend/internal/infra/persistence/relational/account_links.go b/backend/internal/infra/persistence/relational/account_links.go new file mode 100644 index 000000000..41dd734ed --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_links.go @@ -0,0 +1,217 @@ +package relational + +import ( + "context" + "errors" + "log/slog" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/repository" + "gorm.io/gorm" +) + +func linkWebToConsole(tx *gorm.DB, webAccountID, consoleAccountID uint64) error { + var webAccount, consoleAccount accountModel + if err := tx.Select("id", "provider").First(&webAccount, webAccountID).Error; err != nil { + return err + } + if err := tx.Select("id", "provider").First(&consoleAccount, consoleAccountID).Error; err != nil { + return err + } + if webAccount.Provider != string(account.ProviderWeb) || consoleAccount.Provider != string(account.ProviderConsole) { + return repository.ErrConflict + } + var existing webConsoleAccountLinkModel + err := tx.Where("web_account_id = ? OR console_account_id = ?", webAccountID, consoleAccountID).First(&existing).Error + if err == nil { + if existing.WebAccountID == webAccountID && existing.ConsoleAccountID == consoleAccountID { + return nil + } + slog.Debug("account_provider_link_reconcile_skipped", + "relation", "web_console", + "reason", "existing_relation_conflict", + "web_account_id", webAccountID, + "console_account_id", consoleAccountID, + "existing_web_account_id", existing.WebAccountID, + "existing_console_account_id", existing.ConsoleAccountID, + ) + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&webConsoleAccountLinkModel{WebAccountID: webAccountID, ConsoleAccountID: consoleAccountID, CreatedAt: time.Now().UTC()}).Error +} + +func (r *AccountRepository) UpdateIdentityMetadata(ctx context.Context, accountID uint64, email, userID, teamID string) error { + if accountID == 0 { + return repository.ErrNotFound + } + updates := make(map[string]any, 3) + if email = strings.TrimSpace(email); email != "" { + updates["email"] = email + } + if userID = strings.TrimSpace(userID); userID != "" { + updates["user_id"] = userID + } + if teamID = strings.TrimSpace(teamID); teamID != "" { + updates["team_id"] = teamID + } + if len(updates) == 0 { + return nil + } + result := r.db.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", accountID).Updates(updates) + if result.Error != nil { + return mapError(result.Error) + } + if result.RowsAffected == 0 { + return repository.ErrNotFound + } + return nil +} + +// ReconcileProviderLinks 只建立无歧义的高可信关系;已有不同关系和多候选均保持不变。 +func (r *AccountRepository) ReconcileProviderLinks(ctx context.Context, accountID uint64) error { + if accountID == 0 { + return repository.ErrNotFound + } + return mapError(r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var value accountModel + if err := tx.Select("id", "provider", "source_key", "user_id", "team_id").First(&value, accountID).Error; err != nil { + return err + } + switch account.Provider(value.Provider) { + case account.ProviderWeb: + if consoleSource, ok := matchingConsoleSourceKey(value.SourceKey); ok { + if candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_console", account.ProviderConsole, "source_key = ?", consoleSource); err != nil { + return err + } else if found { + if err := linkWebToConsole(tx, value.ID, candidate.ID); err != nil { + return err + } + } + } + if err := reconcileWebConsoleByUserID(tx, value, true); err != nil { + return err + } + return reconcileWebBuildByUserID(tx, value, true) + case account.ProviderConsole: + if webSource, ok := matchingWebSourceKey(value.SourceKey); ok { + if candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_console", account.ProviderWeb, "source_key = ?", webSource); err != nil { + return err + } else if found { + return linkWebToConsole(tx, candidate.ID, value.ID) + } + } + return reconcileWebConsoleByUserID(tx, value, false) + case account.ProviderBuild: + return reconcileWebBuildByUserID(tx, value, false) + } + return nil + })) +} + +func reconcileWebConsoleByUserID(tx *gorm.DB, value accountModel, valueIsWeb bool) error { + userID := strings.TrimSpace(value.UserID) + if userID == "" { + return nil + } + provider := account.ProviderWeb + if valueIsWeb { + provider = account.ProviderConsole + } + candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_console", provider, "user_id = ?", userID) + if err != nil || !found { + return err + } + webID, consoleID := candidate.ID, value.ID + if valueIsWeb { + webID, consoleID = value.ID, candidate.ID + } + return linkWebToConsole(tx, webID, consoleID) +} + +func reconcileWebBuildByUserID(tx *gorm.DB, value accountModel, valueIsWeb bool) error { + userID := strings.TrimSpace(value.UserID) + if userID == "" { + return nil + } + provider := account.ProviderWeb + if valueIsWeb { + provider = account.ProviderBuild + } + candidate, found, err := uniqueLinkCandidate(tx, value.ID, "web_build", provider, "user_id = ?", userID) + if err != nil || !found { + return err + } + webID, buildID := candidate.ID, value.ID + if valueIsWeb { + webID, buildID = value.ID, candidate.ID + } + return linkWebToBuildIfUnambiguous(tx, webID, buildID) +} + +func linkWebToBuildIfUnambiguous(tx *gorm.DB, webAccountID, buildAccountID uint64) error { + var existing accountProviderLinkModel + err := tx.Where("web_account_id = ? OR build_account_id = ?", webAccountID, buildAccountID).First(&existing).Error + if err == nil { + if existing.WebAccountID != webAccountID || existing.BuildAccountID != buildAccountID { + slog.Debug("account_provider_link_reconcile_skipped", + "relation", "web_build", + "reason", "existing_relation_conflict", + "web_account_id", webAccountID, + "build_account_id", buildAccountID, + "existing_web_account_id", existing.WebAccountID, + "existing_build_account_id", existing.BuildAccountID, + ) + } + return nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(&accountProviderLinkModel{WebAccountID: webAccountID, BuildAccountID: buildAccountID, CreatedAt: time.Now().UTC()}).Error +} + +func uniqueLinkCandidate(tx *gorm.DB, sourceAccountID uint64, relation string, provider account.Provider, predicate string, args ...any) (accountModel, bool, error) { + var values []accountModel + err := tx.Select("id", "provider", "source_key", "user_id", "team_id"). + Where("provider = ?", provider).Where(predicate, args...).Limit(2).Find(&values).Error + if err != nil { + return accountModel{}, false, err + } + if len(values) > 1 { + slog.Debug("account_provider_link_reconcile_skipped", + "relation", relation, + "reason", "ambiguous_candidates", + "account_id", sourceAccountID, + "candidate_provider", provider, + "candidate_count", len(values), + ) + } + if len(values) != 1 { + return accountModel{}, false, nil + } + return values[0], true, nil +} + +func matchingConsoleSourceKey(webSourceKey string) (string, bool) { + if _, ok := egressIdentityFromWebSourceKey(webSourceKey); !ok { + return "", false + } + return "console-" + strings.TrimSpace(webSourceKey), true +} + +func matchingWebSourceKey(consoleSourceKey string) (string, bool) { + value := strings.TrimSpace(consoleSourceKey) + if !strings.HasPrefix(value, "console-") { + return "", false + } + webSource := strings.TrimPrefix(value, "console-") + if _, ok := egressIdentityFromWebSourceKey(webSource); !ok { + return "", false + } + return webSource, true +} diff --git a/backend/internal/infra/persistence/relational/account_links_test.go b/backend/internal/infra/persistence/relational/account_links_test.go new file mode 100644 index 000000000..787ea986f --- /dev/null +++ b/backend/internal/infra/persistence/relational/account_links_test.go @@ -0,0 +1,236 @@ +package relational + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +func TestReconcileProviderLinksUsesOnlyHighConfidenceIdentity(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "account-links.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + digest := strings.Repeat("a", 64) + identity := "sso_" + digest[:32] + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "sso:" + digest, + UserID: "user-1", EgressIdentity: identity, + }) + nsfwEnabledAt := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + if err := repo.MarkWebNSFWEnabled(ctx, web.ID, nsfwEnabledAt); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebTermsAccepted(ctx, web.ID, nsfwEnabledAt); err != nil { + t.Fatal(err) + } + console := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", SourceKey: "console-sso:" + digest, + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "build", SourceKey: "build-1", UserID: "user-1", + }) + for _, id := range []uint64{console.ID, build.ID, web.ID} { + if err := repo.ReconcileProviderLinks(ctx, id); err != nil { + t.Fatal(err) + } + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + if len(web.LinkedAccounts) != 2 || web.LinkedAccounts[0].Provider != account.ProviderBuild || web.LinkedAccounts[1].Provider != account.ProviderConsole { + t.Fatalf("web links = %#v", web.LinkedAccounts) + } + build, err = repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + console, err = repo.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if build.EgressIdentity != identity || console.EgressIdentity != identity || web.EgressIdentity != identity { + t.Fatalf("egress identities web=%q build=%q console=%q", web.EgressIdentity, build.EgressIdentity, console.EgressIdentity) + } + if web.WebNSFWEnabledAt == nil || build.WebNSFWEnabledAt == nil || console.WebNSFWEnabledAt == nil || !web.WebNSFWEnabledAt.Equal(nsfwEnabledAt) || !build.WebNSFWEnabledAt.Equal(nsfwEnabledAt) || !console.WebNSFWEnabledAt.Equal(nsfwEnabledAt) { + t.Fatalf("shared NSFW markers web=%v build=%v console=%v", web.WebNSFWEnabledAt, build.WebNSFWEnabledAt, console.WebNSFWEnabledAt) + } + if web.WebTermsAcceptedAt == nil || build.WebTermsAcceptedAt == nil || console.WebTermsAcceptedAt == nil || !web.WebTermsAcceptedAt.Equal(nsfwEnabledAt) || !build.WebTermsAcceptedAt.Equal(nsfwEnabledAt) || !console.WebTermsAcceptedAt.Equal(nsfwEnabledAt) { + t.Fatalf("shared terms markers web=%v build=%v console=%v", web.WebTermsAcceptedAt, build.WebTermsAcceptedAt, console.WebTermsAcceptedAt) + } + if build.LinkedAccountID != web.ID || build.LinkedProvider != account.ProviderWeb || len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID { + t.Fatalf("reverse links build=%#v console=%#v", build.LinkedAccounts, console.LinkedAccounts) + } + for _, provider := range []account.Provider{account.ProviderWeb, account.ProviderBuild, account.ProviderConsole} { + values, listErr := repo.ListEnabled(ctx, provider) + if listErr != nil { + t.Fatal(listErr) + } + if len(values) != 1 || values[0].EgressIdentity != identity { + t.Fatalf("routing identities for %s = %#v", provider, values) + } + } + if _, err := repo.UpdateTokens(ctx, web.ID, "rotated-encrypted-token", "", time.Time{}); err != nil { + t.Fatal(err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil || web.EgressIdentity != identity { + t.Fatalf("token update changed egress identity: %q err=%v", web.EgressIdentity, err) + } + + emailWeb := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "email-web", SourceKey: "sso:" + strings.Repeat("b", 64), Email: "same@example.com", + }) + _ = createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "email-build", SourceKey: "email-build", Email: "same@example.com", + }) + if err := repo.ReconcileProviderLinks(ctx, emailWeb.ID); err != nil { + t.Fatal(err) + } + emailWeb, err = repo.Get(ctx, emailWeb.ID) + if err != nil || len(emailWeb.LinkedAccounts) != 0 { + t.Fatalf("email-only account was linked: %#v err=%v", emailWeb.LinkedAccounts, err) + } + + multiWeb := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "multi-web", SourceKey: "sso:" + strings.Repeat("d", 64), UserID: "shared-user", + }) + for _, teamID := range []string{"team-a", "team-b"} { + _ = createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "multi-" + teamID, SourceKey: "multi-" + teamID, UserID: "shared-user", TeamID: teamID, + }) + } + if err := repo.ReconcileProviderLinks(ctx, multiWeb.ID); err != nil { + t.Fatal(err) + } + multiWeb, err = repo.Get(ctx, multiWeb.ID) + if err != nil || len(multiWeb.LinkedAccounts) != 0 { + t.Fatalf("ambiguous user was linked: %#v err=%v", multiWeb.LinkedAccounts, err) + } + + if err := repo.Delete(ctx, web.ID); err != nil { + t.Fatal(err) + } + build, buildErr := repo.Get(ctx, build.ID) + console, consoleErr := repo.Get(ctx, console.ID) + if buildErr != nil || consoleErr != nil || len(build.LinkedAccounts) != 0 || len(console.LinkedAccounts) != 0 { + t.Fatalf("deleting Web affected linked accounts: build=%#v/%v console=%#v/%v", build, buildErr, console, consoleErr) + } +} + +func TestInitializeSchemaBackfillsStableWebEgressIdentity(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "legacy-account-links.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + digest := strings.Repeat("c", 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "legacy-web", SourceKey: "sso:" + digest, + FailureCount: 3, LastError: "preserve-me", + }) + build := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "legacy-build", SourceKey: "legacy-build", + }) + if err := repo.LinkWebToBuild(ctx, web.ID, build.ID); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := repo.SaveQuotaWindows(ctx, web.ID, account.WebTierAuto, now, []account.QuotaWindow{{ + AccountID: web.ID, Mode: "weekly", Remaining: 7, Total: 10, WindowSeconds: 3600, SyncedAt: &now, Source: account.QuotaSourceUpstream, + }}); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropConstraint(&webAccountProfileModel{}, "chk_web_account_profiles_egress_identity"); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "EgressIdentity"); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("migration is not idempotent: %v", err) + } + web, err = repo.Get(ctx, web.ID) + if err != nil { + t.Fatal(err) + } + build, err = repo.Get(ctx, build.ID) + if err != nil { + t.Fatal(err) + } + windows, err := repo.GetQuotaWindows(ctx, []uint64{web.ID}) + if err != nil { + t.Fatal(err) + } + wantIdentity := "sso_" + digest[:32] + if web.EgressIdentity != wantIdentity || build.EgressIdentity != wantIdentity || web.FailureCount != 3 || web.LastError != "preserve-me" || len(windows[web.ID]) != 1 || windows[web.ID][0].Remaining != 7 { + t.Fatalf("migration result web=%#v buildIdentity=%q windows=%#v", web, build.EgressIdentity, windows[web.ID]) + } +} + +func TestReconcileWebConsoleUsesUniqueUserIDAcrossDifferentSSOTokens(t *testing.T) { + t.Parallel() + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-console-user-link.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + repo := NewAccountRepository(database) + webDigest := strings.Repeat("e", 64) + web := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "sso:" + webDigest, + UserID: "same-user", Email: "same@example.com", EgressIdentity: "sso_" + webDigest[:32], + }) + console := createLinkedAccountTestCredential(t, ctx, repo, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "console", + SourceKey: "console-sso:" + strings.Repeat("f", 64), UserID: "same-user", Email: "same@example.com", + }) + if err := repo.ReconcileProviderLinks(ctx, console.ID); err != nil { + t.Fatal(err) + } + console, err = repo.Get(ctx, console.ID) + if err != nil { + t.Fatal(err) + } + if len(console.LinkedAccounts) != 1 || console.LinkedAccounts[0].ID != web.ID || console.LinkedAccounts[0].Email != "same@example.com" || console.LinkedAccounts[0].UserID != "same-user" || console.EgressIdentity != web.EgressIdentity { + t.Fatalf("console link = %#v identity=%q, web identity=%q", console.LinkedAccounts, console.EgressIdentity, web.EgressIdentity) + } +} + +func createLinkedAccountTestCredential(t *testing.T, ctx context.Context, repo *AccountRepository, value account.Credential) account.Credential { + t.Helper() + value.EncryptedAccessToken = "encrypted" + value.Enabled = true + value.AuthStatus = account.AuthStatusActive + value.Priority = account.DefaultPriority + value.MaxConcurrent = account.DefaultMaxConcurrent + stored, _, err := repo.UpsertByIdentity(ctx, value) + if err != nil { + t.Fatal(err) + } + return stored +} diff --git a/backend/internal/infra/persistence/relational/account_repository.go b/backend/internal/infra/persistence/relational/account_repository.go index f3e3eb36c..467adf7a6 100644 --- a/backend/internal/infra/persistence/relational/account_repository.go +++ b/backend/internal/infra/persistence/relational/account_repository.go @@ -309,6 +309,9 @@ func (r *AccountRepository) ListEnabled(ctx context.Context, provider account.Pr for _, row := range rows { out = append(out, toAccountDomain(row)) } + if err := r.attachRoutingEgressIdentities(ctx, provider, out); err != nil { + return nil, err + } return out, nil } @@ -497,36 +500,151 @@ func (r *AccountRepository) attachAccountLinks(ctx context.Context, values []acc ids = append(ids, values[index].ID) positions[values[index].ID] = index } - var rows []struct { - WebAccountID uint64 - BuildAccountID uint64 - WebName string - BuildName string + var buildRows []struct { + WebAccountID uint64 + BuildAccountID uint64 + WebName string + BuildName string + WebEmail string + BuildEmail string + WebUserID string + BuildUserID string + WebSourceKey string + EgressIdentity string + WebNSFWEnabledAt *time.Time + WebTermsAcceptedAt *time.Time } err := r.db.db.WithContext(ctx).Table("account_provider_links AS link"). - Select("link.web_account_id, link.build_account_id, web.name AS web_name, build.name AS build_name"). + Select("link.web_account_id, link.build_account_id, web.name AS web_name, build.name AS build_name, web.email AS web_email, build.email AS build_email, web.user_id AS web_user_id, build.user_id AS build_user_id, web.source_key AS web_source_key, profile.egress_identity, profile.nsfw_enabled_at AS web_nsfw_enabled_at, profile.terms_accepted_at AS web_terms_accepted_at"). Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). Joins("JOIN provider_accounts AS build ON build.id = link.build_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). Where("link.web_account_id IN ? OR link.build_account_id IN ?", ids, ids). - Scan(&rows).Error + Scan(&buildRows).Error if err != nil { return err } - for _, row := range rows { + for _, row := range buildRows { + egressIdentity := linkedWebEgressIdentity(row.EgressIdentity, row.WebSourceKey) if index, ok := positions[row.WebAccountID]; ok { values[index].LinkedAccountID = row.BuildAccountID values[index].LinkedAccountName = row.BuildName values[index].LinkedProvider = account.ProviderBuild + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.BuildAccountID, Provider: account.ProviderBuild, Name: row.BuildName, Email: row.BuildEmail, UserID: row.BuildUserID}) + if values[index].EgressIdentity == "" { + values[index].EgressIdentity = egressIdentity + } + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedAt = row.WebTermsAcceptedAt } if index, ok := positions[row.BuildAccountID]; ok { values[index].LinkedAccountID = row.WebAccountID values[index].LinkedAccountName = row.WebName values[index].LinkedProvider = account.ProviderWeb + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.WebAccountID, Provider: account.ProviderWeb, Name: row.WebName, Email: row.WebEmail, UserID: row.WebUserID}) + values[index].EgressIdentity = egressIdentity + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedAt = row.WebTermsAcceptedAt + } + } + var consoleRows []struct { + WebAccountID uint64 + ConsoleAccountID uint64 + WebName string + ConsoleName string + WebEmail string + ConsoleEmail string + WebUserID string + ConsoleUserID string + WebSourceKey string + EgressIdentity string + WebNSFWEnabledAt *time.Time + WebTermsAcceptedAt *time.Time + } + if err := r.db.db.WithContext(ctx).Table("web_console_account_links AS link"). + Select("link.web_account_id, link.console_account_id, web.name AS web_name, console.name AS console_name, web.email AS web_email, console.email AS console_email, web.user_id AS web_user_id, console.user_id AS console_user_id, web.source_key AS web_source_key, profile.egress_identity, profile.nsfw_enabled_at AS web_nsfw_enabled_at, profile.terms_accepted_at AS web_terms_accepted_at"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("JOIN provider_accounts AS console ON console.id = link.console_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.web_account_id IN ? OR link.console_account_id IN ?", ids, ids). + Scan(&consoleRows).Error; err != nil { + return err + } + for _, row := range consoleRows { + egressIdentity := linkedWebEgressIdentity(row.EgressIdentity, row.WebSourceKey) + if index, ok := positions[row.WebAccountID]; ok { + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.ConsoleAccountID, Provider: account.ProviderConsole, Name: row.ConsoleName, Email: row.ConsoleEmail, UserID: row.ConsoleUserID}) + if values[index].EgressIdentity == "" { + values[index].EgressIdentity = egressIdentity + } + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedAt = row.WebTermsAcceptedAt + } + if index, ok := positions[row.ConsoleAccountID]; ok { + values[index].LinkedAccounts = append(values[index].LinkedAccounts, account.LinkedAccount{ID: row.WebAccountID, Provider: account.ProviderWeb, Name: row.WebName, Email: row.WebEmail, UserID: row.WebUserID}) + values[index].EgressIdentity = egressIdentity + values[index].WebNSFWEnabledAt = row.WebNSFWEnabledAt + values[index].WebTermsAcceptedAt = row.WebTermsAcceptedAt } } return nil } +// attachRoutingEgressIdentities 只补充推理路由需要的稳定出口身份。 +// 管理端展示所需的账号名称和 linkedAccounts 仍由 attachAccountLinks 加载, +// 避免路由候选缓存刷新时额外查询两类完整关系。 +func (r *AccountRepository) attachRoutingEgressIdentities(ctx context.Context, provider account.Provider, values []account.Credential) error { + if len(values) == 0 || provider == account.ProviderWeb { + return nil + } + ids := make([]uint64, 0, len(values)) + positions := make(map[uint64]int, len(values)) + for index := range values { + ids = append(ids, values[index].ID) + positions[values[index].ID] = index + } + type identityRow struct { + AccountID uint64 + WebSourceKey string + EgressIdentity string + } + var rows []identityRow + query := r.db.db.WithContext(ctx) + switch provider { + case account.ProviderBuild: + query = query.Table("account_provider_links AS link"). + Select("link.build_account_id AS account_id, web.source_key AS web_source_key, profile.egress_identity"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.build_account_id IN ?", ids) + case account.ProviderConsole: + query = query.Table("web_console_account_links AS link"). + Select("link.console_account_id AS account_id, web.source_key AS web_source_key, profile.egress_identity"). + Joins("JOIN provider_accounts AS web ON web.id = link.web_account_id"). + Joins("LEFT JOIN web_account_profiles AS profile ON profile.account_id = web.id"). + Where("link.console_account_id IN ?", ids) + default: + return nil + } + if err := query.Scan(&rows).Error; err != nil { + return err + } + for _, row := range rows { + if index, ok := positions[row.AccountID]; ok { + values[index].EgressIdentity = linkedWebEgressIdentity(row.EgressIdentity, row.WebSourceKey) + } + } + return nil +} + +func linkedWebEgressIdentity(stored, sourceKey string) string { + if value := strings.TrimSpace(stored); value != "" { + return value + } + value, _ := egressIdentityFromWebSourceKey(sourceKey) + return value +} + func (r *AccountRepository) UpsertByIdentity(ctx context.Context, value account.Credential) (account.Credential, bool, error) { var result repository.AccountUpsertResult err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -652,6 +770,13 @@ func upsertKnownAccountByIdentity(tx *gorm.DB, value account.Credential, existin func (r *AccountRepository) Update(ctx context.Context, value account.Credential) (account.Credential, error) { row := fromAccountDomain(value) if err := r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing accountModel + if err := tx.Select("identity_key", "created_at").First(&existing, row.ID).Error; err != nil { + return err + } + // 身份同步补充的 user_id/email 不得让普通编辑重写持久化身份键。 + row.IdentityKey = existing.IdentityKey + row.CreatedAt = existing.CreatedAt if err := tx.Save(&row).Error; err != nil { return err } @@ -673,6 +798,15 @@ func saveAccountRelations(tx *gorm.DB, value account.Credential, accountID uint6 if profile.NSFWEnabledAt != nil { updates = append(updates, "nsfw_enabled_at") } + if profile.TermsAcceptedAt != nil { + updates = append(updates, "terms_accepted_at") + } + if profile.BirthDateSetAt != nil { + updates = append(updates, "birth_date_set_at") + } + if strings.TrimSpace(profile.EgressIdentity) != "" { + updates = append(updates, "egress_identity") + } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "account_id"}}, DoUpdates: clause.AssignmentColumns(updates), @@ -686,27 +820,66 @@ func (r *AccountRepository) MarkWebNSFWEnabled(ctx context.Context, id uint64, e if id == 0 || enabledAt.IsZero() { return fmt.Errorf("Web NSFW 标记参数无效") } - enabledAt = enabledAt.UTC() + return r.markWebProfileTimestamp(ctx, id, "nsfw_enabled_at", enabledAt) +} + +// MarkWebTermsAccepted 幂等保存首次成功接受服务协议的时间。 +func (r *AccountRepository) MarkWebTermsAccepted(ctx context.Context, id uint64, acceptedAt time.Time) error { + if id == 0 || acceptedAt.IsZero() { + return fmt.Errorf("Web 服务协议标记参数无效") + } + return r.markWebProfileTimestamp(ctx, id, "terms_accepted_at", acceptedAt) +} + +// MarkWebBirthDateSet 幂等保存首次成功设置或确认已有生日的时间。 +func (r *AccountRepository) MarkWebBirthDateSet(ctx context.Context, id uint64, setAt time.Time) error { + if id == 0 || setAt.IsZero() { + return fmt.Errorf("Web 生日标记参数无效") + } + return r.markWebProfileTimestamp(ctx, id, "birth_date_set_at", setAt) +} + +func (r *AccountRepository) markWebProfileTimestamp(ctx context.Context, id uint64, column string, value time.Time) error { + value = value.UTC() return mapError(r.db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var value accountModel - if err := tx.Select("id", "provider").First(&value, id).Error; err != nil { + var accountRow accountModel + if err := tx.Select("id", "provider").First(&accountRow, id).Error; err != nil { return err } - if account.Provider(value.Provider) != account.ProviderWeb { - return fmt.Errorf("仅 Grok Web 账号支持 NSFW 标记") + if account.Provider(accountRow.Provider) != account.ProviderWeb { + return fmt.Errorf("仅 Grok Web 账号支持资料状态标记") } - profile := webAccountProfileModel{ - AccountID: id, - Tier: string(account.WebTierAuto), - NSFWEnabledAt: &enabledAt, + profile := webAccountProfileModel{AccountID: id, Tier: string(account.WebTierAuto)} + switch column { + case "nsfw_enabled_at": + profile.NSFWEnabledAt = &value + case "terms_accepted_at": + profile.TermsAcceptedAt = &value + case "birth_date_set_at": + profile.BirthDateSetAt = &value + default: + return fmt.Errorf("Web 资料状态字段无效") } created := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&profile) if created.Error != nil || created.RowsAffected > 0 { return created.Error } - return tx.Model(&webAccountProfileModel{}). - Where("account_id = ? AND nsfw_enabled_at IS NULL", id). - Update("nsfw_enabled_at", enabledAt).Error + switch column { + case "nsfw_enabled_at": + return tx.Model(&webAccountProfileModel{}). + Where("account_id = ? AND nsfw_enabled_at IS NULL", id). + Update("nsfw_enabled_at", value).Error + case "terms_accepted_at": + return tx.Model(&webAccountProfileModel{}). + Where("account_id = ? AND terms_accepted_at IS NULL", id). + Update("terms_accepted_at", value).Error + case "birth_date_set_at": + return tx.Model(&webAccountProfileModel{}). + Where("account_id = ? AND birth_date_set_at IS NULL", id). + Update("birth_date_set_at", value).Error + default: + return fmt.Errorf("Web 资料状态字段无效") + } })) } diff --git a/backend/internal/infra/persistence/relational/mapping.go b/backend/internal/infra/persistence/relational/mapping.go index 20af0a4df..3a7eabecb 100644 --- a/backend/internal/infra/persistence/relational/mapping.go +++ b/backend/internal/infra/persistence/relational/mapping.go @@ -50,10 +50,16 @@ func toAccountDomain(value accountModel) account.Credential { var webTier account.WebTier var webTierSyncedAt *time.Time var webNSFWEnabledAt *time.Time + var webTermsAcceptedAt *time.Time + var webBirthDateSetAt *time.Time + var egressIdentity string if value.WebProfile != nil { webTier = account.WebTier(value.WebProfile.Tier) webTierSyncedAt = value.WebProfile.SyncedAt webNSFWEnabledAt = value.WebProfile.NSFWEnabledAt + webTermsAcceptedAt = value.WebProfile.TermsAcceptedAt + webBirthDateSetAt = value.WebProfile.BirthDateSetAt + egressIdentity = value.WebProfile.EgressIdentity } buildRouteMode := account.BuildRouteMode(value.BuildRouteMode) if account.Provider(value.Provider) != account.ProviderBuild || !buildRouteMode.IsValid() { @@ -69,7 +75,7 @@ func toAccountDomain(value accountModel) account.Credential { MaxConcurrent: value.MaxConcurrent, MinimumRemaining: value.MinimumRemaining, FailureCount: value.FailureCount, CooldownUntil: value.CooldownUntil, LastError: value.LastError, LastUsedAt: value.LastUsedAt, ObservedModel: value.ObservedModel, ObservedModelAt: value.ObservedModelAt, WebTier: webTier, WebTierSyncedAt: webTierSyncedAt, - WebNSFWEnabledAt: webNSFWEnabledAt, + WebNSFWEnabledAt: webNSFWEnabledAt, WebTermsAcceptedAt: webTermsAcceptedAt, WebBirthDateSetAt: webBirthDateSetAt, EgressIdentity: egressIdentity, BuildAPIFallback: value.BuildAPIFallback, BuildRouteMode: buildRouteMode, BuildSuperEntitled: value.BuildSuperEntitled && account.Provider(value.Provider) == account.ProviderBuild, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, @@ -133,7 +139,7 @@ func fromWebProfileDomain(value account.Credential) *webAccountProfileModel { if tier == "" { tier = account.WebTierAuto } - return &webAccountProfileModel{AccountID: value.ID, Tier: string(tier), SyncedAt: value.WebTierSyncedAt, NSFWEnabledAt: value.WebNSFWEnabledAt} + return &webAccountProfileModel{AccountID: value.ID, Tier: string(tier), SyncedAt: value.WebTierSyncedAt, NSFWEnabledAt: value.WebNSFWEnabledAt, TermsAcceptedAt: value.WebTermsAcceptedAt, BirthDateSetAt: value.WebBirthDateSetAt, EgressIdentity: value.EgressIdentity} } func accountIdentity(value account.Credential) string { diff --git a/backend/internal/infra/persistence/relational/models.go b/backend/internal/infra/persistence/relational/models.go index caf9dad64..62b6893bf 100644 --- a/backend/internal/infra/persistence/relational/models.go +++ b/backend/internal/infra/persistence/relational/models.go @@ -87,12 +87,25 @@ type accountProviderLinkModel struct { func (accountProviderLinkModel) TableName() string { return "account_provider_links" } +type webConsoleAccountLinkModel struct { + WebAccountID uint64 `gorm:"primaryKey"` + ConsoleAccountID uint64 `gorm:"uniqueIndex;not null;check:chk_web_console_account_links_distinct,web_account_id <> console_account_id"` + CreatedAt time.Time `gorm:"not null"` + WebAccount *accountModel `gorm:"foreignKey:WebAccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + ConsoleAccount *accountModel `gorm:"foreignKey:ConsoleAccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} + +func (webConsoleAccountLinkModel) TableName() string { return "web_console_account_links" } + type webAccountProfileModel struct { - AccountID uint64 `gorm:"primaryKey"` - Tier string `gorm:"size:16;not null;check:chk_web_account_profiles_tier,tier IN ('auto','basic','super','heavy')"` - SyncedAt *time.Time - NSFWEnabledAt *time.Time - Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + AccountID uint64 `gorm:"primaryKey"` + Tier string `gorm:"size:16;not null;check:chk_web_account_profiles_tier,tier IN ('auto','basic','super','heavy')"` + SyncedAt *time.Time + NSFWEnabledAt *time.Time + TermsAcceptedAt *time.Time + BirthDateSetAt *time.Time + EgressIdentity string `gorm:"size:128;not null;default:'';check:chk_web_account_profiles_egress_identity,length(egress_identity) <= 128"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } func (webAccountProfileModel) TableName() string { return "web_account_profiles" } diff --git a/backend/internal/infra/persistence/relational/postgres_integration_test.go b/backend/internal/infra/persistence/relational/postgres_integration_test.go index 0f72dd60d..a9b516cf5 100644 --- a/backend/internal/infra/persistence/relational/postgres_integration_test.go +++ b/backend/internal/infra/persistence/relational/postgres_integration_test.go @@ -2,6 +2,8 @@ package relational import ( "context" + "crypto/sha256" + "encoding/hex" "os" "testing" "time" @@ -38,4 +40,73 @@ func TestPostgresRepositoriesIntegration(t *testing.T) { if err := repository.Delete(ctx, created.ID); err != nil { t.Fatal(err) } + + unique := time.Now().UTC().Format("20060102150405.000000000") + digestBytes := sha256.Sum256([]byte(unique)) + digest := hex.EncodeToString(digestBytes[:]) + identity := "sso_" + digest[:32] + userID := "postgres-linked-" + unique + web, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "postgres-web", SourceKey: "sso:" + digest, + UserID: userID, EgressIdentity: identity, EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + build, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderBuild, AuthType: account.AuthTypeOAuth, Name: "postgres-build", SourceKey: "postgres-build-" + unique, + UserID: userID, EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + console, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "postgres-console", SourceKey: "console-sso:" + digest, + EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := repository.ReconcileProviderLinks(ctx, web.ID); err != nil { + t.Fatal(err) + } + web, err = repository.Get(ctx, web.ID) + if err != nil || len(web.LinkedAccounts) != 2 { + t.Fatalf("postgres linked accounts = %#v, err = %v", web.LinkedAccounts, err) + } + otherConsole, _, err := repository.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, Name: "postgres-console-conflict", SourceKey: "console-conflict-" + unique, + EncryptedAccessToken: "encrypted", AuthStatus: account.AuthStatusActive, + }) + if err != nil { + t.Fatal(err) + } + if err := database.db.WithContext(ctx).Create(&webConsoleAccountLinkModel{ + WebAccountID: web.ID, ConsoleAccountID: otherConsole.ID, CreatedAt: time.Now().UTC(), + }).Error; err == nil { + t.Fatal("postgres web/console one-to-one constraint was not enforced") + } + if err := repository.Delete(ctx, web.ID); err != nil { + t.Fatal(err) + } + for _, id := range []uint64{build.ID, console.ID} { + linked, getErr := repository.Get(ctx, id) + if getErr != nil { + t.Fatalf("deleting Web removed linked account %d: %v", id, getErr) + } + if len(linked.LinkedAccounts) != 0 { + t.Fatalf("deleting Web retained links for account %d: %#v", id, linked.LinkedAccounts) + } + } + for _, model := range []any{&accountProviderLinkModel{}, &webConsoleAccountLinkModel{}} { + var remainingLinks int64 + if err := database.db.WithContext(ctx).Model(model).Where("web_account_id = ?", web.ID).Count(&remainingLinks).Error; err != nil || remainingLinks != 0 { + t.Fatalf("postgres Web relation cascade model=%T count=%d err=%v", model, remainingLinks, err) + } + } + for _, id := range []uint64{build.ID, console.ID, otherConsole.ID} { + if err := repository.Delete(ctx, id); err != nil { + t.Fatal(err) + } + } } diff --git a/backend/internal/infra/persistence/relational/schema.go b/backend/internal/infra/persistence/relational/schema.go index 519ef9eaa..949c4e9ea 100644 --- a/backend/internal/infra/persistence/relational/schema.go +++ b/backend/internal/infra/persistence/relational/schema.go @@ -12,6 +12,7 @@ var schemaModels = []any{ &accountModel{}, &accountCredentialModel{}, &accountProviderLinkModel{}, + &webConsoleAccountLinkModel{}, &webAccountProfileModel{}, "aWindowModel{}, &billingModel{}, @@ -110,6 +111,9 @@ func (d *Database) InitializeSchema(ctx context.Context) error { if err := d.ensureMediaAssetConstraints(ctx); err != nil { return fmt.Errorf("迁移 media asset 数据库约束: %w", err) } + if err := d.backfillWebEgressIdentities(ctx); err != nil { + return fmt.Errorf("迁移 Web 出口身份: %w", err) + } for _, statement := range schemaIndexes { if err := db.Exec(statement).Error; err != nil { return fmt.Errorf("初始化数据库索引: %w", err) diff --git a/backend/internal/infra/persistence/relational/schema_account_identity.go b/backend/internal/infra/persistence/relational/schema_account_identity.go new file mode 100644 index 000000000..fa7d9006b --- /dev/null +++ b/backend/internal/infra/persistence/relational/schema_account_identity.go @@ -0,0 +1,53 @@ +package relational + +import ( + "context" + "encoding/hex" + "strings" +) + +const ( + webSSOSourcePrefix = "sso:" + stableSSODigestLength = 64 + egressDigestLength = 32 +) + +// backfillWebEgressIdentities 只使用已持久化的 SSO 摘要恢复现有 Resin 身份, +// 不解密凭据、不访问上游,也不会建立新的跨 Provider 关系。 +func (d *Database) backfillWebEgressIdentities(ctx context.Context) error { + var rows []struct { + AccountID uint64 + SourceKey string + } + db := d.db.WithContext(ctx) + if err := db.Table("provider_accounts AS account"). + Select("account.id AS account_id, account.source_key"). + Joins("JOIN web_account_profiles AS profile ON profile.account_id = account.id"). + Where("account.provider = ? AND profile.egress_identity = ''", "grok_web"). + Find(&rows).Error; err != nil { + return err + } + for _, row := range rows { + identity, ok := egressIdentityFromWebSourceKey(row.SourceKey) + if !ok { + continue + } + if err := db.Model(&webAccountProfileModel{}). + Where("account_id = ? AND egress_identity = ''", row.AccountID). + Update("egress_identity", identity).Error; err != nil { + return err + } + } + return nil +} + +func egressIdentityFromWebSourceKey(sourceKey string) (string, bool) { + digest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(sourceKey), webSSOSourcePrefix)) + if !strings.HasPrefix(strings.TrimSpace(sourceKey), webSSOSourcePrefix) || len(digest) != stableSSODigestLength { + return "", false + } + if _, err := hex.DecodeString(digest); err != nil { + return "", false + } + return "sso_" + strings.ToLower(digest[:egressDigestLength]), true +} diff --git a/backend/internal/infra/persistence/relational/web_nsfw_test.go b/backend/internal/infra/persistence/relational/web_nsfw_test.go index e397a84a5..8e547048a 100644 --- a/backend/internal/infra/persistence/relational/web_nsfw_test.go +++ b/backend/internal/infra/persistence/relational/web_nsfw_test.go @@ -28,17 +28,29 @@ func TestWebNSFWMarkerPersistsAcrossAccountUpserts(t *testing.T) { if err := repo.MarkWebNSFWEnabled(ctx, credential.ID, first); err != nil { t.Fatal(err) } + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, first); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebBirthDateSet(ctx, credential.ID, first); err != nil { + t.Fatal(err) + } marked, err := repo.Get(ctx, credential.ID) if err != nil { t.Fatal(err) } - if marked.WebNSFWEnabledAt == nil || !marked.WebNSFWEnabledAt.Equal(first) { - t.Fatalf("marker = %v, want %s", marked.WebNSFWEnabledAt, first) + if marked.WebNSFWEnabledAt == nil || !marked.WebNSFWEnabledAt.Equal(first) || marked.WebTermsAcceptedAt == nil || !marked.WebTermsAcceptedAt.Equal(first) || marked.WebBirthDateSetAt == nil || !marked.WebBirthDateSetAt.Equal(first) { + t.Fatalf("markers nsfw=%v terms=%v birth=%v, want %s", marked.WebNSFWEnabledAt, marked.WebTermsAcceptedAt, marked.WebBirthDateSetAt, first) } if err := repo.MarkWebNSFWEnabled(ctx, credential.ID, first.Add(time.Hour)); err != nil { t.Fatal(err) } + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, first.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if err := repo.MarkWebBirthDateSet(ctx, credential.ID, first.Add(time.Hour)); err != nil { + t.Fatal(err) + } if _, err := repo.UpsertManyByIdentity(ctx, []account.Credential{{ Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web renamed", SourceKey: "web-nsfw", EncryptedAccessToken: "encrypted-new", Enabled: true, AuthStatus: account.AuthStatusActive, @@ -52,8 +64,8 @@ func TestWebNSFWMarkerPersistsAcrossAccountUpserts(t *testing.T) { if err != nil { t.Fatal(err) } - if refreshed.WebNSFWEnabledAt == nil || !refreshed.WebNSFWEnabledAt.Equal(first) { - t.Fatalf("marker after upsert = %v, want first timestamp %s", refreshed.WebNSFWEnabledAt, first) + if refreshed.WebNSFWEnabledAt == nil || !refreshed.WebNSFWEnabledAt.Equal(first) || refreshed.WebTermsAcceptedAt == nil || !refreshed.WebTermsAcceptedAt.Equal(first) || refreshed.WebBirthDateSetAt == nil || !refreshed.WebBirthDateSetAt.Equal(first) { + t.Fatalf("markers after upsert nsfw=%v terms=%v birth=%v, want first timestamp %s", refreshed.WebNSFWEnabledAt, refreshed.WebTermsAcceptedAt, refreshed.WebBirthDateSetAt, first) } } @@ -71,6 +83,12 @@ func TestWebNSFWMarkerRejectsNonWebAccounts(t *testing.T) { if err := repo.MarkWebNSFWEnabled(ctx, credential.ID, time.Now()); err == nil { t.Fatal("expected non-Web marker rejection") } + if err := repo.MarkWebTermsAccepted(ctx, credential.ID, time.Now()); err == nil { + t.Fatal("expected non-Web terms marker rejection") + } + if err := repo.MarkWebBirthDateSet(ctx, credential.ID, time.Now()); err == nil { + t.Fatal("expected non-Web birth marker rejection") + } } func TestInitializeSchemaAddsWebNSFWMarkerColumn(t *testing.T) { @@ -88,9 +106,21 @@ func TestInitializeSchemaAddsWebNSFWMarkerColumn(t *testing.T) { if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "NSFWEnabledAt"); err != nil { t.Fatal(err) } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "TermsAcceptedAt"); err != nil { + t.Fatal(err) + } + if err := database.db.Migrator().DropColumn(&webAccountProfileModel{}, "BirthDateSetAt"); err != nil { + t.Fatal(err) + } if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "NSFWEnabledAt") { t.Fatal("legacy schema still contains NSFW marker column") } + if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "TermsAcceptedAt") { + t.Fatal("legacy schema still contains terms marker column") + } + if database.db.Migrator().HasColumn(&webAccountProfileModel{}, "BirthDateSetAt") { + t.Fatal("legacy schema still contains birth marker column") + } if err := database.InitializeSchema(ctx); err != nil { t.Fatal(err) @@ -98,11 +128,17 @@ func TestInitializeSchemaAddsWebNSFWMarkerColumn(t *testing.T) { if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "NSFWEnabledAt") { t.Fatal("schema migration did not add NSFW marker column") } + if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "TermsAcceptedAt") { + t.Fatal("schema migration did not add terms marker column") + } + if !database.db.Migrator().HasColumn(&webAccountProfileModel{}, "BirthDateSetAt") { + t.Fatal("schema migration did not add birth marker column") + } refreshed, err := repo.Get(ctx, credential.ID) if err != nil { t.Fatal(err) } - if refreshed.ID != credential.ID || refreshed.WebNSFWEnabledAt != nil { + if refreshed.ID != credential.ID || refreshed.WebNSFWEnabledAt != nil || refreshed.WebTermsAcceptedAt != nil || refreshed.WebBirthDateSetAt != nil { t.Fatalf("migrated account = %#v", refreshed) } } diff --git a/backend/internal/infra/provider/browserheaders/chromium.go b/backend/internal/infra/provider/browserheaders/chromium.go new file mode 100644 index 000000000..cdd8e9ee0 --- /dev/null +++ b/backend/internal/infra/provider/browserheaders/chromium.go @@ -0,0 +1,66 @@ +package browserheaders + +import ( + "fmt" + "net/http" + "regexp" + "strconv" + "strings" +) + +var ( + chromiumVersionPattern = regexp.MustCompile(`(?i)\b(?:chrome|chromium|crios)/(\d{2,3})(?:\.\d+)*`) + edgeVersionPattern = regexp.MustCompile(`(?i)\b(?:edg|edga|edgios)/(\d{2,3})(?:\.\d+)*`) +) + +// ApplyChromiumClientHints 根据真实 User-Agent 补齐一致的 Chromium Client Hints。 +// 非 Chromium UA 不生成提示头,避免互相矛盾的浏览器指纹。 +func ApplyChromiumClientHints(header http.Header, userAgent string) { + lower := strings.ToLower(userAgent) + brand := "Google Chrome" + match := chromiumVersionPattern.FindStringSubmatch(userAgent) + if edge := edgeVersionPattern.FindStringSubmatch(userAgent); len(edge) == 2 { + brand, match = "Microsoft Edge", edge + } else if strings.Contains(lower, "chromium/") { + brand = "Chromium" + } + if len(match) != 2 { + return + } + version := match[1] + header.Set("Sec-Ch-Ua", fmt.Sprintf(`"%s";v="%s", "Chromium";v="%s", "Not(A:Brand";v="24"`, brand, version, version)) + + platform := "" + switch { + case strings.Contains(lower, "windows"): + platform = "Windows" + case strings.Contains(lower, "mac os x") || strings.Contains(lower, "macintosh"): + platform = "macOS" + case strings.Contains(lower, "android"): + platform = "Android" + case strings.Contains(lower, "iphone") || strings.Contains(lower, "ipad"): + platform = "iOS" + case strings.Contains(lower, "linux"): + platform = "Linux" + } + header.Set("Sec-Ch-Ua-Mobile", "?0") + if strings.Contains(lower, "mobile") || platform == "Android" || platform == "iOS" { + header.Set("Sec-Ch-Ua-Mobile", "?1") + } + header.Set("Sec-Ch-Ua-Model", "") + if platform != "" { + header.Set("Sec-Ch-Ua-Platform", strconv.Quote(platform)) + } + + arch := "" + switch { + case strings.Contains(lower, "aarch64") || strings.Contains(lower, "arm64") || strings.Contains(lower, " arm"): + arch = "arm" + case strings.Contains(lower, "x86_64") || strings.Contains(lower, "x64") || strings.Contains(lower, "win64") || strings.Contains(lower, "intel"): + arch = "x86" + } + if arch != "" { + header.Set("Sec-Ch-Ua-Arch", arch) + header.Set("Sec-Ch-Ua-Bitness", "64") + } +} diff --git a/backend/internal/infra/provider/cli/adapter.go b/backend/internal/infra/provider/cli/adapter.go index 2d700d8a1..dea3dd8dc 100644 --- a/backend/internal/infra/provider/cli/adapter.go +++ b/backend/internal/infra/provider/cli/adapter.go @@ -313,7 +313,7 @@ func (a *Adapter) doResponseRequest(ctx context.Context, request provider.Respon if len(body) > 0 { bodyReader = bytes.NewReader(body) } - requestCtx := infraegress.WithAccount(ctx, string(account.ProviderBuild), request.Credential.ID) + requestCtx := infraegress.WithCredential(ctx, request.Credential) req, err := http.NewRequestWithContext(requestCtx, request.Method, a.urlWithBase(base, request.Path), bodyReader) if err != nil { return nil, "", err @@ -424,7 +424,7 @@ func (a *Adapter) NormalizeAccountModelCapabilities(models []string, billing *ac } func (a *Adapter) listModelsAt(ctx context.Context, credential account.Credential, accessToken, base string) ([]string, int, error) { - requestCtx := infraegress.WithAccount(ctx, string(account.ProviderBuild), credential.ID) + requestCtx := infraegress.WithCredential(ctx, credential) req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, a.urlWithBase(base, "/models"), nil) if err != nil { return nil, 0, err @@ -526,7 +526,7 @@ func (a *Adapter) RefreshCredential(ctx context.Context, credential account.Cred if strings.TrimSpace(refreshToken) == "" { return provider.RefreshedCredential{}, &provider.CredentialRefreshError{Code: "missing_refresh_token", Permanent: true} } - refreshCtx := infraegress.WithAccount(ctx, string(account.ProviderBuild), credential.ID) + refreshCtx := infraegress.WithCredential(ctx, credential) tokens, err := a.oauth.refresh(refreshCtx, refreshToken) if err != nil { return provider.RefreshedCredential{}, err @@ -693,7 +693,7 @@ func (a *Adapter) getBilling(ctx context.Context, credential account.Credential, if query != "" { endpoint += "?" + query } - requestCtx := infraegress.WithAccount(ctx, string(account.ProviderBuild), credential.ID) + requestCtx := infraegress.WithCredential(ctx, credential) req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint, nil) if err != nil { return account.Billing{}, err @@ -721,7 +721,7 @@ func (a *Adapter) getBilling(ctx context.Context, credential account.Credential, func (a *Adapter) getSubscriptionTier(ctx context.Context, credential account.Credential, accessToken string) (string, error) { endpoint := a.url("/user") + "?include=subscription" - requestCtx, cancel := context.WithTimeout(infraegress.WithAccount(ctx, string(account.ProviderBuild), credential.ID), subscriptionTierTimeout) + requestCtx, cancel := context.WithTimeout(infraegress.WithCredential(ctx, credential), subscriptionTierTimeout) defer cancel() req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, endpoint, nil) if err != nil { diff --git a/backend/internal/infra/provider/cli/video.go b/backend/internal/infra/provider/cli/video.go index c3d2c5e07..51159f48e 100644 --- a/backend/internal/infra/provider/cli/video.go +++ b/backend/internal/infra/provider/cli/video.go @@ -243,7 +243,7 @@ func (a *Adapter) DownloadVideo(ctx context.Context, credential account.Credenti if err != nil || parsed.Scheme != "https" || parsed.User != nil || !trustedBuildVideoAssetHost(parsed.Hostname()) { return nil, "", 0, fmt.Errorf("视频内容 URL 不受信任") } - requestCtx := infraegress.WithAccount(ctx, string(account.ProviderBuild), credential.ID) + requestCtx := infraegress.WithCredential(ctx, credential) req, err := http.NewRequestWithContext(requestCtx, http.MethodGet, parsed.String(), nil) if err != nil { return nil, "", 0, err @@ -352,7 +352,7 @@ func (a *Adapter) doVideoJSON(ctx context.Context, credential account.Credential if len(body) > 0 { bodyReader = bytes.NewReader(body) } - requestCtx := infraegress.WithAccount(ctx, string(account.ProviderBuild), credential.ID) + requestCtx := infraegress.WithCredential(ctx, credential) req, err := http.NewRequestWithContext(requestCtx, method, a.urlWithBase(base, path), bodyReader) if err != nil { return nil, err diff --git a/backend/internal/infra/provider/console/account_identity.go b/backend/internal/infra/provider/console/account_identity.go new file mode 100644 index 000000000..a508b120f --- /dev/null +++ b/backend/internal/infra/provider/console/account_identity.go @@ -0,0 +1,21 @@ +package console + +import ( + "context" + "fmt" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/sessionidentity" +) + +// SyncAccountIdentity 通过 Grok Web Session 接口补充 Console SSO 身份。 +// 请求仍使用该 Console 凭据对应的代理、Cookie 与 Resin 身份。 +func (a *Adapter) SyncAccountIdentity(ctx context.Context, credential account.Credential) (provider.AccountIdentity, error) { + if credential.Provider != account.ProviderConsole || credential.AuthType != account.AuthTypeSSO { + return provider.AccountIdentity{}, fmt.Errorf("仅 Grok Console SSO 账号支持身份同步") + } + return sessionidentity.Fetch(ctx, a.config().SessionBaseURL, credential, a.egress, a.cipher) +} + +var _ provider.AccountIdentityAdapter = (*Adapter)(nil) diff --git a/backend/internal/infra/provider/console/adapter.go b/backend/internal/infra/provider/console/adapter.go index fdfda88bd..46e31081a 100644 --- a/backend/internal/infra/provider/console/adapter.go +++ b/backend/internal/infra/provider/console/adapter.go @@ -22,6 +22,7 @@ import ( type Config struct { BaseURL string + SessionBaseURL string TimeoutSeconds int } diff --git a/backend/internal/infra/provider/console/console_test.go b/backend/internal/infra/provider/console/console_test.go index 16bc2aaa4..a82c9e6f6 100644 --- a/backend/internal/infra/provider/console/console_test.go +++ b/backend/internal/infra/provider/console/console_test.go @@ -68,6 +68,42 @@ func TestCatalogContainsAllConsoleModelsAndAliases(t *testing.T) { } } +func TestSyncAccountIdentityUsesWebSessionWithConsoleCredential(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/auth/session" || request.Method != http.MethodGet { + http.NotFound(writer, request) + return + } + if request.Header.Get("User-Agent") != infraegress.DefaultUserAgent { + t.Errorf("user agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("Cookie") != "sso=test-sso; sso-rw=test-sso; cf_clearance=clear" { + t.Errorf("cookie = %q", request.Header.Get("Cookie")) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"user":{"sub":"console-user","email":"console@example.com","teamId":"team-1"}}`)) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + token, _ := cipher.Encrypt("test-sso") + cookies, _ := cipher.Encrypt("cf_clearance=clear") + adapter := NewAdapter(Config{SessionBaseURL: server.URL}, infraegress.NewManager(consoleEgressRepositoryStub{}, cipher), cipher) + identity, err := adapter.SyncAccountIdentity(context.Background(), account.Credential{ + ID: 1, Provider: account.ProviderConsole, AuthType: account.AuthTypeSSO, + EncryptedAccessToken: token, EncryptedCloudflareCookie: cookies, + }) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "console-user" || identity.Email != "console@example.com" || identity.TeamID != "team-1" { + t.Fatalf("identity = %#v", identity) + } +} + func TestNormalizeRequestAppliesConsoleContract(t *testing.T) { spec, ok := Resolve("grok-4.3") if !ok { diff --git a/backend/internal/infra/provider/console/headers.go b/backend/internal/infra/provider/console/headers.go index 67da81fee..ea943d700 100644 --- a/backend/internal/infra/provider/console/headers.go +++ b/backend/internal/infra/provider/console/headers.go @@ -1,18 +1,11 @@ package console import ( - "fmt" "net/http" - "regexp" - "strconv" "strings" infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" -) - -var ( - chromiumVersionPattern = regexp.MustCompile(`(?i)\b(?:chrome|chromium|crios)/(\d{2,3})(?:\.\d+)*`) - edgeVersionPattern = regexp.MustCompile(`(?i)\b(?:edg|edga|edgios)/(\d{2,3})(?:\.\d+)*`) + "github.com/chenyme/grok2api/backend/internal/infra/provider/browserheaders" ) func applyHeaders(request *http.Request, token string, lease *infraegress.Lease) { @@ -41,51 +34,5 @@ func applyHeaders(request *http.Request, token string, lease *infraegress.Lease) // TLS profile used by the Console transport. Non-Chromium User-Agents do not // receive synthetic hints, avoiding contradictory browser fingerprints. func applyChromiumClientHints(header http.Header, userAgent string) { - lower := strings.ToLower(userAgent) - brand := "Google Chrome" - match := chromiumVersionPattern.FindStringSubmatch(userAgent) - if edge := edgeVersionPattern.FindStringSubmatch(userAgent); len(edge) == 2 { - brand, match = "Microsoft Edge", edge - } else if strings.Contains(lower, "chromium/") { - brand = "Chromium" - } - if len(match) != 2 { - return - } - version := match[1] - header.Set("Sec-Ch-Ua", fmt.Sprintf(`"%s";v="%s", "Chromium";v="%s", "Not(A:Brand";v="24"`, brand, version, version)) - - platform := "" - switch { - case strings.Contains(lower, "windows"): - platform = "Windows" - case strings.Contains(lower, "mac os x") || strings.Contains(lower, "macintosh"): - platform = "macOS" - case strings.Contains(lower, "android"): - platform = "Android" - case strings.Contains(lower, "iphone") || strings.Contains(lower, "ipad"): - platform = "iOS" - case strings.Contains(lower, "linux"): - platform = "Linux" - } - header.Set("Sec-Ch-Ua-Mobile", "?0") - if strings.Contains(lower, "mobile") || platform == "Android" || platform == "iOS" { - header.Set("Sec-Ch-Ua-Mobile", "?1") - } - header.Set("Sec-Ch-Ua-Model", "") - if platform != "" { - header.Set("Sec-Ch-Ua-Platform", strconv.Quote(platform)) - } - - arch := "" - switch { - case strings.Contains(lower, "aarch64") || strings.Contains(lower, "arm64") || strings.Contains(lower, " arm"): - arch = "arm" - case strings.Contains(lower, "x86_64") || strings.Contains(lower, "x64") || strings.Contains(lower, "win64") || strings.Contains(lower, "intel"): - arch = "x86" - } - if arch != "" { - header.Set("Sec-Ch-Ua-Arch", arch) - header.Set("Sec-Ch-Ua-Bitness", "64") - } + browserheaders.ApplyChromiumClientHints(header, userAgent) } diff --git a/backend/internal/infra/provider/provider.go b/backend/internal/infra/provider/provider.go index e9d7de734..6a0eb6954 100644 --- a/backend/internal/infra/provider/provider.go +++ b/backend/internal/infra/provider/provider.go @@ -329,6 +329,19 @@ type CredentialMetadataAdapter interface { CredentialMetadata(credential account.Credential) CredentialMetadata } +// AccountIdentity 是上游确认的非敏感账号身份元数据。 +// Email 只用于展示;跨 Provider 自动关联仅使用稳定 UserID。 +type AccountIdentity struct { + Email string + UserID string + TeamID string +} + +type AccountIdentityAdapter interface { + Adapter + SyncAccountIdentity(ctx context.Context, credential account.Credential) (AccountIdentity, error) +} + type BuildCredentialConverter interface { Adapter ConvertToBuild(ctx context.Context, credential account.Credential) (CredentialSeed, error) @@ -675,6 +688,15 @@ func (r *Registry) CredentialMetadata(credential account.Credential) CredentialM return inspector.CredentialMetadata(credential) } +func (r *Registry) AccountIdentity(value account.Provider) (AccountIdentityAdapter, bool) { + adapter, ok := r.Get(value) + if !ok { + return nil, false + } + result, ok := adapter.(AccountIdentityAdapter) + return result, ok +} + func (r *Registry) BuildConverter(value account.Provider) (BuildCredentialConverter, bool) { adapter, ok := r.Get(value) if !ok { diff --git a/backend/internal/infra/provider/sessionidentity/session.go b/backend/internal/infra/provider/sessionidentity/session.go new file mode 100644 index 000000000..4938c4d6a --- /dev/null +++ b/backend/internal/infra/provider/sessionidentity/session.go @@ -0,0 +1,145 @@ +package sessionidentity + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + domainegress "github.com/chenyme/grok2api/backend/internal/domain/egress" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/browserheaders" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +const responseBodyLimit = 64 << 10 + +// Fetch 通过 Grok Web Session 接口读取 SSO 账号的稳定身份元数据。 +// Web 与 Console 共用该链路,确保代理、Cookie、UA 和 Resin 身份一致。 +func Fetch(ctx context.Context, baseURL string, credential account.Credential, egress *infraegress.Manager, cipher *security.Cipher) (provider.AccountIdentity, error) { + if credential.AuthType != account.AuthTypeSSO || (credential.Provider != account.ProviderWeb && credential.Provider != account.ProviderConsole) { + return provider.AccountIdentity{}, fmt.Errorf("仅 Grok Web 与 Console SSO 账号支持身份同步") + } + if egress == nil || cipher == nil { + return provider.AccountIdentity{}, fmt.Errorf("Session 身份同步依赖未初始化") + } + token, err := cipher.Decrypt(credential.EncryptedAccessToken) + if err != nil { + return provider.AccountIdentity{}, err + } + if strings.TrimSpace(token) == "" { + return provider.AccountIdentity{}, provider.ErrUnauthorized + } + lease, err := egress.AcquireCredential(ctx, domainegress.ScopeWeb, credential) + if err != nil { + return provider.AccountIdentity{}, err + } + defer lease.Release() + + requestCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + origin := strings.TrimRight(strings.TrimSpace(baseURL), "/") + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, origin+"/api/auth/session", nil) + if err != nil { + return provider.AccountIdentity{}, err + } + request.Header = browserHeaders(token, origin, lease) + response, err := lease.Do(request) + if err != nil { + egress.FeedbackForScope(context.WithoutCancel(ctx), domainegress.ScopeWeb, lease.NodeID, 0, err) + return provider.AccountIdentity{}, err + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, responseBodyLimit+1)) + if err != nil { + return provider.AccountIdentity{}, err + } + if len(body) > responseBodyLimit { + return provider.AccountIdentity{}, fmt.Errorf("Grok Session 响应超过安全上限") + } + egress.FeedbackForScope(context.WithoutCancel(ctx), domainegress.ScopeWeb, lease.NodeID, response.StatusCode, nil) + if response.StatusCode == http.StatusUnauthorized { + return provider.AccountIdentity{}, provider.ErrUnauthorized + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return provider.AccountIdentity{}, fmt.Errorf("Grok Session 接口返回 %d", response.StatusCode) + } + return Parse(body) +} + +func Parse(body []byte) (provider.AccountIdentity, error) { + var value struct { + Status string `json:"status"` + Session struct { + UserID string `json:"userId"` + Email string `json:"email"` + OrganizationID string `json:"organizationId"` + } `json:"session"` + User struct { + ID string `json:"id"` + UserID string `json:"userId"` + Sub string `json:"sub"` + Email string `json:"email"` + TeamID string `json:"teamId"` + } `json:"user"` + ID string `json:"id"` + UserID string `json:"userId"` + Sub string `json:"sub"` + Email string `json:"email"` + TeamID string `json:"teamId"` + } + if err := json.Unmarshal(body, &value); err != nil { + return provider.AccountIdentity{}, fmt.Errorf("解析 Grok Session: %w", err) + } + identity := provider.AccountIdentity{ + UserID: firstNonEmpty(value.Session.UserID, value.User.ID, value.User.UserID, value.User.Sub, value.ID, value.UserID, value.Sub), + Email: firstNonEmpty(value.Session.Email, value.User.Email, value.Email), + TeamID: firstNonEmpty(value.Session.OrganizationID, value.User.TeamID, value.TeamID), + } + identity.UserID = strings.TrimSpace(identity.UserID) + identity.Email = strings.TrimSpace(identity.Email) + identity.TeamID = strings.TrimSpace(identity.TeamID) + if identity.UserID == "" && identity.Email == "" { + if strings.EqualFold(strings.TrimSpace(value.Status), "unauthenticated") { + return provider.AccountIdentity{}, provider.ErrUnauthorized + } + return provider.AccountIdentity{}, fmt.Errorf("Grok Session 缺少账号身份") + } + return identity, nil +} + +func browserHeaders(token, origin string, lease *infraegress.Lease) http.Header { + userAgent := strings.TrimSpace(lease.UserAgent) + if userAgent == "" { + userAgent = infraegress.DefaultUserAgent + } + value := http.Header{} + value.Set("Accept", "*/*") + value.Set("Accept-Encoding", "gzip, deflate, br, zstd") + value.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + value.Set("Cache-Control", "no-cache") + value.Set("Cookie", infraegress.BuildSSOCookie(token, lease.CFCookies)) + value.Set("Pragma", "no-cache") + value.Set("Priority", "u=1, i") + value.Set("Referer", origin+"/") + value.Set("Sec-Fetch-Dest", "empty") + value.Set("Sec-Fetch-Mode", "cors") + value.Set("Sec-Fetch-Site", "same-origin") + value.Set("User-Agent", userAgent) + browserheaders.ApplyChromiumClientHints(value, userAgent) + return value +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/infra/provider/web/account_identity.go b/backend/internal/infra/provider/web/account_identity.go new file mode 100644 index 000000000..882f3ef5d --- /dev/null +++ b/backend/internal/infra/provider/web/account_identity.go @@ -0,0 +1,25 @@ +package web + +import ( + "context" + "fmt" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + "github.com/chenyme/grok2api/backend/internal/infra/provider" + "github.com/chenyme/grok2api/backend/internal/infra/provider/sessionidentity" +) + +// SyncAccountIdentity 从 Grok Web 会话读取非敏感身份元数据。 +// 请求复用账号的 Web 出口、浏览器指纹、Cookie 与 Resin 身份。 +func (a *Adapter) SyncAccountIdentity(ctx context.Context, credential account.Credential) (provider.AccountIdentity, error) { + if credential.Provider != account.ProviderWeb || credential.AuthType != account.AuthTypeSSO { + return provider.AccountIdentity{}, fmt.Errorf("仅 Grok Web SSO 账号支持身份同步") + } + return sessionidentity.Fetch(ctx, a.config().BaseURL, credential, a.egress, a.cipher) +} + +func parseAccountIdentity(body []byte) (provider.AccountIdentity, error) { + return sessionidentity.Parse(body) +} + +var _ provider.AccountIdentityAdapter = (*Adapter)(nil) diff --git a/backend/internal/infra/provider/web/account_identity_test.go b/backend/internal/infra/provider/web/account_identity_test.go new file mode 100644 index 000000000..89e0ff1a3 --- /dev/null +++ b/backend/internal/infra/provider/web/account_identity_test.go @@ -0,0 +1,73 @@ +package web + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/chenyme/grok2api/backend/internal/domain/account" + infraegress "github.com/chenyme/grok2api/backend/internal/infra/egress" + "github.com/chenyme/grok2api/backend/internal/infra/security" +) + +func TestSyncAccountIdentityUsesWebBrowserIdentity(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/auth/session" || request.Method != http.MethodGet { + http.NotFound(writer, request) + return + } + if request.Header.Get("User-Agent") != infraegress.DefaultUserAgent { + t.Errorf("user agent = %q", request.Header.Get("User-Agent")) + } + if request.Header.Get("Cookie") != "sso=test-sso; sso-rw=test-sso; cf_clearance=clear" { + t.Errorf("cookie = %q", request.Header.Get("Cookie")) + } + if request.Header.Get("Accept") != "*/*" || request.Header.Get("Referer") != "http://"+request.Host+"/" || request.Header.Get("Sec-Fetch-Site") != "same-origin" { + t.Errorf("browser headers = %#v", request.Header) + } + if request.Header.Get("Sec-Ch-Ua") == "" || request.Header.Get("Sec-Ch-Ua-Platform") != `"macOS"` { + t.Errorf("client hints = %#v", request.Header) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"user":{"id":"user-1","email":"User@Example.com","teamId":"team-1"}}`)) + })) + t.Cleanup(server.Close) + cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) + if err != nil { + t.Fatal(err) + } + token, _ := cipher.Encrypt("test-sso") + cookies, _ := cipher.Encrypt("cf_clearance=clear") + adapter := NewAdapter(Config{BaseURL: server.URL}, infraegress.NewManager(egressRepositoryStub{}, cipher), cipher, nil, nil) + identity, err := adapter.SyncAccountIdentity(context.Background(), account.Credential{ + ID: 1, Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, + EncryptedAccessToken: token, EncryptedCloudflareCookie: cookies, + }) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "user-1" || identity.Email != "User@Example.com" || identity.TeamID != "team-1" { + t.Fatalf("identity = %#v", identity) + } +} + +func TestParseAccountIdentityRejectsMissingIdentity(t *testing.T) { + t.Parallel() + if _, err := parseAccountIdentity([]byte(`{"user":{"name":"anonymous"}}`)); err == nil { + t.Fatal("expected missing identity error") + } +} + +func TestParseAccountIdentityAcceptsAuthenticatedSessionEnvelope(t *testing.T) { + t.Parallel() + identity, err := parseAccountIdentity([]byte(`{"status":"authenticated","session":{"userId":"user-1","email":"user@example.com","organizationId":"org-1"}}`)) + if err != nil { + t.Fatal(err) + } + if identity.UserID != "user-1" || identity.Email != "user@example.com" || identity.TeamID != "org-1" { + t.Fatalf("identity = %#v", identity) + } +} diff --git a/backend/internal/repository/account.go b/backend/internal/repository/account.go index ec2a38224..98a6ef797 100644 --- a/backend/internal/repository/account.go +++ b/backend/internal/repository/account.go @@ -59,6 +59,10 @@ type AccountRepository interface { MarkBuildAPIFallback(ctx context.Context, id uint64, enabled bool) error // MarkWebNSFWEnabled 幂等记录 Web 账号首次确认 NSFW 已开启的时间。 MarkWebNSFWEnabled(ctx context.Context, id uint64, enabledAt time.Time) error + // MarkWebTermsAccepted 幂等记录 Web 账号首次确认服务协议已接受的时间。 + MarkWebTermsAccepted(ctx context.Context, id uint64, acceptedAt time.Time) error + // MarkWebBirthDateSet 幂等记录 Web 账号首次确认生日已设置的时间。 + MarkWebBirthDateSet(ctx context.Context, id uint64, setAt time.Time) error UpsertModelQuotaBlock(ctx context.Context, value account.ModelQuotaBlock) error PruneExpiredModelQuotaBlocks(ctx context.Context, now time.Time, limit int) (int64, error) SaveBilling(ctx context.Context, value account.Billing) error diff --git a/backend/internal/transport/http/account/handler.go b/backend/internal/transport/http/account/handler.go index 6573806db..125cd17a6 100644 --- a/backend/internal/transport/http/account/handler.go +++ b/backend/internal/transport/http/account/handler.go @@ -233,45 +233,55 @@ type accountImportResponse struct { } type accountResponse struct { - ID uint64 `json:"id,string"` - Provider string `json:"provider"` - AuthType string `json:"authType"` - WebTier string `json:"webTier,omitempty"` - WebTierSyncedAt *time.Time `json:"webTierSyncedAt,omitempty"` - WebNSFWEnabledAt *time.Time `json:"nsfwEnabledAt,omitempty"` - Name string `json:"name"` - Email string `json:"email,omitempty"` - UserID string `json:"userId,omitempty"` - TeamID string `json:"teamId,omitempty"` - Enabled bool `json:"enabled"` - AuthStatus string `json:"authStatus"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - Refreshable bool `json:"refreshable"` - RefreshDueAt *time.Time `json:"refreshDueAt,omitempty"` - LastRefreshAt *time.Time `json:"lastRefreshAt,omitempty"` - RefreshFailures int `json:"refreshFailureCount"` - LastRefreshError string `json:"lastRefreshErrorCode,omitempty"` - Priority int `json:"priority"` - MaxConcurrent int `json:"maxConcurrent"` - MinimumRemaining float64 `json:"minimumRemaining"` - FailureCount int `json:"failureCount"` - CooldownUntil *time.Time `json:"cooldownUntil,omitempty"` - LastError string `json:"lastError,omitempty"` - LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` - LinkedAccountID uint64 `json:"linkedAccountId,omitempty,string"` - LinkedName string `json:"linkedAccountName,omitempty"` - LinkedProvider string `json:"linkedProvider,omitempty"` - CreatedAt time.Time `json:"createdAt"` - ObservedModel string `json:"observedModel,omitempty"` - ObservedModelAt *time.Time `json:"observedModelAt,omitempty"` - CloudflareCookieConfigured bool `json:"cloudflareCookieConfigured"` - BuildSuperEntitled bool `json:"buildSuperEntitled"` - BuildRouteMode string `json:"buildRouteMode"` - BuildBotFlagged bool `json:"buildBotFlagged"` - ModelSyncFailed bool `json:"modelSyncFailed,omitempty"` - Billing *billingResponse `json:"billing,omitempty"` - Quota quotaResponse `json:"quota"` - QuotaWindows []quotaWindowResponse `json:"quotaWindows,omitempty"` + ID uint64 `json:"id,string"` + Provider string `json:"provider"` + AuthType string `json:"authType"` + WebTier string `json:"webTier,omitempty"` + WebTierSyncedAt *time.Time `json:"webTierSyncedAt,omitempty"` + WebNSFWEnabledAt *time.Time `json:"nsfwEnabledAt,omitempty"` + WebTermsAcceptedAt *time.Time `json:"termsAcceptedAt,omitempty"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` + TeamID string `json:"teamId,omitempty"` + Enabled bool `json:"enabled"` + AuthStatus string `json:"authStatus"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Refreshable bool `json:"refreshable"` + RefreshDueAt *time.Time `json:"refreshDueAt,omitempty"` + LastRefreshAt *time.Time `json:"lastRefreshAt,omitempty"` + RefreshFailures int `json:"refreshFailureCount"` + LastRefreshError string `json:"lastRefreshErrorCode,omitempty"` + Priority int `json:"priority"` + MaxConcurrent int `json:"maxConcurrent"` + MinimumRemaining float64 `json:"minimumRemaining"` + FailureCount int `json:"failureCount"` + CooldownUntil *time.Time `json:"cooldownUntil,omitempty"` + LastError string `json:"lastError,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + LinkedAccountID uint64 `json:"linkedAccountId,omitempty,string"` + LinkedName string `json:"linkedAccountName,omitempty"` + LinkedProvider string `json:"linkedProvider,omitempty"` + LinkedAccounts []linkedAccountResponse `json:"linkedAccounts,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ObservedModel string `json:"observedModel,omitempty"` + ObservedModelAt *time.Time `json:"observedModelAt,omitempty"` + CloudflareCookieConfigured bool `json:"cloudflareCookieConfigured"` + BuildSuperEntitled bool `json:"buildSuperEntitled"` + BuildRouteMode string `json:"buildRouteMode"` + BuildBotFlagged bool `json:"buildBotFlagged"` + ModelSyncFailed bool `json:"modelSyncFailed,omitempty"` + Billing *billingResponse `json:"billing,omitempty"` + Quota quotaResponse `json:"quota"` + QuotaWindows []quotaWindowResponse `json:"quotaWindows,omitempty"` +} + +type linkedAccountResponse struct { + ID uint64 `json:"id,string"` + Provider string `json:"provider"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` } type quotaWindowResponse struct { @@ -1080,13 +1090,9 @@ func newAccountResponse(value accountapp.View) accountResponse { if c.Provider != accountdomain.ProviderBuild || !buildRouteMode.IsValid() { buildRouteMode = accountdomain.BuildRouteAuto } - var webNSFWEnabledAt *time.Time - if c.Provider == accountdomain.ProviderWeb { - webNSFWEnabledAt = c.WebNSFWEnabledAt - } result := accountResponse{ ID: c.ID, Provider: string(c.Provider), AuthType: string(c.AuthType), WebTier: string(c.WebTier), - WebTierSyncedAt: c.WebTierSyncedAt, WebNSFWEnabledAt: webNSFWEnabledAt, Name: c.Name, Email: c.Email, UserID: c.UserID, TeamID: c.TeamID, + WebTierSyncedAt: c.WebTierSyncedAt, WebNSFWEnabledAt: c.WebNSFWEnabledAt, WebTermsAcceptedAt: c.WebTermsAcceptedAt, Name: c.Name, Email: c.Email, UserID: c.UserID, TeamID: c.TeamID, Enabled: c.Enabled, AuthStatus: string(c.AuthStatus), Refreshable: c.EncryptedRefreshToken != "", RefreshDueAt: c.RefreshDueAt, LastRefreshAt: c.LastRefreshAt, RefreshFailures: c.RefreshFailureCount, LastRefreshError: c.LastRefreshErrorCode, @@ -1100,6 +1106,9 @@ func newAccountResponse(value accountapp.View) accountResponse { BuildBotFlagged: value.BuildBotFlagged && c.Provider == accountdomain.ProviderBuild, Quota: newQuotaResponse(value.Quota), QuotaWindows: make([]quotaWindowResponse, 0, len(value.QuotaWindows)), } + for _, linked := range c.LinkedAccounts { + result.LinkedAccounts = append(result.LinkedAccounts, linkedAccountResponse{ID: linked.ID, Provider: string(linked.Provider), Name: linked.Name, Email: linked.Email, UserID: linked.UserID}) + } for _, window := range value.QuotaWindows { breakdown := make([]quotaBreakdownResponse, 0, len(window.Breakdown)) for _, item := range window.Breakdown { diff --git a/backend/internal/transport/http/account/handler_test.go b/backend/internal/transport/http/account/handler_test.go index 8edb7a384..f865723b7 100644 --- a/backend/internal/transport/http/account/handler_test.go +++ b/backend/internal/transport/http/account/handler_test.go @@ -20,21 +20,34 @@ import ( func TestNewAccountResponseExposesBuildBotFlagOnlyForBuild(t *testing.T) { now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) build := newAccountResponse(accountapp.View{ - Credential: accountdomain.Credential{Provider: accountdomain.ProviderBuild, BuildRouteMode: accountdomain.BuildRouteXAI, WebNSFWEnabledAt: &now}, + Credential: accountdomain.Credential{Provider: accountdomain.ProviderBuild, BuildRouteMode: accountdomain.BuildRouteXAI, WebNSFWEnabledAt: &now, WebTermsAcceptedAt: &now}, BuildBotFlagged: true, }) - if !build.BuildBotFlagged || build.BuildRouteMode != string(accountdomain.BuildRouteXAI) || build.WebNSFWEnabledAt != nil { + if !build.BuildBotFlagged || build.BuildRouteMode != string(accountdomain.BuildRouteXAI) || build.WebNSFWEnabledAt == nil || !build.WebNSFWEnabledAt.Equal(now) || build.WebTermsAcceptedAt == nil || !build.WebTermsAcceptedAt.Equal(now) { t.Fatalf("Build metadata = %#v", build) } web := newAccountResponse(accountapp.View{ - Credential: accountdomain.Credential{Provider: accountdomain.ProviderWeb, WebNSFWEnabledAt: &now}, + Credential: accountdomain.Credential{Provider: accountdomain.ProviderWeb, WebNSFWEnabledAt: &now, WebTermsAcceptedAt: &now}, BuildBotFlagged: true, }) - if web.BuildBotFlagged || web.BuildRouteMode != string(accountdomain.BuildRouteAuto) || web.WebNSFWEnabledAt == nil || !web.WebNSFWEnabledAt.Equal(now) { + if web.BuildBotFlagged || web.BuildRouteMode != string(accountdomain.BuildRouteAuto) || web.WebNSFWEnabledAt == nil || !web.WebNSFWEnabledAt.Equal(now) || web.WebTermsAcceptedAt == nil || !web.WebTermsAcceptedAt.Equal(now) { t.Fatalf("non-Build metadata = %#v", web) } } +func TestNewAccountResponseExposesAllLinkedAccounts(t *testing.T) { + response := newAccountResponse(accountapp.View{Credential: accountdomain.Credential{ + Provider: accountdomain.ProviderWeb, + LinkedAccounts: []accountdomain.LinkedAccount{ + {ID: 2, Provider: accountdomain.ProviderBuild, Name: "build", Email: "build@example.com", UserID: "build-user"}, + {ID: 3, Provider: accountdomain.ProviderConsole, Name: "console", Email: "console@example.com", UserID: "console-user"}, + }, + }}) + if len(response.LinkedAccounts) != 2 || response.LinkedAccounts[0].Provider != string(accountdomain.ProviderBuild) || response.LinkedAccounts[0].Email != "build@example.com" || response.LinkedAccounts[0].UserID != "build-user" || response.LinkedAccounts[1].Provider != string(accountdomain.ProviderConsole) || response.LinkedAccounts[1].Email != "console@example.com" || response.LinkedAccounts[1].UserID != "console-user" { + t.Fatalf("linked accounts = %#v", response.LinkedAccounts) + } +} + type accountSynchronizerStub struct { accountIDs []uint64 } diff --git a/frontend/src/features/accounts/account-name-cell.tsx b/frontend/src/features/accounts/account-name-cell.tsx new file mode 100644 index 000000000..eb8013581 --- /dev/null +++ b/frontend/src/features/accounts/account-name-cell.tsx @@ -0,0 +1,133 @@ +import { Compass, Handshake, SquareTerminal, VenusAndMars, Webhook, type LucideIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { AccountDTO, AccountProvider, LinkedAccountDTO } from "@/features/accounts/accounts-api"; +import { cn } from "@/shared/lib/cn"; +import { formatDateTime } from "@/shared/lib/format"; + +const providerOrder: Record = { + grok_build: 0, + grok_web: 1, + grok_console: 2, +}; + +const providerIcon: Record = { + grok_build: { icon: SquareTerminal, className: "text-quota-product-1" }, + grok_web: { icon: Compass, className: "text-quota-product-2" }, + grok_console: { icon: Webhook, className: "text-quota-product-4" }, +}; + +function identityDetails(name: string, email?: string, userId?: string): string[] { + const values = [email?.trim() || name.trim(), userId?.trim()]; + const seen = new Set(); + return values.filter((value): value is string => { + if (!value) return false; + const normalized = value.toLocaleLowerCase(); + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); +} + +function accountLinks(account: AccountDTO): LinkedAccountDTO[] { + const links = account.linkedAccounts ?? (account.linkedAccountId && account.linkedProvider + ? [{ id: account.linkedAccountId, provider: account.linkedProvider, name: account.linkedAccountName ?? "" }] + : []); + return [...links].sort((left, right) => providerOrder[left.provider] - providerOrder[right.provider] || left.id.localeCompare(right.id)); +} + +export function AccountNameCell({ account }: { account: AccountDTO }) { + const { t, i18n } = useTranslation(); + const links = accountLinks(account); + const providerLabel = (provider: AccountProvider) => provider === "grok_build" + ? t("models.providerGrokBuild") + : provider === "grok_web" + ? t("models.providerGrokWeb") + : t("console.name"); + const connections = [ + { id: account.id, provider: account.provider, details: identityDetails(account.name, account.email, account.userId) }, + ...links.filter((linked) => linked.provider !== account.provider).map((linked) => ({ id: linked.id, provider: linked.provider, details: identityDetails(linked.name, linked.email, linked.userId) })), + ].sort((left, right) => providerOrder[left.provider] - providerOrder[right.provider]); + + return ( +
+
+ + + {account.name} + + {account.name} + + {account.buildBotFlagged ? ( + + + {t("accounts.botRisk")} + + {t("accounts.botRiskTooltip")} + + ) : null} +
+
+ + +
providerLabel(connection.provider)).join(", ")} + className="flex min-w-0 cursor-help items-center gap-1.5 overflow-hidden rounded-sm focus-visible:outline-none" + > + {connections.map((connection) => { + const { icon: ProviderIcon, className } = providerIcon[connection.provider]; + return ; + })} +
+
+ + {connections.map((connection) => { + const label = providerLabel(connection.provider); + const { icon: ProviderIcon, className } = providerIcon[connection.provider]; + return ( +
+
+ + {label} +
+ {(connection.details.length > 0 ? connection.details : [label]).map((detail, index) => ( +

0 && "text-primary-foreground/70")}>{detail}

+ ))} +
+ ); + })} +
+
+ {account.termsAcceptedAt || account.nsfwEnabledAt ? ( + <> +
+
+ ); +} diff --git a/frontend/src/features/accounts/accounts-api.ts b/frontend/src/features/accounts/accounts-api.ts index 142767b5b..7d2bd9e80 100644 --- a/frontend/src/features/accounts/accounts-api.ts +++ b/frontend/src/features/accounts/accounts-api.ts @@ -67,6 +67,7 @@ export type AccountDTO = { webTier?: "auto" | "basic" | "super" | "heavy"; webTierSyncedAt?: string; nsfwEnabledAt?: string; + termsAcceptedAt?: string; name: string; email?: string; userId?: string; @@ -94,12 +95,21 @@ export type AccountDTO = { linkedAccountId?: string; linkedAccountName?: string; linkedProvider?: "grok_build" | "grok_web"; + linkedAccounts?: LinkedAccountDTO[]; createdAt: string; billing?: BillingDTO; quota: QuotaDTO; quotaWindows?: Array<{ mode: string; remaining: number; total: number; usagePercent: number; breakdown?: Array<{ productCode: number; usagePercent: number }>; windowSeconds: number; resetAt?: string; syncedAt?: string; source: "default" | "estimated" | "upstream" }>; }; +export type LinkedAccountDTO = { + id: string; + provider: "grok_build" | "grok_web" | "grok_console"; + name: string; + email?: string; + userId?: string; +}; + export type AccountUpdateInput = { name: string; enabled: boolean; @@ -163,14 +173,15 @@ const quotaWindowValidator = hasShape({ mode: isString, remaining: isNumber, total: isNumber, usagePercent: isNumber, breakdown: isOptional(isArrayOf(quotaBreakdownValidator)), windowSeconds: isNumber, resetAt: isOptional(isString), syncedAt: isOptional(isString), source: isOneOf("default", "estimated", "upstream"), }); +const linkedAccountValidator = hasShape({ id: isString, provider: isOneOf("grok_build", "grok_web", "grok_console"), name: isString, email: isOptional(isString), userId: isOptional(isString) }); const accountValidator = hasShape({ id: isString, provider: isOneOf("grok_build", "grok_web", "grok_console"), authType: isOneOf("oauth", "sso"), webTier: isOptional(isOneOf("auto", "basic", "super", "heavy")), - webTierSyncedAt: isOptional(isString), nsfwEnabledAt: isOptional(isString), name: isString, email: isOptional(isString), userId: isOptional(isString), teamId: isOptional(isString), + webTierSyncedAt: isOptional(isString), nsfwEnabledAt: isOptional(isString), termsAcceptedAt: isOptional(isString), name: isString, email: isOptional(isString), userId: isOptional(isString), teamId: isOptional(isString), enabled: isBoolean, authStatus: isOneOf("active", "reauthRequired"), expiresAt: isOptional(isString), refreshable: isBoolean, cloudflareCookieConfigured: isBoolean, buildSuperEntitled: isBoolean, buildRouteMode: isOneOf("auto", "build", "xai"), buildBotFlagged: isBoolean, modelSyncFailed: isOptional(isBoolean), refreshDueAt: isOptional(isString), lastRefreshAt: isOptional(isString), refreshFailureCount: isNumber, lastRefreshErrorCode: isOptional(isString), priority: isNumber, maxConcurrent: isNumber, minimumRemaining: isNumber, failureCount: isNumber, cooldownUntil: isOptional(isString), lastError: isOptional(isString), lastUsedAt: isOptional(isString), - linkedAccountId: isOptional(isString), linkedAccountName: isOptional(isString), linkedProvider: isOptional(isOneOf("grok_build", "grok_web")), + linkedAccountId: isOptional(isString), linkedAccountName: isOptional(isString), linkedProvider: isOptional(isOneOf("grok_build", "grok_web")), linkedAccounts: isOptional(isArrayOf(linkedAccountValidator)), createdAt: isString, billing: isOptional(billingValidator), quota: quotaValidator, quotaWindows: isOptional(isArrayOf(quotaWindowValidator)), }); const decodeBilling = createValidatedDecoder("billing", billingValidator); diff --git a/frontend/src/features/accounts/accounts-page.tsx b/frontend/src/features/accounts/accounts-page.tsx index 62b2afb17..e6166ba50 100644 --- a/frontend/src/features/accounts/accounts-page.tsx +++ b/frontend/src/features/accounts/accounts-page.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowRight, ClipboardPaste, Compass, Download, ExternalLink, FileUp, Link2, MoreHorizontal, Pencil, Plus, RefreshCw, RotateCw, Search, SquareTerminal, Trash2, TriangleAlert, Webhook } from "lucide-react"; +import { ArrowRight, ClipboardPaste, Compass, Download, ExternalLink, FileUp, MoreHorizontal, Pencil, Plus, RefreshCw, RotateCw, Search, SquareTerminal, Trash2, TriangleAlert, Webhook } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -73,6 +73,7 @@ import { type QuotaDTO, } from "@/features/accounts/accounts-api"; import { AccountQuota, ConsoleQuota, WebQuota } from "@/features/accounts/account-quota"; +import { AccountNameCell } from "@/features/accounts/account-name-cell"; import { WebAccountScriptsDialog } from "@/features/accounts/web-account-scripts"; import { WebAccountSettingsDialogs, WebAccountSettingsMenu, type WebAccountConfirmationTarget } from "@/features/accounts/web-account-settings"; @@ -752,10 +753,10 @@ export function AccountsPage() { - + - + {provider === "grok_build" ? : null} @@ -774,64 +775,20 @@ export function AccountsPage() { {accountsQuery.isPending ? : result?.items.map((account) => { - const accountDetail = account.email ?? account.userId ?? account.teamId; - const showAccountDetail = accountDetail?.trim().toLocaleLowerCase() !== account.name.trim().toLocaleLowerCase(); - const linkedProviderLabel = account.linkedProvider === "grok_build" ? t("models.providerGrokBuild") : account.linkedProvider === "grok_web" ? t("models.providerGrokWeb") : t("console.name"); return ( - + toggleAccount(account.id, checked === true)} aria-label={t("common.selectItem", { name: account.name })} /> - -
- -
{account.name}
- {account.name} -
- {account.buildBotFlagged ? ( - - - {t("accounts.botRisk")} - - {t("accounts.botRiskTooltip")} - - ) : null} - {account.provider === "grok_web" && account.nsfwEnabledAt ? ( - - - {t("accounts.nsfwEnabledMark")} - - {t("accounts.nsfwEnabledTooltip", { time: formatDateTime(account.nsfwEnabledAt, i18n.language) })} - - ) : null} -
- {showAccountDetail || account.linkedAccountId ? ( -
- {showAccountDetail ? {accountDetail} : null} - {showAccountDetail && account.linkedAccountId ? : null} - {account.linkedAccountId ? ( - - - - - {linkedProviderLabel} - - - {t("accounts.linkedAccountTooltip", { name: account.linkedAccountName || linkedProviderLabel })} - - ) : null} -
- ) : null} -
+ {provider === "grok_web" ? : provider === "grok_console" ? : } {provider === "grok_web" ? : provider === "grok_console" ? : } {provider === "grok_build" ? -
{account.refreshable ? ( + {account.refreshable ? ( {t("accountCredential.autoRefresh")} {account.expiresAt ? t("accountCredential.expiresAt", { time: formatDateTime(account.expiresAt, i18n.language) }) : t("accountCredential.expiryUnknown")} - ) : {t("accountCredential.noAutoRefresh")}}
-
{t(`accounts.buildRouteMode.${account.buildRouteMode}`)}
+ ) : {t("accountCredential.noAutoRefresh")}}
: null} {formatDateTime(account.createdAt, i18n.language)} diff --git a/frontend/src/features/accounts/web-account-scripts.tsx b/frontend/src/features/accounts/web-account-scripts.tsx index 0b26b5794..4b57f2812 100644 --- a/frontend/src/features/accounts/web-account-scripts.tsx +++ b/frontend/src/features/accounts/web-account-scripts.tsx @@ -1,4 +1,4 @@ -import { Cake, FileCheck2, ShieldCheck } from "lucide-react"; +import { Cake, Handshake, VenusAndMars, type LucideIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -41,14 +41,14 @@ export function WebAccountScriptsDialog({ targets, pending, progress, onClose, o const items: Array<{ action: keyof WebAccountScriptActions; - icon: typeof FileCheck2; + icon: LucideIcon; label: string; description: string; locked?: boolean; }> = [ { action: "acceptTerms", - icon: FileCheck2, + icon: Handshake, label: t("webAccountSettings.acceptTerms"), description: t("webAccountScripts.acceptTermsDescription"), }, @@ -61,7 +61,7 @@ export function WebAccountScriptsDialog({ targets, pending, progress, onClose, o }, { action: "enableNSFW", - icon: ShieldCheck, + icon: VenusAndMars, label: t("webAccountSettings.enableNSFW"), description: t("webAccountScripts.enableNSFWDescription"), }, diff --git a/frontend/src/shared/i18n/index.ts b/frontend/src/shared/i18n/index.ts index d343f27c8..cddb3fd56 100644 --- a/frontend/src/shared/i18n/index.ts +++ b/frontend/src/shared/i18n/index.ts @@ -24,8 +24,8 @@ const resources = { action: "账号脚本", allTitle: "批量执行账号脚本", selectedTitle: "对 {{count}} 个账号执行脚本", - allDescription: "将处理全部 Grok Web 账号,不受当前筛选影响。", - selectedDescription: "所选步骤按固定顺序执行,单个账号失败不会中断其他账号。", + allDescription: "将检查全部 Grok Web 账号,仅执行尚未记录完成的步骤。", + selectedDescription: "仅执行所选账号尚未记录完成的步骤,单个账号失败不会中断其他账号。", operations: "执行步骤", acceptTermsDescription: "接受当前 xAI 服务协议。", setBirthDateDescription: "随机生成 20–40 岁生日;开启 NSFW 时必选。", @@ -461,7 +461,7 @@ const resources = { botRisk: "风控", botRiskTooltip: "机器人风控。可能影响视频生成能力与其他权限", nsfwEnabledMark: "NSFW", - nsfwEnabledTooltip: "本服务曾成功开启 NSFW · {{time}}", + nsfwEnabledTooltip: "已开启 NSFW · {{time}}", riskFilter: "风控", riskNormal: "正常", riskAccountCount: "风控 {{count}}", @@ -948,8 +948,8 @@ const resources = { action: "Account scripts", allTitle: "Run account scripts", selectedTitle: "Run scripts for {{count}} accounts", - allDescription: "All Grok Web accounts will be processed, regardless of the current filters.", - selectedDescription: "Selected steps run in a fixed order. A failed account does not stop the rest of the batch.", + allDescription: "All Grok Web accounts will be checked, and only unrecorded steps will run.", + selectedDescription: "Only unrecorded steps run for the selected accounts. A failed account does not stop the rest of the batch.", operations: "Steps", acceptTermsDescription: "Accept the current xAI terms of service.", setBirthDateDescription: "Generate a random birth date for an age between 20 and 40; required for NSFW.", @@ -1071,7 +1071,7 @@ const resources = { auth: { title: "Admin sign in", productTitle: "Lightweight API gateway", subtitle: "Manage upstream accounts, model routes, and client access.", username: "Username", password: "Password", usernameRequired: "Enter your username", passwordRequired: "Enter your password", signIn: "Sign in", signingIn: "Signing in", signOut: "Sign out", changePassword: "Change password", currentPassword: "Current password", newPassword: "New password", passwordUpdated: "Password updated. Sign in again.", sessionUnavailable: "Unable to restore the session", sessionUnavailableDescription: "The service may be temporarily unavailable. Your session was not cleared; please retry shortly.", retrySession: "Retry" }, shell: { appearance: "Appearance", dark: "Dark", light: "Light", system: "System", language: "Language", navigation: "Navigation", openNavigation: "Open navigation" }, dashboard: { title: "Dashboard", subtitle: "Welcome, {{name}}", lastUpdated: "Updated {{time}}", welcome: "Welcome, {{name}}", gatewayOnline: "Gateway healthy", gatewayOffline: "Unavailable", gatewayReady: "The gateway is ready to process API requests.", gatewayNeedsAccount: "Connect an available account to process API requests.", manageAccounts: "Manage accounts", usage: "Usage overview", seeAll: "View audits", activeAccounts: "Available accounts", accountCapacity: "{{total}} accounts · {{rate}}% success", manage: "Manage", tokens: "Total tokens", requests: "Requests", successRate: "Success rate", accountCount: "Accounts", accountDistribution: "Build {{build}} · Web {{web}} · Console {{console}}", requestSuccessRate: "{{rate}}% success", averageRequestCost: "{{cost}} per request", availableSummary: "{{active}} / {{total}} available", requestBreakdown: "{{success}} succeeded · {{failed}} failed", requestQualitySummary: "{{success}}% success · {{failed}} failed", periodSummary: "Current {{period}} range", successSummary: "{{success}} / {{total}} requests succeeded", trend: "Usage trend", trendRequests: "Requests", trendTokens: "Tokens", trendSummary: "{{requests}} requests · {{rate}}% success", resourcesTitle: "Resource availability", availability: "Account availability", unavailableAccounts: "Unavailable accounts", unavailableSummary: "{{unavailable}} / {{total}} unavailable", enabledModels: "Enabled models", modelsAvailableSummary: "{{enabled}} / {{total}} enabled", activeClientKeys: "Available keys", keysAvailableSummary: "{{active}} / {{total}} available", allTimeRequests: "All-time requests", allTimeRequestsSummary: "Since the first recorded request", qualityTitle: "Request quality", successfulRequests: "Successful requests", failedRequests: "Failed requests", topModels: "Top 10 model billing", model: "Model", share: "Call share", noTopModels: "No model calls in this period", noTrendData: "No usage data in this period", billing: "Billing", billingSummary: "Accumulated in the current {{period}} range", inputTokens: "Input", cachedTokens: "Cached", outputTokens: "Output", reasoningTokens: "Reasoning", uncachedInput: "Uncached input", visibleOutput: "Visible output", otherModels: "Other models", providerDistribution: "Provider distribution", providerDetail: "{{rate}}% success · {{tokens}} tokens", providerStripeDetail: "{{provider}} · {{requests}} requests · {{share}}%", providerNoRequests: "No provider requests", activityTitle: "Request activity", lastDays: "Last {{count}} days", activityDay: "{{date}} · {{requests}} requests", activityLess: "Less", activityMore: "More", tokenBreakdown: "Input {{input}} · Cached {{cached}} · Output {{output}} · Reasoning {{reasoning}}", tokenEfficiency: "Cache hit rate {{rate}}%", explore: "Manage resources", accountsProduct: "Upstream accounts", accountsProductDescription: "Manage OAuth credentials, health, quota, and concurrency.", keysProduct: "Client keys", keysProductDescription: "Create downstream API keys and control models, RPM, and concurrency.", modelsProduct: "Model routes", modelsProductDescription: "Manage public model IDs, upstream mappings, and availability.", auditsProduct: "Request audits", auditsProductDescription: "Inspect status, latency, and token usage records.", gatewayFooter: "A lightweight API gateway", learnMore: "View models", unknown: "Unknown" }, - accounts: { title: "Upstream accounts", description: "Manage separate Grok Build OAuth and Grok Web SSO pools, health, concurrency, and quotas.", add: "Connect account", connectAccount: "Connect account", convertToBuild: "Convert to Build", convertToBuildTitle: "Convert Grok Web accounts to Build?", convertToBuildDescription: "Choose the conversion scope for the selected {{count}} Web accounts. Conversion may take a moment.", convertingProgress: "Converting {{completed}} / {{total}}", syncingProgress: "Syncing {{completed}} / {{total}}", conversionCompleted: "Conversion complete: {{created}} created, {{linked}} linked, {{skipped}} skipped, {{failed}} failed", linkedAccountTooltip: "Linked account: {{name}}", syncAll: "Sync all", renewAll: "Renew all", syncAllTitle: "Sync all accounts?", syncAllDescription: "Refresh quota data for every enabled Grok Build account.", syncAllWebDescription: "Refresh real quota data for every enabled Grok Web account.", renewAllTitle: "Renew all accounts?", renewAllDescription: "Refresh every renewable Grok Build credential. Accounts without automatic renewal will be skipped.", search: "Search accounts", deviceLogin: "Device OAuth", importAuth: "Import account files", importSSO: "Import SSO JSON", quickImportSSO: "Quick import SSO", importWebFile: "Import account files", quickImportTitle: "Quick import Grok Web accounts", quickImportDescription: "Paste multiple SSO tokens directly. Duplicate entries are ignored.", ssoTokens: "SSO tokens (one per line)", ssoTokenPlaceholder: "sso_token_1\nsso_token_2\nsso=token_3", importAction: "Import", exportAuth: "Export all accounts", exported: "Account credentials exported", exportTitle: "Export all account credentials?", exportDescription: "The exported JSON contains OAuth credentials that can access the upstream service. Store it only in a trusted location and never upload, share, or commit it to version control.", refreshAllBilling: "Sync all quotas", account: "Account", status: "Status", type: "Type", quota: "Quota", routing: "Routing", credentialExpiry: "Credential expiry", credentialRenewal: "Renewal capability", autoRefresh: "Auto-renewal", noAutoRefresh: "No auto-renewal", buildAccountCount: "Grok Build accounts", webAccountCount: "Grok Web accounts", consoleAccountCount: "Grok Console accounts", abnormalAccountCount: "Abnormal accounts", routableAccountCount: "{{count}} routable", abnormalAccountBreakdown: "Recovering {{recovering}} · Needs attention {{attention}}", riskAccountCount: "Risk {{count}}", riskFilter: "Risk", riskNormal: "Normal", lastUsed: "Last used", createdAt: "Created at", name: "Name", priority: "Priority", maxConcurrent: "Max concurrency", minimumRemaining: "Minimum remaining threshold", routingSummary: "Priority {{priority}} · {{count}} concurrent", minimumRemainingSummary: "Minimum remaining {{value}}", minimumRemainingNotApplicable: "Minimum remaining does not apply to Free", refreshToken: "Refresh credential", refreshBilling: "Sync quota", refreshModeQuota: "Sync quota", quotaNotSynced: "Not synced", quotaResetAt: "Next reset {{time}}", quotaResetUnknown: "The upstream did not return a reset time", webQuotaUsage: "Used {{used}} / {{total}} total · {{remaining}} remaining", webModeQuotaRemaining: "{{mode}} has {{remaining}} calls remaining", webWeeklyQuotaUsage: "Weekly quota {{remaining}}% remaining", paidQuotaDetails: "{{remaining}} credits remaining", created: "Account connected", createdWithSyncFailure: "Account connected, but initial quota or model sync failed", updated: "Account updated", updatedWithModelSyncFailure: "Account updated, but model capability sync failed. Run model sync again later.", deleted: "Account deleted", imported: "Import complete: {{created}} created, {{updated}} updated", importedWithSyncFailures: "Import complete: {{created}} created, {{updated}} updated; {{synced}} synced, {{syncFailed}} failed initial sync", billingRefreshed: "Quota synced", authRefreshed: "Credential refreshed", allBillingRefreshed: "Sync complete: {{succeeded}} succeeded, {{failed}} failed", allTokensRefreshed: "Renewal complete: {{succeeded}} succeeded, {{failed}} failed, {{skipped}} skipped", statusDisabled: "Disabled", statusReauthRequired: "Invalid", statusCooldown: "Cooling", statusActive: "Normal", botRisk: "Risk", botRiskTooltip: "Bot risk control. This may affect video generation and other permissions.", nsfwEnabledMark: "NSFW", nsfwEnabledTooltip: "Enabled by this service · {{time}}", buildRouteMode: { label: "Upstream endpoint", auto: "Automatic", build: "Build", xai: "XAI", autoDescription: "Selects an upstream based on account tier and risk status.", buildDescription: "Routes all requests to Build with XAI fallback disabled.", xaiDescription: "Routes all inference and video requests to XAI.", xaiUnconfirmedWarning: "This account has not been confirmed for XAI access; the upstream may reject requests." }, buildSuperEntitled: { label: "Mark as Super manually", description: "Use only when automatic tier detection is inaccurate. Enabled accounts sync models with Super access." }, quotaFree: "Free", quotaSuper: "Super", quotaPaid: "Super", quotaUnknown: "Unknown", paidQuotaUsage: "Upstream quota usage", weeklyQuota: "Weekly", monthlyQuota: "Monthly", weeklyLimit: "Weekly limit {{percent}}% remaining", freeObservedUsage: "{{used}} tokens observed in the last 24 hours", freeEstimatedUsage: "About {{used}} / {{limit}} tokens", freeEstimatedDescription: "Estimated from observed Grok Build Free characteristics; upstream exhaustion data takes precedence", observedRolling: "Free accounts use this proxy's rolling observation and may differ from upstream billing data", upstreamConfirmed: "Exhaustion confirmed by upstream", waitingReset: "Waiting reset", waitingResetUntil: "Waiting for reset · probe after {{time}} on real traffic", paidWaitingResetUntil: "Waiting for reset · check the upstream Billing period after {{time}}", probing: "Probing", probingQuota: "Checking quota recovery with real traffic", paidProbingQuota: "Checking whether the upstream Billing period has recovered", upstreamBilling: "Upstream billing data", deviceTitle: "Connect Grok Build", deviceDescription: "Complete authorization in a new window. This page will check status automatically.", userCode: "Device code", openVerification: "Open authorization", waiting: "Waiting for authorization", expiresAt: "Expires {{time}}", deleteTitle: "Delete account?", deleteDescription: "Credentials and quota snapshots will be removed. This cannot be undone.", batchUpdated: "Selected accounts updated", batchBillingRefreshed: "Quota sync complete: {{succeeded}} succeeded, {{failed}} failed", batchDeleteTitle: "Delete {{count}} selected accounts?" }, + accounts: { title: "Upstream accounts", description: "Manage separate Grok Build OAuth and Grok Web SSO pools, health, concurrency, and quotas.", add: "Connect account", connectAccount: "Connect account", convertToBuild: "Convert to Build", convertToBuildTitle: "Convert Grok Web accounts to Build?", convertToBuildDescription: "Choose the conversion scope for the selected {{count}} Web accounts. Conversion may take a moment.", convertingProgress: "Converting {{completed}} / {{total}}", syncingProgress: "Syncing {{completed}} / {{total}}", conversionCompleted: "Conversion complete: {{created}} created, {{linked}} linked, {{skipped}} skipped, {{failed}} failed", linkedAccountTooltip: "Linked account: {{name}}", syncAll: "Sync all", renewAll: "Renew all", syncAllTitle: "Sync all accounts?", syncAllDescription: "Refresh quota data for every enabled Grok Build account.", syncAllWebDescription: "Refresh real quota data for every enabled Grok Web account.", renewAllTitle: "Renew all accounts?", renewAllDescription: "Refresh every renewable Grok Build credential. Accounts without automatic renewal will be skipped.", search: "Search accounts", deviceLogin: "Device OAuth", importAuth: "Import account files", importSSO: "Import SSO JSON", quickImportSSO: "Quick import SSO", importWebFile: "Import account files", quickImportTitle: "Quick import Grok Web accounts", quickImportDescription: "Paste multiple SSO tokens directly. Duplicate entries are ignored.", ssoTokens: "SSO tokens (one per line)", ssoTokenPlaceholder: "sso_token_1\nsso_token_2\nsso=token_3", importAction: "Import", exportAuth: "Export all accounts", exported: "Account credentials exported", exportTitle: "Export all account credentials?", exportDescription: "The exported JSON contains OAuth credentials that can access the upstream service. Store it only in a trusted location and never upload, share, or commit it to version control.", refreshAllBilling: "Sync all quotas", account: "Account", status: "Status", type: "Type", quota: "Quota", routing: "Routing", credentialExpiry: "Credential expiry", credentialRenewal: "Renewal capability", autoRefresh: "Auto-renewal", noAutoRefresh: "No auto-renewal", buildAccountCount: "Grok Build accounts", webAccountCount: "Grok Web accounts", consoleAccountCount: "Grok Console accounts", abnormalAccountCount: "Abnormal accounts", routableAccountCount: "{{count}} routable", abnormalAccountBreakdown: "Recovering {{recovering}} · Needs attention {{attention}}", riskAccountCount: "Risk {{count}}", riskFilter: "Risk", riskNormal: "Normal", lastUsed: "Last used", createdAt: "Created at", name: "Name", priority: "Priority", maxConcurrent: "Max concurrency", minimumRemaining: "Minimum remaining threshold", routingSummary: "Priority {{priority}} · {{count}} concurrent", minimumRemainingSummary: "Minimum remaining {{value}}", minimumRemainingNotApplicable: "Minimum remaining does not apply to Free", refreshToken: "Refresh credential", refreshBilling: "Sync quota", refreshModeQuota: "Sync quota", quotaNotSynced: "Not synced", quotaResetAt: "Next reset {{time}}", quotaResetUnknown: "The upstream did not return a reset time", webQuotaUsage: "Used {{used}} / {{total}} total · {{remaining}} remaining", webModeQuotaRemaining: "{{mode}} has {{remaining}} calls remaining", webWeeklyQuotaUsage: "Weekly quota {{remaining}}% remaining", paidQuotaDetails: "{{remaining}} credits remaining", created: "Account connected", createdWithSyncFailure: "Account connected, but initial quota or model sync failed", updated: "Account updated", updatedWithModelSyncFailure: "Account updated, but model capability sync failed. Run model sync again later.", deleted: "Account deleted", imported: "Import complete: {{created}} created, {{updated}} updated", importedWithSyncFailures: "Import complete: {{created}} created, {{updated}} updated; {{synced}} synced, {{syncFailed}} failed initial sync", billingRefreshed: "Quota synced", authRefreshed: "Credential refreshed", allBillingRefreshed: "Sync complete: {{succeeded}} succeeded, {{failed}} failed", allTokensRefreshed: "Renewal complete: {{succeeded}} succeeded, {{failed}} failed, {{skipped}} skipped", statusDisabled: "Disabled", statusReauthRequired: "Invalid", statusCooldown: "Cooling", statusActive: "Normal", botRisk: "Risk", botRiskTooltip: "Bot risk control. This may affect video generation and other permissions.", nsfwEnabledMark: "NSFW", nsfwEnabledTooltip: "NSFW enabled · {{time}}", buildRouteMode: { label: "Upstream endpoint", auto: "Automatic", build: "Build", xai: "XAI", autoDescription: "Selects an upstream based on account tier and risk status.", buildDescription: "Routes all requests to Build with XAI fallback disabled.", xaiDescription: "Routes all inference and video requests to XAI.", xaiUnconfirmedWarning: "This account has not been confirmed for XAI access; the upstream may reject requests." }, buildSuperEntitled: { label: "Mark as Super manually", description: "Use only when automatic tier detection is inaccurate. Enabled accounts sync models with Super access." }, quotaFree: "Free", quotaSuper: "Super", quotaPaid: "Super", quotaUnknown: "Unknown", paidQuotaUsage: "Upstream quota usage", weeklyQuota: "Weekly", monthlyQuota: "Monthly", weeklyLimit: "Weekly limit {{percent}}% remaining", freeObservedUsage: "{{used}} tokens observed in the last 24 hours", freeEstimatedUsage: "About {{used}} / {{limit}} tokens", freeEstimatedDescription: "Estimated from observed Grok Build Free characteristics; upstream exhaustion data takes precedence", observedRolling: "Free accounts use this proxy's rolling observation and may differ from upstream billing data", upstreamConfirmed: "Exhaustion confirmed by upstream", waitingReset: "Waiting reset", waitingResetUntil: "Waiting for reset · probe after {{time}} on real traffic", paidWaitingResetUntil: "Waiting for reset · check the upstream Billing period after {{time}}", probing: "Probing", probingQuota: "Checking quota recovery with real traffic", paidProbingQuota: "Checking whether the upstream Billing period has recovered", upstreamBilling: "Upstream billing data", deviceTitle: "Connect Grok Build", deviceDescription: "Complete authorization in a new window. This page will check status automatically.", userCode: "Device code", openVerification: "Open authorization", waiting: "Waiting for authorization", expiresAt: "Expires {{time}}", deleteTitle: "Delete account?", deleteDescription: "Credentials and quota snapshots will be removed. This cannot be undone.", batchUpdated: "Selected accounts updated", batchBillingRefreshed: "Quota sync complete: {{succeeded}} succeeded, {{failed}} failed", batchDeleteTitle: "Delete {{count}} selected accounts?" }, models: { title: "Model routes", description: "Map one client-facing model name to provider-specific Build, Web, and Console routes.", sync: "Sync models", search: "Search models", model: "Public model name", publicId: "Public model name", upstream: "Upstream model", provider: "Model source", providerGrokBuild: "Grok Build", providerGrokWeb: "Grok Web", accountSupport: "Supported accounts", lastSyncedAt: "Last synced", capability: "Account model capabilities", supportSummary: "{{supported}} available / {{total}} total", awaitingCapabilitySync: "Awaiting model capability sync", unknownCapability: "Pending sync", partialCapability: "Awaiting remaining accounts", available: "Available", unavailable: "Unavailable", status: "Status", create: "Add model", createTitle: "Add model route", createDescription: "Add a custom Grok Build route. The public model name has no source prefix; the upstream model uses Build/ to identify its actual channel.", editTitle: "Edit model route", bindAccounts: "Bind specific accounts", boundAccounts: "Bound accounts", bindAccountsDescription: "When enabled, only selected accounts are routed and treated as manually confirmed to support the model. Otherwise model sync decides automatically.", searchAccounts: "Search account name or ID", selectedAccounts: "{{count}} accounts selected", selectAccountRequired: "Select at least one account", noBindableAccounts: "No accounts from this provider can be bound", deleteTitle: "Delete model route?", deleteDescription: "This removes {{name}} and its client-key permissions. A later sync recreates it if upstream accounts still support the model.", batchDeleteTitle: "Delete {{count}} selected model routes?", batchDeleteDescription: "This removes the selected routes and their client-key permissions. Models still supported by upstream accounts are recreated on a later sync.", synced: "Synced {{count}} models", created: "Model route created", updated: "Model route updated", deleted: "Model route deleted", batchDeleted: "Deleted {{count}} model routes", batchUpdated: "Selected models updated" }, keys: { title: "Client keys", description: "Control downstream model access, RPM, concurrency, billed usage, and expiration.", search: "Search keys", create: "Create key", name: "Name", prefix: "Key", limits: "Limits", rpmLimit: "RPM limit", concurrencyLimit: "Concurrency limit", rpmShort: "RPM", concurrencyShort: "Concurrency", billingLimit: "Usage limit", models: "Allowed models", status: "Status", statusActive: "Active", statusExpired: "Expired", modelScope: "Model scope", restrictedModels: "Selected models", expires: "Expires", lastUsed: "Last used", rpm: "Requests per minute", maxConcurrent: "Max concurrency", rpmValue: "{{value}} RPM", concurrentValue: "{{value}} concurrent", unlimited: "Unlimited", billedUsage: "{{value}} billed", billingLimitDescription: "Accumulates billed request audits, preferring upstream-reported cost and otherwise using the official price estimate.", allModels: "All models", modelSearch: "Search models. Leave blank to allow all models.", selectedModels: "{{count}} models selected", neverExpires: "Never expires", clearExpiry: "Clear expiration", expiryTime: "Expiration time", createTitle: "Create client key", editTitle: "Edit client key", secretTitle: "Client key created", secretDescription: "Store this key securely.", secretLabel: "Key", copySecret: "Copy", copySecretTitle: "Copy key", copySecretDescription: "Use the button below to copy your key.", secretReady: "Ready", created: "Client key created", updated: "Client key updated", deleted: "Client key deleted", deleteTitle: "Delete client key?", deleteDescription: "All downstream requests using this key will stop immediately.", batchUpdated: "Selected keys updated", batchDeleteTitle: "Delete {{count}} selected keys?" }, audits: {