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
This commit is contained in:
shaw
2026-07-03 10:13:51 +08:00
parent 0b8e5eec32
commit a5638a4e54
3 changed files with 252 additions and 23 deletions
@@ -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}
}
}
@@ -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{
+3 -1
View File
@@ -609,7 +609,9 @@ export async function importData(payload: {
}
export async function importCodexSession(payload: CodexSessionImportRequest): Promise<CodexSessionImportResult> {
const { data } = await apiClient.post<CodexSessionImportResult>('/admin/accounts/import/codex-session', payload)
const { data } = await apiClient.post<CodexSessionImportResult>('/admin/accounts/import/codex-session', payload, {
timeout: 120000 // 120s timeout for large session imports
})
return data
}