Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/internal/app/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}

Expand Down
76 changes: 76 additions & 0 deletions backend/internal/application/account/provider_links.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
172 changes: 172 additions & 0 deletions backend/internal/application/account/provider_links_test.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 46 additions & 2 deletions backend/internal/application/account/quota_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,54 @@ 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 := &quotaCountingAdapter{}
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) {
return nil, false, nil
}

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 }
Expand All @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/application/account/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading