mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user