From a5638a4e54088192f5be408ae1b36d9083626c06 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 3 Jul 2026 10:13:51 +0800 Subject: [PATCH] fix: match Codex session imports by chatgpt_user_id before shared account id Members of the same ChatGPT team share chatgpt_account_id, so matching imports by the account key first could overwrite another member's account credentials. Identity keys are now ordered by strength (user > email > access > account), and an account-key hit is rejected when both sides carry different chatgpt_user_id values. - Keep the account-key fallback when either side lacks a user id, so legacy accounts without chatgpt_user_id are updated and backfilled instead of duplicated - Index all candidate accounts per shared key so a teammate's row can no longer shadow a legacy account depending on row order - Apply the same conflict check to in-batch dedup and emit a warning when a legacy account is claimed via the shared account key - Scope a 120s timeout to the Codex session import request instead of raising the global client timeout --- .../handler/admin/account_codex_import.go | 95 +++++++--- .../admin/account_codex_import_test.go | 176 ++++++++++++++++++ frontend/src/api/admin/accounts.ts | 4 +- 3 files changed, 252 insertions(+), 23 deletions(-) diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 523f261c43..6ec9495d84 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -106,7 +106,7 @@ type codexJWTOpenAIClaims struct { } type codexAccountIndex struct { - accountsByKey map[string]service.Account + accountsByKey map[string][]service.Account } func (h *AccountHandler) ImportCodexSession(c *gin.Context) { @@ -178,7 +178,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi } skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk - seenIdentity := map[string]int{} + seenIdentity := map[string]codexSeenIdentity{} for _, entry := range entries { item, err := normalizeCodexImportEntry(entry) if err != nil { @@ -225,7 +225,7 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi }) } - if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys); ok { + if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok { message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex) result.Skipped++ result.Items = append(result.Items, CodexSessionImportItem{ @@ -241,9 +241,18 @@ func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessi }) continue } - markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index) + markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID) - if existing := index.Find(item.IdentityKeys); existing != nil && updateExisting { + existing, matchedKey := index.Find(item.IdentityKeys, item.UserID) + if existing != nil && updateExisting { + if strings.HasPrefix(matchedKey, "account:") && item.UserID != "" && + codexCredentialString(existing.Credentials, "chatgpt_user_id") == "" { + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: "已有账号未记录 chatgpt_user_id,已按共享的 chatgpt_account_id 匹配并回填,请确认两者属于同一用户", + }) + } mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) mergedExtra := mergeCodexImportMap(existing.Extra, extra) updateInput := &service.UpdateAccountInput{ @@ -806,13 +815,13 @@ func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { return out } +// buildCodexIdentityKeys 按身份强度排序生成匹配键:chatgpt_account_id 在同一 +// ChatGPT 团队内是共享的,因此 account: 键排在最后,且命中时还需通过 +// codexIdentityConflicts 的跨用户校验才生效。 func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string { - keys := make([]string, 0, 4) + keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) - if accountID != "" { - keys = append(keys, "account:"+accountID) - } if userID != "" { keys = append(keys, "user:"+userID) } @@ -824,11 +833,14 @@ func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []stri if accessToken = strings.TrimSpace(accessToken); accessToken != "" { keys = append(keys, "access:"+codexTokenFingerprint(accessToken)) } + if accountID != "" { + keys = append(keys, "account:"+accountID) + } return keys } func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { - index := &codexAccountIndex{accountsByKey: map[string]service.Account{}} + index := &codexAccountIndex{accountsByKey: map[string][]service.Account{}} for _, account := range accounts { index.Add(account) } @@ -840,7 +852,7 @@ func (i *codexAccountIndex) Add(account service.Account) { return } if i.accountsByKey == nil { - i.accountsByKey = map[string]service.Account{} + i.accountsByKey = map[string][]service.Account{} } keys := buildCodexIdentityKeys( codexCredentialString(account.Credentials, "chatgpt_account_id"), @@ -849,34 +861,73 @@ func (i *codexAccountIndex) Add(account service.Account) { codexCredentialString(account.Credentials, "access_token"), ) for _, key := range keys { - i.accountsByKey[key] = account + i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account) } } -func (i *codexAccountIndex) Find(keys []string) *service.Account { +// upsertCodexAccount 保留同一键下的全部候选账号(共享的 account: 键可对应 +// 团队内多个账号),同一账号重复 Add 时原位替换为最新状态。 +func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account { + for idx := range accounts { + if accounts[idx].ID == account.ID { + accounts[idx] = account + return accounts + } + } + return append(accounts, account) +} + +// Find 返回第一个通过跨用户校验的候选账号及其命中的匹配键。 +func (i *codexAccountIndex) Find(keys []string, userID string) (*service.Account, string) { if i == nil { - return nil + return nil, "" } for _, key := range keys { - if account, ok := i.accountsByKey[key]; ok { - return &account + for _, account := range i.accountsByKey[key] { + if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) { + continue + } + return &account, key } } - return nil + return nil, "" } -func firstSeenCodexIdentity(seen map[string]int, keys []string) (int, bool) { +// codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个 +// 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。任一侧缺少 +// user id 时保留匹配,使早期未记录 chatgpt_user_id 的存量账号仍能被更新 +// (并借助凭据合并回填 user id),而不是产生重复账号。 +func codexIdentityConflicts(key, userID, storedUserID string) bool { + if !strings.HasPrefix(key, "account:") { + return false + } + userID = strings.TrimSpace(userID) + storedUserID = strings.TrimSpace(storedUserID) + return userID != "" && storedUserID != "" && userID != storedUserID +} + +type codexSeenIdentity struct { + index int + userID string +} + +func firstSeenCodexIdentity(seen map[string]codexSeenIdentity, keys []string, userID string) (int, bool) { for _, key := range keys { - if index, ok := seen[key]; ok { - return index, true + entry, ok := seen[key] + if !ok { + continue } + if codexIdentityConflicts(key, userID, entry.userID) { + continue + } + return entry.index, true } return 0, false } -func markCodexIdentitySeen(seen map[string]int, keys []string, index int) { +func markCodexIdentitySeen(seen map[string]codexSeenIdentity, keys []string, index int, userID string) { for _, key := range keys { - seen[key] = index + seen[key] = codexSeenIdentity{index: index, userID: userID} } } diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go index 3cf0d2bbcb..f4ee5bd7db 100644 --- a/backend/internal/handler/admin/account_codex_import_test.go +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/Wei-Shaw/sub2api/internal/service" ) func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) { @@ -300,6 +302,12 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token") + if len(keys) == 0 || keys[0] != "user:user-1" { + t.Fatalf("user key should have highest priority: %v", keys) + } + if keys[len(keys)-1] != "account:acct-1" { + t.Fatalf("shared account key should be the last fallback: %v", keys) + } for _, key := range keys { if strings.HasPrefix(key, "email:") { t.Fatalf("strong identity should not include email fallback: %v", keys) @@ -318,6 +326,174 @@ func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { } } +func TestCodexAccountIndexDoesNotMatchDifferentUsersInSameChatGPTAccount(t *testing.T) { + existing := service.Account{ + ID: 10, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "chatgpt_user_id": "user-1", + "access_token": "token-1", + }, + } + index := buildCodexAccountIndex([]service.Account{existing}) + + keys := buildCodexIdentityKeys("team-1", "user-2", "", "token-2") + if got, _ := index.Find(keys, "user-2"); got != nil { + t.Fatalf("Find matched account ID %d for a different chatgpt_user_id in the same team", got.ID) + } + + keys = buildCodexIdentityKeys("team-1", "user-1", "", "token-2") + got, _ := index.Find(keys, "user-1") + if got == nil || got.ID != existing.ID { + t.Fatalf("Find by same chatgpt_user_id = %v, want account ID %d", got, existing.ID) + } +} + +func TestCodexAccountIndexFallsBackToAccountKeyWhenUserIDMissing(t *testing.T) { + // 存量账号缺少 chatgpt_user_id:携带 user id 的重新导入应命中并更新(回填), + // 而不是创建重复账号。 + legacy := service.Account{ + ID: 20, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "access_token": "token-old", + }, + } + index := buildCodexAccountIndex([]service.Account{legacy}) + + keys := buildCodexIdentityKeys("team-1", "user-1", "", "token-new") + got, matchedKey := index.Find(keys, "user-1") + if got == nil || got.ID != legacy.ID { + t.Fatalf("Find legacy account without stored user id = %v, want account ID %d", got, legacy.ID) + } + if matchedKey != "account:team-1" { + t.Fatalf("matched key = %q, want account:team-1", matchedKey) + } + + // 反向:导入条目无法解析出 user id 时,仍应通过 account 键命中已有账号。 + full := service.Account{ + ID: 21, + Credentials: map[string]any{ + "chatgpt_account_id": "team-2", + "chatgpt_user_id": "user-9", + "access_token": "token-old", + }, + } + index = buildCodexAccountIndex([]service.Account{full}) + + keys = buildCodexIdentityKeys("team-2", "", "", "token-opaque") + got, _ = index.Find(keys, "") + if got == nil || got.ID != full.ID { + t.Fatalf("Find by account key without entry user id = %v, want account ID %d", got, full.ID) + } +} + +func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { + legacy := service.Account{ + ID: 30, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "access_token": "token-legacy", + }, + } + member := service.Account{ + ID: 31, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "chatgpt_user_id": "user-2", + "access_token": "token-member", + }, + } + + // 无论索引构建顺序如何,携带新 user id 的条目都应跳过 user-2 的账号、 + // 命中缺少 user id 的存量账号,而不是因单一候选被遮蔽而落空。 + for _, accounts := range [][]service.Account{ + {member, legacy}, + {legacy, member}, + } { + index := buildCodexAccountIndex(accounts) + + keys := buildCodexIdentityKeys("team-1", "user-1", "", "token-new") + got, matchedKey := index.Find(keys, "user-1") + if got == nil || got.ID != legacy.ID { + t.Fatalf("Find with shared account key = %v, want legacy account ID %d", got, legacy.ID) + } + if matchedKey != "account:team-1" { + t.Fatalf("matched key = %q, want account:team-1", matchedKey) + } + + keys = buildCodexIdentityKeys("team-1", "user-2", "", "token-new") + got, matchedKey = index.Find(keys, "user-2") + if got == nil || got.ID != member.ID { + t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID) + } + if matchedKey != "user:user-2" { + t.Fatalf("matched key = %q, want user:user-2", matchedKey) + } + } +} + +func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { + legacy := service.Account{ + ID: 40, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "access_token": "token-old", + }, + } + index := buildCodexAccountIndex([]service.Account{legacy}) + + backfilled := service.Account{ + ID: 40, + Credentials: map[string]any{ + "chatgpt_account_id": "team-1", + "chatgpt_user_id": "user-1", + "access_token": "token-new", + }, + } + index.Add(backfilled) + + // 回填后同一账号在 account 键下应被原位替换而非残留旧副本: + // 其他成员的条目不应再通过旧副本(无 user id)命中该账号。 + keys := buildCodexIdentityKeys("team-1", "user-2", "", "token-other") + if got, _ := index.Find(keys, "user-2"); got != nil { + t.Fatalf("stale candidate matched after upsert: account ID %d", got.ID) + } + + keys = buildCodexIdentityKeys("team-1", "user-1", "", "token-other") + got, _ := index.Find(keys, "user-1") + if got == nil || got.ID != backfilled.ID { + t.Fatalf("Find after upsert = %v, want account ID %d", got, backfilled.ID) + } + if uid := codexCredentialString(got.Credentials, "chatgpt_user_id"); uid != "user-1" { + t.Fatalf("upsert did not replace credentials, chatgpt_user_id = %q", uid) + } +} + +func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) { + seen := map[string]codexSeenIdentity{} + member1 := buildCodexIdentityKeys("team-1", "user-1", "", "token-1") + markCodexIdentitySeen(seen, member1, 1, "user-1") + + member2 := buildCodexIdentityKeys("team-1", "user-2", "", "token-2") + if index, ok := firstSeenCodexIdentity(seen, member2, "user-2"); ok { + t.Fatalf("different team member treated as duplicate of entry %d", index) + } + + again := buildCodexIdentityKeys("team-1", "user-1", "", "token-3") + index, ok := firstSeenCodexIdentity(seen, again, "user-1") + if !ok || index != 1 { + t.Fatalf("same user re-entry dedup = (%d, %v), want (1, true)", index, ok) + } + + // 无 user id 的条目与已见同 account 条目视为重复(保守跳过,与既有行为一致)。 + opaque := buildCodexIdentityKeys("team-1", "", "", "token-4") + index, ok = firstSeenCodexIdentity(seen, opaque, "") + if !ok || index != 1 { + t.Fatalf("entry without user id dedup = (%d, %v), want (1, true)", index, ok) + } +} + func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string { t.Helper() header := map[string]any{ diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ce7cb694da..7cdb092820 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -609,7 +609,9 @@ export async function importData(payload: { } export async function importCodexSession(payload: CodexSessionImportRequest): Promise { - const { data } = await apiClient.post('/admin/accounts/import/codex-session', payload) + const { data } = await apiClient.post('/admin/accounts/import/codex-session', payload, { + timeout: 120000 // 120s timeout for large session imports + }) return data }