From fda1ed459d863fe883a091215da783a39e456faa Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 8 May 2026 11:36:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20OAuth=20=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=AF=BC=E5=85=A5=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/admin/account_codex_import.go | 1045 +++++++++++++++++ .../admin/account_codex_import_test.go | 344 ++++++ backend/internal/server/routes/admin.go | 1 + .../openai_oauth_service_refresh_test.go | 44 + .../internal/service/openai_token_provider.go | 6 + .../service/openai_token_provider_test.go | 30 +- backend/internal/service/token_refresher.go | 4 + frontend/src/api/admin/accounts.ts | 8 + .../components/account/CreateAccountModal.vue | 111 ++ .../account/OAuthAuthorizationFlow.vue | 114 +- .../admin/account/AccountTableActions.vue | 3 +- frontend/src/composables/useAccountOAuth.ts | 2 +- frontend/src/i18n/locales/en.ts | 15 + frontend/src/i18n/locales/zh.ts | 15 + frontend/src/types/index.ts | 45 + frontend/src/views/admin/AccountsView.vue | 187 ++- 16 files changed, 1900 insertions(+), 74 deletions(-) create mode 100644 backend/internal/handler/admin/account_codex_import.go create mode 100644 backend/internal/handler/admin/account_codex_import_test.go diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go new file mode 100644 index 00000000..59fe30a0 --- /dev/null +++ b/backend/internal/handler/admin/account_codex_import.go @@ -0,0 +1,1045 @@ +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 + 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 +} + +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 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]int{} + 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 + } + for _, warning := range expiryWarnings { + item.WarningTexts = append(item.WarningTexts, warning) + } + 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); 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) + + if existing := index.Find(item.IdentityKeys); existing != nil && updateExisting { + 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: + 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.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 = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken) + 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("导入项为空") + } + + 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": {}, + } + 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 +} + +func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string { + keys := make([]string, 0, 4) + accountID = strings.TrimSpace(accountID) + userID = strings.TrimSpace(userID) + if accountID != "" { + keys = append(keys, "account:"+accountID) + } + 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 = strings.TrimSpace(accessToken); accessToken != "" { + keys = append(keys, "access:"+codexTokenFingerprint(accessToken)) + } + return keys +} + +func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { + index := &codexAccountIndex{accountsByKey: map[string]service.Account{}} + 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{} + } + keys := buildCodexIdentityKeys( + codexCredentialString(account.Credentials, "chatgpt_account_id"), + codexCredentialString(account.Credentials, "chatgpt_user_id"), + codexCredentialString(account.Credentials, "email"), + codexCredentialString(account.Credentials, "access_token"), + ) + for _, key := range keys { + i.accountsByKey[key] = account + } +} + +func (i *codexAccountIndex) Find(keys []string) *service.Account { + if i == nil { + return nil + } + for _, key := range keys { + if account, ok := i.accountsByKey[key]; ok { + return &account + } + } + return nil +} + +func firstSeenCodexIdentity(seen map[string]int, keys []string) (int, bool) { + for _, key := range keys { + if index, ok := seen[key]; ok { + return index, true + } + } + return 0, false +} + +func markCodexIdentitySeen(seen map[string]int, keys []string, index int) { + for _, key := range keys { + seen[key] = index + } +} + +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) == "" { + delete(out, "refresh_token") + delete(out, "client_id") + } + 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 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() +} diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go new file mode 100644 index 00000000..3cf0d2bb --- /dev/null +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -0,0 +1,344 @@ +package admin + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) { + token1 := "raw-access-token-1" + token2 := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "email": "json@example.com", + }) + token3 := "raw-access-token-3" + + req := CodexSessionImportRequest{ + Content: fmt.Sprintf("%s\n{\"accessToken\":%q}\n[%q]", token1, token2, token3), + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + t.Fatalf("parseCodexSessionImportEntries error = %v", err) + } + if len(entries) != 3 { + t.Fatalf("len(entries) = %d, want 3", len(entries)) + } + + first, err := normalizeCodexImportEntry(entries[0]) + if err != nil { + t.Fatalf("normalize raw token error = %v", err) + } + if first.Credentials["access_token"] != token1 { + t.Fatalf("raw token access_token = %v, want %s", first.Credentials["access_token"], token1) + } + + second, err := normalizeCodexImportEntry(entries[1]) + if err != nil { + t.Fatalf("normalize json token error = %v", err) + } + if second.Email != "json@example.com" { + t.Fatalf("email = %q, want json@example.com", second.Email) + } + + third, err := normalizeCodexImportEntry(entries[2]) + if err != nil { + t.Fatalf("normalize array token error = %v", err) + } + if third.Credentials["access_token"] != token3 { + t.Fatalf("array token access_token = %v, want %s", third.Credentials["access_token"], token3) + } +} + +func TestParseCodexSessionImportEntriesFallsBackToLineModeForMixedJSONAndToken(t *testing.T) { + req := CodexSessionImportRequest{ + Content: "{\"accessToken\":\"json-line-token\"}\nraw-line-token", + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + t.Fatalf("parseCodexSessionImportEntries error = %v", err) + } + if len(entries) != 2 { + t.Fatalf("len(entries) = %d, want 2", len(entries)) + } + + first, err := normalizeCodexImportEntry(entries[0]) + if err != nil { + t.Fatalf("normalize json line error = %v", err) + } + if first.Credentials["access_token"] != "json-line-token" { + t.Fatalf("json line access_token = %v, want json-line-token", first.Credentials["access_token"]) + } + + second, err := normalizeCodexImportEntry(entries[1]) + if err != nil { + t.Fatalf("normalize raw line error = %v", err) + } + if second.Credentials["access_token"] != "raw-line-token" { + t.Fatalf("raw line access_token = %v, want raw-line-token", second.Credentials["access_token"]) + } +} + +func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *testing.T) { + accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "email": "claim@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct-from-claim", + "chatgpt_user_id": "user-from-claim", + "chatgpt_plan_type": "plus", + "poid": "org-from-claim", + }, + }) + raw := map[string]any{ + "user": map[string]any{ + "id": "user-from-json", + "name": "Sup OO", + "email": "json@example.com", + "image": "https://example.com/avatar.png", + }, + "account": map[string]any{ + "id": "acct-from-json", + "planType": "free", + }, + "accessToken": accessToken, + "sessionToken": "secret-session-token", + "expires": "2026-08-05T13:40:42.836Z", + } + + item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: raw}) + if err != nil { + t.Fatalf("normalizeCodexImportEntry error = %v", err) + } + if item.Credentials["access_token"] != accessToken { + t.Fatalf("access_token not stored") + } + if item.Credentials["email"] != "json@example.com" { + t.Fatalf("email = %v, want json@example.com", item.Credentials["email"]) + } + if item.Credentials["chatgpt_account_id"] != "acct-from-json" { + t.Fatalf("chatgpt_account_id = %v, want acct-from-json", item.Credentials["chatgpt_account_id"]) + } + if item.Credentials["chatgpt_user_id"] != "user-from-json" { + t.Fatalf("chatgpt_user_id = %v, want user-from-json", item.Credentials["chatgpt_user_id"]) + } + if item.Credentials["plan_type"] != "free" { + t.Fatalf("plan_type = %v, want free", item.Credentials["plan_type"]) + } + if _, ok := item.Credentials["session_token"]; ok { + t.Fatalf("session_token should not be written to credentials") + } + if item.Extra["session_token_present"] != true { + t.Fatalf("session_token_present = %v, want true", item.Extra["session_token_present"]) + } + if item.Extra["session_expires_at"] != "2026-08-05T13:40:42Z" { + t.Fatalf("session_expires_at = %v", item.Extra["session_expires_at"]) + } + if item.TokenExpiresAt == nil { + t.Fatalf("TokenExpiresAt should be parsed from accessToken") + } +} + +func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { + existing := map[string]any{ + "access_token": "old-access-token", + "refresh_token": "old-refresh-token", + "client_id": "old-client-id", + "id_token": "old-id-token", + "model_mapping": map[string]any{"from": "existing"}, + "chatgpt_account_id": "acct-old", + "unrelated_existing": "keep", + } + incoming := map[string]any{ + "access_token": "new-access-token", + "expires_at": "2026-08-05T13:40:42Z", + "chatgpt_account_id": "acct-new", + } + item := &codexImportAccount{ + AccessToken: "new-access-token", + } + + merged := mergeCodexImportCredentials(existing, incoming, item) + + if merged["access_token"] != "new-access-token" { + t.Fatalf("access_token = %v, want new-access-token", merged["access_token"]) + } + if merged["chatgpt_account_id"] != "acct-new" { + t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"]) + } + if _, ok := merged["refresh_token"]; ok { + t.Fatalf("refresh_token should be cleared") + } + if _, ok := merged["client_id"]; ok { + t.Fatalf("client_id should be cleared") + } + if _, ok := merged["id_token"]; ok { + t.Fatalf("id_token should be cleared") + } + if merged["unrelated_existing"] != "keep" { + t.Fatalf("unrelated_existing = %v, want keep", merged["unrelated_existing"]) + } + if _, ok := merged["model_mapping"]; !ok { + t.Fatalf("model_mapping should be preserved") + } +} + +func TestMergeCodexImportCredentialsKeepsRefreshFieldsWhenIncomingHasRefreshToken(t *testing.T) { + existing := map[string]any{ + "refresh_token": "old-refresh-token", + "client_id": "old-client-id", + "id_token": "old-id-token", + } + incoming := map[string]any{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "client_id": "new-client-id", + "id_token": "new-id-token", + } + item := &codexImportAccount{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + IDToken: "new-id-token", + } + + merged := mergeCodexImportCredentials(existing, incoming, item) + + if merged["refresh_token"] != "new-refresh-token" { + t.Fatalf("refresh_token = %v, want new-refresh-token", merged["refresh_token"]) + } + if merged["client_id"] != "new-client-id" { + t.Fatalf("client_id = %v, want new-client-id", merged["client_id"]) + } + if merged["id_token"] != "new-id-token" { + t.Fatalf("id_token = %v, want new-id-token", merged["id_token"]) + } +} + +func TestNormalizeCodexImportRejectsExpiredAccessToken(t *testing.T) { + expiredToken := buildCodexImportTestJWT(t, time.Now().Add(-time.Hour), map[string]any{}) + + _, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: expiredToken}) + if err == nil { + t.Fatal("normalizeCodexImportEntry error = nil, want expired token error") + } + if !strings.Contains(err.Error(), "已过期") { + t.Fatalf("error = %v, want expired token message", err) + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenUsesTokenExpiry(t *testing.T) { + tokenExpiresAt := time.Now().Add(time.Hour).UTC() + item := &codexImportAccount{ + AccessToken: "access-token", + Credentials: map[string]any{"access_token": "access-token"}, + TokenExpiresAt: &tokenExpiresAt, + WarningTexts: []string{}, + } + disabled := false + req := CodexSessionImportRequest{AutoPauseOnExpired: &disabled} + + accountExpiresAt, credentialExpiresAt, autoPause, warnings, err := resolveCodexImportExpiry(req, item) + if err != nil { + t.Fatalf("resolveCodexImportExpiry error = %v", err) + } + if accountExpiresAt == nil || *accountExpiresAt != tokenExpiresAt.Unix() { + t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, tokenExpiresAt.Unix()) + } + if credentialExpiresAt == nil || credentialExpiresAt.Unix() != tokenExpiresAt.Unix() { + t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, tokenExpiresAt) + } + if autoPause == nil || !*autoPause { + t.Fatalf("autoPause = %v, want true", autoPause) + } + if len(warnings) == 0 { + t.Fatalf("warnings should not be empty") + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenRequiresExpiry(t *testing.T) { + item := &codexImportAccount{ + AccessToken: "opaque-access-token", + Credentials: map[string]any{"access_token": "opaque-access-token"}, + WarningTexts: []string{}, + } + + _, _, _, _, err := resolveCodexImportExpiry(CodexSessionImportRequest{}, item) + if err == nil { + t.Fatal("resolveCodexImportExpiry error = nil, want missing expiry error") + } + if !strings.Contains(err.Error(), "无法解析 accessToken 过期时间") { + t.Fatalf("error = %v, want missing expiry message", err) + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *testing.T) { + tokenExpiresAt := time.Now().Add(2 * time.Hour).UTC() + requestExpiresAt := time.Now().Add(time.Hour).UTC() + item := &codexImportAccount{ + AccessToken: "access-token", + Credentials: map[string]any{"access_token": "access-token"}, + TokenExpiresAt: &tokenExpiresAt, + WarningTexts: []string{}, + } + reqUnix := requestExpiresAt.Unix() + req := CodexSessionImportRequest{ExpiresAt: &reqUnix} + + accountExpiresAt, credentialExpiresAt, _, _, err := resolveCodexImportExpiry(req, item) + if err != nil { + t.Fatalf("resolveCodexImportExpiry error = %v", err) + } + if accountExpiresAt == nil || *accountExpiresAt != requestExpiresAt.Unix() { + t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, requestExpiresAt.Unix()) + } + if credentialExpiresAt == nil || credentialExpiresAt.Unix() != requestExpiresAt.Unix() { + t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, requestExpiresAt) + } +} + +func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { + keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token") + for _, key := range keys { + if strings.HasPrefix(key, "email:") { + t.Fatalf("strong identity should not include email fallback: %v", keys) + } + } + + keys = buildCodexIdentityKeys("", "", "same@example.com", "token") + hasEmail := false + for _, key := range keys { + if key == "email:same@example.com" { + hasEmail = true + } + } + if !hasEmail { + t.Fatalf("weak identity should include email fallback: %v", keys) + } +} + +func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string { + t.Helper() + header := map[string]any{ + "alg": "none", + "typ": "JWT", + } + claims := map[string]any{ + "sub": "user-from-sub", + "exp": exp.Unix(), + "iat": time.Now().Unix(), + } + for k, v := range extraClaims { + claims[k] = v + } + headerBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + claimBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(claimBytes) + "." +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5eb0d34b..6e1059bc 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -282,6 +282,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) + accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) diff --git a/backend/internal/service/openai_oauth_service_refresh_test.go b/backend/internal/service/openai_oauth_service_refresh_test.go index a31eb8cb..84b68ea6 100644 --- a/backend/internal/service/openai_oauth_service_refresh_test.go +++ b/backend/internal/service/openai_oauth_service_refresh_test.go @@ -52,3 +52,47 @@ func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccess require.Equal(t, "client-id-1", info.ClientID) require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh") } + +func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) { + refresher := NewOpenAITokenRefresher(nil, nil) + expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + + withoutRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "access-token", + "expires_at": expiresAt, + }, + } + require.False(t, refresher.NeedsRefresh(withoutRT, 5*time.Minute)) + + withRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, + }, + } + require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute)) +} + +func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) { + provider := NewOpenAITokenProvider(nil, nil, nil) + expiresAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "expired-access-token", + "expires_at": expiresAt, + }, + } + + token, err := provider.GetAccessToken(context.Background(), account) + require.Error(t, err) + require.Empty(t, token) + require.Contains(t, err.Error(), "refresh_token is missing") +} diff --git a/backend/internal/service/openai_token_provider.go b/backend/internal/service/openai_token_provider.go index e438588e..a680d451 100644 --- a/backend/internal/service/openai_token_provider.go +++ b/backend/internal/service/openai_token_provider.go @@ -152,6 +152,12 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou // 2) Refresh if needed (pre-expiry skew). expiresAt := account.GetCredentialAsTime("expires_at") needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew + if needsRefresh && strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { + if expiresAt != nil && !time.Now().Before(*expiresAt) { + return "", errors.New("openai access_token expired and refresh_token is missing") + } + needsRefresh = false + } refreshFailed := false if needsRefresh && p.refreshAPI != nil && p.executor != nil { diff --git a/backend/internal/service/openai_token_provider_test.go b/backend/internal/service/openai_token_provider_test.go index e81fb465..4b69db8a 100644 --- a/backend/internal/service/openai_token_provider_test.go +++ b/backend/internal/service/openai_token_provider_test.go @@ -424,8 +424,9 @@ func TestOpenAITokenProvider_CacheGetError(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -650,8 +651,9 @@ func TestOpenAITokenProvider_Real_LockFailedWait(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -819,8 +821,9 @@ func TestOpenAITokenProvider_Real_LockRace_PollingHitsCache(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -848,8 +851,9 @@ func TestOpenAITokenProvider_Real_LockRace_ContextCanceled(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -875,8 +879,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockWaitHitAndSnapshot(t *testing.T) Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } cacheKey := OpenAITokenCacheKey(account) @@ -911,8 +916,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockAcquireFailure(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } diff --git a/backend/internal/service/token_refresher.go b/backend/internal/service/token_refresher.go index 916c2267..823f9812 100644 --- a/backend/internal/service/token_refresher.go +++ b/backend/internal/service/token_refresher.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "time" ) @@ -95,6 +96,9 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool { // NeedsRefresh 检查token是否需要刷新 // expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期 func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { + if strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { + return false + } expiresAt := account.GetCredentialAsTime("expires_at") if expiresAt == nil { return account.IsRateLimited() diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8a127793..00ed4087 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -16,6 +16,8 @@ import type { TempUnschedulableStatus, AdminDataPayload, AdminDataImportResult, + CodexSessionImportRequest, + CodexSessionImportResult, CheckMixedChannelRequest, CheckMixedChannelResponse } from '@/types' @@ -547,6 +549,11 @@ export async function importData(payload: { return data } +export async function importCodexSession(payload: CodexSessionImportRequest): Promise { + const { data } = await apiClient.post('/admin/accounts/import/codex-session', payload) + return data +} + /** * Get Antigravity default model mapping from backend * @returns Default model mapping (from -> to) @@ -663,6 +670,7 @@ export const accountsAPI = { syncFromCrs, exportData, importData, + importCodexSession, getAntigravityDefaultModelMapping, batchClearError, batchRefresh, diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index d38c31c5..9ef6c9d2 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2765,6 +2765,7 @@ :show-mobile-refresh-token-option="form.platform === 'openai'" :show-session-token-option="false" :show-access-token-option="false" + :show-codex-session-import-option="form.platform === 'openai'" :platform="form.platform" :show-project-id="geminiOAuthType === 'code_assist'" @generate-url="handleGenerateUrl" @@ -2772,6 +2773,7 @@ @validate-refresh-token="handleValidateRefreshToken" @validate-mobile-refresh-token="handleOpenAIValidateMobileRT" @validate-session-token="handleValidateSessionToken" + @import-codex-session="handleOpenAIImportCodexSession" /> @@ -3119,6 +3121,7 @@ import type { AccountType, CheckMixedChannelResponse, CreateAccountRequest, + CodexSessionImportMessage, OpenAICompactMode } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -3152,6 +3155,7 @@ interface OAuthFlowExposed { sessionKey: string refreshToken: string sessionToken: string + codexSession: string inputMethod: AuthInputMethod reset: () => void } @@ -4631,6 +4635,113 @@ const handleOpenAIExchange = async (authCode: string) => { // OpenAI Mobile RT client_id const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK' +const buildOpenAICodexImportCredentialExtras = (): Record | null => { + const credentials: Record = {} + if (!isOpenAIModelRestrictionDisabled.value) { + const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + if (modelMapping) { + credentials.model_mapping = modelMapping + } + } + + const compactModelMapping = buildOpenAICompactModelMapping() + if (compactModelMapping) { + credentials.compact_model_mapping = compactModelMapping + } + + if (!applyTempUnschedConfig(credentials)) { + return null + } + return credentials +} + +const formatCodexImportMessages = (messages?: CodexSessionImportMessage[]) => { + return (messages || []) + .map((item) => { + const name = item.name ? ` ${item.name}` : '' + return `#${item.index}${name}: ${item.message}` + }) + .join('\n') +} + +const handleOpenAIImportCodexSession = async (content: string) => { + const oauthClient = openaiOAuth + const trimmed = content.trim() + if (!trimmed) { + oauthClient.error.value = t('admin.accounts.oauth.openai.codexSessionEmpty') + return + } + + const credentialExtras = buildOpenAICodexImportCredentialExtras() + if (credentialExtras === null) { + return + } + + oauthClient.loading.value = true + oauthClient.error.value = '' + + try { + const extra = buildOpenAIExtra() + const result = await adminAPI.accounts.importCodexSession({ + content: trimmed, + name: form.name, + notes: form.notes || null, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + load_factor: form.load_factor ?? undefined, + priority: form.priority, + rate_multiplier: form.rate_multiplier, + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value, + credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, + extra, + update_existing: true + }) + + const successCount = result.created + result.updated + const params = { + created: result.created, + updated: result.updated, + skipped: result.skipped, + failed: result.failed + } + + if (successCount > 0 && result.failed === 0) { + appStore.showSuccess(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params)) + emit('created') + handleClose() + return + } + + const errorText = formatCodexImportMessages(result.errors) + const warningText = formatCodexImportMessages(result.warnings) + oauthClient.error.value = [errorText, warningText].filter(Boolean).join('\n') + + if (result.failed === 0) { + appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params)) + return + } + + if (successCount > 0) { + appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportPartial', params)) + emit('created') + return + } + + appStore.showError(t('admin.accounts.oauth.openai.codexSessionImportFailed')) + } catch (error: any) { + oauthClient.error.value = + error.response?.data?.detail || + error.response?.data?.message || + error.message || + t('admin.accounts.oauth.openai.codexSessionImportFailed') + appStore.showError(oauthClient.error.value) + } finally { + oauthClient.loading.value = false + } +} + // OpenAI RT 批量验证和创建(共享逻辑) const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => { const oauthClient = openaiOAuth diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 08c67494..9526e878 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -81,6 +81,17 @@ t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT') }} + @@ -168,6 +179,85 @@ + +
+
+

+ {{ t('admin.accounts.oauth.openai.codexSessionDesc') }} +

+ +
+ + +

+ {{ t('admin.accounts.oauth.openai.codexSessionHint') }} +

+
+ +
+

+ {{ error }} +

+
+ + +
+
+
(), { showMobileRefreshTokenOption: false, showSessionTokenOption: false, showAccessTokenOption: false, + showCodexSessionImportOption: false, platform: 'anthropic', showProjectId: true }) @@ -591,6 +683,7 @@ const emit = defineEmits<{ 'validate-mobile-refresh-token': [refreshToken: string] 'validate-session-token': [sessionToken: string] 'import-access-token': [accessToken: string] + 'import-codex-session': [content: string] 'update:inputMethod': [method: AuthInputMethod] }>() @@ -630,12 +723,13 @@ const authCodeInput = ref('') const sessionKeyInput = ref('') const refreshTokenInput = ref('') const sessionTokenInput = ref('') +const codexSessionInput = ref('') const showHelpDialog = ref(false) const oauthState = ref('') const projectId = ref('') // Computed: show method selection when either cookie or refresh token option is enabled -const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption) +const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption) // Clipboard const { copied, copyToClipboard } = useClipboard() @@ -656,6 +750,16 @@ const parsedRefreshTokenCount = computed(() => { .filter((rt) => rt).length }) +const parsedCodexSessionCount = computed(() => { + const trimmed = codexSessionInput.value.trim() + if (!trimmed) return 0 + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 1 + return trimmed + .split('\n') + .map((item) => item.trim()) + .filter((item) => item).length +}) + // Watchers watch(inputMethod, (newVal) => { emit('update:inputMethod', newVal) @@ -727,6 +831,12 @@ const handleValidateRefreshToken = () => { } } +const handleImportCodexSession = () => { + if (codexSessionInput.value.trim()) { + emit('import-codex-session', codexSessionInput.value.trim()) + } +} + // Expose methods and state defineExpose({ authCode: authCodeInput, @@ -735,6 +845,7 @@ defineExpose({ sessionKey: sessionKeyInput, refreshToken: refreshTokenInput, sessionToken: sessionTokenInput, + codexSession: codexSessionInput, inputMethod, reset: () => { authCodeInput.value = '' @@ -743,6 +854,7 @@ defineExpose({ sessionKeyInput.value = '' refreshTokenInput.value = '' sessionTokenInput.value = '' + codexSessionInput.value = '' inputMethod.value = 'manual' showHelpDialog.value = false } diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue index ee521f83..6874625b 100644 --- a/frontend/src/components/admin/account/AccountTableActions.vue +++ b/frontend/src/components/admin/account/AccountTableActions.vue @@ -5,7 +5,6 @@ - @@ -17,7 +16,7 @@ import { useI18n } from 'vue-i18n' import Icon from '@/components/icons/Icon.vue' defineProps(['loading']) -defineEmits(['refresh', 'sync', 'create']) +defineEmits(['refresh', 'create']) const { t } = useI18n() diff --git a/frontend/src/composables/useAccountOAuth.ts b/frontend/src/composables/useAccountOAuth.ts index 564e7d95..ab4c640a 100644 --- a/frontend/src/composables/useAccountOAuth.ts +++ b/frontend/src/composables/useAccountOAuth.ts @@ -3,7 +3,7 @@ import { useAppStore } from '@/stores/app' import { adminAPI } from '@/api/admin' export type AddMethod = 'oauth' | 'setup-token' -export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' +export type AuthInputMethod = 'manual' | 'cookie' | 'refresh_token' | 'mobile_refresh_token' | 'session_token' | 'access_token' | 'codex_session' export interface OAuthState { authUrl: string diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 90bf23f7..d18a895c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2777,6 +2777,11 @@ export default { dataExportSelected: 'Export Selected', dataExportIncludeProxies: 'Include proxies linked to the exported accounts', dataImport: 'Import', + moreActions: 'More Actions', + dataActions: 'Data', + toolActions: 'Tools', + viewColumns: 'Columns', + selectedCount: '{count} selected', dataExportConfirmMessage: 'The exported data contains sensitive account and proxy information. Store it securely.', dataExportConfirm: 'Confirm Export', dataExported: 'Data exported successfully', @@ -3470,6 +3475,16 @@ export default { refreshTokenAuth: 'Manual RT Input', refreshTokenDesc: 'Enter your existing OpenAI Refresh Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', refreshTokenPlaceholder: 'Paste your OpenAI Refresh Token...\nSupports multiple, one per line', + codexSessionAuth: 'Codex JSON / AT Batch Input', + codexSessionDesc: 'Paste Codex JSON or an accessToken. Accounts use the step 1 settings.', + codexSessionInputLabel: 'Codex JSON or accessToken', + codexSessionPlaceholder: 'Multiple lines supported, one token or JSON per line', + codexSessionHint: 'sessionToken will not be saved as refresh_token. Without refresh_token, the account expires with the accessToken expiry; import is rejected if the expiry cannot be parsed and step 1 has no expiration.', + codexSessionImportAndCreate: 'Import & Create Account', + codexSessionEmpty: 'Please enter Codex JSON or accessToken', + codexSessionImportFailed: 'Failed to import Codex account', + codexSessionImportSuccess: 'Import completed: created {created}, updated {updated}, skipped {skipped}', + codexSessionImportPartial: 'Partial success: created {created}, updated {updated}, skipped {skipped}, failed {failed}', sessionTokenAuth: 'Manual ST Input', sessionTokenDesc: 'Enter your existing Session Token(s). Supports batch input (one per line). The system will automatically validate and create accounts.', sessionTokenPlaceholder: 'Paste your Session Token...\nSupports multiple, one per line', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 87482f9d..4f473f94 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2853,6 +2853,11 @@ export default { dataExportSelected: '导出选中', dataExportIncludeProxies: '导出代理(导出账号关联的代理)', dataImport: '导入', + moreActions: '更多操作', + dataActions: '数据操作', + toolActions: '工具', + viewColumns: '列显示', + selectedCount: '已选 {count}', dataExportConfirmMessage: '导出的数据包含账号与代理的敏感信息,请妥善保存。', dataExportConfirm: '确认导出', dataExported: '数据导出成功', @@ -3605,6 +3610,16 @@ export default { refreshTokenAuth: '手动输入 RT', refreshTokenDesc: '输入您已有的 OpenAI Refresh Token,支持批量输入(每行一个),系统将自动验证并创建账号。', refreshTokenPlaceholder: '粘贴您的 OpenAI Refresh Token...\n支持多个,每行一个', + codexSessionAuth: 'Codex JSON / AT 批量输入', + codexSessionDesc: '粘贴 Codex JSON 或 accessToken,按第一步配置创建账号。', + codexSessionInputLabel: 'Codex JSON 或 accessToken', + codexSessionPlaceholder: '支持多行,每行一个 token 或 JSON', + codexSessionHint: 'sessionToken 不会作为 refresh_token 保存;未包含 refresh_token 时会按 accessToken 过期时间设置账号过期,无法解析且第一步未设置过期时间时会拒绝导入。', + codexSessionImportAndCreate: '导入并创建账号', + codexSessionEmpty: '请输入 Codex JSON 或 accessToken', + codexSessionImportFailed: 'Codex 账号导入失败', + codexSessionImportSuccess: '导入完成:新增 {created},更新 {updated},跳过 {skipped}', + codexSessionImportPartial: '部分成功:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}', sessionTokenAuth: '手动输入 ST', sessionTokenDesc: '输入您已有的 Session Token,支持批量输入(每行一个),系统将自动验证并创建账号。', sessionTokenPlaceholder: '粘贴您的 Session Token...\n支持多个,每行一个', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 328b7c04..ec7d0636 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1105,6 +1105,51 @@ export interface AdminDataImportResult { errors?: AdminDataImportError[] } +export interface CodexSessionImportRequest { + content?: string + contents?: string[] + name?: string + notes?: string | null + group_ids?: number[] + proxy_id?: number | null + concurrency?: number + priority?: number + rate_multiplier?: number + load_factor?: number | null + expires_at?: number | null + auto_pause_on_expired?: boolean + credential_extras?: Record + extra?: Record + update_existing?: boolean + skip_default_group_bind?: boolean + confirm_mixed_channel_risk?: boolean +} + +export interface CodexSessionImportMessage { + index: number + name?: string + message: string +} + +export interface CodexSessionImportItem { + index: number + name?: string + action: 'created' | 'updated' | 'skipped' | 'failed' + account_id?: number + message?: string +} + +export interface CodexSessionImportResult { + total: number + created: number + updated: number + skipped: number + failed: number + items?: CodexSessionImportItem[] + warnings?: CodexSessionImportMessage[] + errors?: CodexSessionImportMessage[] +} + // ==================== Usage & Redeem Types ==================== export type RedeemCodeType = 'balance' | 'concurrency' | 'subscription' | 'invitation' diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 04c376cc..c2159f6f 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -14,7 +14,6 @@ -
(null) const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null }) const exportingData = ref(false) -// Column settings -const showColumnDropdown = ref(false) -const columnDropdownRef = ref(null) +// Account tools dropdown +const showAccountToolsDropdown = ref(false) +const accountToolsDropdownRef = ref(null) const hiddenColumns = reactive>(new Set()) const DEFAULT_HIDDEN_COLUMNS = ['today_stats', 'proxy', 'notes', 'priority', 'rate_multiplier'] const HIDDEN_COLUMNS_KEY = 'account-hidden-columns' @@ -820,7 +851,8 @@ const isAnyModalOpen = computed(() => { showTest.value || showStats.value || showSchedulePanel.value || - showErrorPassthrough.value + showErrorPassthrough.value || + showTLSFingerprintProfiles.value ) }) @@ -931,6 +963,35 @@ const handleManualRefresh = async () => { usageManualRefreshToken.value += 1 } +const closeAccountToolsDropdown = () => { + showAccountToolsDropdown.value = false +} + +const openSyncFromCrs = () => { + closeAccountToolsDropdown() + showSync.value = true +} + +const openImportData = () => { + closeAccountToolsDropdown() + showImportData.value = true +} + +const openExportDataDialogFromMenu = () => { + closeAccountToolsDropdown() + openExportDataDialog() +} + +const openErrorPassthrough = () => { + closeAccountToolsDropdown() + showErrorPassthrough.value = true +} + +const openTLSFingerprintProfiles = () => { + closeAccountToolsDropdown() + showTLSFingerprintProfiles.value = true +} + const syncPendingListChanges = async () => { hasPendingListSync.value = false await load() @@ -944,7 +1005,7 @@ const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn( if (document.hidden) return if (loading.value || autoRefreshFetching.value) return if (isAnyModalOpen.value) return - if (menu.show) return + if (menu.show || showAccountToolsDropdown.value || showAutoRefreshDropdown.value) return if (inAutoRefreshSilentWindow()) { autoRefreshCountdown.value = Math.max( 0, @@ -1572,11 +1633,11 @@ const handleScroll = () => { menu.show = false } -// 点击外部关闭列设置下拉菜单 +// 点击外部关闭顶部下拉菜单 const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement - if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) { - showColumnDropdown.value = false + if (accountToolsDropdownRef.value && !accountToolsDropdownRef.value.contains(target)) { + showAccountToolsDropdown.value = false } if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) { showAutoRefreshDropdown.value = false @@ -1608,3 +1669,13 @@ onUnmounted(() => { document.removeEventListener('click', handleClickOutside) }) + +