diff --git a/internal/plugin/access.go b/internal/plugin/access.go index c7c5e0a..f716ba7 100644 --- a/internal/plugin/access.go +++ b/internal/plugin/access.go @@ -353,7 +353,7 @@ func (a *App) syncUpstreamAccounts(ctx context.Context) error { Disabled: entry.Disabled, Unavailable: entry.Unavailable, Priority: entry.Priority, LastSeenAt: now, }) } - return store.SyncUpstreamAccounts(ctx, accounts) + return store.ReconcileUpstreamAccounts(ctx, accounts) } func firstNonEmpty(values ...string) string { diff --git a/internal/repository/sqlite_access.go b/internal/repository/sqlite_access.go index 9dd93db..4fbf1f0 100644 --- a/internal/repository/sqlite_access.go +++ b/internal/repository/sqlite_access.go @@ -282,11 +282,27 @@ func (r *SQLiteUsageRepository) managedKeyModels(ctx context.Context, keyID stri } func (r *SQLiteUsageRepository) SyncUpstreamAccounts(ctx context.Context, accounts []managedaccess.UpstreamAccount) error { + return r.syncUpstreamAccounts(ctx, accounts, false) +} + +// ReconcileUpstreamAccounts treats accounts as the complete current CPA auth snapshot. +// Historical rows remain available for existing strict bindings, but are excluded from +// new route selection once CPA no longer reports them. +func (r *SQLiteUsageRepository) ReconcileUpstreamAccounts(ctx context.Context, accounts []managedaccess.UpstreamAccount) error { + return r.syncUpstreamAccounts(ctx, accounts, true) +} + +func (r *SQLiteUsageRepository) syncUpstreamAccounts(ctx context.Context, accounts []managedaccess.UpstreamAccount, snapshot bool) error { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("开始同步上游账号: %w", err) } defer func() { _ = tx.Rollback() }() + if snapshot { + if _, err := tx.ExecContext(ctx, `UPDATE upstream_accounts SET current=0`); err != nil { + return fmt.Errorf("重置上游账号快照: %w", err) + } + } for _, account := range accounts { var existingID string err := tx.QueryRowContext(ctx, ` @@ -300,14 +316,14 @@ SELECT id FROM upstream_accounts WHERE cpa_auth_id=? OR (cpa_auth_index <> '' AN existingID = fmt.Sprintf("up_%x", sum[:10]) } _, err = tx.ExecContext(ctx, ` -INSERT INTO upstream_accounts (id, cpa_auth_id, cpa_auth_index, provider, display_name, status, status_message, disabled, unavailable, priority, last_seen_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO upstream_accounts (id, cpa_auth_id, cpa_auth_index, provider, display_name, status, status_message, disabled, unavailable, current, priority, last_seen_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) ON CONFLICT(id) DO UPDATE SET cpa_auth_id=excluded.cpa_auth_id, provider=excluded.provider, cpa_auth_index=CASE WHEN excluded.cpa_auth_index <> '' THEN excluded.cpa_auth_index ELSE upstream_accounts.cpa_auth_index END, display_name=CASE WHEN excluded.display_name = excluded.cpa_auth_id AND upstream_accounts.display_name <> '' THEN upstream_accounts.display_name ELSE excluded.display_name END, status=excluded.status, status_message=excluded.status_message, disabled=excluded.disabled, unavailable=excluded.unavailable, - priority=excluded.priority, last_seen_at=excluded.last_seen_at`, existingID, account.CPAAuthID, + current=1, priority=excluded.priority, last_seen_at=excluded.last_seen_at`, existingID, account.CPAAuthID, account.CPAAuthIndex, account.Provider, account.DisplayName, account.Status, account.StatusMessage, account.Disabled, account.Unavailable, account.Priority, formatTime(account.LastSeenAt)) if err != nil { @@ -324,7 +340,7 @@ func (r *SQLiteUsageRepository) ListUpstreamAccounts(ctx context.Context) ([]man rows, err := r.readDB.QueryContext(ctx, ` SELECT id, cpa_auth_id, cpa_auth_index, provider, display_name, status, status_message, disabled, unavailable, priority, last_seen_at -FROM upstream_accounts ORDER BY provider, display_name COLLATE NOCASE`) +FROM upstream_accounts WHERE current=1 ORDER BY provider, display_name COLLATE NOCASE, cpa_auth_index`) if err != nil { return nil, fmt.Errorf("查询上游账号: %w", err) } diff --git a/internal/repository/sqlite_access_test.go b/internal/repository/sqlite_access_test.go index 6221cf4..c9807a7 100644 --- a/internal/repository/sqlite_access_test.go +++ b/internal/repository/sqlite_access_test.go @@ -86,6 +86,46 @@ func TestManagedKeyStatsUseShanghaiDayBoundary(t *testing.T) { } } +func TestReconcileUpstreamAccountsListsOnlyCurrentSnapshot(t *testing.T) { + store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + ctx := context.Background() + old := managedaccess.UpstreamAccount{ + CPAAuthID: "codex-user-prolite.json", CPAAuthIndex: "old-index", + Provider: "codex", DisplayName: "user@example.com", LastSeenAt: time.Now().Add(-time.Hour), + } + current := managedaccess.UpstreamAccount{ + CPAAuthID: "codex-user-pro.json", CPAAuthIndex: "current-index", + Provider: "codex", DisplayName: "user@example.com", LastSeenAt: time.Now(), + } + if err := store.ReconcileUpstreamAccounts(ctx, []managedaccess.UpstreamAccount{old, current}); err != nil { + t.Fatal(err) + } + initial, err := store.ListUpstreamAccounts(ctx) + if err != nil || len(initial) != 2 { + t.Fatalf("initial accounts = %+v, err=%v", initial, err) + } + var oldID string + for _, account := range initial { + if account.CPAAuthID == old.CPAAuthID { + oldID = account.ID + } + } + if err := store.ReconcileUpstreamAccounts(ctx, []managedaccess.UpstreamAccount{current}); err != nil { + t.Fatal(err) + } + listed, err := store.ListUpstreamAccounts(ctx) + if err != nil || len(listed) != 1 || listed[0].CPAAuthID != current.CPAAuthID { + t.Fatalf("current accounts = %+v, err=%v", listed, err) + } + if historical, err := store.UpstreamAccountByID(ctx, oldID); err != nil || historical.CPAAuthID != old.CPAAuthID { + t.Fatalf("historical account = %+v, err=%v", historical, err) + } +} + func TestManagedKeyStatsCountsOrphanUsageAndAliasLifecycleOnce(t *testing.T) { store, err := repository.OpenSQLiteUsage(filepath.Join(t.TempDir(), "usage.db")) if err != nil { diff --git a/internal/repository/sqlite_usage.go b/internal/repository/sqlite_usage.go index ddfab10..0861bca 100644 --- a/internal/repository/sqlite_usage.go +++ b/internal/repository/sqlite_usage.go @@ -149,6 +149,7 @@ CREATE TABLE IF NOT EXISTS upstream_accounts ( status_message TEXT NOT NULL DEFAULT '', disabled INTEGER NOT NULL DEFAULT 0, unavailable INTEGER NOT NULL DEFAULT 0, + current INTEGER NOT NULL DEFAULT 1, priority INTEGER NOT NULL DEFAULT 0, last_seen_at TEXT NOT NULL ); @@ -290,6 +291,7 @@ func OpenSQLiteUsage(databasePath string) (*SQLiteUsageRepository, error) { `ALTER TABLE model_prices ADD COLUMN source_revision TEXT NOT NULL DEFAULT ''`, `ALTER TABLE model_prices ADD COLUMN source_fetched_at TEXT NOT NULL DEFAULT ''`, `ALTER TABLE billing_accounts ADD COLUMN lifetime_spent_micros INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE upstream_accounts ADD COLUMN current INTEGER NOT NULL DEFAULT 1`, } { if _, err := db.Exec(migration); err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") { _ = db.Close() diff --git a/internal/web/app/features/keys.js b/internal/web/app/features/keys.js index afce8b7..be2597b 100644 --- a/internal/web/app/features/keys.js +++ b/internal/web/app/features/keys.js @@ -467,7 +467,14 @@ function modelRules() { } function upstreamLabel(account) { - return `${account.provider || "unknown"} · ${account.display_name || account.cpa_auth_id || account.id}`; + const provider = account.provider || "unknown"; + const name = account.display_name || account.cpa_auth_id || account.id; + const duplicated = upstreamAccounts.some(item => item !== account && + (item.provider || "unknown") === provider && + (item.display_name || item.cpa_auth_id || item.id) === name); + if (!duplicated) return `${provider} · ${name}`; + const identity = (account.cpa_auth_index || account.cpa_auth_id || account.id).slice(0, 8); + return `${provider} · ${name} · ${identity}`; } function routeLabel(key) {