package admin import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) const codexImportClockSkewSeconds int64 = 120 type CodexSessionImportRequest struct { Content string `json:"content"` Contents []string `json:"contents"` Name string `json:"name"` Notes *string `json:"notes"` GroupIDs []int64 `json:"group_ids"` ProxyID *int64 `json:"proxy_id"` Concurrency *int `json:"concurrency"` Priority *int `json:"priority"` RateMultiplier *float64 `json:"rate_multiplier"` LoadFactor *int `json:"load_factor"` ExpiresAt *int64 `json:"expires_at"` AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` CredentialExtras map[string]any `json:"credential_extras"` Extra map[string]any `json:"extra"` UpdateExisting *bool `json:"update_existing"` SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` } type CodexSessionImportResult struct { Total int `json:"total"` Created int `json:"created"` Updated int `json:"updated"` Skipped int `json:"skipped"` Failed int `json:"failed"` Items []CodexSessionImportItem `json:"items,omitempty"` Warnings []CodexSessionImportMessage `json:"warnings,omitempty"` Errors []CodexSessionImportMessage `json:"errors,omitempty"` } type CodexSessionImportItem struct { Index int `json:"index"` Name string `json:"name,omitempty"` Action string `json:"action"` AccountID int64 `json:"account_id,omitempty"` Message string `json:"message,omitempty"` } type CodexSessionImportMessage struct { Index int `json:"index"` Name string `json:"name,omitempty"` Message string `json:"message"` } type codexImportEntry struct { Index int Value any } type codexImportAccount struct { Name string AccessToken string RefreshToken string IDToken string Email string AccountID string UserID string PlanType string Organization string AgentRuntimeID string AgentPrivateKey string AgentTaskID string AgentFedRAMP bool IsAgentIdentity bool Credentials map[string]any Extra map[string]any TokenExpiresAt *time.Time IdentityKeys []string WarningTexts []string } type codexJWTClaims struct { Sub string `json:"sub"` Email string `json:"email"` Exp int64 `json:"exp"` Iat int64 `json:"iat"` OpenAIAuth *codexJWTOpenAIClaims `json:"https://api.openai.com/auth,omitempty"` } type codexJWTOpenAIClaims struct { ChatGPTAccountID string `json:"chatgpt_account_id"` ChatGPTUserID string `json:"chatgpt_user_id"` ChatGPTPlanType string `json:"chatgpt_plan_type"` UserID string `json:"user_id"` POID string `json:"poid"` Organizations []openai.OrganizationClaim `json:"organizations"` } type codexAccountIndex struct { accountsByKey map[string][]service.Account keysByAccountID map[int64]map[string]struct{} } func (h *AccountHandler) ImportCodexSession(c *gin.Context) { var req CodexSessionImportRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } if err := service.ValidateOpenAILongContextBillingExtra(service.PlatformOpenAI, req.Extra); err != nil { response.ErrorFrom(c, err) return } if req.Concurrency != nil && *req.Concurrency < 0 { response.BadRequest(c, "concurrency must be >= 0") return } if req.Priority != nil && *req.Priority < 0 { response.BadRequest(c, "priority must be >= 0") return } if req.RateMultiplier != nil && *req.RateMultiplier < 0 { response.BadRequest(c, "rate_multiplier must be >= 0") return } if req.LoadFactor != nil && *req.LoadFactor > 10000 { response.BadRequest(c, "load_factor must be <= 10000") return } entries, err := parseCodexSessionImportEntries(req) if err != nil { response.BadRequest(c, err.Error()) return } if len(entries) == 0 { response.BadRequest(c, "请输入 accessToken 或 Codex session JSON") return } executeAdminIdempotentJSON(c, "admin.accounts.import_codex_session", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { return h.importCodexSessions(ctx, req, entries) }) } func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) { result := CodexSessionImportResult{ Total: len(entries), Items: make([]CodexSessionImportItem, 0, len(entries)), } existingAccounts, err := h.listAccountsFiltered(ctx, service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, "", "created_at", "desc") if err != nil { return result, err } index := buildCodexAccountIndex(existingAccounts) updateExisting := true if req.UpdateExisting != nil { updateExisting = *req.UpdateExisting } concurrency := 3 if req.Concurrency != nil { concurrency = *req.Concurrency } priority := 50 if req.Priority != nil { priority = *req.Priority } credentialExtras := sanitizeCodexImportCredentialExtras(req.CredentialExtras) skipDefaultGroupBind := false if req.SkipDefaultGroupBind != nil { skipDefaultGroupBind = *req.SkipDefaultGroupBind } skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk seenIdentity := map[string]codexSeenIdentity{} for _, entry := range entries { item, err := normalizeCodexImportEntry(entry) if err != nil { result.Failed++ result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Action: "failed", Message: err.Error(), }) result.Errors = append(result.Errors, CodexSessionImportMessage{ Index: entry.Index, Message: err.Error(), }) continue } accountName := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries)) effectiveExpiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, expiryErr := resolveCodexImportExpiry(req, item) if expiryErr != nil { result.Failed++ result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Name: accountName, Action: "failed", Message: expiryErr.Error(), }) result.Errors = append(result.Errors, CodexSessionImportMessage{ Index: entry.Index, Name: accountName, Message: expiryErr.Error(), }) continue } item.WarningTexts = append(item.WarningTexts, expiryWarnings...) if credentialExpiresAt != nil { item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339) } credentials := mergeCodexImportMap(item.Credentials, credentialExtras) extra := mergeCodexImportMap(req.Extra, item.Extra) for _, warning := range item.WarningTexts { result.Warnings = append(result.Warnings, CodexSessionImportMessage{ Index: entry.Index, Name: accountName, Message: warning, }) } if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys, item.UserID); ok { message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex) result.Skipped++ result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Name: accountName, Action: "skipped", Message: message, }) result.Warnings = append(result.Warnings, CodexSessionImportMessage{ Index: entry.Index, Name: accountName, Message: message, }) continue } markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index, item.UserID) 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 匹配并回填,请确认两者属于同一用户", }) } preserveExistingRefresh := item.RefreshToken == "" && codexCredentialString(existing.Credentials, "refresh_token") != "" if preserveExistingRefresh { result.Warnings = append(result.Warnings, CodexSessionImportMessage{ Index: entry.Index, Name: accountName, Message: "已有账号包含 refresh_token,本次 accessToken-only 导入已保留自动续期凭据", }) effectiveExpiresAt = nil autoPauseOnExpired = nil } mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) mergedExtra := mergeCodexImportMap(existing.Extra, extra) updateInput := &service.UpdateAccountInput{ Credentials: mergedCredentials, Extra: mergedExtra, Concurrency: req.Concurrency, Priority: req.Priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, ExpiresAt: effectiveExpiresAt, AutoPauseOnExpired: autoPauseOnExpired, } if req.ProxyID != nil { updateInput.ProxyID = req.ProxyID } if len(req.GroupIDs) > 0 { groupIDs := append([]int64(nil), req.GroupIDs...) updateInput.GroupIDs = &groupIDs updateInput.SkipMixedChannelCheck = skipMixedChannelCheck } updated, updateErr := h.adminService.UpdateAccount(ctx, existing.ID, updateInput) if updateErr != nil { result.Failed++ result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Name: accountName, Action: "failed", Message: updateErr.Error(), }) result.Errors = append(result.Errors, CodexSessionImportMessage{ Index: entry.Index, Name: accountName, Message: updateErr.Error(), }) continue } if h.tokenCacheInvalidator != nil && updated != nil { _ = h.tokenCacheInvalidator.InvalidateToken(ctx, updated) } result.Updated++ accountID := existing.ID if updated != nil { accountID = updated.ID index.Add(*updated) } result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Name: accountName, Action: "updated", AccountID: accountID, }) continue } account, createErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{ Name: accountName, Notes: req.Notes, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Credentials: credentials, Extra: extra, ProxyID: req.ProxyID, Concurrency: concurrency, Priority: priority, RateMultiplier: req.RateMultiplier, LoadFactor: req.LoadFactor, GroupIDs: req.GroupIDs, ExpiresAt: effectiveExpiresAt, AutoPauseOnExpired: autoPauseOnExpired, SkipDefaultGroupBind: skipDefaultGroupBind, SkipMixedChannelCheck: skipMixedChannelCheck, }) if createErr != nil { result.Failed++ result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Name: accountName, Action: "failed", Message: createErr.Error(), }) result.Errors = append(result.Errors, CodexSessionImportMessage{ Index: entry.Index, Name: accountName, Message: createErr.Error(), }) continue } if account != nil { index.Add(*account) } result.Created++ accountID := int64(0) if account != nil { accountID = account.ID } result.Items = append(result.Items, CodexSessionImportItem{ Index: entry.Index, Name: accountName, Action: "created", AccountID: accountID, }) } return result, nil } func parseCodexSessionImportEntries(req CodexSessionImportRequest) ([]codexImportEntry, error) { contents := make([]string, 0, 1+len(req.Contents)) if strings.TrimSpace(req.Content) != "" { contents = append(contents, req.Content) } for _, content := range req.Contents { if strings.TrimSpace(content) != "" { contents = append(contents, content) } } var entries []codexImportEntry for _, content := range contents { values, err := parseCodexSessionImportContent(content) if err != nil { return nil, err } for _, value := range values { entries = append(entries, codexImportEntry{ Index: len(entries) + 1, Value: value, }) } } return entries, nil } func parseCodexSessionImportContent(content string) ([]any, error) { trimmed := strings.TrimSpace(content) if trimmed == "" { return nil, nil } if looksLikeJSON(trimmed) { values, err := decodeCodexJSONStream(trimmed) if err != nil { if strings.Contains(trimmed, "\n") { if lineValues, lineErr := parseCodexSessionImportLines(trimmed); lineErr == nil { return lineValues, nil } } return nil, fmt.Errorf("JSON 解析失败: %w", err) } return flattenCodexImportValues(values), nil } return parseCodexSessionImportLines(trimmed) } func parseCodexSessionImportLines(content string) ([]any, error) { values := make([]any, 0) for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) if line == "" { continue } if looksLikeJSON(line) { lineValues, err := decodeCodexJSONStream(line) if err != nil { return nil, fmt.Errorf("第 %d 行 JSON 解析失败: %w", len(values)+1, err) } values = append(values, flattenCodexImportValues(lineValues)...) continue } values = append(values, line) } return values, nil } func decodeCodexJSONStream(content string) ([]any, error) { decoder := json.NewDecoder(strings.NewReader(content)) decoder.UseNumber() values := make([]any, 0, 1) for { var value any err := decoder.Decode(&value) if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } values = append(values, value) } if len(values) == 0 { return nil, errors.New("空 JSON 内容") } return values, nil } func flattenCodexImportValues(values []any) []any { out := make([]any, 0, len(values)) var appendValue func(any) appendValue = func(value any) { if arr, ok := value.([]any); ok { for _, item := range arr { appendValue(item) } return } out = append(out, value) } for _, value := range values { appendValue(value) } return out } func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, error) { now := time.Now().UTC() item := &codexImportAccount{ Credentials: map[string]any{}, Extra: map[string]any{ "import_source": "codex_session", "imported_at": now.Format(time.RFC3339), }, } switch raw := entry.Value.(type) { case string: item.AccessToken = strings.TrimSpace(raw) case map[string]any: if agentIdentity, ok := firstCodexMap(raw, []string{"agent_identity"}, []string{"agentIdentity"}); ok || strings.EqualFold(firstCodexString(raw, []string{"auth_mode"}, []string{"authMode"}), service.OpenAIAuthModeAgentIdentity) { if !ok { agentIdentity = raw } item.IsAgentIdentity = true item.AgentRuntimeID = firstCodexString(agentIdentity, []string{"agent_runtime_id"}, []string{"agentRuntimeId"}) item.AgentPrivateKey = firstCodexString(agentIdentity, []string{"agent_private_key"}, []string{"agentPrivateKey"}) item.AgentTaskID = firstCodexString(agentIdentity, []string{"task_id"}, []string{"taskId"}) item.AccountID = firstCodexString(agentIdentity, []string{"account_id"}, []string{"accountId"}) item.UserID = firstCodexString(agentIdentity, []string{"chatgpt_user_id"}, []string{"chatgptUserId"}) item.Email = firstCodexString(agentIdentity, []string{"email"}) item.PlanType = firstCodexString(agentIdentity, []string{"plan_type"}, []string{"planType"}) item.AgentFedRAMP = firstCodexBool(agentIdentity, []string{"chatgpt_account_is_fedramp"}, []string{"chatgptAccountIsFedramp"}) if item.AgentRuntimeID == "" || item.AgentPrivateKey == "" || item.AccountID == "" || item.UserID == "" { return nil, errors.New("agent identity 缺少必要字段") } if err := service.ValidateOpenAIAgentIdentityPrivateKey(item.AgentPrivateKey); err != nil { return nil, errors.New("agent identity private key 格式无效") } item.Credentials["auth_mode"] = service.OpenAIAuthModeAgentIdentity item.Credentials["agent_runtime_id"] = item.AgentRuntimeID item.Credentials["agent_private_key"] = item.AgentPrivateKey item.Credentials["chatgpt_account_id"] = item.AccountID item.Credentials["chatgpt_user_id"] = item.UserID item.Credentials["chatgpt_account_is_fedramp"] = item.AgentFedRAMP setCodexCredentialIfNotEmpty(item.Credentials, "task_id", item.AgentTaskID) setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email) setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType) if item.AgentTaskID == "" { item.WarningTexts = append(item.WarningTexts, "未包含 task_id,首次请求会使用现有 runtime 注册新 task") } item.IdentityKeys = buildCodexAgentIdentityKeys(item.AccountID) item.Name = buildCodexImportAccountName(item, entry.Index) return item, nil } item.AccessToken = firstCodexString(raw, []string{"tokens", "access_token"}, []string{"tokens", "accessToken"}, []string{"access_token"}, []string{"accessToken"}, []string{"token"}, ) item.RefreshToken = firstCodexString(raw, []string{"tokens", "refresh_token"}, []string{"tokens", "refreshToken"}, []string{"refresh_token"}, []string{"refreshToken"}, ) item.IDToken = firstCodexString(raw, []string{"tokens", "id_token"}, []string{"tokens", "idToken"}, []string{"id_token"}, []string{"idToken"}, ) item.Email = firstCodexString(raw, []string{"email"}, []string{"user", "email"}) item.AccountID = firstCodexString(raw, []string{"chatgpt_account_id"}, []string{"chatgptAccountId"}, []string{"account_id"}, []string{"accountId"}, []string{"account", "id"}, []string{"account", "account_id"}, []string{"account", "chatgpt_account_id"}, ) item.UserID = firstCodexString(raw, []string{"chatgpt_user_id"}, []string{"chatgptUserId"}, []string{"user_id"}, []string{"userId"}, []string{"user", "id"}, ) item.PlanType = firstCodexString(raw, []string{"plan_type"}, []string{"planType"}, []string{"account", "plan_type"}, []string{"account", "planType"}, ) item.Organization = firstCodexString(raw, []string{"organization_id"}, []string{"organizationId"}, []string{"org_id"}, []string{"orgId"}, ) item.Name = firstCodexString(raw, []string{"name"}, []string{"user", "name"}) authProvider := firstCodexString(raw, []string{"auth_provider"}, []string{"authProvider"}) if authProvider != "" { item.Extra["auth_provider"] = authProvider } if sessionToken := firstCodexString(raw, []string{"session_token"}, []string{"sessionToken"}); sessionToken != "" { item.Extra["session_token_present"] = true item.WarningTexts = append(item.WarningTexts, "sessionToken 已忽略,不会作为 OAuth refresh_token 存储") } if sessionExpiresAt, ok := codexTimeAt(raw, []string{"expires"}); ok { item.Extra["session_expires_at"] = sessionExpiresAt.Format(time.RFC3339) } if tokenExpiresAt, ok := firstCodexTime(raw, []string{"tokens", "expires_at"}, []string{"tokens", "expiresAt"}, []string{"expires_at"}, []string{"expiresAt"}, ); ok { if tokenExpiresAt.Unix() <= now.Unix()-codexImportClockSkewSeconds { return nil, fmt.Errorf("access_token 已过期: %s", tokenExpiresAt.Format(time.RFC3339)) } item.TokenExpiresAt = &tokenExpiresAt item.Credentials["expires_at"] = tokenExpiresAt.Format(time.RFC3339) } copyCodexExtraString(raw, item.Extra, "user_image", []string{"user", "image"}) copyCodexExtraString(raw, item.Extra, "user_picture", []string{"user", "picture"}) copyCodexExtraString(raw, item.Extra, "account_structure", []string{"account", "structure"}) copyCodexExtraString(raw, item.Extra, "account_residency_region", []string{"account", "residencyRegion"}) copyCodexExtraString(raw, item.Extra, "compute_residency", []string{"account", "computeResidency"}) default: return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index) } if item.IsAgentIdentity { return item, nil } if item.AccessToken == "" { return nil, errors.New("缺少 accessToken/access_token") } item.Credentials["access_token"] = item.AccessToken if item.RefreshToken != "" { item.Credentials["refresh_token"] = item.RefreshToken item.Credentials["client_id"] = openai.ClientID } if item.IDToken != "" { item.Credentials["id_token"] = item.IDToken _ = enrichCodexImportAccountFromJWT(item, item.IDToken, false, now) } if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil { return nil, err } if _, ok := item.Credentials["expires_at"]; !ok { item.WarningTexts = append(item.WarningTexts, "无法从 accessToken 解析过期时间,导入后需自行确认令牌有效性") } if item.RefreshToken == "" { item.WarningTexts = append(item.WarningTexts, "未包含 refresh_token,accessToken 过期后无法自动续期") } setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email) setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_account_id", item.AccountID) setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_user_id", item.UserID) setCodexCredentialIfNotEmpty(item.Credentials, "organization_id", item.Organization) setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType) fingerprint := codexTokenFingerprint(item.AccessToken) item.Extra["access_token_sha256"] = fingerprint item.IdentityKeys = buildCodexImportIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken, item.RefreshToken) item.Name = buildCodexImportAccountName(item, entry.Index) return item, nil } func enrichCodexImportAccountFromJWT(item *codexImportAccount, token string, validateExpiry bool, now time.Time) error { claims, err := decodeCodexJWTClaims(token) if err != nil { if validateExpiry { item.WarningTexts = append(item.WarningTexts, "accessToken 不是可解析 JWT,无法校验过期时间和账号身份") } return nil } if validateExpiry && claims.Exp > 0 { if now.Unix() > claims.Exp+codexImportClockSkewSeconds { return fmt.Errorf("access_token 已过期: %s", time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339)) } expiresAt := time.Unix(claims.Exp, 0).UTC() item.TokenExpiresAt = &expiresAt item.Credentials["expires_at"] = expiresAt.Format(time.RFC3339) } if item.Email == "" { item.Email = strings.TrimSpace(claims.Email) } if claims.OpenAIAuth == nil { if item.UserID == "" { item.UserID = strings.TrimSpace(claims.Sub) } return nil } if item.AccountID == "" { item.AccountID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTAccountID) } if item.UserID == "" { item.UserID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTUserID) } if item.UserID == "" { item.UserID = strings.TrimSpace(claims.OpenAIAuth.UserID) } if item.PlanType == "" { item.PlanType = strings.TrimSpace(claims.OpenAIAuth.ChatGPTPlanType) } if item.Organization == "" { item.Organization = strings.TrimSpace(claims.OpenAIAuth.POID) } if item.Organization == "" { for _, org := range claims.OpenAIAuth.Organizations { if org.IsDefault { item.Organization = org.ID break } } } if item.Organization == "" && len(claims.OpenAIAuth.Organizations) > 0 { item.Organization = claims.OpenAIAuth.Organizations[0].ID } if item.UserID == "" { item.UserID = strings.TrimSpace(claims.Sub) } return nil } func decodeCodexJWTClaims(token string) (*codexJWTClaims, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWT format") } payload, err := decodeCodexJWTSegment(parts[1]) if err != nil { return nil, err } var claims codexJWTClaims if err := json.Unmarshal(payload, &claims); err != nil { return nil, err } return &claims, nil } func decodeCodexJWTSegment(segment string) ([]byte, error) { if decoded, err := base64.RawURLEncoding.DecodeString(segment); err == nil { return decoded, nil } if decoded, err := base64.RawStdEncoding.DecodeString(segment); err == nil { return decoded, nil } padded := segment if rem := len(padded) % 4; rem > 0 { padded += strings.Repeat("=", 4-rem) } if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil { return decoded, nil } return base64.StdEncoding.DecodeString(padded) } func buildCodexImportAccountName(item *codexImportAccount, index int) string { for _, candidate := range []string{item.Name, item.Email, item.AccountID, item.UserID} { candidate = strings.TrimSpace(candidate) if candidate != "" { return candidate } } return fmt.Sprintf("Codex 导入账号 %d", index) } func buildCodexCreateAccountName(base string, item *codexImportAccount, index, total int) string { base = strings.TrimSpace(base) if base == "" { if item == nil { return fmt.Sprintf("Codex 导入账号 %d", index) } return item.Name } if total > 1 { return fmt.Sprintf("%s #%d", base, index) } return base } func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAccount) (*int64, *time.Time, *bool, []string, error) { if item == nil { return nil, nil, nil, nil, errors.New("导入项为空") } // Agent Identity has no OAuth access-token lifetime. Its runtime/task // lifecycle is handled by the upstream task recovery path, so it must not // be rejected or auto-paused by the OAuth import expiry policy. if item.IsAgentIdentity { return nil, nil, nil, nil, nil } var requestExpiresAt *time.Time if req.ExpiresAt != nil && *req.ExpiresAt > 0 { t := time.Unix(*req.ExpiresAt, 0).UTC() requestExpiresAt = &t } var accountExpiresAt *time.Time var credentialExpiresAt *time.Time warnings := make([]string, 0, 2) if item.RefreshToken == "" { if item.TokenExpiresAt != nil { tokenExpiresAt := item.TokenExpiresAt.UTC() accountExpiresAt = &tokenExpiresAt credentialExpiresAt = &tokenExpiresAt } if requestExpiresAt != nil { accountExpiresAt = earlierCodexTime(accountExpiresAt, requestExpiresAt) credentialExpiresAt = earlierCodexTime(credentialExpiresAt, requestExpiresAt) } if accountExpiresAt == nil { return nil, nil, nil, nil, errors.New("未包含 refresh_token,且无法解析 accessToken 过期时间;请在第一步设置过期时间后再导入") } if accountExpiresAt.Unix() <= time.Now().UTC().Unix()-codexImportClockSkewSeconds { return nil, nil, nil, nil, fmt.Errorf("过期时间已过期: %s", accountExpiresAt.Format(time.RFC3339)) } warnings = append(warnings, "未包含 refresh_token,已按 accessToken/账号过期时间设置自动停止调度") if req.AutoPauseOnExpired != nil && !*req.AutoPauseOnExpired { warnings = append(warnings, "未包含 refresh_token,已强制开启过期自动暂停") } autoPause := true expiresAtUnix := accountExpiresAt.Unix() return &expiresAtUnix, credentialExpiresAt, &autoPause, warnings, nil } if requestExpiresAt != nil { accountExpiresAt = requestExpiresAt } if item.TokenExpiresAt != nil { tokenExpiresAt := item.TokenExpiresAt.UTC() credentialExpiresAt = &tokenExpiresAt } var expiresAtUnix *int64 if accountExpiresAt != nil { v := accountExpiresAt.Unix() expiresAtUnix = &v } return expiresAtUnix, credentialExpiresAt, req.AutoPauseOnExpired, warnings, nil } func earlierCodexTime(current, candidate *time.Time) *time.Time { if candidate == nil { return current } if current == nil || candidate.Before(*current) { t := candidate.UTC() return &t } t := current.UTC() return &t } func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { if len(input) == 0 { return nil } protected := map[string]struct{}{ "access_token": {}, "refresh_token": {}, "id_token": {}, "expires_at": {}, "email": {}, "chatgpt_account_id": {}, "chatgpt_user_id": {}, "organization_id": {}, "plan_type": {}, "client_id": {}, "auth_mode": {}, "openai_auth_mode": {}, "token_type": {}, "chatgpt_account_is_fedramp": {}, "agent_runtime_id": {}, "agent_private_key": {}, "task_id": {}, } out := make(map[string]any, len(input)) for key, value := range input { normalizedKey := strings.TrimSpace(key) if normalizedKey == "" { continue } if _, ok := protected[strings.ToLower(normalizedKey)]; ok { continue } out[normalizedKey] = value } if len(out) == 0 { return nil } return out } // buildCodexImportIdentityKeys 生成导入条目的匹配键。refresh_token 缺失时 // Codex session 只能作为 accessToken-only 凭据使用,此时以 access token // 指纹作为唯一稳定身份,避免同 workspace 下共享的 account/user 标识误合并。 func buildCodexImportIdentityKeys(accountID, userID, email, accessToken, refreshToken string) []string { accessToken = strings.TrimSpace(accessToken) refreshToken = strings.TrimSpace(refreshToken) if refreshToken == "" && accessToken != "" { return []string{"access:" + codexTokenFingerprint(accessToken)} } return buildCodexStoredIdentityKeys(accountID, userID, email, accessToken) } func buildCodexAgentIdentityKeys(accountID string) []string { // Agent Identity credentials belonging to the same ChatGPT account are // intentionally merged, while the same user may own multiple accounts. // Do not use user/email/runtime as fallback keys here: user_id is shared // across Team workspaces and runtime_id changes when a new runtime is // registered for the same account. accountID = strings.TrimSpace(accountID) if accountID == "" { return nil } return []string{"account:" + accountID} } // buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度, // 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。 func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string { keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) accessToken = strings.TrimSpace(accessToken) if userID != "" { keys = append(keys, "user:"+userID) } if accountID == "" && userID == "" { if email = strings.ToLower(strings.TrimSpace(email)); email != "" { keys = append(keys, "email:"+email) } } if 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{}, keysByAccountID: map[int64]map[string]struct{}{}, } for _, account := range accounts { index.Add(account) } return index } func (i *codexAccountIndex) Add(account service.Account) { if i == nil { return } if i.accountsByKey == nil { i.accountsByKey = map[string][]service.Account{} } if i.keysByAccountID == nil { i.keysByAccountID = map[int64]map[string]struct{}{} } keys := buildCodexStoredIdentityKeys( codexCredentialString(account.Credentials, "chatgpt_account_id"), codexCredentialString(account.Credentials, "chatgpt_user_id"), codexCredentialString(account.Credentials, "email"), codexCredentialString(account.Credentials, "access_token"), ) orderedKeys := make([]string, 0, len(keys)+1) accountKeys := make(map[string]struct{}, len(keys)+1) for _, key := range keys { if _, exists := accountKeys[key]; exists { continue } accountKeys[key] = struct{}{} orderedKeys = append(orderedKeys, key) } if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" { key := "agent:" + runtimeID if _, exists := accountKeys[key]; !exists { accountKeys[key] = struct{}{} orderedKeys = append(orderedKeys, key) } } previousKeys := i.keysByAccountID[account.ID] for key := range previousKeys { if _, retained := accountKeys[key]; retained { i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account) continue } i.removeFromKey(key, account.ID) } for _, key := range orderedKeys { if _, retained := previousKeys[key]; retained { continue } i.accountsByKey[key] = append(i.accountsByKey[key], account) } if len(accountKeys) > 0 { i.keysByAccountID[account.ID] = accountKeys return } delete(i.keysByAccountID, account.ID) } func (i *codexAccountIndex) removeFromKey(key string, accountID int64) { accounts := i.accountsByKey[key] kept := accounts[:0] for _, account := range accounts { if account.ID != accountID { kept = append(kept, account) } } if len(kept) == 0 { delete(i.accountsByKey, key) return } i.accountsByKey[key] = kept } // upsertCodexAccount keeps all candidates for shared keys while replacing an // existing account in place so ambiguous legacy matches retain their order. 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, "" } for _, key := range keys { for _, account := range i.accountsByKey[key] { if codexIdentityConflicts(key, userID, codexCredentialString(account.Credentials, "chatgpt_user_id")) { continue } return &account, key } } return nil, "" } // codexIdentityConflicts 判断 account: 键的命中是否把同一 ChatGPT 团队的两个 // 不同成员误连到一起:双方都携带 user id 且不相等时视为冲突。存量索引侧 // 仍保留 account 键,任一侧缺少 user id 时允许匹配,使含 refresh_token // 的常规导入和 accessToken-only 账号升级为完整 OAuth 时仍能更新原账号。 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 { 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]codexSeenIdentity, keys []string, index int, userID string) { for _, key := range keys { seen[key] = codexSeenIdentity{index: index, userID: userID} } } func mergeCodexImportMap(existing, incoming map[string]any) map[string]any { out := make(map[string]any, len(existing)+len(incoming)) for k, v := range existing { out[k] = v } for k, v := range incoming { out[k] = v } return out } func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexImportAccount) map[string]any { out := mergeCodexImportMap(existing, incoming) if item == nil { return out } if strings.TrimSpace(item.RefreshToken) == "" { if codexCredentialString(existing, "refresh_token") == "" { delete(out, "refresh_token") delete(out, "client_id") } else { out["refresh_token"] = existing["refresh_token"] if clientID, ok := existing["client_id"]; ok { out["client_id"] = clientID } } } if strings.TrimSpace(item.IDToken) == "" { delete(out, "id_token") } return out } func codexCredentialString(credentials map[string]any, key string) string { if credentials == nil { return "" } return codexStringValue(credentials[key]) } func codexTokenFingerprint(token string) string { sum := sha256.Sum256([]byte(strings.TrimSpace(token))) return hex.EncodeToString(sum[:]) } func looksLikeJSON(content string) bool { if content == "" { return false } switch content[0] { case '{', '[': return true default: return false } } func firstCodexString(obj map[string]any, paths ...[]string) string { for _, path := range paths { if value, ok := codexPathValue(obj, path); ok { if str := codexStringValue(value); str != "" { return str } } } return "" } func firstCodexMap(obj map[string]any, paths ...[]string) (map[string]any, bool) { for _, path := range paths { value, ok := codexPathValue(obj, path) if !ok || value == nil { continue } if mapped, ok := value.(map[string]any); ok { return mapped, true } } return nil, false } func firstCodexBool(obj map[string]any, paths ...[]string) bool { for _, path := range paths { value, ok := codexPathValue(obj, path) if !ok { continue } switch value := value.(type) { case bool: return value case string: parsed, err := strconv.ParseBool(strings.TrimSpace(value)) if err == nil { return parsed } } } return false } func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) { value := firstCodexString(obj, path) if value != "" { extra[key] = value } } func firstCodexTime(obj map[string]any, paths ...[]string) (time.Time, bool) { for _, path := range paths { if value, ok := codexTimeAt(obj, path); ok { return value, true } } return time.Time{}, false } func codexTimeAt(obj map[string]any, path []string) (time.Time, bool) { value, ok := codexPathValue(obj, path) if !ok { return time.Time{}, false } return parseCodexTimeValue(value) } func codexPathValue(obj map[string]any, path []string) (any, bool) { var current any = obj for _, key := range path { currentObj, ok := current.(map[string]any) if !ok { return nil, false } value, ok := currentObj[key] if !ok { return nil, false } current = value } return current, true } func codexStringValue(value any) string { switch v := value.(type) { case string: return strings.TrimSpace(v) case json.Number: return strings.TrimSpace(v.String()) case float64: return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64)) case float32: return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32)) case int: return strconv.Itoa(v) case int64: return strconv.FormatInt(v, 10) case int32: return strconv.FormatInt(int64(v), 10) default: return "" } } func setCodexCredentialIfNotEmpty(credentials map[string]any, key, value string) { value = strings.TrimSpace(value) if value != "" { credentials[key] = value } } func parseCodexTimeValue(value any) (time.Time, bool) { switch v := value.(type) { case string: v = strings.TrimSpace(v) if v == "" { return time.Time{}, false } if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil { return parsed.UTC(), true } if n, err := strconv.ParseInt(v, 10, 64); err == nil { return codexUnixTime(n), true } case json.Number: if n, err := v.Int64(); err == nil { return codexUnixTime(n), true } if f, err := v.Float64(); err == nil { return codexUnixTime(int64(f)), true } case float64: return codexUnixTime(int64(v)), true case int: return codexUnixTime(int64(v)), true case int64: return codexUnixTime(v), true } return time.Time{}, false } func codexUnixTime(value int64) time.Time { if value > 1_000_000_000_000 { return time.UnixMilli(value).UTC() } return time.Unix(value, 0).UTC() }