1813 lines
80 KiB
Python
1813 lines
80 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import secrets
|
||
import time
|
||
import uuid
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from threading import Condition, Lock, Thread
|
||
from typing import Any
|
||
from urllib.parse import urlencode
|
||
|
||
from services.config import config
|
||
from services.log_service import (
|
||
LOG_TYPE_ACCOUNT,
|
||
log_service,
|
||
)
|
||
from services.storage.base import StorageBackend
|
||
from services.upload_limits import UploadLimit, UploadUnavailableError
|
||
from utils.helper import anonymize_token
|
||
|
||
|
||
class AccountService:
|
||
"""账号池服务,使用 token -> account 的 dict 保存账号。"""
|
||
|
||
_NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
|
||
_INVALID_CONFIRM_SECONDS = 30
|
||
_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
|
||
_REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
|
||
_REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
|
||
_REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
|
||
_TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
|
||
_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||
_OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
|
||
_OAUTH_USER_AGENT = (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||
"Chrome/145.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
# 刷新进度追踪
|
||
_refresh_progress: dict[str, dict] = {}
|
||
_refresh_progress_lock = Lock()
|
||
# 重新登录进度追踪
|
||
_relogin_progress: dict[str, dict] = {}
|
||
_relogin_progress_lock = Lock()
|
||
|
||
def __init__(self, storage_backend: StorageBackend):
|
||
self.storage = storage_backend
|
||
self._lock = Lock()
|
||
self._token_refresh_lock = Lock()
|
||
self._image_slot_condition = Condition(self._lock)
|
||
self._index = 0
|
||
self._accounts = self._load_accounts()
|
||
self._image_inflight: dict[str, int] = {}
|
||
self._token_aliases: dict[str, str] = {}
|
||
self._cumulative_total = self._load_cumulative_total()
|
||
|
||
def _get_cumulative_file(self) -> Path:
|
||
from services.config import DATA_DIR
|
||
return DATA_DIR / ".cumulative_total"
|
||
|
||
def _load_cumulative_total(self) -> int:
|
||
try:
|
||
f = self._get_cumulative_file()
|
||
if f.exists():
|
||
return int(f.read_text().strip())
|
||
except Exception:
|
||
pass
|
||
return len(self._accounts)
|
||
|
||
def _save_cumulative_total(self) -> None:
|
||
try:
|
||
self._get_cumulative_file().write_text(str(self._cumulative_total))
|
||
except Exception:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _now() -> str:
|
||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
@staticmethod
|
||
def _decode_jwt_payload(token: str) -> dict:
|
||
try:
|
||
payload = str(token or "").split(".")[1]
|
||
payload += "=" * ((4 - len(payload) % 4) % 4)
|
||
import base64
|
||
import json
|
||
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||
return data if isinstance(data, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
@staticmethod
|
||
def _parse_time(value: object) -> datetime | None:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return None
|
||
try:
|
||
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||
except Exception:
|
||
try:
|
||
parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
|
||
except Exception:
|
||
return None
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
return parsed.astimezone(timezone.utc)
|
||
|
||
@staticmethod
|
||
def _timestamp_to_iso(value: object) -> str:
|
||
try:
|
||
ts = int(value)
|
||
except (TypeError, ValueError):
|
||
return ""
|
||
tz = timezone(timedelta(hours=8))
|
||
return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
|
||
|
||
def _load_accounts(self) -> dict[str, dict]:
|
||
accounts = self.storage.load_accounts()
|
||
return {
|
||
normalized["access_token"]: normalized
|
||
for item in accounts
|
||
if (normalized := self._normalize_account(item)) is not None
|
||
}
|
||
|
||
def _save_accounts(self) -> None:
|
||
self.storage.save_accounts(list(self._accounts.values()))
|
||
|
||
@staticmethod
|
||
def _is_image_account_available(account: dict) -> bool:
|
||
if not isinstance(account, dict):
|
||
return False
|
||
if account.get("status") in {"禁用", "限流", "异常"}:
|
||
return False
|
||
return int(account.get("quota") or 0) > 0
|
||
|
||
@classmethod
|
||
def _is_upload_available(cls, account: dict) -> bool:
|
||
until = cls._parse_time(account.get("upload_blocked_until"))
|
||
return until is None or until <= datetime.now(timezone.utc)
|
||
|
||
def mark_upload_limited(self, access_token: str, limit: UploadLimit) -> None:
|
||
# Persist before releasing the image slot so queued edits cannot select it again.
|
||
with self._image_slot_condition:
|
||
token = self._resolve_access_token_locked(access_token)
|
||
account = self._accounts.get(token)
|
||
if account is None:
|
||
return
|
||
previous = self._parse_time(account.get("upload_blocked_until"))
|
||
if previous is None or limit.blocked_until >= previous:
|
||
account["upload_blocked_until"] = limit.blocked_until.isoformat()
|
||
account["upload_limit_reason"] = limit.reason
|
||
self._save_accounts()
|
||
self._image_slot_condition.notify_all()
|
||
|
||
def clear_expired_upload_limit(self, access_token: str) -> None:
|
||
with self._lock:
|
||
token = self._resolve_access_token_locked(access_token)
|
||
account = self._accounts.get(token)
|
||
# A simultaneous request may have discovered a newer limit; retain it.
|
||
if account and account.get("upload_blocked_until") and self._is_upload_available(account):
|
||
account.pop("upload_blocked_until", None)
|
||
account.pop("upload_limit_reason", None)
|
||
self._save_accounts()
|
||
|
||
@classmethod
|
||
def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
|
||
if not plan_type:
|
||
return True
|
||
normalized_plan = cls._normalize_account_type(plan_type)
|
||
normalized_account = cls._normalize_account_type(account.get("type"))
|
||
if not normalized_plan or not normalized_account:
|
||
return False
|
||
return normalized_plan.lower() == normalized_account.lower()
|
||
|
||
@classmethod
|
||
def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
|
||
if not source_type:
|
||
return True
|
||
return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
|
||
|
||
@classmethod
|
||
def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
|
||
if not plan_types:
|
||
return True
|
||
normalized_account = cls._normalize_account_type(account.get("type"))
|
||
normalized_plans = {
|
||
normalized
|
||
for plan_type in plan_types
|
||
if (normalized := cls._normalize_account_type(plan_type))
|
||
}
|
||
return bool(normalized_account and normalized_account in normalized_plans)
|
||
|
||
@staticmethod
|
||
def _normalize_source_type(value: object) -> str:
|
||
return str(value or "web").strip().lower() or "web"
|
||
|
||
@staticmethod
|
||
def _normalize_account_type(value: object) -> str | None:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return None
|
||
key = raw.lower().replace("-", "_").replace(" ", "_")
|
||
compact = key.replace("_", "")
|
||
aliases = {
|
||
"free": "free",
|
||
"plus": "Plus",
|
||
"pro": "Pro",
|
||
"prolite": "ProLite",
|
||
"team": "Team",
|
||
"business": "Team",
|
||
"enterprise": "Enterprise",
|
||
}
|
||
return aliases.get(compact) or aliases.get(key) or raw
|
||
|
||
def _search_account_type(self, payload: object) -> str | None:
|
||
if isinstance(payload, dict):
|
||
for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
|
||
plan = self._normalize_account_type(payload.get(key))
|
||
if plan:
|
||
return plan
|
||
for value in payload.values():
|
||
plan = self._search_account_type(value)
|
||
if plan:
|
||
return plan
|
||
elif isinstance(payload, list):
|
||
for value in payload:
|
||
plan = self._search_account_type(value)
|
||
if plan:
|
||
return plan
|
||
return None
|
||
|
||
def _normalize_account(self, item: dict) -> dict | None:
|
||
if not isinstance(item, dict):
|
||
return None
|
||
access_token = item.get("access_token") or item.get("accessToken") or ""
|
||
if not access_token:
|
||
return None
|
||
normalized = dict(item)
|
||
normalized.pop("accessToken", None)
|
||
normalized["access_token"] = access_token
|
||
if str(normalized.get("type") or "").strip().lower() == "codex":
|
||
normalized["export_type"] = "codex"
|
||
normalized.pop("type", None)
|
||
normalized["type"] = normalized.get("type") or "free"
|
||
normalized["status"] = normalized.get("status") or "正常"
|
||
# Older records have no status source. Preserve disabled accounts, but
|
||
# keep legacy quota-based rate limits eligible for automatic recovery.
|
||
manual_status = normalized.get(
|
||
"manual_status", "禁用" if normalized["status"] == "禁用" else None
|
||
)
|
||
normalized["manual_status"] = manual_status if manual_status in {"禁用", "限流"} else None
|
||
if normalized["manual_status"]:
|
||
normalized["status"] = normalized["manual_status"]
|
||
normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
|
||
normalized["email"] = normalized.get("email") or None
|
||
normalized["user_id"] = normalized.get("user_id") or None
|
||
normalized["proxy"] = str(normalized.get("proxy") or "").strip()
|
||
source_type = normalized.get("source_type")
|
||
if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
|
||
source_type = "codex"
|
||
normalized["source_type"] = self._normalize_source_type(source_type)
|
||
limits_progress = normalized.get("limits_progress")
|
||
normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
|
||
normalized["default_model_slug"] = normalized.get("default_model_slug") or None
|
||
normalized["restore_at"] = normalized.get("restore_at") or None
|
||
normalized["success"] = int(normalized.get("success") or 0)
|
||
normalized["fail"] = int(normalized.get("fail") or 0)
|
||
normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
|
||
normalized["last_used_at"] = normalized.get("last_used_at")
|
||
normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
|
||
normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
|
||
normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
|
||
normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
|
||
normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
|
||
normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
|
||
normalized["created_at"] = normalized.get("created_at") or AccountService._now()
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _jwt_exp(access_token: str) -> int:
|
||
try:
|
||
return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
@classmethod
|
||
def _token_expires_in(cls, access_token: str) -> int | None:
|
||
exp = cls._jwt_exp(access_token)
|
||
if exp <= 0:
|
||
return None
|
||
return exp - int(time.time())
|
||
|
||
@classmethod
|
||
def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
|
||
if force:
|
||
return True
|
||
remaining = cls._token_expires_in(access_token)
|
||
return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
||
|
||
@classmethod
|
||
def _token_issued_at(cls, access_token: str) -> datetime | None:
|
||
try:
|
||
iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if iat <= 0:
|
||
return None
|
||
return datetime.fromtimestamp(iat, tz=timezone.utc)
|
||
|
||
@staticmethod
|
||
def _safe_response_text(response: object, limit: int = 300) -> str:
|
||
try:
|
||
return str(getattr(response, "text", "") or "")[:limit]
|
||
except Exception:
|
||
return ""
|
||
|
||
def _resolve_access_token_locked(self, access_token: str) -> str:
|
||
token = str(access_token or "").strip()
|
||
seen: set[str] = set()
|
||
while token and token not in self._accounts and token in self._token_aliases and token not in seen:
|
||
seen.add(token)
|
||
token = self._token_aliases.get(token, token)
|
||
return token
|
||
|
||
def resolve_access_token(self, access_token: str) -> str:
|
||
if not access_token:
|
||
return ""
|
||
with self._lock:
|
||
return self._resolve_access_token_locked(access_token)
|
||
|
||
def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
|
||
with self._lock:
|
||
resolved = self._resolve_access_token_locked(access_token)
|
||
account = self._accounts.get(resolved)
|
||
return resolved, dict(account) if account else None
|
||
|
||
def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
with self._lock:
|
||
resolved = self._resolve_access_token_locked(access_token)
|
||
current = self._accounts.get(resolved)
|
||
if current is None:
|
||
return
|
||
next_item = dict(current)
|
||
next_item["last_token_refresh_error"] = str(error or "refresh token failed")
|
||
next_item["last_token_refresh_error_at"] = now
|
||
account = self._normalize_account(next_item)
|
||
if account is not None:
|
||
self._accounts[resolved] = account
|
||
self._save_accounts()
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"refresh_token 刷新 access_token 失败",
|
||
{"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
|
||
)
|
||
|
||
def _recent_token_refresh_error(self, account: dict) -> bool:
|
||
last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
|
||
if last_error_at is None:
|
||
return False
|
||
return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
|
||
|
||
def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
|
||
last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
|
||
if last_error_at is None:
|
||
return False
|
||
return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
|
||
|
||
def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
|
||
return (
|
||
self._parse_time(account.get("last_token_refresh_at"))
|
||
or self._token_issued_at(str(account.get("access_token") or ""))
|
||
or self._parse_time(account.get("created_at"))
|
||
)
|
||
|
||
def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
|
||
if not str(account.get("refresh_token") or "").strip():
|
||
return None
|
||
if account.get("status") == "禁用":
|
||
return None
|
||
if self._recent_refresh_token_keepalive_error(account, now):
|
||
return None
|
||
anchor = self._refresh_token_keepalive_anchor(account)
|
||
if anchor is None:
|
||
return now
|
||
due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
|
||
return due_at if due_at <= now else None
|
||
|
||
def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]:
|
||
from curl_cffi import requests
|
||
from services.proxy_service import proxy_settings
|
||
|
||
session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome110", verify=True))
|
||
try:
|
||
response = session.post(
|
||
self._OAUTH_TOKEN_URL,
|
||
headers={
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
"User-Agent": self._OAUTH_USER_AGENT,
|
||
},
|
||
data={
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": refresh_token,
|
||
"client_id": self._OAUTH_CLIENT_ID,
|
||
},
|
||
timeout=60,
|
||
)
|
||
data = response.json() if response.text else {}
|
||
if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
|
||
detail = ""
|
||
if isinstance(data, dict):
|
||
detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
|
||
detail = detail or self._safe_response_text(response)
|
||
raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
|
||
return {
|
||
"access_token": str(data.get("access_token") or "").strip(),
|
||
"refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
|
||
"id_token": str(data.get("id_token") or "").strip(),
|
||
}
|
||
finally:
|
||
session.close()
|
||
|
||
def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
|
||
now = datetime.now(timezone.utc).isoformat()
|
||
with self._image_slot_condition:
|
||
old_token = self._resolve_access_token_locked(old_access_token)
|
||
current = self._accounts.get(old_token)
|
||
if current is None:
|
||
return old_token
|
||
new_token = str(token_data.get("access_token") or old_token).strip()
|
||
if not new_token:
|
||
return old_token
|
||
|
||
next_item = dict(current)
|
||
next_item["access_token"] = new_token
|
||
if token_data.get("refresh_token"):
|
||
next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
|
||
if token_data.get("id_token"):
|
||
next_item["id_token"] = str(token_data.get("id_token") or "").strip()
|
||
next_item["last_token_refresh_at"] = now
|
||
next_item["last_token_refresh_error"] = None
|
||
next_item["last_token_refresh_error_at"] = None
|
||
next_item["invalid_count"] = 0
|
||
next_item["last_invalid_at"] = None
|
||
next_item["last_refresh_error"] = None
|
||
next_item["last_refresh_error_at"] = None
|
||
|
||
account = self._normalize_account(next_item)
|
||
if account is None:
|
||
return old_token
|
||
|
||
rotated = new_token != old_token
|
||
if rotated:
|
||
self._accounts.pop(old_token, None)
|
||
self._token_aliases[old_token] = new_token
|
||
old_inflight = int(self._image_inflight.pop(old_token, 0))
|
||
if old_inflight:
|
||
self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
|
||
self._accounts[new_token] = account
|
||
self._save_accounts()
|
||
self._image_slot_condition.notify_all()
|
||
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"refresh_token 已刷新 access_token",
|
||
{"source": event, "token": anonymize_token(new_token), "rotated": rotated},
|
||
)
|
||
return new_token
|
||
|
||
def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
|
||
if not access_token:
|
||
return ""
|
||
with self._token_refresh_lock:
|
||
resolved_token, account = self._get_account_for_token(access_token)
|
||
if not account:
|
||
return access_token
|
||
active_token = str(account.get("access_token") or resolved_token or access_token)
|
||
if not self._token_needs_refresh(active_token, force=force):
|
||
return active_token
|
||
refresh_token = str(account.get("refresh_token") or "").strip()
|
||
if not refresh_token:
|
||
return active_token
|
||
if not force and self._recent_token_refresh_error(account):
|
||
return active_token
|
||
try:
|
||
token_data = self._request_access_token_refresh(refresh_token, account)
|
||
except Exception as exc:
|
||
error_str = str(exc or "")
|
||
self._record_token_refresh_error(active_token, event, error_str)
|
||
# 如果是 app_session_terminated 错误,尝试密码重新登录
|
||
if "app_session_terminated" in error_str.lower():
|
||
# 获取账号信息(email, password)
|
||
email = str(account.get("email") or "").strip()
|
||
password = str(account.get("password") or "").strip()
|
||
if email and password:
|
||
# 创建新线程执行密码重新登录
|
||
t = Thread(
|
||
target=self._password_re_login_thread,
|
||
args=(active_token, email, password, event),
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
return active_token
|
||
return self._apply_refreshed_tokens(active_token, token_data, event)
|
||
|
||
def _password_re_login_thread(self, access_token: str, email: str, password: str, event: str, progress_id: str | None = None) -> None:
|
||
"""密码重新登录线程入口"""
|
||
try:
|
||
result = self._login_with_password(email, password)
|
||
if result.get("ok"):
|
||
# 登录成功,更新账号
|
||
new_access_token = result.get("access_token", "")
|
||
new_refresh_token = result.get("refresh_token", "")
|
||
new_id_token = result.get("id_token", "")
|
||
new_expires_at = result.get("expires_at")
|
||
|
||
# 构建 token_data 供 _apply_refreshed_tokens 使用
|
||
token_data = {
|
||
"access_token": new_access_token,
|
||
"refresh_token": new_refresh_token,
|
||
"id_token": new_id_token,
|
||
}
|
||
|
||
# 使用 _apply_refreshed_tokens 更新账号(处理 token 别名)
|
||
new_token = self._apply_refreshed_tokens(access_token, token_data, f"{event}:password_relogin")
|
||
|
||
# 额外更新 source_type 和 status(静默,避免重复日志)
|
||
self.update_account(new_token, {
|
||
"source_type": result.get("source_type", "password"),
|
||
"status": "正常",
|
||
}, quiet=True)
|
||
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"更新账号",
|
||
{
|
||
"source": event,
|
||
"old_token": anonymize_token(access_token),
|
||
"new_token": anonymize_token(new_access_token),
|
||
"email": email,
|
||
"status": "成功",
|
||
},
|
||
)
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, access_token, "成功")
|
||
else:
|
||
# 登录失败
|
||
error_type = result.get("error", "")
|
||
if error_type == "password_verify_failed_403" and isinstance(result.get("detail"), dict):
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"更新账号",
|
||
{
|
||
"source": event,
|
||
"token": anonymize_token(access_token),
|
||
"email": email,
|
||
"status": "失败",
|
||
"error": error_type,
|
||
"detail": result.get("detail", {}),
|
||
},
|
||
)
|
||
detail_error = result["detail"].get("error", {})
|
||
if isinstance(detail_error, dict) and detail_error.get("code") == "account_deactivated":
|
||
# 账号已删除/停用 → 标记为禁用
|
||
self.update_account(access_token, {"status": "禁用", "quota": 0}, quiet=True)
|
||
account = self.get_account(access_token) or {}
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"账号已停用-标记禁用",
|
||
{
|
||
"source": event,
|
||
"token": anonymize_token(access_token),
|
||
"email": email,
|
||
"detail": result.get("detail", {}),
|
||
},
|
||
)
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, access_token, "禁用")
|
||
else:
|
||
# 永久故障:将账号标记为异常(或自动移除)
|
||
self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, access_token, "异常", error_type)
|
||
else:
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"更新账号",
|
||
{
|
||
"source": event,
|
||
"token": anonymize_token(access_token),
|
||
"email": email,
|
||
"status": "失败",
|
||
"error": error_type,
|
||
"detail": result.get("detail", {}),
|
||
},
|
||
)
|
||
# 永久故障:将账号标记为异常(或自动移除)
|
||
self.remove_invalid_token(access_token, f"{event}:password_relogin_failed", quiet=True)
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, access_token, "异常", error_type)
|
||
except Exception as exc:
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"更新账号",
|
||
{
|
||
"source": event,
|
||
"token": anonymize_token(access_token),
|
||
"email": email,
|
||
"status": "异常",
|
||
"error": str(exc),
|
||
},
|
||
)
|
||
# 将账号标记为异常(或自动移除)
|
||
self.remove_invalid_token(access_token, f"{event}:password_relogin_exception", quiet=True)
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, access_token, "异常", str(exc))
|
||
|
||
def _login_with_password(self, email: str, password: str) -> dict:
|
||
"""通过邮箱+密码登录,返回 {access_token, refresh_token, id_token, ...}"""
|
||
from curl_cffi import requests
|
||
|
||
# 常量
|
||
auth_base = "https://auth.openai.com"
|
||
platform_oauth_audience = "https://api.openai.com/v1"
|
||
platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
|
||
platform_oauth_client_id = self._OAUTH_CLIENT_ID
|
||
platform_oauth_redirect_uri = "https://platform.openai.com/auth/callback"
|
||
user_agent = self._OAUTH_USER_AGENT
|
||
|
||
# 创建 session
|
||
session_kwargs = {"impersonate": "chrome110", "verify": False}
|
||
proxy = config.get_proxy_settings()
|
||
if proxy:
|
||
session_kwargs["proxy"] = proxy
|
||
session = requests.Session(**session_kwargs)
|
||
|
||
try:
|
||
device_id = str(uuid.uuid4())
|
||
|
||
# ─── 方式2: OAuth authorize 流程 ──────────────────────────
|
||
# 使用 Platform Client + PKCE
|
||
|
||
from utils.pkce import generate_pkce
|
||
code_verifier, code_challenge = generate_pkce()
|
||
|
||
# ② 发起 OAuth authorize 请求 (使用 Platform Client + PKCE)
|
||
session.cookies.set("oai-did", device_id, domain=".auth.openai.com")
|
||
session.cookies.set("oai-did", device_id, domain="auth.openai.com")
|
||
params = {
|
||
"issuer": auth_base,
|
||
"client_id": platform_oauth_client_id,
|
||
"audience": platform_oauth_audience,
|
||
"redirect_uri": platform_oauth_redirect_uri,
|
||
"device_id": device_id,
|
||
"screen_hint": "login_or_signup",
|
||
"max_age": "0",
|
||
"login_hint": email,
|
||
"scope": "openid profile email offline_access",
|
||
"response_type": "code",
|
||
"response_mode": "query",
|
||
"state": secrets.token_urlsafe(32),
|
||
"nonce": secrets.token_urlsafe(32),
|
||
"code_challenge": code_challenge,
|
||
"code_challenge_method": "S256",
|
||
"auth0Client": platform_auth0_client,
|
||
}
|
||
authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
|
||
resp = session.get(
|
||
authorize_url,
|
||
headers={
|
||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"user-agent": user_agent,
|
||
"sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": '"Windows"',
|
||
"sec-fetch-dest": "document",
|
||
"sec-fetch-mode": "navigate",
|
||
"sec-fetch-site": "cross-site",
|
||
"sec-fetch-user": "?1",
|
||
"upgrade-insecure-requests": "1",
|
||
"referer": "https://platform.openai.com/",
|
||
},
|
||
allow_redirects=True,
|
||
timeout=30,
|
||
)
|
||
|
||
if resp.status_code not in (200, 302):
|
||
return {"ok": False, "error": f"authorize_failed_{resp.status_code}", "detail": {"url": resp.url, "text": resp.text[:500]}}
|
||
|
||
# 检测最终 URL 是否指向错误页面
|
||
final_url = str(resp.url)
|
||
if "/error" in final_url and "payload=" in final_url:
|
||
from urllib.parse import parse_qs, urlparse
|
||
try:
|
||
parsed_query = parse_qs(urlparse(final_url).query)
|
||
error_payload_b64 = parsed_query.get("payload", [""])[0]
|
||
error_payload_b64 += "=" * ((4 - len(error_payload_b64) % 4) % 4)
|
||
error_payload = json.loads(base64.b64decode(error_payload_b64))
|
||
error_code = error_payload.get("errorCode", "")
|
||
if error_code == "rate_limit_exceeded":
|
||
return {"ok": False, "error": "rate_limit_exceeded", "detail": error_payload}
|
||
else:
|
||
return {"ok": False, "error": f"authorize_error_{error_code}", "detail": error_payload}
|
||
except Exception as e:
|
||
return {"ok": False, "error": "authorize_redirect_error", "detail": {"url": final_url, "parse_error": str(e)}}
|
||
|
||
# ③ 提交密码验证
|
||
login_headers = {
|
||
"accept": "application/json",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"content-type": "application/json",
|
||
"origin": auth_base,
|
||
"priority": "u=1, i",
|
||
"user-agent": user_agent,
|
||
"sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": '"Windows"',
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-origin",
|
||
"referer": f"{auth_base}/email-verification",
|
||
"oai-device-id": device_id,
|
||
}
|
||
|
||
# 添加 sentinel token
|
||
try:
|
||
from utils.sentinel import build_sentinel_token
|
||
sentinel_val, oai_sc_val = build_sentinel_token(session, device_id, "password_verify")
|
||
login_headers["openai-sentinel-token"] = sentinel_val
|
||
if oai_sc_val:
|
||
session.cookies.set("oai-sc", oai_sc_val, domain=".openai.com")
|
||
except Exception:
|
||
pass
|
||
|
||
login_resp = session.post(
|
||
f"{auth_base}/api/accounts/password/verify",
|
||
headers=login_headers,
|
||
json={"password": password},
|
||
timeout=30,
|
||
)
|
||
|
||
login_data = {}
|
||
try:
|
||
login_data = login_resp.json() if login_resp.text else {}
|
||
except Exception:
|
||
pass
|
||
|
||
if login_resp.status_code != 200:
|
||
error_code = login_data.get("error", {}).get("code", "")
|
||
error_msg = login_data.get("error", {}).get("message", "")
|
||
if error_code == "unsupported_country_region_territory":
|
||
return {"ok": False, "error": "unsupported_country_region_territory", "detail": login_data}
|
||
elif error_code == "invalid_state":
|
||
return {"ok": False, "error": "invalid_state", "detail": login_data}
|
||
elif "Invalid credentials" in error_msg or "wrong password" in error_msg.lower():
|
||
return {"ok": False, "error": "invalid_password", "detail": login_data}
|
||
return {"ok": False, "error": f"password_verify_failed_{login_resp.status_code}", "detail": login_data}
|
||
|
||
# 获取 authorization code
|
||
continue_url = str(login_data.get("continue_url") or "").strip()
|
||
auth_code = ""
|
||
if continue_url:
|
||
from urllib.parse import parse_qs, urlparse
|
||
parsed_params = parse_qs(urlparse(continue_url).query)
|
||
auth_code = str((parsed_params.get("code") or [""])[0]).strip()
|
||
|
||
# ─── 处理邮箱 OTP 验证 ──────────────────────────
|
||
if not auth_code:
|
||
page_type = ""
|
||
page_info = login_data.get("page")
|
||
if isinstance(page_info, dict):
|
||
page_type = str(page_info.get("type") or "")
|
||
|
||
if page_type == "email_otp_verification":
|
||
# 需要验证码才能登录,直接标记为账号异常
|
||
return {"ok": False, "error": "need_verification_code", "detail": login_data}
|
||
else:
|
||
return {"ok": False, "error": "no_auth_code", "detail": login_data}
|
||
|
||
# ④ 用 code 换 token (使用 Platform Client + code_verifier)
|
||
platform_base = "https://platform.openai.com"
|
||
token_resp = session.post(
|
||
f"{auth_base}/api/accounts/oauth/token",
|
||
headers={
|
||
"accept": "*/*",
|
||
"accept-language": "zh-CN,zh;q=0.9",
|
||
"auth0-client": platform_auth0_client,
|
||
"cache-control": "no-cache",
|
||
"content-type": "application/json",
|
||
"origin": platform_base,
|
||
"pragma": "no-cache",
|
||
"priority": "u=1, i",
|
||
"referer": f"{platform_base}/",
|
||
"sec-ch-ua": '"Chromium";v="145", "Google Chrome";v="145", "Not/A)Brand";v="99"',
|
||
"sec-ch-ua-mobile": "?0",
|
||
"sec-ch-ua-platform": '"Windows"',
|
||
"sec-fetch-dest": "empty",
|
||
"sec-fetch-mode": "cors",
|
||
"sec-fetch-site": "same-site",
|
||
"user-agent": user_agent,
|
||
},
|
||
json={
|
||
"client_id": platform_oauth_client_id,
|
||
"code_verifier": code_verifier,
|
||
"grant_type": "authorization_code",
|
||
"code": auth_code,
|
||
"redirect_uri": platform_oauth_redirect_uri,
|
||
},
|
||
verify=False,
|
||
timeout=60,
|
||
)
|
||
|
||
token_data = {}
|
||
try:
|
||
token_data = token_resp.json() if token_resp.text else {}
|
||
except Exception:
|
||
pass
|
||
|
||
if token_resp.status_code != 200 or not token_data.get("access_token"):
|
||
return {"ok": False, "error": "token_exchange_failed", "detail": token_data}
|
||
|
||
access_token = str(token_data.get("access_token") or "").strip()
|
||
refresh_token = str(token_data.get("refresh_token") or "").strip()
|
||
id_token = str(token_data.get("id_token") or "").strip()
|
||
|
||
# ⑤ 用 access_token 获取用户信息
|
||
user_info = {}
|
||
try:
|
||
me_resp = session.get(
|
||
"https://chatgpt.com/backend-api/me",
|
||
headers={
|
||
"accept": "application/json",
|
||
"authorization": f"Bearer {access_token}",
|
||
"user-agent": user_agent,
|
||
},
|
||
timeout=30,
|
||
)
|
||
if me_resp.status_code == 200:
|
||
user_info = me_resp.json() if me_resp.text else {}
|
||
except Exception:
|
||
pass
|
||
|
||
# 解析 JWT payload
|
||
jwt_payload = self._decode_jwt_payload(access_token)
|
||
|
||
email_from_jwt = str(jwt_payload.get("https://api.openai.com/profile", {}).get("email") or "").strip()
|
||
account_id_from_jwt = str(
|
||
jwt_payload.get("https://api.openai.com/auth", {}).get("chatgpt_account_id") or ""
|
||
).strip()
|
||
|
||
account_info = user_info.get("account") if isinstance(user_info.get("account"), dict) else {}
|
||
result = {
|
||
"ok": True,
|
||
"email": email_from_jwt or email,
|
||
"account_id": account_id_from_jwt or account_info.get("account_id", ""),
|
||
"access_token": access_token,
|
||
"refresh_token": refresh_token,
|
||
"id_token": id_token,
|
||
"expires_at": jwt_payload.get("exp"),
|
||
"source_type": "password",
|
||
}
|
||
|
||
return result
|
||
|
||
finally:
|
||
session.close()
|
||
|
||
def list_expiring_access_tokens(self) -> list[str]:
|
||
with self._lock:
|
||
return [
|
||
token
|
||
for account in self._accounts.values()
|
||
if str(account.get("refresh_token") or "").strip()
|
||
and (token := str(account.get("access_token") or "").strip())
|
||
and self._token_needs_refresh(token)
|
||
]
|
||
|
||
def list_refresh_token_keepalive_tokens(self) -> list[str]:
|
||
now = datetime.now(timezone.utc)
|
||
due_items: list[tuple[datetime, str]] = []
|
||
with self._lock:
|
||
for account in self._accounts.values():
|
||
due_at = self._refresh_token_keepalive_due_at(account, now)
|
||
token = str(account.get("access_token") or "").strip()
|
||
if due_at is not None and token:
|
||
due_items.append((due_at, token))
|
||
due_items.sort(key=lambda item: item[0])
|
||
return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
|
||
|
||
def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
|
||
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
||
if not access_tokens:
|
||
return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
|
||
|
||
refreshed = 0
|
||
errors = []
|
||
for access_token in access_tokens:
|
||
before = self.resolve_access_token(access_token)
|
||
after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
|
||
account = self.get_account(after)
|
||
if account and str(account.get("last_token_refresh_error") or "").strip():
|
||
errors.append({
|
||
"token": anonymize_token(before),
|
||
"error": str(account.get("last_token_refresh_error") or "refresh token failed"),
|
||
})
|
||
continue
|
||
if account:
|
||
refreshed += 1
|
||
|
||
return {
|
||
"refreshed": refreshed,
|
||
"errors": errors,
|
||
"items": self.list_accounts(),
|
||
"relogined": 0,
|
||
}
|
||
|
||
def list_tokens(self) -> list[str]:
|
||
with self._lock:
|
||
return list(self._accounts)
|
||
|
||
def _list_ready_candidate_tokens(
|
||
self,
|
||
excluded_tokens: set[str] | None = None,
|
||
plan_type: str | None = None,
|
||
source_type: str | None = None,
|
||
plan_types: set[str] | tuple[str, ...] | None = None,
|
||
requires_upload: bool = False,
|
||
) -> list[str]:
|
||
excluded = {self._resolve_access_token_locked(token) for token in (excluded_tokens or set())}
|
||
return [
|
||
token
|
||
for item in self._accounts.values()
|
||
if self._is_image_account_available(item)
|
||
and (not requires_upload or self._is_upload_available(item))
|
||
and self._account_matches_plan_type(item, plan_type)
|
||
and self._account_matches_any_plan_type(item, plan_types)
|
||
and self._account_matches_source_type(item, source_type)
|
||
and (token := item.get("access_token") or "")
|
||
and token not in excluded
|
||
]
|
||
|
||
def _list_available_candidate_tokens(
|
||
self,
|
||
excluded_tokens: set[str] | None = None,
|
||
plan_type: str | None = None,
|
||
source_type: str | None = None,
|
||
plan_types: set[str] | tuple[str, ...] | None = None,
|
||
requires_upload: bool = False,
|
||
) -> list[str]:
|
||
max_concurrency = max(1, int(config.image_account_concurrency or 1))
|
||
return [
|
||
token
|
||
for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types, requires_upload)
|
||
if int(self._image_inflight.get(token, 0)) < max_concurrency
|
||
]
|
||
|
||
def _acquire_next_candidate_token(
|
||
self,
|
||
excluded_tokens: set[str] | None = None,
|
||
plan_type: str | None = None,
|
||
source_type: str | None = None,
|
||
plan_types: set[str] | tuple[str, ...] | None = None,
|
||
requires_upload: bool = False,
|
||
deadline: float | None = None,
|
||
) -> str:
|
||
with self._image_slot_condition:
|
||
while True:
|
||
if deadline is not None and time.monotonic() >= deadline:
|
||
raise RuntimeError("等待可用图片账号超时,请稍后重试。")
|
||
if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types, requires_upload):
|
||
if requires_upload:
|
||
blocked = [
|
||
self._parse_time(item.get("upload_blocked_until"))
|
||
for item in self._accounts.values()
|
||
if self._is_image_account_available(item)
|
||
and self._account_matches_plan_type(item, plan_type)
|
||
and self._account_matches_any_plan_type(item, plan_types)
|
||
and self._account_matches_source_type(item, source_type)
|
||
and not self._is_upload_available(item)
|
||
]
|
||
if blocked:
|
||
raise UploadUnavailableError(min(blocked))
|
||
raise RuntimeError(
|
||
f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
|
||
if plan_type or source_type else "no available image quota"
|
||
)
|
||
tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types, requires_upload)
|
||
if tokens:
|
||
access_token = tokens[self._index % len(tokens)]
|
||
self._index += 1
|
||
self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
|
||
return access_token
|
||
self._image_slot_condition.wait(timeout=1.0)
|
||
|
||
def release_image_slot(self, access_token: str) -> None:
|
||
if not access_token:
|
||
return
|
||
with self._image_slot_condition:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
current_inflight = int(self._image_inflight.get(access_token, 0))
|
||
if current_inflight <= 1:
|
||
self._image_inflight.pop(access_token, None)
|
||
else:
|
||
self._image_inflight[access_token] = current_inflight - 1
|
||
self._image_slot_condition.notify_all()
|
||
|
||
def get_available_access_token(
|
||
self,
|
||
plan_type: str | None = None,
|
||
source_type: str | None = None,
|
||
plan_types: set[str] | tuple[str, ...] | None = None,
|
||
requires_upload: bool = False,
|
||
excluded_tokens: set[str] | None = None,
|
||
deadline: float | None = None,
|
||
) -> str:
|
||
"""从候选池中获取一个可用的图片生图 token。
|
||
|
||
基于本地状态初筛,每次取号都通过 fetch_remote_info 实时验证账号及额度。
|
||
限制最大尝试次数防止 token rotation 导致无限循环。
|
||
"""
|
||
max_attempts = 20 # 防止无限循环
|
||
attempted_tokens: set[str] = set(excluded_tokens or set())
|
||
for _attempt in range(max_attempts):
|
||
access_token = self._acquire_next_candidate_token(
|
||
excluded_tokens=attempted_tokens,
|
||
plan_type=plan_type,
|
||
source_type=source_type,
|
||
plan_types=plan_types,
|
||
requires_upload=requires_upload,
|
||
deadline=deadline,
|
||
)
|
||
attempted_tokens.add(access_token)
|
||
try:
|
||
account = self.fetch_remote_info(access_token, "get_available_access_token")
|
||
except Exception:
|
||
self.release_image_slot(access_token)
|
||
continue
|
||
# fetch_remote_info 内部可能因 token rotation 导致 access_token 变化,
|
||
# 把新 token 也加入排除列表,防止重复尝试
|
||
resolved = str((account or {}).get("access_token") or "")
|
||
if resolved and resolved != access_token:
|
||
attempted_tokens.add(resolved)
|
||
if requires_upload and account:
|
||
# Refresh responses are snapshots; another in-flight upload may have
|
||
# set a cooldown while this account was being remotely validated.
|
||
account = self.get_account(resolved or access_token)
|
||
if (
|
||
self._is_image_account_available(account or {})
|
||
and (not requires_upload or self._is_upload_available(account or {}))
|
||
and self._account_matches_plan_type(account or {}, plan_type)
|
||
and self._account_matches_any_plan_type(account or {}, plan_types)
|
||
and self._account_matches_source_type(account or {}, source_type)
|
||
):
|
||
return str((account or {}).get("access_token") or access_token)
|
||
self.release_image_slot(access_token)
|
||
raise RuntimeError(
|
||
f"no available {plan_type or source_type or ''} image quota (tried {len(attempted_tokens)} tokens)".replace(" ", " ").strip()
|
||
if plan_type or source_type else f"no available image quota (tried {len(attempted_tokens)} tokens)"
|
||
)
|
||
|
||
def get_text_access_token(
|
||
self,
|
||
excluded_tokens: set[str] | None = None,
|
||
model: str = "auto",
|
||
) -> str:
|
||
excluded = set(excluded_tokens or set())
|
||
requested_model = str(model or "auto").strip() or "auto"
|
||
route = None
|
||
if requested_model != "auto":
|
||
from services.model_service import model_catalog_service
|
||
|
||
route = model_catalog_service.route_for_model(requested_model)
|
||
with self._lock:
|
||
candidates = [
|
||
token
|
||
for account in self._accounts.values()
|
||
if account.get("status") not in {"禁用", "异常"}
|
||
and (
|
||
route is None
|
||
or self._normalize_account_type(account.get("type")) in route.account_types
|
||
)
|
||
and (token := account.get("access_token") or "")
|
||
and token not in excluded
|
||
]
|
||
if not candidates:
|
||
if route is None or route.allow_anonymous:
|
||
return ""
|
||
from services.model_service import ModelUnavailableError
|
||
|
||
raise ModelUnavailableError(
|
||
f"model {requested_model!r} is not available to any active account"
|
||
)
|
||
access_token = candidates[self._index % len(candidates)]
|
||
self._index += 1
|
||
return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
|
||
|
||
def mark_text_used(self, access_token: str) -> None:
|
||
if not access_token:
|
||
return
|
||
with self._lock:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
current = self._accounts.get(access_token)
|
||
if current is None:
|
||
return
|
||
next_item = dict(current)
|
||
next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
account = self._normalize_account(next_item)
|
||
if account is None:
|
||
return
|
||
self._accounts[access_token] = account
|
||
self._save_accounts()
|
||
|
||
def remove_invalid_token(self, access_token: str, event: str, quiet: bool = False) -> bool:
|
||
with self._lock:
|
||
current = self._accounts.get(self._resolve_access_token_locked(access_token))
|
||
if current and current.get("manual_status"):
|
||
return False
|
||
if not config.auto_remove_invalid_accounts:
|
||
self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
|
||
return False
|
||
removed = bool(self.delete_accounts([access_token], preserve_manual=True)["removed"])
|
||
if removed:
|
||
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
|
||
{"source": event, "token": anonymize_token(access_token)})
|
||
elif access_token:
|
||
self.update_account(access_token, {"status": "异常", "quota": 0}, quiet=quiet)
|
||
return removed
|
||
|
||
def get_account(self, access_token: str) -> dict | None:
|
||
if not access_token:
|
||
return None
|
||
with self._lock:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
account = self._accounts.get(access_token)
|
||
return dict(account) if account else None
|
||
|
||
def list_accounts(self) -> list[dict]:
|
||
"""返回所有账号的副本,并为每个账号附加当前图片在途数 image_inflight。
|
||
|
||
image_inflight 为内存态并发计数(账号正在生成、尚未结束的图片数)。号池空闲时
|
||
若某账号该值持续 > 0,说明其并发槽位泄漏、已被静默排除出调度,可借此在 UI 上诊断。
|
||
"""
|
||
with self._lock:
|
||
result = []
|
||
for item in self._accounts.values():
|
||
account = dict(item)
|
||
token = account.get("access_token") or ""
|
||
account["image_inflight"] = int(self._image_inflight.get(token, 0))
|
||
result.append(account)
|
||
return result
|
||
|
||
def list_limited_tokens(self) -> list[str]:
|
||
with self._lock:
|
||
return [
|
||
token
|
||
for item in self._accounts.values()
|
||
if item.get("status") == "限流"
|
||
and not item.get("manual_status")
|
||
and (token := item.get("access_token") or "")
|
||
]
|
||
|
||
def list_normal_tokens(self) -> list[str]:
|
||
with self._lock:
|
||
return [
|
||
token
|
||
for item in self._accounts.values()
|
||
if item.get("status") == "正常"
|
||
and (token := item.get("access_token") or "")
|
||
]
|
||
|
||
@staticmethod
|
||
def _account_payload_token(item: dict) -> str:
|
||
return str(item.get("access_token") or item.get("accessToken") or "").strip()
|
||
|
||
@staticmethod
|
||
def _prepare_account_payload(item: dict) -> dict | None:
|
||
if not isinstance(item, dict):
|
||
return None
|
||
access_token = AccountService._account_payload_token(item)
|
||
if not access_token:
|
||
return None
|
||
payload = dict(item)
|
||
payload.pop("accessToken", None)
|
||
payload["access_token"] = access_token
|
||
# CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
|
||
if str(payload.get("type") or "").strip().lower() == "codex":
|
||
payload["export_type"] = "codex"
|
||
payload["source_type"] = "codex"
|
||
payload.pop("type", None)
|
||
if str(payload.get("export_type") or "").strip().lower() == "codex":
|
||
payload["source_type"] = "codex"
|
||
if payload.get("plan_type") and not payload.get("type"):
|
||
payload["type"] = str(payload.get("plan_type") or "").strip()
|
||
return payload
|
||
|
||
def add_account_items(self, items: list[dict]) -> dict:
|
||
payloads = [
|
||
payload
|
||
for item in items
|
||
if (payload := self._prepare_account_payload(item)) is not None
|
||
]
|
||
return self._add_account_payloads(payloads)
|
||
|
||
def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
|
||
tokens = list(dict.fromkeys(token for token in tokens if token))
|
||
if not tokens:
|
||
return {"added": 0, "skipped": 0, "items": self.list_accounts()}
|
||
return self._add_account_payloads([
|
||
{"access_token": token, "source_type": self._normalize_source_type(source_type)}
|
||
for token in tokens
|
||
])
|
||
|
||
def _add_account_payloads(self, payloads: list[dict]) -> dict:
|
||
deduped: dict[str, dict] = {}
|
||
for payload in payloads:
|
||
if not isinstance(payload, dict):
|
||
continue
|
||
access_token = self._account_payload_token(payload)
|
||
if not access_token:
|
||
continue
|
||
current = deduped.get(access_token, {})
|
||
deduped[access_token] = {**current, **payload, "access_token": access_token}
|
||
|
||
if not deduped:
|
||
return {"added": 0, "skipped": 0, "items": self.list_accounts()}
|
||
|
||
with self._lock:
|
||
added = 0
|
||
skipped = 0
|
||
for access_token, payload in deduped.items():
|
||
current = self._accounts.get(access_token)
|
||
if current is None:
|
||
added += 1
|
||
self._cumulative_total += 1
|
||
self._save_cumulative_total()
|
||
current = {"created_at": self._now()}
|
||
else:
|
||
skipped += 1
|
||
incoming = dict(payload)
|
||
if not incoming.get("created_at"):
|
||
incoming.pop("created_at", None)
|
||
account = self._normalize_account(
|
||
{
|
||
**current,
|
||
**incoming,
|
||
"access_token": access_token,
|
||
"type": str(incoming.get("type") or current.get("type") or "free"),
|
||
}
|
||
)
|
||
if account is not None:
|
||
self._accounts[access_token] = account
|
||
self._save_accounts()
|
||
items = [dict(item) for item in self._accounts.values()]
|
||
log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
|
||
{"added": added, "skipped": skipped})
|
||
return {"added": added, "skipped": skipped, "items": items}
|
||
|
||
def delete_accounts(self, tokens: list[str], *, preserve_manual: bool = False) -> dict:
|
||
target_set = set(token for token in tokens if token)
|
||
if not target_set:
|
||
return {"removed": 0, "items": self.list_accounts()}
|
||
with self._lock:
|
||
target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
|
||
if preserve_manual:
|
||
target_set = {
|
||
token for token in target_set
|
||
if not self._accounts.get(token, {}).get("manual_status")
|
||
}
|
||
removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
|
||
for token in target_set:
|
||
self._image_inflight.pop(token, None)
|
||
self._token_aliases = {
|
||
old: new
|
||
for old, new in self._token_aliases.items()
|
||
if old not in target_set and new not in target_set
|
||
}
|
||
if removed:
|
||
if self._accounts:
|
||
self._index %= len(self._accounts)
|
||
else:
|
||
self._index = 0
|
||
self._save_accounts()
|
||
log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
|
||
items = [dict(item) for item in self._accounts.values()]
|
||
return {"removed": removed, "items": items}
|
||
|
||
def update_account(
|
||
self, access_token: str, updates: dict, quiet: bool = False, *, manual: bool = False
|
||
) -> dict | None:
|
||
"""Merge account information; only an explicit admin status edit clears a manual override."""
|
||
if not access_token:
|
||
return None
|
||
with self._lock:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
current = self._accounts.get(access_token)
|
||
if current is None:
|
||
return None
|
||
next_item = {**current, **updates, "access_token": access_token}
|
||
if manual and "status" in updates:
|
||
status = updates["status"]
|
||
next_item["manual_status"] = status if status in {"禁用", "限流"} else None
|
||
account = self._normalize_account(next_item)
|
||
if account is None:
|
||
return None
|
||
if (
|
||
account.get("status") == "限流"
|
||
and not account.get("manual_status")
|
||
and config.auto_remove_rate_limited_accounts
|
||
):
|
||
self._accounts.pop(access_token, None)
|
||
self._save_accounts()
|
||
log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
|
||
return None
|
||
self._accounts[access_token] = account
|
||
self._save_accounts()
|
||
if not quiet:
|
||
log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
|
||
{"token": anonymize_token(access_token), "status": account.get("status")})
|
||
return dict(account)
|
||
return None
|
||
|
||
def _record_refresh_success(self, access_token: str) -> None:
|
||
with self._lock:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
current = self._accounts.get(access_token)
|
||
if current is None:
|
||
return
|
||
next_item = dict(current)
|
||
next_item["invalid_count"] = 0
|
||
next_item["last_invalid_at"] = None
|
||
next_item["last_refresh_error"] = None
|
||
next_item["last_refresh_error_at"] = None
|
||
account = self._normalize_account(next_item)
|
||
if account is not None:
|
||
self._accounts[access_token] = account
|
||
|
||
def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
|
||
if not isinstance(account, dict):
|
||
return False
|
||
created_at = self._parse_time(account.get("created_at"))
|
||
if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
|
||
return True
|
||
last_invalid_at = self._parse_time(account.get("last_invalid_at"))
|
||
invalid_count = int(account.get("invalid_count") or 0)
|
||
if invalid_count <= 1:
|
||
return True
|
||
if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
|
||
return True
|
||
return False
|
||
|
||
def _record_invalid_token_seen(
|
||
self,
|
||
access_token: str,
|
||
event: str,
|
||
error: str,
|
||
defer_invalid_removal: bool = True,
|
||
) -> bool:
|
||
now = datetime.now(timezone.utc)
|
||
with self._lock:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
current = self._accounts.get(access_token)
|
||
if current is None:
|
||
return True
|
||
should_defer = defer_invalid_removal and self._should_defer_invalid_token(current, now)
|
||
next_item = dict(current)
|
||
next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
|
||
next_item["last_invalid_at"] = now.isoformat()
|
||
next_item["last_refresh_error"] = str(error or "invalid access token")
|
||
next_item["last_refresh_error_at"] = now.isoformat()
|
||
account = self._normalize_account(next_item)
|
||
if account is not None:
|
||
self._accounts[access_token] = account
|
||
self._save_accounts()
|
||
if should_defer:
|
||
log_service.add(
|
||
LOG_TYPE_ACCOUNT,
|
||
"暂缓标记异常账号",
|
||
{"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
|
||
)
|
||
return False
|
||
return True
|
||
|
||
def mark_image_result(self, access_token: str, success: bool) -> dict | None:
|
||
if not access_token:
|
||
return None
|
||
self.release_image_slot(access_token)
|
||
with self._lock:
|
||
access_token = self._resolve_access_token_locked(access_token)
|
||
current = self._accounts.get(access_token)
|
||
if current is None:
|
||
return None
|
||
next_item = dict(current)
|
||
next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
if success:
|
||
next_item["success"] = int(next_item.get("success") or 0) + 1
|
||
next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
|
||
if next_item["quota"] == 0:
|
||
next_item["status"] = "限流"
|
||
next_item["restore_at"] = next_item.get("restore_at") or None
|
||
elif next_item.get("status") == "限流":
|
||
next_item["status"] = "正常"
|
||
else:
|
||
next_item["fail"] = int(next_item.get("fail") or 0) + 1
|
||
account = self._normalize_account(next_item)
|
||
if account is None:
|
||
return None
|
||
if (
|
||
account.get("status") == "限流"
|
||
and not account.get("manual_status")
|
||
and config.auto_remove_rate_limited_accounts
|
||
):
|
||
self._accounts.pop(access_token, None)
|
||
self._save_accounts()
|
||
log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
|
||
return None
|
||
self._accounts[access_token] = account
|
||
self._save_accounts()
|
||
return dict(account)
|
||
return None
|
||
|
||
def fetch_remote_info(
|
||
self,
|
||
access_token: str,
|
||
event: str = "fetch_remote_info",
|
||
defer_invalid_removal: bool = True,
|
||
) -> dict[str, Any] | None:
|
||
if not access_token:
|
||
raise ValueError("access_token is required")
|
||
|
||
active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
|
||
try:
|
||
from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
|
||
backend = OpenAIBackendAPI(active_token)
|
||
try:
|
||
result = backend.get_user_info()
|
||
finally:
|
||
backend.close()
|
||
except InvalidAccessTokenError as exc:
|
||
refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
|
||
if refreshed_token and refreshed_token != active_token:
|
||
try:
|
||
backend = OpenAIBackendAPI(refreshed_token)
|
||
try:
|
||
result = backend.get_user_info()
|
||
finally:
|
||
backend.close()
|
||
except InvalidAccessTokenError as retry_exc:
|
||
if self._record_invalid_token_seen(
|
||
refreshed_token,
|
||
event,
|
||
str(retry_exc),
|
||
defer_invalid_removal=defer_invalid_removal,
|
||
):
|
||
self.remove_invalid_token(refreshed_token, event)
|
||
raise
|
||
active_token = refreshed_token
|
||
else:
|
||
if self._record_invalid_token_seen(
|
||
active_token,
|
||
event,
|
||
str(exc),
|
||
defer_invalid_removal=defer_invalid_removal,
|
||
):
|
||
self.remove_invalid_token(active_token, event)
|
||
raise
|
||
self._record_refresh_success(active_token)
|
||
return self.update_account(active_token, result)
|
||
|
||
# ---- 刷新进度追踪 ----
|
||
|
||
def init_refresh_progress(self, progress_id: str, total: int) -> None:
|
||
"""初始化刷新进度记录。"""
|
||
with self._refresh_progress_lock:
|
||
self._refresh_progress[progress_id] = {
|
||
"total": total,
|
||
"processed": 0,
|
||
"done": False,
|
||
"error": None,
|
||
"status_counts": {"正常": 0, "限流": 0, "异常": 0, "禁用": 0},
|
||
"total_quota": 0,
|
||
}
|
||
|
||
def update_refresh_progress(self, progress_id: str, token: str) -> None:
|
||
"""刷新单个账号后,更新进度计数。"""
|
||
account = self.get_account(token)
|
||
status = str(account.get("status") or "正常").strip() if account else "正常"
|
||
quota = max(0, int(account.get("quota") or 0)) if account else 0
|
||
|
||
with self._refresh_progress_lock:
|
||
progress = self._refresh_progress.get(progress_id)
|
||
if progress is None:
|
||
return
|
||
progress["processed"] += 1
|
||
progress["status_counts"][status] = progress["status_counts"].get(status, 0) + 1
|
||
progress["total_quota"] += quota
|
||
|
||
def finish_refresh_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
|
||
"""标记刷新完成。"""
|
||
with self._refresh_progress_lock:
|
||
progress = self._refresh_progress.get(progress_id)
|
||
if progress is None:
|
||
return
|
||
progress["done"] = True
|
||
progress["result"] = result
|
||
if error:
|
||
progress["error"] = error
|
||
|
||
def get_refresh_progress(self, progress_id: str) -> dict | None:
|
||
"""查询刷新进度。"""
|
||
with self._refresh_progress_lock:
|
||
progress = self._refresh_progress.get(progress_id)
|
||
return dict(progress) if progress else None
|
||
|
||
def clean_refresh_progress(self, progress_id: str) -> None:
|
||
"""清理过期进度记录。"""
|
||
with self._refresh_progress_lock:
|
||
self._refresh_progress.pop(progress_id, None)
|
||
|
||
# ---- 重新登录进度追踪 ----
|
||
|
||
def init_relogin_progress(self, progress_id: str, total: int) -> None:
|
||
"""初始化重新登录进度记录。"""
|
||
with self._relogin_progress_lock:
|
||
self._relogin_progress[progress_id] = {
|
||
"total": total,
|
||
"processed": 0,
|
||
"done": False,
|
||
"error": None,
|
||
"results": [],
|
||
}
|
||
|
||
def update_relogin_progress(self, progress_id: str, token: str, status: str, error: str | None = None) -> None:
|
||
"""更新单个重新登录进度。当所有账号处理完毕时自动标记完成。"""
|
||
with self._relogin_progress_lock:
|
||
progress = self._relogin_progress.get(progress_id)
|
||
if progress is None:
|
||
return
|
||
progress["processed"] += 1
|
||
progress["results"].append({
|
||
"token": anonymize_token(token),
|
||
"status": status,
|
||
"error": error,
|
||
})
|
||
if progress["processed"] >= progress["total"]:
|
||
progress["done"] = True
|
||
|
||
def finish_relogin_progress(self, progress_id: str, result: dict | None = None, error: str | None = None) -> None:
|
||
"""标记重新登录完成。"""
|
||
with self._relogin_progress_lock:
|
||
progress = self._relogin_progress.get(progress_id)
|
||
if progress is None:
|
||
return
|
||
progress["done"] = True
|
||
progress["result"] = result
|
||
if error:
|
||
progress["error"] = error
|
||
|
||
def get_relogin_progress(self, progress_id: str) -> dict | None:
|
||
"""查询重新登录进度。"""
|
||
with self._relogin_progress_lock:
|
||
progress = self._relogin_progress.get(progress_id)
|
||
return dict(progress) if progress else None
|
||
|
||
def clean_relogin_progress(self, progress_id: str) -> None:
|
||
"""清理过期进度记录。"""
|
||
with self._relogin_progress_lock:
|
||
self._relogin_progress.pop(progress_id, None)
|
||
|
||
def refresh_accounts(
|
||
self,
|
||
access_tokens: list[str],
|
||
progress_id: str | None = None,
|
||
defer_invalid_removal: bool = True,
|
||
) -> dict[str, Any]:
|
||
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
||
if not access_tokens:
|
||
items = self.list_accounts()
|
||
result = {"refreshed": 0, "errors": [], "items": items, "relogined": 0}
|
||
if progress_id:
|
||
self.finish_refresh_progress(progress_id, result)
|
||
return result
|
||
|
||
refreshed = 0
|
||
errors = []
|
||
max_workers = min(10, len(access_tokens))
|
||
|
||
if progress_id:
|
||
self.init_refresh_progress(progress_id, len(access_tokens))
|
||
|
||
executor = ThreadPoolExecutor(max_workers=max_workers)
|
||
try:
|
||
futures = {
|
||
executor.submit(self.fetch_remote_info, token, "refresh_accounts", defer_invalid_removal): token
|
||
for token in access_tokens
|
||
}
|
||
for future in as_completed(futures):
|
||
token = futures[future]
|
||
try:
|
||
account = future.result()
|
||
except (KeyboardInterrupt, SystemExit):
|
||
executor.shutdown(wait=False, cancel_futures=True)
|
||
raise
|
||
except Exception as exc:
|
||
error_str = str(exc)
|
||
# TLS/代理连接错误是网络问题,不计入账号失败
|
||
from services.protocol.conversation import is_tls_connection_error
|
||
if not is_tls_connection_error(error_str):
|
||
errors.append({"token": anonymize_token(token), "error": error_str})
|
||
else:
|
||
if account is not None:
|
||
refreshed += 1
|
||
|
||
if progress_id:
|
||
self.update_refresh_progress(progress_id, token)
|
||
except (KeyboardInterrupt, SystemExit):
|
||
if progress_id:
|
||
self.finish_refresh_progress(progress_id, error="cancelled")
|
||
executor.shutdown(wait=False, cancel_futures=True)
|
||
raise
|
||
else:
|
||
executor.shutdown(wait=True, cancel_futures=True)
|
||
|
||
# 自动重新登录异常账号(仅当配置开启时)
|
||
relogined = 0
|
||
if config.auto_relogin_after_refresh:
|
||
for token in access_tokens:
|
||
account = self.get_account(token)
|
||
if not account:
|
||
continue
|
||
status = str(account.get("status") or "").strip()
|
||
if status != "异常":
|
||
continue
|
||
email = str(account.get("email") or "").strip()
|
||
password = str(account.get("password") or "").strip()
|
||
if not email or not password:
|
||
continue
|
||
t = Thread(
|
||
target=self._password_re_login_thread,
|
||
args=(token, email, password, "auto_relogin_after_refresh"),
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
relogined += 1
|
||
|
||
result = {
|
||
"refreshed": refreshed,
|
||
"errors": errors,
|
||
"items": self.list_accounts(),
|
||
"relogined": relogined,
|
||
}
|
||
|
||
if progress_id:
|
||
self.finish_refresh_progress(progress_id, result)
|
||
|
||
return result
|
||
|
||
def re_login_accounts(self, access_tokens: list[str], progress_id: str | None = None) -> dict[str, Any]:
|
||
"""对选中账号执行密码重新登录流程。
|
||
|
||
仅对包含 email + password 的账号有效。
|
||
登录成功后自动将状态设为"正常"。
|
||
"""
|
||
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
||
if not access_tokens:
|
||
result = {"relogined": 0, "skipped": 0, "errors": [], "items": self.list_accounts()}
|
||
if progress_id:
|
||
self.finish_relogin_progress(progress_id, result)
|
||
return result
|
||
|
||
if progress_id:
|
||
self.init_relogin_progress(progress_id, len(access_tokens))
|
||
|
||
relogined = 0
|
||
skipped = 0
|
||
errors = []
|
||
|
||
for token in access_tokens:
|
||
account = self.get_account(token)
|
||
if not account:
|
||
errors.append({"token": anonymize_token(token), "error": "账号不存在"})
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, token, "跳过", "账号不存在")
|
||
continue
|
||
|
||
email = str(account.get("email") or "").strip()
|
||
password = str(account.get("password") or "").strip()
|
||
if not email or not password:
|
||
skipped += 1
|
||
if progress_id:
|
||
self.update_relogin_progress(progress_id, token, "跳过", "无邮箱密码")
|
||
continue
|
||
|
||
# 在新线程中执行密码重新登录
|
||
t = Thread(
|
||
target=self._password_re_login_thread,
|
||
args=(token, email, password, "manual_relogin", progress_id),
|
||
daemon=True,
|
||
)
|
||
t.start()
|
||
relogined += 1
|
||
|
||
result = {
|
||
"relogined": relogined,
|
||
"skipped": skipped,
|
||
"errors": errors,
|
||
"items": self.list_accounts(),
|
||
}
|
||
if progress_id:
|
||
# 如果所有账号都已同步处理完毕(没有启动线程),直接标记完成
|
||
if relogined == 0:
|
||
self.finish_relogin_progress(progress_id, result)
|
||
else:
|
||
# 有线程在运行,等线程结束后再完成
|
||
pass
|
||
return result
|
||
|
||
def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
|
||
target_tokens = set(token for token in (access_tokens or []) if token)
|
||
with self._lock:
|
||
accounts = [
|
||
dict(item)
|
||
for item in self._accounts.values()
|
||
if not target_tokens or str(item.get("access_token") or "") in target_tokens
|
||
]
|
||
|
||
items: list[dict[str, str]] = []
|
||
for account in accounts:
|
||
access_token = str(account.get("access_token") or "").strip()
|
||
refresh_token = str(account.get("refresh_token") or "").strip()
|
||
id_token = str(account.get("id_token") or "").strip()
|
||
if not access_token or not refresh_token or not id_token:
|
||
continue
|
||
|
||
access_payload = self._decode_jwt_payload(access_token)
|
||
id_payload = self._decode_jwt_payload(id_token)
|
||
auth_claim = access_payload.get("https://api.openai.com/auth")
|
||
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
|
||
profile_claim = access_payload.get("https://api.openai.com/profile")
|
||
profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
|
||
|
||
email = (
|
||
str(account.get("email") or "").strip()
|
||
or str(profile_claim.get("email") or "").strip()
|
||
or str(id_payload.get("email") or "").strip()
|
||
)
|
||
account_id = (
|
||
str(account.get("account_id") or "").strip()
|
||
or str(auth_claim.get("chatgpt_account_id") or "").strip()
|
||
or str(account.get("user_id") or "").strip()
|
||
)
|
||
item = {
|
||
"type": str(account.get("export_type") or "codex"),
|
||
"email": email,
|
||
"account_id": account_id,
|
||
"access_token": access_token,
|
||
"refresh_token": refresh_token,
|
||
"id_token": id_token,
|
||
"expired": self._timestamp_to_iso(access_payload.get("exp")),
|
||
"last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
|
||
}
|
||
password = str(account.get("password") or "").strip()
|
||
if password:
|
||
item["password"] = password
|
||
items.append(item)
|
||
return items
|
||
|
||
def get_stats(self) -> dict:
|
||
with self._lock:
|
||
items = list(self._accounts.values())
|
||
total = len(items)
|
||
active = sum(1 for a in items if a.get("status") == "正常")
|
||
limited = sum(1 for a in items if a.get("status") == "限流")
|
||
upload_limited = sum(1 for a in items if not self._is_upload_available(a))
|
||
abnormal = sum(1 for a in items if a.get("status") == "异常")
|
||
disabled = sum(1 for a in items if a.get("status") == "禁用")
|
||
total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
|
||
total_success = sum(int(a.get("success") or 0) for a in items)
|
||
total_fail = sum(int(a.get("fail") or 0) for a in items)
|
||
by_type = {}
|
||
for a in items:
|
||
t = a.get("type", "unknown")
|
||
by_type[t] = by_type.get(t, 0) + 1
|
||
return {
|
||
"total": total,
|
||
"cumulative_total": self._cumulative_total,
|
||
"active": active,
|
||
"limited": limited,
|
||
"upload_limited": upload_limited,
|
||
"abnormal": abnormal,
|
||
"disabled": disabled,
|
||
"total_quota": total_quota,
|
||
"total_success": total_success,
|
||
"total_fail": total_fail,
|
||
"by_type": by_type,
|
||
}
|
||
|
||
def account_health(self) -> dict:
|
||
stats = self.get_stats()
|
||
return {
|
||
"healthy": stats["active"] > 0,
|
||
"status": "ok" if stats["active"] > 0 else "degraded",
|
||
**stats,
|
||
}
|
||
|
||
|
||
account_service = AccountService(config.get_storage_backend())
|